From 48c8906a89097a3b63ff49c479ed2e2e221e5fb6 Mon Sep 17 00:00:00 2001 From: Romit <85230081+romitg2@users.noreply.github.com> Date: Sun, 29 Jun 2025 22:01:26 +0530 Subject: [PATCH 01/23] fix: Preview in Email embed is broken (#6120) --- .../ui/components/editor/components/toolbar-plugin.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/modules/ui/components/editor/components/toolbar-plugin.tsx b/apps/web/modules/ui/components/editor/components/toolbar-plugin.tsx index 8a7afd29b5..3177d7f7c4 100644 --- a/apps/web/modules/ui/components/editor/components/toolbar-plugin.tsx +++ b/apps/web/modules/ui/components/editor/components/toolbar-plugin.tsx @@ -397,7 +397,10 @@ export const ToolbarPlugin = (props: TextEditorProps & { container: HTMLElement editor.registerUpdateListener(({ editorState, prevEditorState }) => { editorState.read(() => { - const textInHtml = $generateHtmlFromNodes(editor).replace(/</g, "<").replace(/>/g, ">"); + const textInHtml = $generateHtmlFromNodes(editor) + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/white-space:\s*pre-wrap;?/g, ""); setText.current(textInHtml); }); if (!prevEditorState._selection) editor.blur(); From e81190214fafa21cdd5f07d4646e7d2ac38d850a Mon Sep 17 00:00:00 2001 From: Varun Singh Date: Sun, 29 Jun 2025 22:54:54 +0530 Subject: [PATCH 02/23] feat: Enable recall for welcome cards. (#5963) Co-authored-by: Dhruwang --- .../components/recall-item-select.test.tsx | 25 +++++++++++++++++++ .../components/recall-item-select.tsx | 3 +++ .../components/question-form-input/index.tsx | 16 ++++++------ .../editor/components/end-screen-form.tsx | 2 +- 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/apps/web/modules/survey/components/question-form-input/components/recall-item-select.test.tsx b/apps/web/modules/survey/components/question-form-input/components/recall-item-select.test.tsx index d11c34470b..cf09f4be6b 100644 --- a/apps/web/modules/survey/components/question-form-input/components/recall-item-select.test.tsx +++ b/apps/web/modules/survey/components/question-form-input/components/recall-item-select.test.tsx @@ -89,6 +89,31 @@ describe("RecallItemSelect", () => { expect(screen.queryByText("_File Upload Question_")).not.toBeInTheDocument(); }); + test("do not render questions if questionId is 'start' (welcome card)", async () => { + render( + + ); + + expect(screen.queryByText("_Question 1_")).not.toBeInTheDocument(); + expect(screen.queryByText("_Question 2_")).not.toBeInTheDocument(); + + expect(screen.getByText("_hidden1_")).toBeInTheDocument(); + expect(screen.getByText("_hidden2_")).toBeInTheDocument(); + expect(screen.getByText("_Variable 1_")).toBeInTheDocument(); + expect(screen.getByText("_Variable 2_")).toBeInTheDocument(); + + expect(screen.queryByText("_Current Question_")).not.toBeInTheDocument(); + expect(screen.queryByText("_File Upload Question_")).not.toBeInTheDocument(); + }); + test("filters recall items based on search input", async () => { const user = userEvent.setup(); render( diff --git a/apps/web/modules/survey/components/question-form-input/components/recall-item-select.tsx b/apps/web/modules/survey/components/question-form-input/components/recall-item-select.tsx index 4e33171b65..9af5cfb11b 100644 --- a/apps/web/modules/survey/components/question-form-input/components/recall-item-select.tsx +++ b/apps/web/modules/survey/components/question-form-input/components/recall-item-select.tsx @@ -109,6 +109,9 @@ export const RecallItemSelect = ({ }, [localSurvey.variables, recallItemIds]); const surveyQuestionRecallItems = useMemo(() => { + const isWelcomeCard = questionId === "start"; + if (isWelcomeCard) return []; + const isEndingCard = !localSurvey.questions.map((question) => question.id).includes(questionId); const idx = isEndingCard ? localSurvey.questions.length diff --git a/apps/web/modules/survey/components/question-form-input/index.tsx b/apps/web/modules/survey/components/question-form-input/index.tsx index 085eb4e7f5..06963b0bc6 100644 --- a/apps/web/modules/survey/components/question-form-input/index.tsx +++ b/apps/web/modules/survey/components/question-form-input/index.tsx @@ -129,9 +129,9 @@ export const QuestionFormInput = ({ (question && (id.includes(".") ? // Handle nested properties - (question[id.split(".")[0] as keyof TSurveyQuestion] as any)?.[id.split(".")[1]] + (question[id.split(".")[0] as keyof TSurveyQuestion] as any)?.[id.split(".")[1]] : // Original behavior - (question[id as keyof TSurveyQuestion] as TI18nString))) || + (question[id as keyof TSurveyQuestion] as TI18nString))) || createI18nString("", surveyLanguageCodes) ); }, [ @@ -309,7 +309,7 @@ export const QuestionFormInput = ({ onAddFallback={() => { inputRef.current?.focus(); }} - isRecallAllowed={!isWelcomeCard && (id === "headline" || id === "subheader")} + isRecallAllowed={id === "headline" || id === "subheader"} usedLanguageCode={usedLanguageCode} render={({ value, @@ -351,9 +351,8 @@ export const QuestionFormInput = ({
1 ? "pr-24" : "" - }`} + className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent ${localSurvey.languages?.length > 1 ? "pr-24" : "" + }`} dir="auto" key={highlightedJSX.toString()}> {highlightedJSX} @@ -380,9 +379,8 @@ export const QuestionFormInput = ({ maxLength={maxLength} ref={inputRef} onBlur={onBlur} - className={`absolute top-0 text-black caret-black ${ - localSurvey.languages?.length > 1 ? "pr-24" : "" - } ${className}`} + className={`absolute top-0 text-black caret-black ${localSurvey.languages?.length > 1 ? "pr-24" : "" + } ${className}`} isInvalid={ isInvalid && text[usedLanguageCode]?.trim() === "" && diff --git a/apps/web/modules/survey/editor/components/end-screen-form.tsx b/apps/web/modules/survey/editor/components/end-screen-form.tsx index 7ce0931a64..bad651d438 100644 --- a/apps/web/modules/survey/editor/components/end-screen-form.tsx +++ b/apps/web/modules/survey/editor/components/end-screen-form.tsx @@ -42,7 +42,7 @@ export const EndScreenForm = ({ const [showEndingCardCTA, setshowEndingCardCTA] = useState( endingCard.type === "endScreen" && - (!!getLocalizedValue(endingCard.buttonLabel, selectedLanguageCode) || !!endingCard.buttonLink) + (!!getLocalizedValue(endingCard.buttonLabel, selectedLanguageCode) || !!endingCard.buttonLink) ); return (
From 1536bf690792ec2c3e31903e6b3654043cd10373 Mon Sep 17 00:00:00 2001 From: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Date: Sun, 29 Jun 2025 23:40:30 +0530 Subject: [PATCH 03/23] fix: question change issue (#6091) --- packages/types/surveys/types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/types/surveys/types.ts b/packages/types/surveys/types.ts index 6aeb5e5497..e1b73f9a72 100644 --- a/packages/types/surveys/types.ts +++ b/packages/types/surveys/types.ts @@ -545,7 +545,6 @@ export const ZSurveyConsentQuestion = ZSurveyQuestionBase.extend({ type: z.literal(TSurveyQuestionTypeEnum.Consent), html: ZI18nString.optional(), label: ZI18nString, - placeholder: z.string().optional(), }); export type TSurveyConsentQuestion = z.infer; From 0b7734f72564954b171b879f1d4fca51ce7187a9 Mon Sep 17 00:00:00 2001 From: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com> Date: Mon, 30 Jun 2025 11:43:42 +0530 Subject: [PATCH 04/23] fix: optional fields in update response API (#6113) --- apps/web/lib/fileValidation.ts | 3 ++- apps/web/modules/api/v2/lib/question.ts | 3 ++- packages/types/responses.ts | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/web/lib/fileValidation.ts b/apps/web/lib/fileValidation.ts index 47bc3ba1c1..f19fdf98bb 100644 --- a/apps/web/lib/fileValidation.ts +++ b/apps/web/lib/fileValidation.ts @@ -65,7 +65,8 @@ export const validateSingleFile = ( return !allowedFileExtensions || allowedFileExtensions.includes(extension as TAllowedFileExtension); }; -export const validateFileUploads = (data: TResponseData, questions?: TSurveyQuestion[]): boolean => { +export const validateFileUploads = (data?: TResponseData, questions?: TSurveyQuestion[]): boolean => { + if (!data) return true; for (const key of Object.keys(data)) { const question = questions?.find((q) => q.id === key); if (!question || question.type !== TSurveyQuestionTypeEnum.FileUpload) continue; diff --git a/apps/web/modules/api/v2/lib/question.ts b/apps/web/modules/api/v2/lib/question.ts index cad3cd78a8..232b2d01a0 100644 --- a/apps/web/modules/api/v2/lib/question.ts +++ b/apps/web/modules/api/v2/lib/question.ts @@ -33,10 +33,11 @@ export const validateOtherOptionLengthForMultipleChoice = ({ surveyQuestions, responseLanguage, }: { - responseData: TResponseData; + responseData?: TResponseData; surveyQuestions: TSurveyQuestion[]; responseLanguage?: string; }): string | undefined => { + if (!responseData) return undefined; for (const [questionId, answer] of Object.entries(responseData)) { const question = surveyQuestions.find((q) => q.id === questionId); if (!question) continue; diff --git a/packages/types/responses.ts b/packages/types/responses.ts index 9d552ae752..0f42499b5c 100644 --- a/packages/types/responses.ts +++ b/packages/types/responses.ts @@ -325,9 +325,9 @@ export const ZResponseInput = z.object({ export type TResponseInput = z.infer; export const ZResponseUpdateInput = z.object({ - finished: z.boolean(), + finished: z.boolean().optional(), endingId: z.string().nullish(), - data: ZResponseData, + data: ZResponseData.optional(), variables: ZResponseVariables.optional(), ttc: ZResponseTtc.optional(), language: z.string().optional(), From 6644bba6ea82f73c82851bc81e4d4e6e3cf581cf Mon Sep 17 00:00:00 2001 From: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Date: Mon, 30 Jun 2025 11:45:50 +0530 Subject: [PATCH 05/23] fix: formatted databse error message for response endpoint (#6111) --- .../api/v1/management/responses/lib/response.test.ts | 12 ++++++++++++ .../app/api/v1/management/responses/lib/response.ts | 4 ++++ apps/web/app/api/v1/management/responses/route.ts | 6 +++++- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/web/app/api/v1/management/responses/lib/response.test.ts b/apps/web/app/api/v1/management/responses/lib/response.test.ts index 33f5b5bf1a..81bb17bf49 100644 --- a/apps/web/app/api/v1/management/responses/lib/response.test.ts +++ b/apps/web/app/api/v1/management/responses/lib/response.test.ts @@ -186,6 +186,18 @@ describe("Response Lib Tests", () => { expect(logger.error).not.toHaveBeenCalled(); // Should be caught and re-thrown as DatabaseError }); + test("should handle RelatedRecordDoesNotExist error with specific message", async () => { + const prismaError = new Prisma.PrismaClientKnownRequestError("Related record does not exist", { + code: "P2025", // PrismaErrorType.RelatedRecordDoesNotExist + clientVersion: "2.0", + }); + vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization); + vi.mocked(prisma.response.create).mockRejectedValue(prismaError); + + await expect(createResponse(mockResponseInput)).rejects.toThrow(DatabaseError); + await expect(createResponse(mockResponseInput)).rejects.toThrow("Display ID does not exist"); + }); + test("should handle generic errors", async () => { const genericError = new Error("Something went wrong"); vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization); diff --git a/apps/web/app/api/v1/management/responses/lib/response.ts b/apps/web/app/api/v1/management/responses/lib/response.ts index a7e6fa176a..bfe31c2dc4 100644 --- a/apps/web/app/api/v1/management/responses/lib/response.ts +++ b/apps/web/app/api/v1/management/responses/lib/response.ts @@ -12,6 +12,7 @@ import { validateInputs } from "@/lib/utils/validate"; import { Prisma } from "@prisma/client"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; +import { PrismaErrorType } from "@formbricks/database/types/error"; import { logger } from "@formbricks/logger"; import { ZId, ZOptionalNumber } from "@formbricks/types/common"; import { TContactAttributes } from "@formbricks/types/contact-attribute"; @@ -176,6 +177,9 @@ export const createResponse = async (responseInput: TResponseInput): Promise Date: Mon, 30 Jun 2025 14:59:06 +0530 Subject: [PATCH 06/23] fix: allow dynamic height for action cards to show full text (#6106) Co-authored-by: Piyush Gupta --- .../actions/components/ActionRowData.tsx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionRowData.tsx b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionRowData.tsx index cb5a49a39c..e4b4c12c6e 100644 --- a/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionRowData.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/actions/components/ActionRowData.tsx @@ -11,22 +11,21 @@ export const ActionClassDataRow = ({ locale: TUserLocale; }) => { return ( -
-
-
-
+
+
+
+
{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
-
-
{actionClass.name}
-
{actionClass.description}
+
+
{actionClass.name}
+
{actionClass.description}
{timeSince(actionClass.createdAt.toString(), locale)}
-
); }; From da72101320ce0628983682259f5474f136f8abcc Mon Sep 17 00:00:00 2001 From: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Date: Mon, 30 Jun 2025 16:40:33 +0530 Subject: [PATCH 07/23] fix: active tab scaling issue (#6127) --- .../ui/components/options-switch/index.tsx | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/apps/web/modules/ui/components/options-switch/index.tsx b/apps/web/modules/ui/components/options-switch/index.tsx index 35044c708d..5d3ece3686 100644 --- a/apps/web/modules/ui/components/options-switch/index.tsx +++ b/apps/web/modules/ui/components/options-switch/index.tsx @@ -21,16 +21,27 @@ export const OptionsSwitch = ({ const [highlightStyle, setHighlightStyle] = useState({}); const containerRef = useRef(null); useEffect(() => { - if (containerRef.current) { - const activeElement = containerRef.current.querySelector(`[data-value="${currentOption}"]`); - if (activeElement) { - const { offsetLeft, offsetWidth } = activeElement as HTMLElement; - setHighlightStyle({ - left: `${offsetLeft}px`, - width: `${offsetWidth}px`, - }); + const updateHighlight = () => { + if (containerRef.current) { + const activeElement = containerRef.current.querySelector(`[data-value="${currentOption}"]`); + if (activeElement) { + const { offsetLeft, offsetWidth } = activeElement as HTMLElement; + setHighlightStyle({ + left: `${offsetLeft}px`, + width: `${offsetWidth}px`, + }); + } else { + // Hide highlight if no matching element found + setHighlightStyle({ opacity: 0 }); + } } - } + }; + // Initial call + updateHighlight(); + + // Listen to resize + window.addEventListener("resize", updateHighlight); + return () => window.removeEventListener("resize", updateHighlight); }, [currentOption]); return ( From d10cff917d6c1a7d6414ef9a5bb9d7a3d453acc9 Mon Sep 17 00:00:00 2001 From: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Date: Tue, 1 Jul 2025 13:46:14 +0530 Subject: [PATCH 08/23] fix: recall parsing for headlines with empty strings (#6131) --- packages/surveys/src/components/general/headline.tsx | 2 +- packages/surveys/src/lib/i18n.test.ts | 3 +++ packages/surveys/src/lib/i18n.ts | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/surveys/src/components/general/headline.tsx b/packages/surveys/src/components/general/headline.tsx index f4236e64d5..792e7ce561 100644 --- a/packages/surveys/src/components/general/headline.tsx +++ b/packages/surveys/src/components/general/headline.tsx @@ -14,7 +14,7 @@ export function Headline({ headline, questionId, required = true, alignTextCente
- {headline} +

{headline}

{!required && ( { test("should return empty string for undefined value", () => { expect(getLocalizedValue(undefined, "en")).toBe(""); }); + test("should return empty string for empty string", () => { + expect(getLocalizedValue({ default: "" }, "en")).toBe(""); + }); test("should return empty string for non-i18n string", () => { expect(getLocalizedValue("not an i18n string" as any, "en")).toBe(""); diff --git a/packages/surveys/src/lib/i18n.ts b/packages/surveys/src/lib/i18n.ts index 371b50e3ec..02207e2b93 100644 --- a/packages/surveys/src/lib/i18n.ts +++ b/packages/surveys/src/lib/i18n.ts @@ -10,7 +10,7 @@ export const getLocalizedValue = (value: TI18nString | undefined, languageId: st return ""; } if (isI18nObject(value)) { - if (value[languageId]) { + if (typeof value[languageId] === "string") { return value[languageId]; } return value.default; From 1be23eebbbed6648a0501afee21811ac00d63519 Mon Sep 17 00:00:00 2001 From: Harsh Bhat <90265455+harshsbhat@users.noreply.github.com> Date: Tue, 1 Jul 2025 17:27:42 +0530 Subject: [PATCH 09/23] docs: Add audit logs, domain split in the license details (#6139) --- apps/web/locales/de-DE.json | 2 +- apps/web/locales/en-US.json | 2 +- apps/web/locales/fr-FR.json | 2 +- apps/web/locales/pt-BR.json | 2 +- apps/web/locales/pt-PT.json | 2 +- apps/web/locales/zh-Hant-TW.json | 2 +- docs/self-hosting/advanced/license.mdx | 8 +++++--- 7 files changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index a59485f9bc..f18a3cf21a 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -1230,7 +1230,7 @@ "copy_survey_error": "Kopieren der Umfrage fehlgeschlagen", "copy_survey_link_to_clipboard": "Umfragelink in die Zwischenablage kopieren", "copy_survey_success": "Umfrage erfolgreich kopiert!", - "delete_survey_and_responses_warning": "Bist Du sicher, dass Du diese Umfrage und alle ihre Antworten löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.", + "delete_survey_and_responses_warning": "Bist Du sicher, dass Du diese Umfrage und alle ihre Antworten löschen möchtest?", "edit": { "1_choose_the_default_language_for_this_survey": "1. Wähle die Standardsprache für diese Umfrage:", "2_activate_translation_for_specific_languages": "2. Übersetzung für bestimmte Sprachen aktivieren:", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index 4985d8272f..605a5e80d0 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -1230,7 +1230,7 @@ "copy_survey_error": "Failed to copy survey", "copy_survey_link_to_clipboard": "Copy survey link to clipboard", "copy_survey_success": "Survey copied successfully!", - "delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses? This action cannot be undone.", + "delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses?", "edit": { "1_choose_the_default_language_for_this_survey": "1. Choose the default language for this survey:", "2_activate_translation_for_specific_languages": "2. Activate translation for specific languages:", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 8837e90254..8f3e2afc92 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -1230,7 +1230,7 @@ "copy_survey_error": "Échec de la copie du sondage", "copy_survey_link_to_clipboard": "Copier le lien du sondage dans le presse-papiers", "copy_survey_success": "Enquête copiée avec succès !", - "delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses ? Cette action ne peut pas être annulée.", + "delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses?", "edit": { "1_choose_the_default_language_for_this_survey": "1. Choisissez la langue par défaut pour ce sondage :", "2_activate_translation_for_specific_languages": "2. Activer la traduction pour des langues spécifiques :", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 6ba2003efe..f402928904 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -1230,7 +1230,7 @@ "copy_survey_error": "Falha ao copiar pesquisa", "copy_survey_link_to_clipboard": "Copiar link da pesquisa para a área de transferência", "copy_survey_success": "Pesquisa copiada com sucesso!", - "delete_survey_and_responses_warning": "Você tem certeza de que quer deletar essa pesquisa e todas as suas respostas? Essa ação não pode ser desfeita.", + "delete_survey_and_responses_warning": "Você tem certeza de que quer deletar essa pesquisa e todas as suas respostas?", "edit": { "1_choose_the_default_language_for_this_survey": "1. Escolha o idioma padrão para essa pesquisa:", "2_activate_translation_for_specific_languages": "2. Ativar tradução para idiomas específicos:", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 6d7d714cec..89c8243a2d 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -1230,7 +1230,7 @@ "copy_survey_error": "Falha ao copiar inquérito", "copy_survey_link_to_clipboard": "Copiar link do inquérito para a área de transferência", "copy_survey_success": "Inquérito copiado com sucesso!", - "delete_survey_and_responses_warning": "Tem a certeza de que deseja eliminar este inquérito e todas as suas respostas? Esta ação não pode ser desfeita.", + "delete_survey_and_responses_warning": "Tem a certeza de que deseja eliminar este inquérito e todas as suas respostas?", "edit": { "1_choose_the_default_language_for_this_survey": "1. Escolha o idioma padrão para este inquérito:", "2_activate_translation_for_specific_languages": "2. Ativar tradução para idiomas específicos:", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index bde88fcd34..c6e84cfb83 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -1230,7 +1230,7 @@ "copy_survey_error": "無法複製問卷", "copy_survey_link_to_clipboard": "將問卷連結複製到剪貼簿", "copy_survey_success": "問卷已成功複製!", - "delete_survey_and_responses_warning": "您確定要刪除此問卷及其所有回應嗎?此操作無法復原。", + "delete_survey_and_responses_warning": "您確定要刪除此問卷及其所有回應嗎?", "edit": { "1_choose_the_default_language_for_this_survey": "1. 選擇此問卷的預設語言:", "2_activate_translation_for_specific_languages": "2. 啟用特定語言的翻譯:", diff --git a/docs/self-hosting/advanced/license.mdx b/docs/self-hosting/advanced/license.mdx index c743c684e6..085d742472 100644 --- a/docs/self-hosting/advanced/license.mdx +++ b/docs/self-hosting/advanced/license.mdx @@ -83,16 +83,18 @@ The Enterprise Edition allows us to fund the development of Formbricks sustainab | Email follow-ups | ✅ | ✅ | | Multi-language UI | ✅ | ✅ | | All integrations (Slack, Zapier, Notion, etc.) | ✅ | ✅ | +| Domain Split Configuration | ✅ | ✅ | | Hide "Powered by Formbricks" | ❌ | ✅ | | Whitelabel email follow-ups | ❌ | ✅ | | Teams & access roles | ❌ | ✅ | | Contact management & segments | ❌ | ✅ | | Multi-language surveys | ❌ | ✅ | +| Audit Logs | ❌ | ✅ | | OIDC SSO (AzureAD, Google, OpenID) | ❌ | ✅ | | SAML SSO | ❌ | ✅ | -| Spam protection (ReCaptchaV3) | ❌ | ✅ | -| Two-factor authentication | ❌ | ✅ | -| Custom 'Project' count | ❌ | ✅ | +| Spam protection (ReCaptchaV3) | ❌ | ✅ | +| Two-factor authentication | ❌ | ✅ | +| Custom 'Project' count | ❌ | ✅ | | White-glove onboarding | ❌ | ✅ | | Support SLAs | ❌ | ✅ | From 979fd71a118fb18c49f5c35f65e9c4b0c8db520c Mon Sep 17 00:00:00 2001 From: Kunal Garg <112847288+ikunal-04@users.noreply.github.com> Date: Tue, 1 Jul 2025 21:11:14 +0530 Subject: [PATCH 10/23] feat: reset password in accounts page (#5219) Co-authored-by: Piyush Gupta Co-authored-by: Johannes --- .../components/landing-sidebar.test.tsx | 1 + .../landing/components/landing-sidebar.tsx | 1 + .../components/MainNavigation.test.tsx | 8 +- .../components/MainNavigation.tsx | 4 +- .../settings/(account)/profile/actions.ts | 20 +++- .../EditProfileDetailsForm.test.tsx | 97 ++++++++++++++++++- .../components/EditProfileDetailsForm.tsx | 60 +++++++++++- .../settings/(account)/profile/page.test.tsx | 3 +- .../settings/(account)/profile/page.tsx | 10 +- apps/web/locales/de-DE.json | 3 +- apps/web/locales/en-US.json | 3 +- apps/web/locales/fr-FR.json | 3 +- apps/web/locales/pt-BR.json | 3 +- apps/web/locales/pt-PT.json | 3 +- apps/web/locales/zh-Hant-TW.json | 3 +- .../DeleteAccountModal/index.test.tsx | 9 +- .../components/DeleteAccountModal/index.tsx | 4 +- apps/web/modules/auth/actions/sign-out.ts | 8 +- .../modules/auth/forgot-password/actions.ts | 10 +- apps/web/modules/auth/hooks/use-sign-out.ts | 14 ++- apps/web/modules/auth/lib/user.ts | 1 + apps/web/modules/auth/lib/utils.ts | 8 +- .../modules/ee/audit-logs/types/audit-log.ts | 1 + .../components/preview-email-template.tsx | 10 +- .../components/question-form-input/index.tsx | 14 +-- .../editor/components/end-screen-form.tsx | 2 +- .../components/client-logout/index.test.tsx | 15 +-- .../ui/components/client-logout/index.tsx | 3 +- 28 files changed, 249 insertions(+), 72 deletions(-) diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.test.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.test.tsx index 40ab57335f..fb33d1991c 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.test.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.test.tsx @@ -94,6 +94,7 @@ describe("LandingSidebar component", () => { organizationId: "o1", redirect: true, callbackUrl: "/auth/login", + clearEnvironmentId: true, }); }); }); diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.tsx index ce5e8b7b4a..f50e589875 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar.tsx @@ -130,6 +130,7 @@ export const LandingSidebar = ({ organizationId: organization.id, redirect: true, callbackUrl: "/auth/login", + clearEnvironmentId: true, }); }} icon={}> diff --git a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.test.tsx b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.test.tsx index 0809609434..56df681c75 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.test.tsx @@ -221,7 +221,6 @@ describe("MainNavigation", () => { vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut }); // Set up localStorage spy on the mocked localStorage - const removeItemSpy = vi.spyOn(window.localStorage, "removeItem"); render(); @@ -243,23 +242,18 @@ describe("MainNavigation", () => { const logoutButton = screen.getByText("common.logout"); await userEvent.click(logoutButton); - // Verify localStorage.removeItem is called with the correct key - expect(removeItemSpy).toHaveBeenCalledWith("formbricks-environment-id"); - expect(mockSignOut).toHaveBeenCalledWith({ reason: "user_initiated", redirectUrl: "/auth/login", organizationId: "org1", redirect: false, callbackUrl: "/auth/login", + clearEnvironmentId: true, }); await waitFor(() => { expect(mockRouterPush).toHaveBeenCalledWith("/auth/login"); }); - - // Clean up spy - removeItemSpy.mockRestore(); }); test("handles organization switching", async () => { diff --git a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx index d492cfa87b..8a955cbf34 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/MainNavigation.tsx @@ -4,7 +4,6 @@ import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[enviro import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink"; import FBLogo from "@/images/formbricks-wordmark.svg"; import { cn } from "@/lib/cn"; -import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage"; import { getAccessFlags } from "@/lib/membership/utils"; import { capitalizeFirstLetter } from "@/lib/utils/strings"; import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; @@ -391,14 +390,13 @@ export const MainNavigation = ({ { - localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS); - const route = await signOutWithAudit({ reason: "user_initiated", redirectUrl: "/auth/login", organizationId: organization.id, redirect: false, callbackUrl: "/auth/login", + clearEnvironmentId: true, }); router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings }} 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 065a9f9309..cdfd3efeab 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 @@ -13,7 +13,7 @@ import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/co import { rateLimit } from "@/lib/utils/rate-limit"; import { updateBrevoCustomer } from "@/modules/auth/lib/brevo"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; -import { sendVerificationNewEmail } from "@/modules/email"; +import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email"; import { z } from "zod"; import { ZId } from "@formbricks/types/common"; import { @@ -162,3 +162,21 @@ export const removeAvatarAction = authenticatedActionClient.schema(ZRemoveAvatar } ) ); + +export const resetPasswordAction = authenticatedActionClient.action( + withAuditLogging( + "passwordReset", + "user", + async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => { + if (ctx.user.identityProvider !== "email") { + throw new OperationNotAllowedError("auth.reset-password.not-allowed"); + } + + await sendForgotPasswordEmail(ctx.user); + + ctx.auditLoggingCtx.userId = ctx.user.id; + + return { success: true }; + } + ) +); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.test.tsx index ea6c290c8b..a9a779e55b 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm.test.tsx @@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event"; import toast from "react-hot-toast"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { TUser } from "@formbricks/types/user"; -import { updateUserAction } from "../actions"; +import { resetPasswordAction, updateUserAction } from "../actions"; import { EditProfileDetailsForm } from "./EditProfileDetailsForm"; const mockUser = { @@ -24,6 +24,8 @@ const mockUser = { objective: "other", } as unknown as TUser; +vi.mock("next-auth/react", () => ({ signOut: vi.fn() })); + // Mock window.location.reload const originalLocation = window.location; beforeEach(() => { @@ -35,6 +37,11 @@ beforeEach(() => { vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({ updateUserAction: vi.fn(), + resetPasswordAction: vi.fn(), +})); + +vi.mock("@/modules/auth/forgot-password/actions", () => ({ + forgotPasswordAction: vi.fn(), })); afterEach(() => { @@ -50,7 +57,13 @@ describe("EditProfileDetailsForm", () => { test("renders with initial user data and updates successfully", async () => { vi.mocked(updateUserAction).mockResolvedValue({ ...mockUser, name: "New Name" } as any); - render(); + render( + + ); const nameInput = screen.getByPlaceholderText("common.full_name"); expect(nameInput).toHaveValue(mockUser.name); @@ -91,7 +104,13 @@ describe("EditProfileDetailsForm", () => { const errorMessage = "Update failed"; vi.mocked(updateUserAction).mockRejectedValue(new Error(errorMessage)); - render(); + render( + + ); const nameInput = screen.getByPlaceholderText("common.full_name"); await userEvent.clear(nameInput); @@ -109,7 +128,13 @@ describe("EditProfileDetailsForm", () => { }); test("update button is disabled initially and enables on change", async () => { - render(); + render( + + ); const updateButton = screen.getByText("common.update"); expect(updateButton).toBeDisabled(); @@ -117,4 +142,68 @@ describe("EditProfileDetailsForm", () => { await userEvent.type(nameInput, " updated"); expect(updateButton).toBeEnabled(); }); + + test("reset password button works", async () => { + vi.mocked(resetPasswordAction).mockResolvedValue({ data: { success: true } }); + + render( + + ); + + const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" }); + await userEvent.click(resetButton); + + await waitFor(() => { + expect(resetPasswordAction).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith("auth.forgot-password.email-sent.heading"); + }); + }); + + test("reset password button handles error correctly", async () => { + const errorMessage = "Reset failed"; + vi.mocked(resetPasswordAction).mockResolvedValue({ serverError: errorMessage }); + + render( + + ); + + const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" }); + await userEvent.click(resetButton); + + await waitFor(() => { + expect(resetPasswordAction).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith(errorMessage); + }); + }); + + test("reset password button shows loading state", async () => { + vi.mocked(resetPasswordAction).mockImplementation(() => new Promise(() => {})); // Never resolves + + render( + + ); + + const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" }); + await userEvent.click(resetButton); + + expect(resetButton).toBeDisabled(); + }); }); 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 2b794c20cf..8c85a2d780 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 @@ -14,6 +14,7 @@ import { } from "@/modules/ui/components/dropdown-menu"; import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form"; import { Input } from "@/modules/ui/components/input"; +import { Label } from "@/modules/ui/components/label"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslate } from "@tolgee/react"; import { ChevronDownIcon } from "lucide-react"; @@ -22,7 +23,7 @@ import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; import toast from "react-hot-toast"; import { z } from "zod"; import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user"; -import { updateUserAction } from "../actions"; +import { resetPasswordAction, updateUserAction } from "../actions"; // Schema & types const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true }).extend({ @@ -30,13 +31,17 @@ const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: }); type TEditProfileNameForm = z.infer; +interface IEditProfileDetailsFormProps { + user: TUser; + isPasswordResetEnabled?: boolean; + emailVerificationDisabled: boolean; +} + export const EditProfileDetailsForm = ({ user, + isPasswordResetEnabled, emailVerificationDisabled, -}: { - user: TUser; - emailVerificationDisabled: boolean; -}) => { +}: IEditProfileDetailsFormProps) => { const { t } = useTranslate(); const form = useForm({ @@ -50,6 +55,8 @@ export const EditProfileDetailsForm = ({ }); const { isSubmitting, isDirty } = form.formState; + + const [isResettingPassword, setIsResettingPassword] = useState(false); const [showModal, setShowModal] = useState(false); const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email }); @@ -90,6 +97,7 @@ export const EditProfileDetailsForm = ({ redirectUrl: "/email-change-without-verification-success", redirect: true, callbackUrl: "/email-change-without-verification-success", + clearEnvironmentId: true, }); return; } @@ -121,6 +129,28 @@ export const EditProfileDetailsForm = ({ } }; + const handleResetPassword = async () => { + setIsResettingPassword(true); + + const result = await resetPasswordAction(); + if (result?.data) { + toast.success(t("auth.forgot-password.email-sent.heading")); + + await signOutWithAudit({ + reason: "password_reset", + redirectUrl: "/auth/login", + redirect: true, + callbackUrl: "/auth/login", + clearEnvironmentId: true, + }); + } else { + const errorMessage = getFormattedErrorMessage(result); + toast.error(t(errorMessage)); + } + + setIsResettingPassword(false); + }; + return ( <> @@ -205,6 +235,26 @@ export const EditProfileDetailsForm = ({ )} /> + {isPasswordResetEnabled && ( +
+ +

+ {t("auth.forgot-password.reset_password_description")} +

+
+ + +
+
+ )} + + ); + } + + if (plan.id === projectFeatureKeys.STARTUP) { if (organization.billing.plan === projectFeatureKeys.FREE) { return ( ); } @@ -100,15 +105,20 @@ export const PricingCard = ({ ); } - return <>; + return null; }, [ isCurrentPlan, loading, onUpgrade, organization.billing.plan, + plan.CTA, + plan.featured, + plan.href, plan.id, projectFeatureKeys.ENTERPRISE, projectFeatureKeys.FREE, + projectFeatureKeys.STARTUP, + t, ]); return ( @@ -147,7 +157,7 @@ export const PricingCard = ({ : plan.price.yearly : t(plan.price.monthly)}

- {plan.name !== "Enterprise" && ( + {plan.id !== projectFeatureKeys.ENTERPRISE && (

/ {planPeriod === "monthly" ? "Month" : "Year"} @@ -171,16 +181,9 @@ export const PricingCard = ({ {t("environments.settings.billing.manage_subscription")} )} - - {organization.billing.plan !== plan.id && plan.id === projectFeatureKeys.ENTERPRISE && ( - - )}