From 12aa959f50869fd16ca8b9dec2038d0f42087f71 Mon Sep 17 00:00:00 2001 From: Matti Nannt Date: Wed, 21 May 2025 06:13:31 +0200 Subject: [PATCH 1/6] fix: slow responses query slowing down database (#5846) --- apps/web/locales/en-US.json | 2 +- apps/web/locales/pt-PT.json | 2 +- .../v2/management/responses/lib/response.ts | 13 +++---- .../responses/lib/tests/response.test.ts | 37 ++++++++++++++----- .../migration.sql | 2 + packages/database/schema.prisma | 1 + 6 files changed, 37 insertions(+), 20 deletions(-) create mode 100644 packages/database/migration/20250520163831_add_created_at_index_to_responses/migration.sql diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index 4e845f829a..01bff6866e 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -82,7 +82,7 @@ "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", "resend_verification_email": "Resend verification email", - "verification_email_successfully_sent": "Verification email successfully sent. Please check your inbox.", + "verification_email_successfully_sent": "Verification email sent to {email}. Please verify to complete the update.", "we_sent_an_email_to": "We sent an email to {email}. ", "you_didnt_receive_an_email_or_your_link_expired": "You didn't receive an email or your link expired?" }, diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 4cb0be4996..e0f00e4457 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -82,7 +82,7 @@ "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", "resend_verification_email": "Reenviar email de verificação", - "verification_email_successfully_sent": "Email de verificação enviado com sucesso. Por favor, verifique a sua caixa de entrada.", + "verification_email_successfully_sent": "Email de verificação enviado para {email}. Por favor, verifique para completar a atualização.", "we_sent_an_email_to": "Enviámos um email para {email}. ", "you_didnt_receive_an_email_or_your_link_expired": "Não recebeu um email ou o seu link expirou?" }, diff --git a/apps/web/modules/api/v2/management/responses/lib/response.ts b/apps/web/modules/api/v2/management/responses/lib/response.ts index c64fb607cc..0dc4a2eb76 100644 --- a/apps/web/modules/api/v2/management/responses/lib/response.ts +++ b/apps/web/modules/api/v2/management/responses/lib/response.ts @@ -135,14 +135,11 @@ export const getResponses = async ( ): Promise, ApiErrorResponseV2>> => { try { const query = getResponsesQuery(environmentIds, params); + const whereClause = query.where; - const [responses, count] = await prisma.$transaction([ - prisma.response.findMany({ - ...query, - }), - prisma.response.count({ - where: query.where, - }), + const [responses, totalCount] = await Promise.all([ + prisma.response.findMany(query), + prisma.response.count({ where: whereClause }), ]); if (!responses) { @@ -152,7 +149,7 @@ export const getResponses = async ( return ok({ data: responses, meta: { - total: count, + total: totalCount, limit: params.limit, offset: params.skip, }, diff --git a/apps/web/modules/api/v2/management/responses/lib/tests/response.test.ts b/apps/web/modules/api/v2/management/responses/lib/tests/response.test.ts index ddeda79802..07d3b5dfcb 100644 --- a/apps/web/modules/api/v2/management/responses/lib/tests/response.test.ts +++ b/apps/web/modules/api/v2/management/responses/lib/tests/response.test.ts @@ -214,17 +214,18 @@ describe("Response Lib", () => { describe("getResponses", () => { test("return responses with meta information", async () => { - const responses = [response]; - prisma.$transaction = vi.fn().mockResolvedValue([responses, responses.length]); + (prisma.response.findMany as any).mockResolvedValue([response]); + (prisma.response.count as any).mockResolvedValue(1); - const result = await getResponses(environmentId, responseFilter); - expect(prisma.$transaction).toHaveBeenCalled(); + const result = await getResponses([environmentId], responseFilter); + expect(prisma.response.findMany).toHaveBeenCalled(); + expect(prisma.response.count).toHaveBeenCalled(); expect(result.ok).toBe(true); if (result.ok) { expect(result.data).toEqual({ data: [response], meta: { - total: responses.length, + total: 1, limit: responseFilter.limit, offset: responseFilter.skip, }, @@ -233,9 +234,10 @@ describe("Response Lib", () => { }); test("return a not_found error if responses are not found", async () => { - prisma.$transaction = vi.fn().mockResolvedValue([null, 0]); + (prisma.response.findMany as any).mockResolvedValue(null); + (prisma.response.count as any).mockResolvedValue(0); - const result = await getResponses(environmentId, responseFilter); + const result = await getResponses([environmentId], responseFilter); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toEqual({ @@ -245,10 +247,25 @@ describe("Response Lib", () => { } }); - test("return an internal_server_error error if prisma transaction fails", async () => { - prisma.$transaction = vi.fn().mockRejectedValue(new Error("Internal server error")); + test("return an internal_server_error error if prisma findMany fails", async () => { + (prisma.response.findMany as any).mockRejectedValue(new Error("Internal server error")); + (prisma.response.count as any).mockResolvedValue(0); - const result = await getResponses(environmentId, responseFilter); + const result = await getResponses([environmentId], responseFilter); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toEqual({ + type: "internal_server_error", + details: [{ field: "responses", issue: "Internal server error" }], + }); + } + }); + + test("return an internal_server_error error if prisma count fails", async () => { + (prisma.response.findMany as any).mockResolvedValue([response]); + (prisma.response.count as any).mockRejectedValue(new Error("Internal server error")); + + const result = await getResponses([environmentId], responseFilter); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toEqual({ diff --git a/packages/database/migration/20250520163831_add_created_at_index_to_responses/migration.sql b/packages/database/migration/20250520163831_add_created_at_index_to_responses/migration.sql new file mode 100644 index 0000000000..17cde7c9ce --- /dev/null +++ b/packages/database/migration/20250520163831_add_created_at_index_to_responses/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "Response_created_at_idx" ON "Response"("created_at"); diff --git a/packages/database/schema.prisma b/packages/database/schema.prisma index 69da7dd129..0b0e20be89 100644 --- a/packages/database/schema.prisma +++ b/packages/database/schema.prisma @@ -172,6 +172,7 @@ model Response { display Display? @relation(fields: [displayId], references: [id]) @@unique([surveyId, singleUseId]) + @@index([createdAt]) @@index([surveyId, createdAt]) // to determine monthly response count @@index([contactId, createdAt]) // to determine monthly identified users (persons) @@index([surveyId]) From 15279685f76998883b34accfbb0a4bd85d2d9261 Mon Sep 17 00:00:00 2001 From: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com> Date: Wed, 21 May 2025 09:53:05 +0530 Subject: [PATCH 2/6] fix: delete pre-filled value (#5839) --- .../request-verification-email.test.tsx | 81 +++++++++++++++++++ .../components/request-verification-email.tsx | 2 +- .../general/question-conditional.test.tsx | 22 ++--- .../general/question-conditional.tsx | 16 ++-- 4 files changed, 103 insertions(+), 18 deletions(-) create mode 100644 apps/web/modules/auth/verification-requested/components/request-verification-email.test.tsx diff --git a/apps/web/modules/auth/verification-requested/components/request-verification-email.test.tsx b/apps/web/modules/auth/verification-requested/components/request-verification-email.test.tsx new file mode 100644 index 0000000000..a4857e7571 --- /dev/null +++ b/apps/web/modules/auth/verification-requested/components/request-verification-email.test.tsx @@ -0,0 +1,81 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import toast from "react-hot-toast"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { resendVerificationEmailAction } from "../actions"; +import { RequestVerificationEmail } from "./request-verification-email"; + +// Mock dependencies +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string, params?: { email?: string }) => { + if (key === "auth.verification-requested.no_email_provided") { + return "No email provided"; + } + if (key === "auth.verification-requested.verification_email_successfully_sent") { + return `Verification email sent to ${params?.email}`; + } + if (key === "auth.verification-requested.resend_verification_email") { + return "Resend verification email"; + } + return key; + }, + }), +})); + +vi.mock("react-hot-toast", () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("../actions", () => ({ + resendVerificationEmailAction: vi.fn(), +})); + +describe("RequestVerificationEmail", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("renders resend verification email button", () => { + render(); + expect(screen.getByText("Resend verification email")).toBeInTheDocument(); + }); + + test("shows error toast when no email is provided", async () => { + render(); + const button = screen.getByText("Resend verification email"); + await fireEvent.click(button); + expect(toast.error).toHaveBeenCalledWith("No email provided"); + }); + + test("shows success toast when verification email is sent successfully", async () => { + const mockEmail = "test@example.com"; + vi.mocked(resendVerificationEmailAction).mockResolvedValueOnce({ data: true }); + + render(); + const button = screen.getByText("Resend verification email"); + await fireEvent.click(button); + + expect(resendVerificationEmailAction).toHaveBeenCalledWith({ email: mockEmail }); + expect(toast.success).toHaveBeenCalledWith(`Verification email sent to ${mockEmail}`); + }); + + test("reloads page when visibility changes to visible", () => { + const mockReload = vi.fn(); + Object.defineProperty(window, "location", { + value: { reload: mockReload }, + writable: true, + }); + + render(); + + // Simulate visibility change + document.dispatchEvent(new Event("visibilitychange")); + + expect(mockReload).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/modules/auth/verification-requested/components/request-verification-email.tsx b/apps/web/modules/auth/verification-requested/components/request-verification-email.tsx index 0e8f9d672c..8670f5e7dd 100644 --- a/apps/web/modules/auth/verification-requested/components/request-verification-email.tsx +++ b/apps/web/modules/auth/verification-requested/components/request-verification-email.tsx @@ -31,7 +31,7 @@ export const RequestVerificationEmail = ({ email }: RequestVerificationEmailProp if (!email) return toast.error(t("auth.verification-requested.no_email_provided")); const response = await resendVerificationEmailAction({ email }); if (response?.data) { - toast.success(t("auth.verification-requested.verification_email_successfully_sent")); + toast.success(t("auth.verification-requested.verification_email_successfully_sent", { email })); } else { const errorMessage = getFormattedErrorMessage(response); toast.error(errorMessage); diff --git a/packages/surveys/src/components/general/question-conditional.test.tsx b/packages/surveys/src/components/general/question-conditional.test.tsx index 2225aa88f1..69a1ecad79 100644 --- a/packages/surveys/src/components/general/question-conditional.test.tsx +++ b/packages/surveys/src/components/general/question-conditional.test.tsx @@ -1,6 +1,6 @@ import "@testing-library/jest-dom/vitest"; import { render, screen } from "@testing-library/preact"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; import { QuestionConditional } from "./question-conditional"; @@ -40,7 +40,7 @@ describe("QuestionConditional", () => { vi.clearAllMocks(); }); - it("renders OpenText question correctly", () => { + test("renders OpenText question correctly", () => { const question = { id: "q1", type: TSurveyQuestionTypeEnum.OpenText as const, @@ -59,7 +59,7 @@ describe("QuestionConditional", () => { expect(screen.getByPlaceholderText("Type your answer here")).toBeInTheDocument(); }); - it("renders MultipleChoiceSingle question correctly", () => { + test("renders MultipleChoiceSingle question correctly", () => { const question = { id: "q2", type: TSurveyQuestionTypeEnum.MultipleChoiceSingle as const, @@ -81,7 +81,7 @@ describe("QuestionConditional", () => { expect(screen.getByText("Blue")).toBeInTheDocument(); }); - it("handles prefilled values correctly", () => { + test("handles prefilled values correctly", () => { const question = { id: "q1", type: TSurveyQuestionTypeEnum.OpenText as const, @@ -98,7 +98,7 @@ describe("QuestionConditional", () => { @@ -107,7 +107,7 @@ describe("QuestionConditional", () => { expect(mockOnSubmit).toHaveBeenCalledWith({ [question.id]: "John" }, { [question.id]: 0 }); }); - it("renders Rating question correctly", () => { + test("renders Rating question correctly", () => { const question = { id: "q3", type: TSurveyQuestionTypeEnum.Rating as const, @@ -128,7 +128,7 @@ describe("QuestionConditional", () => { expect(screen.getByText("Excellent")).toBeInTheDocument(); }); - it("renders MultipleChoiceMulti question correctly", () => { + test("renders MultipleChoiceMulti question correctly", () => { const question = { id: "q4", type: TSurveyQuestionTypeEnum.MultipleChoiceMulti as const, @@ -150,7 +150,7 @@ describe("QuestionConditional", () => { expect(screen.getByText("Banana")).toBeInTheDocument(); }); - it("renders NPS question correctly", () => { + test("renders NPS question correctly", () => { const question = { id: "q5", type: TSurveyQuestionTypeEnum.NPS as const, @@ -169,7 +169,7 @@ describe("QuestionConditional", () => { expect(screen.getByText("Very likely")).toBeInTheDocument(); }); - it("renders Date question correctly", () => { + test("renders Date question correctly", () => { const question = { id: "q6", type: TSurveyQuestionTypeEnum.Date as const, @@ -186,7 +186,7 @@ describe("QuestionConditional", () => { expect(screen.getByText("When is your birthday?")).toBeInTheDocument(); }); - it("renders PictureSelection question correctly", () => { + test("renders PictureSelection question correctly", () => { const question = { id: "q7", type: TSurveyQuestionTypeEnum.PictureSelection as const, @@ -206,7 +206,7 @@ describe("QuestionConditional", () => { expect(screen.getByText("Choose your favorite picture")).toBeInTheDocument(); }); - it("handles unimplemented question type correctly", () => { + test("handles unimplemented question type correctly", () => { const question: TSurveyQuestion = { id: "invalid", type: TSurveyQuestionTypeEnum.Address, // Address type doesn't have a matching case in the component diff --git a/packages/surveys/src/components/general/question-conditional.tsx b/packages/surveys/src/components/general/question-conditional.tsx index 5f4cd3521a..b0d774a5ea 100644 --- a/packages/surveys/src/components/general/question-conditional.tsx +++ b/packages/surveys/src/components/general/question-conditional.tsx @@ -14,6 +14,7 @@ import { PictureSelectionQuestion } from "@/components/questions/picture-selecti import { RankingQuestion } from "@/components/questions/ranking-question"; import { RatingQuestion } from "@/components/questions/rating-question"; import { getLocalizedValue } from "@/lib/i18n"; +import { useEffect } from "react"; import { type TJsFileUploadParams } from "@formbricks/types/js"; import { type TResponseData, type TResponseDataValue, type TResponseTtc } from "@formbricks/types/responses"; import { type TUploadFileConfig } from "@formbricks/types/storage"; @@ -74,13 +75,16 @@ export function QuestionConditional({ .filter((id): id is TSurveyQuestionChoice["id"] => id !== undefined); }; - if (!value && (prefilledQuestionValue || prefilledQuestionValue === "")) { - if (skipPrefilled) { - onSubmit({ [question.id]: prefilledQuestionValue }, { [question.id]: 0 }); - } else { - onChange({ [question.id]: prefilledQuestionValue }); + useEffect(() => { + if (value === undefined && (prefilledQuestionValue || prefilledQuestionValue === "")) { + if (skipPrefilled) { + onSubmit({ [question.id]: prefilledQuestionValue }, { [question.id]: 0 }); + } else { + onChange({ [question.id]: prefilledQuestionValue }); + } } - } + // eslint-disable-next-line react-hooks/exhaustive-deps -- we want to run this only once when the question renders for the first time + }, []); return question.type === TSurveyQuestionTypeEnum.OpenText ? ( Date: Wed, 21 May 2025 11:03:13 +0530 Subject: [PATCH 3/6] fix: response getting stuck (#5849) --- .../app/api/v2/client/[environmentId]/responses/lib/utils.ts | 2 +- apps/web/locales/de-DE.json | 2 +- apps/web/locales/fr-FR.json | 2 +- apps/web/locales/pt-BR.json | 2 +- apps/web/locales/zh-Hant-TW.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/app/api/v2/client/[environmentId]/responses/lib/utils.ts b/apps/web/app/api/v2/client/[environmentId]/responses/lib/utils.ts index 6ce24c2ab8..2a20085738 100644 --- a/apps/web/app/api/v2/client/[environmentId]/responses/lib/utils.ts +++ b/apps/web/app/api/v2/client/[environmentId]/responses/lib/utils.ts @@ -26,7 +26,7 @@ export const checkSurveyValidity = async ( ); } - if (survey.singleUse?.enabled) { + if (survey.type === "link" && survey.singleUse?.enabled) { if (!responseInput.singleUseId) { return responses.badRequestResponse("Missing single use id", { surveyId: survey.id, diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 04c1517c34..a73addc70f 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -82,7 +82,7 @@ "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", "resend_verification_email": "Bestätigungs-E-Mail erneut senden", - "verification_email_successfully_sent": "Bestätigungs-E-Mail erfolgreich gesendet. Bitte überprüfe dein Postfach.", + "verification_email_successfully_sent": "Bestätigungs-E-Mail an {email} gesendet. Bitte überprüfen Sie, um das Update abzuschließen.", "we_sent_an_email_to": "Wir haben eine E-Mail an {email} gesendet", "you_didnt_receive_an_email_or_your_link_expired": "Hast Du keine E-Mail erhalten oder ist dein Link abgelaufen?" }, diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 06a52303a9..e9a9efce50 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -82,7 +82,7 @@ "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.", "resend_verification_email": "Renvoyer l'email de vérification", - "verification_email_successfully_sent": "Email de vérification envoyé avec succès. Veuillez vérifier votre boîte de réception.", + "verification_email_successfully_sent": "Email de vérification envoyé à {email}. Veuillez vérifier pour compléter la mise à jour.", "we_sent_an_email_to": "Nous avons envoyé un email à {email}", "you_didnt_receive_an_email_or_your_link_expired": "Vous n'avez pas reçu d'email ou votre lien a expiré ?" }, diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 94baa75a99..1d84874ca0 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -82,7 +82,7 @@ "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", "resend_verification_email": "Reenviar e-mail de verificação", - "verification_email_successfully_sent": "Email de verificação enviado com sucesso. Por favor, verifique sua caixa de entrada.", + "verification_email_successfully_sent": "E-mail de verificação enviado para {email}. Verifique para concluir a atualização.", "we_sent_an_email_to": "Enviamos um email para {email}", "you_didnt_receive_an_email_or_your_link_expired": "Você não recebeu um e-mail ou seu link expirou?" }, diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 766bfdb91f..28f8498df9 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -82,7 +82,7 @@ "please_click_the_link_in_the_email_to_activate_your_account": "請點擊電子郵件中的連結以啟用您的帳戶。", "please_confirm_your_email_address": "請確認您的電子郵件地址", "resend_verification_email": "重新發送驗證電子郵件", - "verification_email_successfully_sent": "驗證電子郵件已成功發送。請檢查您的收件匣。", + "verification_email_successfully_sent": "验证电子邮件已发送至 {email}。请验证以完成更新。", "we_sent_an_email_to": "我們已發送一封電子郵件至 '{'email'}'。", "you_didnt_receive_an_email_or_your_link_expired": "您沒有收到電子郵件或您的連結已過期?" }, From 0e7f3adf53afec9b068e019dc8741dac85add597 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 21 May 2025 05:49:18 +0000 Subject: [PATCH 4/6] feat: Make session maxAge configurable with environment variable (#5830) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Matti Nannt Co-authored-by: Matthias Nannt Co-authored-by: Piyush Gupta --- .env.example | 5 ++++- .../environments/[environmentId]/layout.test.tsx | 1 + .../[organizationId]/landing/layout.test.tsx | 1 + .../organizations/[organizationId]/landing/page.test.tsx | 1 + .../organizations/[organizationId]/layout.test.tsx | 1 + .../[organizationId]/projects/new/layout.test.tsx | 1 + .../(contacts)/contacts/[contactId]/page.test.tsx | 1 + .../[environmentId]/integrations/airtable/page.test.tsx | 1 + .../notion/components/NotionWrapper.test.tsx | 1 + .../project/(setup)/app-connection/page.test.tsx | 1 + .../[environmentId]/project/general/page.test.tsx | 1 + .../[environmentId]/project/languages/page.test.tsx | 1 + .../[environmentId]/project/look/page.test.tsx | 1 + .../[environmentId]/project/tags/page.test.tsx | 1 + .../[environmentId]/project/teams/page.test.tsx | 1 + .../[environmentId]/settings/(account)/layout.test.tsx | 1 + .../settings/(organization)/teams/page.test.tsx | 1 + .../components/SurveyAnalysisNavigation.test.tsx | 1 + .../summary/components/SurveyAnalysisCTA.test.tsx | 1 + apps/web/app/(app)/layout.test.tsx | 1 + apps/web/lib/constants.ts | 2 ++ apps/web/lib/env.ts | 2 ++ apps/web/modules/auth/invite/page.test.tsx | 1 + apps/web/modules/auth/lib/authOptions.ts | 9 +++++++-- .../settings/api-keys/components/api-key-list.test.tsx | 1 + .../organization/settings/teams/tests/actions.test.ts | 1 + .../organization/[organizationId]/invite/page.test.tsx | 1 + apps/web/modules/setup/organization/create/page.test.tsx | 1 + .../survey/editor/components/end-screen-form.test.tsx | 1 + .../modules/survey/link/components/verify-email.test.tsx | 1 + .../modules/survey/list/components/survey-card.test.tsx | 1 + .../survey/list/components/survey-filters.test.tsx | 1 + docker/docker-compose.yml | 3 +++ .../self-hosting/configuration/environment-variables.mdx | 1 + turbo.json | 1 + 35 files changed, 48 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 428a8a016b..3d537c29f6 100644 --- a/.env.example +++ b/.env.example @@ -212,4 +212,7 @@ UNKEY_ROOT_KEY= # SENTRY_AUTH_TOKEN= # Configure the minimum role for user management from UI(owner, manager, disabled) -# USER_MANAGEMENT_MINIMUM_ROLE="manager" \ No newline at end of file +# USER_MANAGEMENT_MINIMUM_ROLE="manager" + +# Configure the maximum age for the session in seconds. Default is 43200 (12 hours) +# SESSION_MAX_AGE=43200 diff --git a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/layout.test.tsx b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/layout.test.tsx index d6c33fa0a5..bd4d24d3c3 100644 --- a/apps/web/app/(app)/(onboarding)/environments/[environmentId]/layout.test.tsx +++ b/apps/web/app/(app)/(onboarding)/environments/[environmentId]/layout.test.tsx @@ -85,6 +85,7 @@ vi.mock("@/lib/constants", () => ({ OIDC_AUTH_URL: "https://mock-oidc-auth-url.com", OIDC_ISSUER: "https://mock-oidc-issuer.com", OIDC_SIGNING_ALGORITHM: "RS256", + SESSION_MAX_AGE: 1000, })); vi.mock("next/navigation", () => ({ diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/layout.test.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/layout.test.tsx index ee5c91f00a..b5e4dcd294 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/layout.test.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/layout.test.tsx @@ -88,6 +88,7 @@ vi.mock("@/lib/constants", () => ({ OIDC_AUTH_URL: "https://mock-oidc-auth-url.com", OIDC_ISSUER: "https://mock-oidc-issuer.com", OIDC_SIGNING_ALGORITHM: "RS256", + SESSION_MAX_AGE: 1000, })); vi.mock("@/lib/environment/service"); diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.test.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.test.tsx index 5db73164d2..40f6d65b43 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.test.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/landing/page.test.tsx @@ -97,6 +97,7 @@ vi.mock("@/lib/constants", () => ({ OIDC_AUTH_URL: "https://mock-oidc-auth-url.com", OIDC_ISSUER: "https://mock-oidc-issuer.com", OIDC_SIGNING_ALGORITHM: "RS256", + SESSION_MAX_AGE: 1000, })); vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({ diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.test.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.test.tsx index 46e49eb3e2..39b545c286 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.test.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/layout.test.tsx @@ -34,6 +34,7 @@ vi.mock("@/lib/constants", () => ({ OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", WEBAPP_URL: "test-webapp-url", IS_PRODUCTION: false, + SESSION_MAX_AGE: 1000, })); vi.mock("next-auth", () => ({ diff --git a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/layout.test.tsx b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/layout.test.tsx index 7fc487f2de..bde260ce1b 100644 --- a/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/layout.test.tsx +++ b/apps/web/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/layout.test.tsx @@ -33,6 +33,7 @@ vi.mock("@/lib/constants", () => ({ OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", WEBAPP_URL: "test-webapp-url", IS_PRODUCTION: false, + SESSION_MAX_AGE: 1000, })); // Mock dependencies diff --git a/apps/web/app/(app)/environments/[environmentId]/(contacts)/contacts/[contactId]/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/(contacts)/contacts/[contactId]/page.test.tsx index 65ca595b02..5a1febf9e3 100644 --- a/apps/web/app/(app)/environments/[environmentId]/(contacts)/contacts/[contactId]/page.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/(contacts)/contacts/[contactId]/page.test.tsx @@ -25,6 +25,7 @@ vi.mock("@/lib/constants", () => ({ SMTP_HOST: "mock-smtp-host", SMTP_PORT: "mock-smtp-port", IS_POSTHOG_CONFIGURED: true, + SESSION_MAX_AGE: 1000, })); describe("Contact Page Re-export", () => { diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/page.test.tsx index 6b68a04a7e..f8cf9ede03 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/page.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/page.test.tsx @@ -48,6 +48,7 @@ vi.mock("@/lib/constants", () => ({ OIDC_CLIENT_SECRET: "test-oidc-client-secret", OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", SENTRY_DSN: "mock-sentry-dsn", + SESSION_MAX_AGE: 1000, })); vi.mock("@/lib/integration/service"); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper.test.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper.test.tsx index 633d614fa0..5c977e2eed 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper.test.tsx @@ -31,6 +31,7 @@ vi.mock("@/lib/constants", () => ({ SENTRY_DSN: "mock-sentry-dsn", GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret", GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url", + SESSION_MAX_AGE: 1000, })); // Mock child components diff --git a/apps/web/app/(app)/environments/[environmentId]/project/(setup)/app-connection/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/project/(setup)/app-connection/page.test.tsx index d3581b85ca..63463a437d 100644 --- a/apps/web/app/(app)/environments/[environmentId]/project/(setup)/app-connection/page.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/project/(setup)/app-connection/page.test.tsx @@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({ WEBAPP_URL: "test-webapp-url", IS_PRODUCTION: false, SENTRY_DSN: "mock-sentry-dsn", + SESSION_MAX_AGE: 1000, })); describe("AppConnectionPage Re-export", () => { diff --git a/apps/web/app/(app)/environments/[environmentId]/project/general/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/project/general/page.test.tsx index 43956d5941..3048a24336 100644 --- a/apps/web/app/(app)/environments/[environmentId]/project/general/page.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/project/general/page.test.tsx @@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({ WEBAPP_URL: "test-webapp-url", IS_PRODUCTION: false, SENTRY_DSN: "mock-sentry-dsn", + SESSION_MAX_AGE: 1000, })); describe("GeneralSettingsPage re-export", () => { diff --git a/apps/web/app/(app)/environments/[environmentId]/project/languages/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/project/languages/page.test.tsx index f08a99a2cd..daa874b683 100644 --- a/apps/web/app/(app)/environments/[environmentId]/project/languages/page.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/project/languages/page.test.tsx @@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({ WEBAPP_URL: "test-webapp-url", IS_PRODUCTION: false, SENTRY_DSN: "mock-sentry-dsn", + SESSION_MAX_AGE: 1000, })); describe("LanguagesPage re-export", () => { diff --git a/apps/web/app/(app)/environments/[environmentId]/project/look/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/project/look/page.test.tsx index 0e0acc9735..2e4c7604bf 100644 --- a/apps/web/app/(app)/environments/[environmentId]/project/look/page.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/project/look/page.test.tsx @@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({ WEBAPP_URL: "test-webapp-url", IS_PRODUCTION: false, SENTRY_DSN: "mock-sentry-dsn", + SESSION_MAX_AGE: 1000, })); describe("ProjectLookSettingsPage re-export", () => { diff --git a/apps/web/app/(app)/environments/[environmentId]/project/tags/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/project/tags/page.test.tsx index 024d89a90d..06b24b9e34 100644 --- a/apps/web/app/(app)/environments/[environmentId]/project/tags/page.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/project/tags/page.test.tsx @@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({ WEBAPP_URL: "test-webapp-url", IS_PRODUCTION: false, SENTRY_DSN: "mock-sentry-dsn", + SESSION_MAX_AGE: 1000, })); describe("TagsPage re-export", () => { diff --git a/apps/web/app/(app)/environments/[environmentId]/project/teams/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/project/teams/page.test.tsx index a2ed73bdea..919015e361 100644 --- a/apps/web/app/(app)/environments/[environmentId]/project/teams/page.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/project/teams/page.test.tsx @@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({ WEBAPP_URL: "test-webapp-url", IS_PRODUCTION: false, SENTRY_DSN: "mock-sentry-dsn", + SESSION_MAX_AGE: 1000, })); describe("ProjectTeams re-export", () => { diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/layout.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/layout.test.tsx index b632a2214c..982f4b52ef 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/layout.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/layout.test.tsx @@ -40,6 +40,7 @@ vi.mock("@/lib/constants", () => ({ WEBAPP_URL: "test-webapp-url", IS_PRODUCTION: false, SENTRY_DSN: "mock-sentry-dsn", + SESSION_MAX_AGE: 1000, })); const mockGetOrganizationByEnvironmentId = vi.mocked(getOrganizationByEnvironmentId); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/teams/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/teams/page.test.tsx index 596f921133..a2f442a574 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/teams/page.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/teams/page.test.tsx @@ -29,6 +29,7 @@ vi.mock("@/lib/constants", () => ({ SMTP_PORT: 587, SMTP_USER: "mock-smtp-user", SMTP_PASSWORD: "mock-smtp-password", + SESSION_MAX_AGE: 1000, })); describe("TeamsPage re-export", () => { diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation.test.tsx index ba27ba9d66..ca4bbe8775 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation.test.tsx @@ -45,6 +45,7 @@ vi.mock("@/lib/constants", () => ({ SMTP_PORT: 587, SMTP_USER: "mock-smtp-user", SMTP_PASSWORD: "mock-smtp-password", + SESSION_MAX_AGE: 1000, })); vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext"); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx index 00955153d4..e5f9f7961a 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx @@ -30,6 +30,7 @@ vi.mock("@/lib/constants", () => ({ SMTP_HOST: "mock-smtp-host", SMTP_PORT: "mock-smtp-port", IS_POSTHOG_CONFIGURED: true, + SESSION_MAX_AGE: 1000, })); // Create a spy for refreshSingleUseId so we can override it in tests diff --git a/apps/web/app/(app)/layout.test.tsx b/apps/web/app/(app)/layout.test.tsx index eaf82442a8..02f6c2dbfe 100644 --- a/apps/web/app/(app)/layout.test.tsx +++ b/apps/web/app/(app)/layout.test.tsx @@ -38,6 +38,7 @@ vi.mock("@/lib/constants", () => ({ POSTHOG_API_KEY: "test-posthog-api-key", FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id", IS_FORMBRICKS_ENABLED: true, + SESSION_MAX_AGE: 1000, })); vi.mock("@/app/intercom/IntercomClientWrapper", () => ({ diff --git a/apps/web/lib/constants.ts b/apps/web/lib/constants.ts index b72f0d8901..440d5f5cbf 100644 --- a/apps/web/lib/constants.ts +++ b/apps/web/lib/constants.ts @@ -283,3 +283,5 @@ export const SENTRY_DSN = env.SENTRY_DSN; export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1"; export const USER_MANAGEMENT_MINIMUM_ROLE = env.USER_MANAGEMENT_MINIMUM_ROLE ?? "manager"; + +export const SESSION_MAX_AGE = Number(env.SESSION_MAX_AGE) || 86400; diff --git a/apps/web/lib/env.ts b/apps/web/lib/env.ts index 56d1ce8b7b..2ec0ea4cce 100644 --- a/apps/web/lib/env.ts +++ b/apps/web/lib/env.ts @@ -105,6 +105,7 @@ export const env = createEnv({ PROMETHEUS_EXPORTER_PORT: z.string().optional(), PROMETHEUS_ENABLED: z.enum(["1", "0"]).optional(), USER_MANAGEMENT_MINIMUM_ROLE: z.enum(["owner", "manager", "disabled"]).optional(), + SESSION_MAX_AGE: z.string().transform((val) => parseInt(val)).optional(), }, /* @@ -200,5 +201,6 @@ export const env = createEnv({ PROMETHEUS_ENABLED: process.env.PROMETHEUS_ENABLED, PROMETHEUS_EXPORTER_PORT: process.env.PROMETHEUS_EXPORTER_PORT, USER_MANAGEMENT_MINIMUM_ROLE: process.env.USER_MANAGEMENT_MINIMUM_ROLE, + SESSION_MAX_AGE: process.env.SESSION_MAX_AGE, }, }); diff --git a/apps/web/modules/auth/invite/page.test.tsx b/apps/web/modules/auth/invite/page.test.tsx index ce4600cbb1..a873ad9e30 100644 --- a/apps/web/modules/auth/invite/page.test.tsx +++ b/apps/web/modules/auth/invite/page.test.tsx @@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({ FB_LOGO_URL: "https://formbricks.com/logo.png", SMTP_HOST: "smtp.example.com", SMTP_PORT: "587", + SESSION_MAX_AGE: 1000, })); vi.mock("next-auth", () => ({ diff --git a/apps/web/modules/auth/lib/authOptions.ts b/apps/web/modules/auth/lib/authOptions.ts index 83d55ac541..16ed34fedd 100644 --- a/apps/web/modules/auth/lib/authOptions.ts +++ b/apps/web/modules/auth/lib/authOptions.ts @@ -1,4 +1,9 @@ -import { EMAIL_VERIFICATION_DISABLED, ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY } from "@/lib/constants"; +import { + EMAIL_VERIFICATION_DISABLED, + ENCRYPTION_KEY, + ENTERPRISE_LICENSE_KEY, + SESSION_MAX_AGE, +} from "@/lib/constants"; import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto"; import { verifyToken } from "@/lib/jwt"; import { getUserByEmail, updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user"; @@ -178,7 +183,7 @@ export const authOptions: NextAuthOptions = { ...(ENTERPRISE_LICENSE_KEY ? getSSOProviders() : []), ], session: { - maxAge: 3600, + maxAge: SESSION_MAX_AGE, }, callbacks: { async jwt({ token }) { diff --git a/apps/web/modules/organization/settings/api-keys/components/api-key-list.test.tsx b/apps/web/modules/organization/settings/api-keys/components/api-key-list.test.tsx index 7976265e35..cc300e85fe 100644 --- a/apps/web/modules/organization/settings/api-keys/components/api-key-list.test.tsx +++ b/apps/web/modules/organization/settings/api-keys/components/api-key-list.test.tsx @@ -30,6 +30,7 @@ vi.mock("@/lib/constants", () => ({ OIDC_CLIENT_SECRET: "test-oidc-client-secret", OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", WEBAPP_URL: "test-webapp-url", + SESSION_MAX_AGE: 1000, })); // Mock @/lib/env diff --git a/apps/web/modules/organization/settings/teams/tests/actions.test.ts b/apps/web/modules/organization/settings/teams/tests/actions.test.ts index f0af80431d..bf81754026 100644 --- a/apps/web/modules/organization/settings/teams/tests/actions.test.ts +++ b/apps/web/modules/organization/settings/teams/tests/actions.test.ts @@ -122,6 +122,7 @@ vi.mock("@/lib/constants", () => ({ SAML_DATABASE_URL: "test-saml-db-url", NEXTAUTH_SECRET: "test-nextauth-secret", WEBAPP_URL: "http://localhost:3000", + SESSION_MAX_AGE: 1000, })); describe("Organization Settings Teams Actions", () => { diff --git a/apps/web/modules/setup/organization/[organizationId]/invite/page.test.tsx b/apps/web/modules/setup/organization/[organizationId]/invite/page.test.tsx index 65501963af..4571adcddb 100644 --- a/apps/web/modules/setup/organization/[organizationId]/invite/page.test.tsx +++ b/apps/web/modules/setup/organization/[organizationId]/invite/page.test.tsx @@ -54,6 +54,7 @@ vi.mock("@/lib/constants", () => ({ TURNSTILE_SITE_KEY: "test-turnstile-site-key", SAML_OAUTH_ENABLED: true, SMTP_PASSWORD: "smtp-password", + SESSION_MAX_AGE: 1000, })); // Mock the InviteMembers component diff --git a/apps/web/modules/setup/organization/create/page.test.tsx b/apps/web/modules/setup/organization/create/page.test.tsx index e8eaaddcc6..0185cb0cf0 100644 --- a/apps/web/modules/setup/organization/create/page.test.tsx +++ b/apps/web/modules/setup/organization/create/page.test.tsx @@ -56,6 +56,7 @@ vi.mock("@/lib/constants", () => ({ TURNSTILE_SITE_KEY: "test-turnstile-site-key", SAML_OAUTH_ENABLED: true, SMTP_PASSWORD: "smtp-password", + SESSION_MAX_AGE: 1000, })); // Mock the CreateOrganization component diff --git a/apps/web/modules/survey/editor/components/end-screen-form.test.tsx b/apps/web/modules/survey/editor/components/end-screen-form.test.tsx index ea9ea63bb1..74452c20be 100644 --- a/apps/web/modules/survey/editor/components/end-screen-form.test.tsx +++ b/apps/web/modules/survey/editor/components/end-screen-form.test.tsx @@ -49,6 +49,7 @@ vi.mock("@/lib/constants", () => ({ SMTP_HOST: "mock-smtp-host", SMTP_PORT: "mock-smtp-port", IS_POSTHOG_CONFIGURED: true, + SESSION_MAX_AGE: 1000, })); vi.mock("@tolgee/react", () => ({ diff --git a/apps/web/modules/survey/link/components/verify-email.test.tsx b/apps/web/modules/survey/link/components/verify-email.test.tsx index b457ecc6c2..3bcac28b07 100644 --- a/apps/web/modules/survey/link/components/verify-email.test.tsx +++ b/apps/web/modules/survey/link/components/verify-email.test.tsx @@ -37,6 +37,7 @@ vi.mock("@/lib/constants", () => ({ SMTP_PORT: 587, SMTP_USERNAME: "user@example.com", SMTP_PASSWORD: "password", + SESSION_MAX_AGE: 1000, })); vi.mock("@/modules/survey/link/actions"); diff --git a/apps/web/modules/survey/list/components/survey-card.test.tsx b/apps/web/modules/survey/list/components/survey-card.test.tsx index 3bbf085d67..a9583e8ddc 100644 --- a/apps/web/modules/survey/list/components/survey-card.test.tsx +++ b/apps/web/modules/survey/list/components/survey-card.test.tsx @@ -25,6 +25,7 @@ vi.mock("@/lib/constants", () => ({ FB_LOGO_URL: "https://example.com/mock-logo.png", SMTP_HOST: "mock-smtp-host", SMTP_PORT: "mock-smtp-port", + SESSION_MAX_AGE: 1000, })); describe("SurveyCard", () => { diff --git a/apps/web/modules/survey/list/components/survey-filters.test.tsx b/apps/web/modules/survey/list/components/survey-filters.test.tsx index 7cec105530..4dbc5e392d 100644 --- a/apps/web/modules/survey/list/components/survey-filters.test.tsx +++ b/apps/web/modules/survey/list/components/survey-filters.test.tsx @@ -36,6 +36,7 @@ vi.mock("@/lib/constants", () => ({ WEBAPP_URL: "https://example.com", ENCRYPTION_KEY: "mock-encryption-key", ENTERPRISE_LICENSE_KEY: "mock-license-key", + SESSION_MAX_AGE: 1000, })); // Track the callback for useDebounce to better control when it fires diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index f61fa9bde5..3d26326978 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -190,6 +190,9 @@ x-environment: &environment # Configure the minimum role for user management from UI(owner, manager, disabled) # USER_MANAGEMENT_MINIMUM_ROLE="manager" + # Configure the maximum age for the session in seconds. Default is 43200 (12 hours) + # SESSION_MAX_AGE=43200 + services: postgres: restart: always diff --git a/docs/self-hosting/configuration/environment-variables.mdx b/docs/self-hosting/configuration/environment-variables.mdx index 7095adb5c3..debdf80648 100644 --- a/docs/self-hosting/configuration/environment-variables.mdx +++ b/docs/self-hosting/configuration/environment-variables.mdx @@ -69,6 +69,7 @@ These variables are present inside your machine's docker-compose file. Restart t | SURVEY_URL | Set this to change the domain of the survey. | optional | WEBAPP_URL | | SENTRY_DSN | Set this to track errors and monitor performance in Sentry. | optional | | SENTRY_AUTH_TOKEN | Set this if you want to make errors more readable in Sentry. | optional | +| SESSION_MAX_AGE | Configure the maximum age for the session in seconds. | optional | 86400 (24 hours) | | USER_MANAGEMENT_MINIMUM_ROLE | Set this to control which roles can access user management features. Accepted values: "owner", "manager", "disabled" | optional | manager | Note: If you want to configure something that is not possible via above, please open an issue on our GitHub repo here or reach out to us on Github Discussions and we'll try our best to work out a solution with you. diff --git a/turbo.json b/turbo.json index 46855cc06d..6d01827bf3 100644 --- a/turbo.json +++ b/turbo.json @@ -139,6 +139,7 @@ "S3_REGION", "S3_SECRET_KEY", "SAML_DATABASE_URL", + "SESSION_MAX_AGE", "SENTRY_DSN", "SLACK_CLIENT_ID", "SLACK_CLIENT_SECRET", From 745f5487e9eccaee693a66dee68f4b86ceea4d6e Mon Sep 17 00:00:00 2001 From: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Date: Wed, 21 May 2025 11:50:40 +0530 Subject: [PATCH 5/6] fix: tweaks in open text question (#5841) Co-authored-by: Piyush Gupta --- .../questions/open-text-question.tsx | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/surveys/src/components/questions/open-text-question.tsx b/packages/surveys/src/components/questions/open-text-question.tsx index 1fa8032bb1..64b3f76422 100644 --- a/packages/surveys/src/components/questions/open-text-question.tsx +++ b/packages/surveys/src/components/questions/open-text-question.tsx @@ -58,29 +58,30 @@ export function OpenTextQuestion({ }, [isCurrent, autoFocusEnabled]); const handleInputChange = (inputValue: string) => { + inputRef.current?.setCustomValidity(""); setCurrentLength(inputValue.length); onChange({ [question.id]: inputValue }); }; - const handleInputResize = (event: { target: any }) => { - const maxHeight = 160; // 8 lines - const textarea = event.target; - textarea.style.height = "auto"; - const newHeight = Math.min(textarea.scrollHeight, maxHeight); - textarea.style.height = `${newHeight}px`; - textarea.style.overflow = newHeight >= maxHeight ? "auto" : "hidden"; + const handleOnSubmit = (e: Event) => { + e.preventDefault(); + const input = inputRef.current; + input?.setCustomValidity(""); + + if (question.required && (!value || value.trim() === "")) { + input?.setCustomValidity("Please fill out this field."); + input?.reportValidity(); + return; + } + + // at this point, validity is clean + const updatedTtc = getUpdatedTtc(ttc, question.id, performance.now() - startTime); + setTtc(updatedTtc); + onSubmit({ [question.id]: value }, updatedTtc); }; return ( -
{ - e.preventDefault(); - const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime); - setTtc(updatedttc); - onSubmit({ [question.id]: value }, updatedttc); - }} - className="fb-w-full"> +
{isMediaAvailable ? ( @@ -139,7 +140,6 @@ export function OpenTextQuestion({ value={value} onInput={(e) => { handleInputChange(e.currentTarget.value); - handleInputResize(e); }} className="fb-border-border placeholder:fb-text-placeholder fb-bg-input-bg fb-text-subheading focus:fb-border-brand fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-ring-0 sm:fb-text-sm" title={question.inputType === "phone" ? "Please enter a valid phone number" : undefined} From f7e5ef96d219ad2d0dc851bb7dbf8b0d50edc6b8 Mon Sep 17 00:00:00 2001 From: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com> Date: Wed, 21 May 2025 16:53:12 +0530 Subject: [PATCH 6/6] feat: added email change feature (#5837) Co-authored-by: Paribesh01 Co-authored-by: Paribesh Nepal <100255987+Paribesh01@users.noreply.github.com> --- .env.example | 4 +- .../settings/(account)/profile/actions.ts | 69 ++++- .../EditProfileDetailsForm.test.tsx | 13 +- .../components/EditProfileDetailsForm.tsx | 285 ++++++++++++------ .../password-confirmation-modal.test.tsx | 132 ++++++++ .../password-confirmation-modal.tsx | 117 +++++++ .../(account)/profile/lib/user.test.ts | 146 +++++++++ .../settings/(account)/profile/lib/user.ts | 70 +++++ .../settings/(account)/profile/page.test.tsx | 1 + .../settings/(account)/profile/page.tsx | 4 +- .../page.test.tsx | 20 ++ .../page.tsx | 3 + .../app/(auth)/verify-email-change/page.tsx | 3 + apps/web/lib/jwt.test.ts | 84 ++---- apps/web/lib/jwt.ts | 70 +++-- apps/web/lib/utils/action-client.ts | 4 +- apps/web/locales/de-DE.json | 13 + apps/web/locales/en-US.json | 13 + apps/web/locales/fr-FR.json | 13 + apps/web/locales/pt-BR.json | 13 + apps/web/locales/pt-PT.json | 13 + apps/web/locales/zh-Hant-TW.json | 13 + .../page.test.tsx | 61 ++++ .../page.tsx | 29 ++ .../page.tsx | 22 +- .../auth/verify-email-change/actions.ts | 21 ++ .../components/email-change-sign-in.test.tsx | 68 +++++ .../components/email-change-sign-in.tsx | 55 ++++ .../auth/verify-email-change/page.test.tsx | 47 +++ .../modules/auth/verify-email-change/page.tsx | 16 + .../emails/auth/new-email-verification.tsx | 34 +++ apps/web/modules/email/index.tsx | 22 +- docker/docker-compose.yml | 4 +- packages/types/errors.ts | 9 + 34 files changed, 1286 insertions(+), 205 deletions(-) create mode 100644 apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal.test.tsx create mode 100644 apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal.tsx create mode 100644 apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user.test.ts create mode 100644 apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user.ts create mode 100644 apps/web/app/(auth)/email-change-without-verification-success/page.test.tsx create mode 100644 apps/web/app/(auth)/email-change-without-verification-success/page.tsx create mode 100644 apps/web/app/(auth)/verify-email-change/page.tsx create mode 100644 apps/web/modules/auth/email-change-without-verification-success/page.test.tsx create mode 100644 apps/web/modules/auth/email-change-without-verification-success/page.tsx create mode 100644 apps/web/modules/auth/verify-email-change/actions.ts create mode 100644 apps/web/modules/auth/verify-email-change/components/email-change-sign-in.test.tsx create mode 100644 apps/web/modules/auth/verify-email-change/components/email-change-sign-in.tsx create mode 100644 apps/web/modules/auth/verify-email-change/page.test.tsx create mode 100644 apps/web/modules/auth/verify-email-change/page.tsx create mode 100644 apps/web/modules/email/emails/auth/new-email-verification.tsx diff --git a/.env.example b/.env.example index 3d537c29f6..6c5eed3fb2 100644 --- a/.env.example +++ b/.env.example @@ -214,5 +214,5 @@ UNKEY_ROOT_KEY= # Configure the minimum role for user management from UI(owner, manager, disabled) # USER_MANAGEMENT_MINIMUM_ROLE="manager" -# Configure the maximum age for the session in seconds. Default is 43200 (12 hours) -# SESSION_MAX_AGE=43200 +# Configure the maximum age for the session in seconds. Default is 86400 (24 hours) +# SESSION_MAX_AGE=86400 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 9836387b40..3b31d78ce0 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/actions.ts +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/actions.ts @@ -1,17 +1,80 @@ "use server"; +import { + checkUserExistsByEmail, + verifyUserPassword, +} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user"; +import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants"; import { deleteFile } from "@/lib/storage/service"; 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 { sendVerificationNewEmail } from "@/modules/email"; import { z } from "zod"; import { ZId } from "@formbricks/types/common"; -import { ZUserUpdateInput } from "@formbricks/types/user"; +import { + AuthenticationError, + AuthorizationError, + InvalidInputError, + OperationNotAllowedError, + TooManyRequestsError, +} from "@formbricks/types/errors"; +import { TUserUpdateInput, ZUserPassword, ZUserUpdateInput } from "@formbricks/types/user"; + +const limiter = rateLimit({ + interval: 60 * 60, // 1 hour + allowedPerInterval: 3, // max 3 calls for email verification per hour +}); export const updateUserAction = authenticatedActionClient - .schema(ZUserUpdateInput.pick({ name: true, locale: true })) + .schema( + ZUserUpdateInput.pick({ name: true, email: true, locale: true }).extend({ + password: ZUserPassword.optional(), + }) + ) .action(async ({ parsedInput, ctx }) => { - return await updateUser(ctx.user.id, parsedInput); + const inputEmail = parsedInput.email?.trim().toLowerCase(); + + let payload: TUserUpdateInput = { + name: parsedInput.name, + locale: parsedInput.locale, + }; + + if (inputEmail && ctx.user.email !== inputEmail) { + // Check rate limit + try { + await limiter(ctx.user.id); + } catch { + throw new TooManyRequestsError("Too many requests"); + } + if (ctx.user.identityProvider !== "email") { + throw new OperationNotAllowedError("Email update is not allowed for non-credential users."); + } + + if (!parsedInput.password) { + throw new AuthenticationError("Password is required to update email."); + } + + const isCorrectPassword = await verifyUserPassword(ctx.user.id, parsedInput.password); + if (!isCorrectPassword) { + throw new AuthorizationError("Incorrect credentials"); + } + + const doesUserExist = await checkUserExistsByEmail(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); + } + } + + return await updateUser(ctx.user.id, payload); }); const ZUpdateAvatarAction = z.object({ 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 47b14900ad..ea6c290c8b 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 @@ -50,11 +50,10 @@ 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); - expect(screen.getByDisplayValue(mockUser.email)).toBeDisabled(); // Check initial language (English) expect(screen.getByText("English (US)")).toBeInTheDocument(); @@ -72,7 +71,11 @@ describe("EditProfileDetailsForm", () => { await userEvent.click(updateButton); await waitFor(() => { - expect(updateUserAction).toHaveBeenCalledWith({ name: "New Name", locale: "de-DE" }); + expect(updateUserAction).toHaveBeenCalledWith({ + name: "New Name", + locale: "de-DE", + email: mockUser.email, + }); }); await waitFor(() => { expect(toast.success).toHaveBeenCalledWith( @@ -88,7 +91,7 @@ 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); @@ -106,7 +109,7 @@ describe("EditProfileDetailsForm", () => { }); test("update button is disabled initially and enables on change", async () => { - render(); + render(); const updateButton = screen.getByText("common.update"); expect(updateButton).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 c9225594e6..fa27b0dd0e 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 @@ -1,6 +1,8 @@ "use client"; +import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal"; import { appLanguages } from "@/lib/i18n/utils"; +import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { Button } from "@/modules/ui/components/button"; import { DropdownMenu, @@ -8,129 +10,214 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/modules/ui/components/dropdown-menu"; -import { - FormControl, - FormError, - FormField, - FormItem, - FormLabel, - FormProvider, -} from "@/modules/ui/components/form"; +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"; -import { SubmitHandler, useForm } from "react-hook-form"; +import { signOut } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; import toast from "react-hot-toast"; import { z } from "zod"; -import { TUser, ZUser } from "@formbricks/types/user"; +import { TUser, TUserUpdateInput, ZUser } from "@formbricks/types/user"; import { updateUserAction } from "../actions"; -const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true }); +// Schema & types +const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true }); type TEditProfileNameForm = z.infer; -export const EditProfileDetailsForm = ({ user }: { user: TUser }) => { +export const EditProfileDetailsForm = ({ + user, + emailVerificationDisabled, +}: { + user: TUser; + emailVerificationDisabled: boolean; +}) => { + const { t } = useTranslate(); + const router = useRouter(); + const form = useForm({ - defaultValues: { name: user.name, locale: user.locale || "en" }, + defaultValues: { + name: user.name, + locale: user.locale, + email: user.email, + }, mode: "onChange", resolver: zodResolver(ZEditProfileNameFormSchema), }); const { isSubmitting, isDirty } = form.formState; - const { t } = useTranslate(); + const [showModal, setShowModal] = useState(false); + + const handleConfirmPassword = async (password: string) => { + const values = form.getValues(); + const dirtyFields = form.formState.dirtyFields; + + const emailChanged = "email" in dirtyFields; + const nameChanged = "name" in dirtyFields; + const localeChanged = "locale" in dirtyFields; + + const name = values.name.trim(); + const email = values.email.trim().toLowerCase(); + const locale = values.locale; + + const data: TUserUpdateInput = {}; + + if (emailChanged) { + data.email = email; + data.password = password; + } + if (nameChanged) { + data.name = name; + } + if (localeChanged) { + data.locale = locale; + } + + const updatedUserResult = await updateUserAction(data); + + if (updatedUserResult?.data) { + if (!emailVerificationDisabled) { + toast.success(t("auth.verification-requested.verification_email_successfully_sent", { email })); + } else { + toast.success(t("environments.settings.profile.profile_updated_successfully")); + await signOut({ redirect: false }); + router.push(`/email-change-without-verification-success`); + return; + } + } else { + const errorMessage = getFormattedErrorMessage(updatedUserResult); + toast.error(errorMessage); + return; + } + + window.location.reload(); + setShowModal(false); + }; const onSubmit: SubmitHandler = async (data) => { - try { - const name = data.name.trim(); - const locale = data.locale; - await updateUserAction({ name, locale }); - toast.success(t("environments.settings.profile.profile_updated_successfully")); - window.location.reload(); - form.reset({ name, locale }); - } catch (error) { - toast.error(`${t("common.error")}: ${error.message}`); + 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 { + try { + await updateUserAction({ + ...data, + name: data.name.trim(), + }); + toast.success(t("environments.settings.profile.profile_updated_successfully")); + window.location.reload(); + form.reset(data); + } catch (error: any) { + toast.error(`${t("common.error")}: ${error.message}`); + } } }; return ( - - - ( - - {t("common.full_name")} - - - - - - )} - /> + <> + + + ( + + {t("common.full_name")} + + + + + + )} + /> - {/* disabled email field */} -
- - -
+ ( + + {t("common.email")} + + + + + + )} + /> - ( - - {t("common.language")} - - - - - - - {appLanguages.map((language) => ( - field.onChange(language.code)} - className="min-h-8 cursor-pointer"> - {language.label[field.value]} - - ))} - - - - - - )} - /> + ( + + {t("common.language")} + + + + + + + {appLanguages.map((lang) => ( + field.onChange(lang.code)} + className="min-h-8 cursor-pointer"> + {lang.label[field.value]} + + ))} + + + + + + )} + /> - - -
+ + +
+ + + ); }; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal.test.tsx new file mode 100644 index 0000000000..d00f95754e --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal.test.tsx @@ -0,0 +1,132 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { PasswordConfirmationModal } from "./password-confirmation-modal"; + +// Mock the Modal component +vi.mock("@/modules/ui/components/modal", () => ({ + Modal: ({ children, open, setOpen, title }: any) => + open ? ( +
+
{title}
+ {children} + +
+ ) : null, +})); + +// Mock the PasswordInput component +vi.mock("@/modules/ui/components/password-input", () => ({ + PasswordInput: ({ onChange, value, placeholder }: any) => ( + onChange(e.target.value)} + placeholder={placeholder} + data-testid="password-input" + /> + ), +})); + +// Mock the useTranslate hook +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, + }), +})); + +describe("PasswordConfirmationModal", () => { + const defaultProps = { + open: true, + setOpen: vi.fn(), + oldEmail: "old@example.com", + newEmail: "new@example.com", + onConfirm: vi.fn(), + }; + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("renders nothing when open is false", () => { + render(); + expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); + }); + + test("renders modal content when open is true", () => { + render(); + expect(screen.getByTestId("modal")).toBeInTheDocument(); + expect(screen.getByTestId("modal-title")).toBeInTheDocument(); + }); + + test("displays old and new email addresses", () => { + render(); + expect(screen.getByText("old@example.com")).toBeInTheDocument(); + expect(screen.getByText("new@example.com")).toBeInTheDocument(); + }); + + test("shows password input field", () => { + render(); + const passwordInput = screen.getByTestId("password-input"); + expect(passwordInput).toBeInTheDocument(); + expect(passwordInput).toHaveAttribute("placeholder", "*******"); + }); + + test("disables confirm button when form is not dirty", () => { + render(); + const confirmButton = screen.getByText("common.confirm"); + expect(confirmButton).toBeDisabled(); + }); + + test("disables confirm button when old and new emails are the same", () => { + render( + + ); + const confirmButton = screen.getByText("common.confirm"); + expect(confirmButton).toBeDisabled(); + }); + + test("enables confirm button when password is entered and emails are different", async () => { + const user = userEvent.setup(); + render(); + + const passwordInput = screen.getByTestId("password-input"); + await user.type(passwordInput, "password123"); + + const confirmButton = screen.getByText("common.confirm"); + expect(confirmButton).not.toBeDisabled(); + }); + + test("shows error message when password is too short", async () => { + const user = userEvent.setup(); + render(); + + const passwordInput = screen.getByTestId("password-input"); + await user.type(passwordInput, "short"); + + const confirmButton = screen.getByText("common.confirm"); + await user.click(confirmButton); + + expect(screen.getByText("String must contain at least 8 character(s)")).toBeInTheDocument(); + }); + + test("handles cancel button click and resets form", async () => { + const user = userEvent.setup(); + render(); + + const passwordInput = screen.getByTestId("password-input"); + await user.type(passwordInput, "password123"); + + const cancelButton = screen.getByText("common.cancel"); + await user.click(cancelButton); + + expect(defaultProps.setOpen).toHaveBeenCalledWith(false); + await waitFor(() => { + expect(passwordInput).toHaveValue(""); + }); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal.tsx new file mode 100644 index 0000000000..ce8db7449f --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { Button } from "@/modules/ui/components/button"; +import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form"; +import { Modal } from "@/modules/ui/components/modal"; +import { PasswordInput } from "@/modules/ui/components/password-input"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslate } from "@tolgee/react"; +import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; +import { z } from "zod"; +import { ZUserPassword } from "@formbricks/types/user"; + +interface PasswordConfirmationModalProps { + open: boolean; + setOpen: (open: boolean) => void; + oldEmail: string; + newEmail: string; + onConfirm: (password: string) => Promise; +} + +const PasswordConfirmationSchema = z.object({ + password: ZUserPassword, +}); + +type FormValues = z.infer; + +export const PasswordConfirmationModal = ({ + open, + setOpen, + oldEmail, + newEmail, + onConfirm, +}: PasswordConfirmationModalProps) => { + const { t } = useTranslate(); + + const form = useForm({ + resolver: zodResolver(PasswordConfirmationSchema), + }); + const { isSubmitting, isDirty } = form.formState; + + const onSubmit: SubmitHandler = async (data) => { + try { + await onConfirm(data.password); + form.reset(); + } catch (error) { + form.setError("password", { + message: error instanceof Error ? error.message : "Authentication failed", + }); + } + }; + const handleCancel = () => { + form.reset(); + setOpen(false); + }; + + return ( + + +
+

+ {t("auth.email-change.confirm_password_description")} +

+ +
+

+ {t("auth.email-change.old_email")}: +
{oldEmail.toLowerCase()} +

+

+ {t("auth.email-change.new_email")}: +
{newEmail.toLowerCase()} +

+
+ + ( + + +
+ field.onChange(password)} + /> + {error?.message && {error.message}} +
+
+
+ )} + /> + +
+ + +
+ +
+
+ ); +}; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user.test.ts b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user.test.ts new file mode 100644 index 0000000000..ad43ed19a9 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user.test.ts @@ -0,0 +1,146 @@ +import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib/utils"; +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"; + +// Mock dependencies +vi.mock("@/lib/user/cache", () => ({ + userCache: { + tag: { + byId: vi.fn((id) => `user-${id}-tag`), + byEmail: vi.fn((email) => `user-email-${email}-tag`), + }, + }, +})); + +vi.mock("@/modules/auth/lib/utils", () => ({ + verifyPassword: vi.fn(), +})); + +vi.mock("@formbricks/database", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + }, + }, +})); + +// reactCache (from "react") and unstable_cache (from "next/cache") are mocked in vitestSetup.ts +// to be pass-through, so the inner logic of cached functions is tested. + +const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique); +const mockVerifyPasswordUtil = vi.mocked(mockVerifyPasswordImported); + +describe("User Library Tests", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe("verifyUserPassword", () => { + const userId = "test-user-id"; + const password = "test-password"; + + test("should return true for correct password", async () => { + mockPrismaUserFindUnique.mockResolvedValue({ + password: "hashed-password", + identityProvider: "email", + } as any); + mockVerifyPasswordUtil.mockResolvedValue(true); + + const result = await verifyUserPassword(userId, password); + expect(result).toBe(true); + expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({ + where: { id: userId }, + select: { password: true, identityProvider: true }, + }); + expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password"); + }); + + test("should return false for incorrect password", async () => { + mockPrismaUserFindUnique.mockResolvedValue({ + password: "hashed-password", + identityProvider: "email", + } as any); + mockVerifyPasswordUtil.mockResolvedValue(false); + + const result = await verifyUserPassword(userId, password); + expect(result).toBe(false); + expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({ + where: { id: userId }, + select: { password: true, identityProvider: true }, + }); + expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password"); + }); + + test("should throw ResourceNotFoundError if user not found", async () => { + mockPrismaUserFindUnique.mockResolvedValue(null); + + await expect(verifyUserPassword(userId, password)).rejects.toThrow(ResourceNotFoundError); + await expect(verifyUserPassword(userId, password)).rejects.toThrow(`user with ID ${userId} not found`); + expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({ + where: { id: userId }, + select: { password: true, identityProvider: true }, + }); + expect(mockVerifyPasswordUtil).not.toHaveBeenCalled(); + }); + + test("should throw InvalidInputError if identityProvider is not email", async () => { + mockPrismaUserFindUnique.mockResolvedValue({ + password: "hashed-password", + identityProvider: "google", // Not 'email' + } as any); + + await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError); + await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user"); + expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({ + where: { id: userId }, + select: { password: true, identityProvider: true }, + }); + expect(mockVerifyPasswordUtil).not.toHaveBeenCalled(); + }); + + test("should throw InvalidInputError if password is not set for email provider", async () => { + mockPrismaUserFindUnique.mockResolvedValue({ + password: null, // Password not set + identityProvider: "email", + } as any); + + await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError); + await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user"); + expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({ + where: { id: userId }, + select: { password: true, identityProvider: true }, + }); + expect(mockVerifyPasswordUtil).not.toHaveBeenCalled(); + }); + }); + + describe("checkUserExistsByEmail", () => { + const email = "test@example.com"; + + test("should return true if user exists", async () => { + mockPrismaUserFindUnique.mockResolvedValue({ + id: "some-user-id", + } as any); + + const result = await checkUserExistsByEmail(email); + expect(result).toBe(true); + expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({ + where: { email }, + select: { id: true }, + }); + }); + + test("should return false if user does not exist", async () => { + mockPrismaUserFindUnique.mockResolvedValue(null); + + const result = await checkUserExistsByEmail(email); + expect(result).toBe(false); + expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({ + where: { email }, + select: { id: true }, + }); + }); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user.ts b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user.ts new file mode 100644 index 0000000000..7096fd6b67 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user.ts @@ -0,0 +1,70 @@ +import { cache } from "@/lib/cache"; +import { userCache } from "@/lib/user/cache"; +import { verifyPassword } from "@/modules/auth/lib/utils"; +import { User } from "@prisma/client"; +import { cache as reactCache } from "react"; +import { prisma } from "@formbricks/database"; +import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; + +export const getUserById = reactCache( + async (userId: string): Promise> => + cache( + async () => { + const user = await prisma.user.findUnique({ + where: { + id: userId, + }, + select: { + password: true, + identityProvider: true, + }, + }); + if (!user) { + throw new ResourceNotFoundError("user", userId); + } + return user; + }, + [`getUserById-${userId}`], + { + tags: [userCache.tag.byId(userId)], + } + )() +); + +export const verifyUserPassword = async (userId: string, password: string): Promise => { + const user = await getUserById(userId); + + if (user.identityProvider !== "email" || !user.password) { + throw new InvalidInputError("Password is not set for this user"); + } + + const isCorrectPassword = await verifyPassword(password, user.password); + + if (!isCorrectPassword) { + return false; + } + + return true; +}; + +export const checkUserExistsByEmail = reactCache( + async (email: string): Promise => + cache( + async () => { + const user = await prisma.user.findUnique({ + where: { + email: email.toLowerCase(), + }, + select: { + id: true, + }, + }); + + return !!user; + }, + [`checkUserExistsByEmail-${email}`], + { + tags: [userCache.tag.byEmail(email)], + } + )() +); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.test.tsx index 6f4bdec59c..5c44ba733f 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.test.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.test.tsx @@ -13,6 +13,7 @@ import Page from "./page"; // Mock services and utils vi.mock("@/lib/constants", () => ({ IS_FORMBRICKS_CLOUD: true, + EMAIL_VERIFICATION_DISABLED: true, })); vi.mock("@/lib/organization/service", () => ({ getOrganizationsWhereUserIsSingleOwner: vi.fn(), diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.tsx index d761e40718..ba3d4107d2 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(account)/profile/page.tsx @@ -1,6 +1,6 @@ import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar"; import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity"; -import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; +import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service"; import { getUser } from "@/lib/user/service"; import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils"; @@ -42,7 +42,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => { - + ({ + EmailChangeWithoutVerificationSuccessPage: ({ children }) => ( +
{children}
+ ), +})); + +describe("EmailChangeWithoutVerificationSuccessPage", () => { + afterEach(() => { + cleanup(); + }); + + test("renders EmailChangeWithoutVerificationSuccessPage", () => { + const { getByTestId } = render(); + expect(getByTestId("email-change-success-page")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/(auth)/email-change-without-verification-success/page.tsx b/apps/web/app/(auth)/email-change-without-verification-success/page.tsx new file mode 100644 index 0000000000..1d2fd29b01 --- /dev/null +++ b/apps/web/app/(auth)/email-change-without-verification-success/page.tsx @@ -0,0 +1,3 @@ +import { EmailChangeWithoutVerificationSuccessPage } from "@/modules/auth/email-change-without-verification-success/page"; + +export default EmailChangeWithoutVerificationSuccessPage; diff --git a/apps/web/app/(auth)/verify-email-change/page.tsx b/apps/web/app/(auth)/verify-email-change/page.tsx new file mode 100644 index 0000000000..fb9b6bd635 --- /dev/null +++ b/apps/web/app/(auth)/verify-email-change/page.tsx @@ -0,0 +1,3 @@ +import { VerifyEmailChangePage } from "@/modules/auth/verify-email-change/page"; + +export default VerifyEmailChangePage; diff --git a/apps/web/lib/jwt.test.ts b/apps/web/lib/jwt.test.ts index ad1210c813..de2a4b5a49 100644 --- a/apps/web/lib/jwt.test.ts +++ b/apps/web/lib/jwt.test.ts @@ -2,11 +2,13 @@ import { env } from "@/lib/env"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { + createEmailChangeToken, createEmailToken, createInviteToken, createToken, createTokenForLinkSurvey, getEmailFromEmailToken, + verifyEmailChangeToken, verifyInviteToken, verifyToken, verifyTokenForLinkSurvey, @@ -46,16 +48,6 @@ describe("JWT Functions", () => { expect(token).toBeDefined(); expect(typeof token).toBe("string"); }); - - test("should throw error if ENCRYPTION_KEY is not set", () => { - const originalKey = env.ENCRYPTION_KEY; - try { - (env as any).ENCRYPTION_KEY = undefined; - expect(() => createToken(mockUser.id, mockUser.email)).toThrow("ENCRYPTION_KEY is not set"); - } finally { - (env as any).ENCRYPTION_KEY = originalKey; - } - }); }); describe("createTokenForLinkSurvey", () => { @@ -65,18 +57,6 @@ describe("JWT Functions", () => { expect(token).toBeDefined(); expect(typeof token).toBe("string"); }); - - test("should throw error if ENCRYPTION_KEY is not set", () => { - const originalKey = env.ENCRYPTION_KEY; - try { - (env as any).ENCRYPTION_KEY = undefined; - expect(() => createTokenForLinkSurvey("test-survey-id", mockUser.email)).toThrow( - "ENCRYPTION_KEY is not set" - ); - } finally { - (env as any).ENCRYPTION_KEY = originalKey; - } - }); }); describe("createEmailToken", () => { @@ -86,16 +66,6 @@ describe("JWT Functions", () => { expect(typeof token).toBe("string"); }); - test("should throw error if ENCRYPTION_KEY is not set", () => { - const originalKey = env.ENCRYPTION_KEY; - try { - (env as any).ENCRYPTION_KEY = undefined; - expect(() => createEmailToken(mockUser.email)).toThrow("ENCRYPTION_KEY is not set"); - } finally { - (env as any).ENCRYPTION_KEY = originalKey; - } - }); - test("should throw error if NEXTAUTH_SECRET is not set", () => { const originalSecret = env.NEXTAUTH_SECRET; try { @@ -113,16 +83,6 @@ describe("JWT Functions", () => { const extractedEmail = getEmailFromEmailToken(token); expect(extractedEmail).toBe(mockUser.email); }); - - test("should throw error if ENCRYPTION_KEY is not set", () => { - const originalKey = env.ENCRYPTION_KEY; - try { - (env as any).ENCRYPTION_KEY = undefined; - expect(() => getEmailFromEmailToken("invalid-token")).toThrow("ENCRYPTION_KEY is not set"); - } finally { - (env as any).ENCRYPTION_KEY = originalKey; - } - }); }); describe("createInviteToken", () => { @@ -132,18 +92,6 @@ describe("JWT Functions", () => { expect(token).toBeDefined(); expect(typeof token).toBe("string"); }); - - test("should throw error if ENCRYPTION_KEY is not set", () => { - const originalKey = env.ENCRYPTION_KEY; - try { - (env as any).ENCRYPTION_KEY = undefined; - expect(() => createInviteToken("test-invite-id", mockUser.email)).toThrow( - "ENCRYPTION_KEY is not set" - ); - } finally { - (env as any).ENCRYPTION_KEY = originalKey; - } - }); }); describe("verifyTokenForLinkSurvey", () => { @@ -192,4 +140,32 @@ describe("JWT Functions", () => { expect(() => verifyInviteToken("invalid-token")).toThrow("Invalid or expired invite token"); }); }); + + describe("verifyEmailChangeToken", () => { + test("should verify and decrypt valid email change token", async () => { + const userId = "test-user-id"; + const email = "test@example.com"; + const token = createEmailChangeToken(userId, email); + const result = await verifyEmailChangeToken(token); + expect(result).toEqual({ id: userId, email }); + }); + + test("should throw error if token is invalid or missing fields", async () => { + // Create a token with missing fields + const jwt = await import("jsonwebtoken"); + const token = jwt.sign({ foo: "bar" }, env.NEXTAUTH_SECRET as string); + await expect(verifyEmailChangeToken(token)).rejects.toThrow( + "Token is invalid or missing required fields" + ); + }); + + test("should return original id/email if decryption fails", async () => { + // Create a token with non-encrypted id/email + const jwt = await import("jsonwebtoken"); + const payload = { id: "plain-id", email: "plain@example.com" }; + const token = jwt.sign(payload, env.NEXTAUTH_SECRET as string); + const result = await verifyEmailChangeToken(token); + expect(result).toEqual(payload); + }); + }); }); diff --git a/apps/web/lib/jwt.ts b/apps/web/lib/jwt.ts index bff3289440..88095db6bc 100644 --- a/apps/web/lib/jwt.ts +++ b/apps/web/lib/jwt.ts @@ -5,27 +5,60 @@ import { prisma } from "@formbricks/database"; import { logger } from "@formbricks/logger"; export const createToken = (userId: string, userEmail: string, options = {}): string => { - if (!env.ENCRYPTION_KEY) { - throw new Error("ENCRYPTION_KEY is not set"); - } - const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY); return jwt.sign({ id: encryptedUserId }, env.NEXTAUTH_SECRET + userEmail, options); }; export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => { - if (!env.ENCRYPTION_KEY) { - throw new Error("ENCRYPTION_KEY is not set"); - } - const encryptedEmail = symmetricEncrypt(userEmail, env.ENCRYPTION_KEY); return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET + surveyId); }; -export const createEmailToken = (email: string): string => { - if (!env.ENCRYPTION_KEY) { - throw new Error("ENCRYPTION_KEY is not set"); +export const verifyEmailChangeToken = async (token: string): Promise<{ id: string; email: string }> => { + if (!env.NEXTAUTH_SECRET) { + throw new Error("NEXTAUTH_SECRET is not set"); } + const payload = jwt.verify(token, env.NEXTAUTH_SECRET) as { id: string; email: string }; + + if (!payload?.id || !payload?.email) { + throw new Error("Token is invalid or missing required fields"); + } + + let decryptedId: string; + let decryptedEmail: string; + + try { + decryptedId = symmetricDecrypt(payload.id, env.ENCRYPTION_KEY); + } catch { + decryptedId = payload.id; + } + + try { + decryptedEmail = symmetricDecrypt(payload.email, env.ENCRYPTION_KEY); + } catch { + decryptedEmail = payload.email; + } + + return { + id: decryptedId, + email: decryptedEmail, + }; +}; + +export const createEmailChangeToken = (userId: string, email: string): string => { + const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY); + const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY); + + const payload = { + id: encryptedUserId, + email: encryptedEmail, + }; + + return jwt.sign(payload, env.NEXTAUTH_SECRET as string, { + expiresIn: "1d", + }); +}; +export const createEmailToken = (email: string): string => { if (!env.NEXTAUTH_SECRET) { throw new Error("NEXTAUTH_SECRET is not set"); } @@ -35,10 +68,6 @@ export const createEmailToken = (email: string): string => { }; export const getEmailFromEmailToken = (token: string): string => { - if (!env.ENCRYPTION_KEY) { - throw new Error("ENCRYPTION_KEY is not set"); - } - if (!env.NEXTAUTH_SECRET) { throw new Error("NEXTAUTH_SECRET is not set"); } @@ -55,10 +84,6 @@ export const getEmailFromEmailToken = (token: string): string => { }; export const createInviteToken = (inviteId: string, email: string, options = {}): string => { - if (!env.ENCRYPTION_KEY) { - throw new Error("ENCRYPTION_KEY is not set"); - } - if (!env.NEXTAUTH_SECRET) { throw new Error("NEXTAUTH_SECRET is not set"); } @@ -87,9 +112,6 @@ export const verifyTokenForLinkSurvey = (token: string, surveyId: string): strin }; export const verifyToken = async (token: string): Promise => { - if (!env.ENCRYPTION_KEY) { - throw new Error("ENCRYPTION_KEY is not set"); - } // First decode to get the ID const decoded = jwt.decode(token); const payload: JwtPayload = decoded as JwtPayload; @@ -127,10 +149,6 @@ export const verifyToken = async (token: string): Promise => { export const verifyInviteToken = (token: string): { inviteId: string; email: string } => { try { - if (!env.ENCRYPTION_KEY) { - throw new Error("ENCRYPTION_KEY is not set"); - } - const decoded = jwt.decode(token); const payload: JwtPayload = decoded as JwtPayload; diff --git a/apps/web/lib/utils/action-client.ts b/apps/web/lib/utils/action-client.ts index 8c5c6ba908..4b92a48ef4 100644 --- a/apps/web/lib/utils/action-client.ts +++ b/apps/web/lib/utils/action-client.ts @@ -10,6 +10,7 @@ import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError, + TooManyRequestsError, UnknownError, } from "@formbricks/types/errors"; @@ -23,7 +24,8 @@ export const actionClient = createSafeActionClient({ e instanceof InvalidInputError || e instanceof UnknownError || e instanceof AuthenticationError || - e instanceof OperationNotAllowedError + e instanceof OperationNotAllowedError || + e instanceof TooManyRequestsError ) { return e.message; } diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index a73addc70f..3958b866f8 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -7,6 +7,16 @@ "continue_with_oidc": "Weiter mit {oidcDisplayName}", "continue_with_openid": "Login mit OpenID", "continue_with_saml": "Login mit SAML SSO", + "email-change": { + "confirm_password_description": "Bitte bestätige dein Passwort, bevor du deine E-Mail-Adresse änderst", + "email_already_exists": "Diese E-Mail wird bereits verwendet", + "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", + "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" + }, "forgot-password": { "back_to_login": "Zurück zum Login", "email-sent": { @@ -451,6 +461,7 @@ "live_survey_notification_view_more_responses": "Zeige {responseCount} weitere Antworten", "live_survey_notification_view_previous_responses": "Vorherige Antworten anzeigen", "live_survey_notification_view_response": "Antwort anzeigen", + "new_email_verification_text": "Um Ihre neue E-Mail-Adresse zu bestätigen, klicken Sie bitte auf die Schaltfläche unten:", "notification_footer_all_the_best": "Alles Gute,", "notification_footer_in_your_settings": "in deinen Einstellungen \uD83D\uDE4F", "notification_footer_please_turn_them_off": "Bitte ausstellen", @@ -500,6 +511,8 @@ "verification_email_thanks": "Danke, dass Du deine E-Mail bestätigt hast!", "verification_email_to_fill_survey": "Um die Umfrage auszufüllen, klicke bitte auf den untenstehenden Button:", "verification_email_verify_email": "E-Mail bestätigen", + "verification_new_email_subject": "E-Mail-Änderungsbestätigung", + "verification_security_notice": "Wenn du diese E-Mail-Änderung nicht angefordert hast, ignoriere bitte diese E-Mail oder kontaktiere sofort den Support.", "verified_link_survey_email_subject": "Deine Umfrage ist bereit zum Ausfüllen.", "weekly_summary_create_reminder_notification_body_cal_slot": "Wähle einen 15-minütigen Termin im Kalender unseres Gründers aus.", "weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Lass keine Woche vergehen, ohne etwas über deine Nutzer zu lernen:", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index 01bff6866e..1d5c934e69 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -7,6 +7,16 @@ "continue_with_oidc": "Continue with {oidcDisplayName}", "continue_with_openid": "Continue with OpenID", "continue_with_saml": "Continue with SAML SSO", + "email-change": { + "confirm_password_description": "Please confirm your password before changing your email address", + "email_already_exists": "This email is already in use", + "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", + "invalid_or_expired_token": "Email change failed. Your token is invalid or expired.", + "new_email": "New Email", + "old_email": "Old Email" + }, "forgot-password": { "back_to_login": "Back to login", "email-sent": { @@ -451,6 +461,7 @@ "live_survey_notification_view_more_responses": "View {responseCount} more Responses", "live_survey_notification_view_previous_responses": "View previous responses", "live_survey_notification_view_response": "View Response", + "new_email_verification_text": "To verify your new email address, please click the button below:", "notification_footer_all_the_best": "All the best,", "notification_footer_in_your_settings": "in your settings \uD83D\uDE4F", "notification_footer_please_turn_them_off": "please turn them off", @@ -500,6 +511,8 @@ "verification_email_thanks": "Thanks for validating your email!", "verification_email_to_fill_survey": "To fill out the survey please click on the button below:", "verification_email_verify_email": "Verify email", + "verification_new_email_subject": "Email change verification", + "verification_security_notice": "If you did not request this email change, please ignore this email or contact support immediately.", "verified_link_survey_email_subject": "Your survey is ready to be filled out.", "weekly_summary_create_reminder_notification_body_cal_slot": "Pick a 15-minute slot in our CEOs calendar", "weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Don't let a week pass without learning about your users:", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index e9a9efce50..502f6a7bed 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -7,6 +7,16 @@ "continue_with_oidc": "Continuer avec {oidcDisplayName}", "continue_with_openid": "Continuer avec OpenID", "continue_with_saml": "Continuer avec SAML SSO", + "email-change": { + "confirm_password_description": "Veuillez confirmer votre mot de passe avant de changer votre adresse e-mail", + "email_already_exists": "Cet e-mail est déjà utilisé", + "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", + "invalid_or_expired_token": "Échec du changement d'email. Votre jeton est invalide ou expiré.", + "new_email": "Nouvel Email", + "old_email": "Ancien Email" + }, "forgot-password": { "back_to_login": "Retour à la connexion", "email-sent": { @@ -451,6 +461,7 @@ "live_survey_notification_view_more_responses": "Voir {responseCount} réponses supplémentaires", "live_survey_notification_view_previous_responses": "Voir les réponses précédentes", "live_survey_notification_view_response": "Voir la réponse", + "new_email_verification_text": "Pour confirmer votre nouvelle adresse e-mail, veuillez cliquer sur le bouton ci-dessous :", "notification_footer_all_the_best": "Tous mes vœux,", "notification_footer_in_your_settings": "dans vos paramètres \uD83D\uDE4F", "notification_footer_please_turn_them_off": "veuillez les éteindre", @@ -500,6 +511,8 @@ "verification_email_thanks": "Merci de valider votre email !", "verification_email_to_fill_survey": "Pour remplir le questionnaire, veuillez cliquer sur le bouton ci-dessous :", "verification_email_verify_email": "Vérifier l'email", + "verification_new_email_subject": "Vérification du changement d'email", + "verification_security_notice": "Si vous n'avez pas demandé ce changement d'email, veuillez ignorer cet email ou contacter le support immédiatement.", "verified_link_survey_email_subject": "Votre enquête est prête à être remplie.", "weekly_summary_create_reminder_notification_body_cal_slot": "Choisissez un créneau de 15 minutes dans le calendrier de notre PDG.", "weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Ne laissez pas une semaine passer sans en apprendre davantage sur vos utilisateurs :", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 1d84874ca0..352a8ce437 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -7,6 +7,16 @@ "continue_with_oidc": "Continuar com {oidcDisplayName}", "continue_with_openid": "Continuar com OpenID", "continue_with_saml": "Continuar com SAML SSO", + "email-change": { + "confirm_password_description": "Por favor, confirme sua senha antes de mudar seu endereço de e-mail", + "email_already_exists": "Este e-mail já está em uso", + "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", + "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" + }, "forgot-password": { "back_to_login": "Voltar para o login", "email-sent": { @@ -451,6 +461,7 @@ "live_survey_notification_view_more_responses": "Ver mais {responseCount} respostas", "live_survey_notification_view_previous_responses": "Ver respostas anteriores", "live_survey_notification_view_response": "Ver Resposta", + "new_email_verification_text": "Para verificar seu novo endereço de e-mail, clique no botão abaixo:", "notification_footer_all_the_best": "Tudo de bom,", "notification_footer_in_your_settings": "nas suas configurações \uD83D\uDE4F", "notification_footer_please_turn_them_off": "por favor, desliga eles", @@ -500,6 +511,8 @@ "verification_email_thanks": "Valeu por validar seu e-mail!", "verification_email_to_fill_survey": "Para preencher a pesquisa, por favor clique no botão abaixo:", "verification_email_verify_email": "Verificar e-mail", + "verification_new_email_subject": "Verificação de alteração de e-mail", + "verification_security_notice": "Se você não solicitou essa mudança de email, por favor ignore este email ou entre em contato com o suporte imediatamente.", "verified_link_survey_email_subject": "Sua pesquisa está pronta para ser preenchida.", "weekly_summary_create_reminder_notification_body_cal_slot": "Escolha um horário de 15 minutos na agenda do nosso CEO", "weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Não deixe uma semana passar sem aprender sobre seus usuários:", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index e0f00e4457..4fd58150d0 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -7,6 +7,16 @@ "continue_with_oidc": "Continuar com {oidcDisplayName}", "continue_with_openid": "Continuar com OpenID", "continue_with_saml": "Continuar com SAML SSO", + "email-change": { + "confirm_password_description": "Por favor, confirme a sua palavra-passe antes de alterar o seu endereço de email", + "email_already_exists": "Este email já está a ser utilizado", + "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", + "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" + }, "forgot-password": { "back_to_login": "Voltar ao login", "email-sent": { @@ -451,6 +461,7 @@ "live_survey_notification_view_more_responses": "Ver mais {responseCount} respostas", "live_survey_notification_view_previous_responses": "Ver respostas anteriores", "live_survey_notification_view_response": "Ver Resposta", + "new_email_verification_text": "Para verificar o seu novo endereço de email, por favor clique no botão abaixo:", "notification_footer_all_the_best": "Tudo de bom,", "notification_footer_in_your_settings": "nas suas definições \uD83D\uDE4F", "notification_footer_please_turn_them_off": "por favor, desative-os", @@ -500,6 +511,8 @@ "verification_email_thanks": "Obrigado por validar o seu email!", "verification_email_to_fill_survey": "Para preencher o questionário, clique no botão abaixo:", "verification_email_verify_email": "Verificar email", + "verification_new_email_subject": "Verificação de alteração de email", + "verification_security_notice": "Se não solicitou esta alteração de email, ignore este email ou contacte o suporte imediatamente.", "verified_link_survey_email_subject": "O seu inquérito está pronto para ser preenchido.", "weekly_summary_create_reminder_notification_body_cal_slot": "Escolha um intervalo de 15 minutos no calendário do nosso CEO", "weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Não deixe passar uma semana sem aprender sobre os seus utilizadores:", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 28f8498df9..08bbd64bd2 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -7,6 +7,16 @@ "continue_with_oidc": "使用 '{'oidcDisplayName'}' 繼續", "continue_with_openid": "使用 OpenID 繼續", "continue_with_saml": "使用 SAML SSO 繼續", + "email-change": { + "confirm_password_description": "在更改您的電子郵件地址之前,請確認您的密碼", + "email_already_exists": "此電子郵件地址已被使用", + "email_change_success": "電子郵件已成功更改", + "email_change_success_description": "您已成功更改電子郵件地址。請使用您的新電子郵件地址登入。", + "email_verification_failed": "電子郵件驗證失敗", + "invalid_or_expired_token": "電子郵件更改失敗。您的 token 無效或已過期。", + "new_email": "新 電子郵件", + "old_email": "舊 電子郵件" + }, "forgot-password": { "back_to_login": "返回登入", "email-sent": { @@ -451,6 +461,7 @@ "live_survey_notification_view_more_responses": "檢視另外 '{'responseCount'}' 個回應", "live_survey_notification_view_previous_responses": "檢視先前的回應", "live_survey_notification_view_response": "檢視回應", + "new_email_verification_text": "要驗證您的新電子郵件地址,請點擊下面的按鈕:", "notification_footer_all_the_best": "祝您一切順利,", "notification_footer_in_your_settings": "在您的設定中 \uD83D\uDE4F", "notification_footer_please_turn_them_off": "請關閉它們", @@ -500,6 +511,8 @@ "verification_email_thanks": "感謝您驗證您的電子郵件!", "verification_email_to_fill_survey": "若要填寫問卷,請點擊下方的按鈕:", "verification_email_verify_email": "驗證電子郵件", + "verification_new_email_subject": "電子郵件更改驗證", + "verification_security_notice": "如果您沒有要求更改此電子郵件,請忽略此電子郵件或立即聯繫支援。", "verified_link_survey_email_subject": "您的 survey 已準備好填寫。", "weekly_summary_create_reminder_notification_body_cal_slot": "在我們 CEO 的日曆中選擇一個 15 分鐘的時段", "weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "不要讓一週過去而沒有了解您的使用者:", diff --git a/apps/web/modules/auth/email-change-without-verification-success/page.test.tsx b/apps/web/modules/auth/email-change-without-verification-success/page.test.tsx new file mode 100644 index 0000000000..98772b5cc1 --- /dev/null +++ b/apps/web/modules/auth/email-change-without-verification-success/page.test.tsx @@ -0,0 +1,61 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { getServerSession } from "next-auth"; +import { redirect } from "next/navigation"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { EmailChangeWithoutVerificationSuccessPage } from "./page"; + +// Mock the necessary dependencies +vi.mock("@/modules/auth/components/back-to-login-button", () => ({ + BackToLoginButton: () =>
Back to Login
, +})); + +vi.mock("@/modules/auth/components/form-wrapper", () => ({ + FormWrapper: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("@/modules/auth/lib/authOptions", () => ({ + authOptions: {}, +})); + +vi.mock("next-auth", () => ({ + getServerSession: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) })); + +describe("EmailChangeWithoutVerificationSuccessPage", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("renders success page with correct translations when user is not logged in", async () => { + vi.mocked(getServerSession).mockResolvedValue(null); + + const page = await EmailChangeWithoutVerificationSuccessPage(); + render(page); + + expect(screen.getByTestId("form-wrapper")).toBeInTheDocument(); + expect(screen.getByTestId("back-to-login")).toBeInTheDocument(); + expect(screen.getByText("auth.email-change.email_change_success")).toBeInTheDocument(); + expect(screen.getByText("auth.email-change.email_change_success_description")).toBeInTheDocument(); + }); + + test("redirects to home page when user is logged in", async () => { + vi.mocked(getServerSession).mockResolvedValue({ + user: { id: "123", email: "test@example.com" }, + expires: new Date().toISOString(), + }); + + await EmailChangeWithoutVerificationSuccessPage(); + + expect(redirect).toHaveBeenCalledWith("/"); + }); +}); diff --git a/apps/web/modules/auth/email-change-without-verification-success/page.tsx b/apps/web/modules/auth/email-change-without-verification-success/page.tsx new file mode 100644 index 0000000000..29a1720b10 --- /dev/null +++ b/apps/web/modules/auth/email-change-without-verification-success/page.tsx @@ -0,0 +1,29 @@ +import { BackToLoginButton } from "@/modules/auth/components/back-to-login-button"; +import { FormWrapper } from "@/modules/auth/components/form-wrapper"; +import { authOptions } from "@/modules/auth/lib/authOptions"; +import { getTranslate } from "@/tolgee/server"; +import { getServerSession } from "next-auth"; +import type { Session } from "next-auth"; +import { redirect } from "next/navigation"; + +export const EmailChangeWithoutVerificationSuccessPage = async () => { + const t = await getTranslate(); + const session: Session | null = await getServerSession(authOptions); + + if (session) { + redirect("/"); + } + + return ( +
+ +

+ {t("auth.email-change.email_change_success")} +

+

{t("auth.email-change.email_change_success_description")}

+
+ +
+
+ ); +}; diff --git a/apps/web/modules/auth/signup-without-verification-success/page.tsx b/apps/web/modules/auth/signup-without-verification-success/page.tsx index 687cdb9f8f..43a233c7ed 100644 --- a/apps/web/modules/auth/signup-without-verification-success/page.tsx +++ b/apps/web/modules/auth/signup-without-verification-success/page.tsx @@ -5,15 +5,17 @@ import { getTranslate } from "@/tolgee/server"; export const SignupWithoutVerificationSuccessPage = async () => { const t = await getTranslate(); return ( - -

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

-

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

-
- -
+
+ +

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

+

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

+
+ +
+
); }; diff --git a/apps/web/modules/auth/verify-email-change/actions.ts b/apps/web/modules/auth/verify-email-change/actions.ts new file mode 100644 index 0000000000..b6ac0209ba --- /dev/null +++ b/apps/web/modules/auth/verify-email-change/actions.ts @@ -0,0 +1,21 @@ +"use server"; + +import { verifyEmailChangeToken } from "@/lib/jwt"; +import { actionClient } from "@/lib/utils/action-client"; +import { updateUser } from "@/modules/auth/lib/user"; +import { z } from "zod"; + +export const verifyEmailChangeAction = actionClient + .schema(z.object({ token: z.string() })) + .action(async ({ parsedInput }) => { + const { id, email } = await verifyEmailChangeToken(parsedInput.token); + + if (!email) { + throw new Error("Email not found in token"); + } + const user = await updateUser(id, { email, emailVerified: new Date() }); + if (!user) { + throw new Error("User not found or email update failed"); + } + return user; + }); diff --git a/apps/web/modules/auth/verify-email-change/components/email-change-sign-in.test.tsx b/apps/web/modules/auth/verify-email-change/components/email-change-sign-in.test.tsx new file mode 100644 index 0000000000..df88c30c9e --- /dev/null +++ b/apps/web/modules/auth/verify-email-change/components/email-change-sign-in.test.tsx @@ -0,0 +1,68 @@ +import { verifyEmailChangeAction } from "@/modules/auth/verify-email-change/actions"; +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { signOut } from "next-auth/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { EmailChangeSignIn } from "./email-change-sign-in"; + +// Mock dependencies +vi.mock("@tolgee/react", () => ({ + useTranslate: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock("next-auth/react", () => ({ + signOut: vi.fn(), +})); + +vi.mock("@/modules/auth/verify-email-change/actions", () => ({ + verifyEmailChangeAction: vi.fn(), +})); + +describe("EmailChangeSignIn", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + test("shows loading state initially", () => { + render(); + expect(screen.getByText("auth.email-change.email_verification_failed")).toBeInTheDocument(); + }); + + test("handles successful email change verification", async () => { + vi.mocked(verifyEmailChangeAction).mockResolvedValueOnce({ + data: { id: "123", email: "test@example.com", emailVerified: new Date(), locale: "en-US" }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("auth.email-change.email_change_success")).toBeInTheDocument(); + expect(screen.getByText("auth.email-change.email_change_success_description")).toBeInTheDocument(); + }); + + expect(signOut).toHaveBeenCalledWith({ redirect: false }); + }); + + test("handles failed email change verification", async () => { + vi.mocked(verifyEmailChangeAction).mockResolvedValueOnce({ serverError: "Error" }); + + render(); + + await waitFor(() => { + expect(screen.getByText("auth.email-change.email_verification_failed")).toBeInTheDocument(); + expect(screen.getByText("auth.email-change.invalid_or_expired_token")).toBeInTheDocument(); + }); + + expect(signOut).not.toHaveBeenCalled(); + }); + + test("handles empty token", () => { + render(); + + expect(screen.getByText("auth.email-change.email_verification_failed")).toBeInTheDocument(); + expect(screen.getByText("auth.email-change.invalid_or_expired_token")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/auth/verify-email-change/components/email-change-sign-in.tsx b/apps/web/modules/auth/verify-email-change/components/email-change-sign-in.tsx new file mode 100644 index 0000000000..0382df2a29 --- /dev/null +++ b/apps/web/modules/auth/verify-email-change/components/email-change-sign-in.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { verifyEmailChangeAction } from "@/modules/auth/verify-email-change/actions"; +import { useTranslate } from "@tolgee/react"; +import { signOut } from "next-auth/react"; +import { useEffect, useState } from "react"; + +export const EmailChangeSignIn = ({ token }: { token: string }) => { + const { t } = useTranslate(); + const [status, setStatus] = useState<"success" | "error" | "loading">("loading"); + + useEffect(() => { + const validateToken = async () => { + if (typeof token === "string" && token.trim() !== "") { + const result = await verifyEmailChangeAction({ token }); + + if (!result?.data) { + setStatus("error"); + } else { + setStatus("success"); + } + } else { + setStatus("error"); + } + }; + + if (token) { + validateToken(); + } else { + setStatus("error"); + } + }, [token]); + + useEffect(() => { + if (status === "success") { + signOut({ redirect: false }); + } + }, [status]); + + return ( + <> +

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

+

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

+
+ + ); +}; diff --git a/apps/web/modules/auth/verify-email-change/page.test.tsx b/apps/web/modules/auth/verify-email-change/page.test.tsx new file mode 100644 index 0000000000..fd9d8d6a36 --- /dev/null +++ b/apps/web/modules/auth/verify-email-change/page.test.tsx @@ -0,0 +1,47 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { VerifyEmailChangePage } from "./page"; + +// Mock the necessary dependencies +vi.mock("@/modules/auth/components/back-to-login-button", () => ({ + BackToLoginButton: () =>
Back to Login
, +})); + +vi.mock("@/modules/auth/components/form-wrapper", () => ({ + FormWrapper: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock("@/modules/auth/verify-email-change/components/email-change-sign-in", () => ({ + EmailChangeSignIn: ({ token }: { token: string }) => ( +
Email Change Sign In with token: {token}
+ ), +})); + +describe("VerifyEmailChangePage", () => { + afterEach(() => { + cleanup(); + }); + + test("renders the page with form wrapper and components", async () => { + const searchParams = { token: "test-token" }; + render(await VerifyEmailChangePage({ searchParams })); + + expect(screen.getByTestId("form-wrapper")).toBeInTheDocument(); + expect(screen.getByTestId("email-change-sign-in")).toBeInTheDocument(); + expect(screen.getByTestId("back-to-login")).toBeInTheDocument(); + expect(screen.getByText("Email Change Sign In with token: test-token")).toBeInTheDocument(); + }); + + test("handles missing token", async () => { + const searchParams = {}; + render(await VerifyEmailChangePage({ searchParams })); + + expect(screen.getByTestId("form-wrapper")).toBeInTheDocument(); + expect(screen.getByTestId("email-change-sign-in")).toBeInTheDocument(); + expect(screen.getByTestId("back-to-login")).toBeInTheDocument(); + expect(screen.getByText("Email Change Sign In with token:")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/auth/verify-email-change/page.tsx b/apps/web/modules/auth/verify-email-change/page.tsx new file mode 100644 index 0000000000..f4813eac26 --- /dev/null +++ b/apps/web/modules/auth/verify-email-change/page.tsx @@ -0,0 +1,16 @@ +import { BackToLoginButton } from "@/modules/auth/components/back-to-login-button"; +import { FormWrapper } from "@/modules/auth/components/form-wrapper"; +import { EmailChangeSignIn } from "@/modules/auth/verify-email-change/components/email-change-sign-in"; + +export const VerifyEmailChangePage = async ({ searchParams }) => { + const { token } = await searchParams; + + return ( +
+ + + + +
+ ); +}; diff --git a/apps/web/modules/email/emails/auth/new-email-verification.tsx b/apps/web/modules/email/emails/auth/new-email-verification.tsx new file mode 100644 index 0000000000..b20bc79a81 --- /dev/null +++ b/apps/web/modules/email/emails/auth/new-email-verification.tsx @@ -0,0 +1,34 @@ +import { getTranslate } from "@/tolgee/server"; +import { Container, Heading, Link, Text } from "@react-email/components"; +import React from "react"; +import { EmailButton } from "../../components/email-button"; +import { EmailFooter } from "../../components/email-footer"; +import { EmailTemplate } from "../../components/email-template"; + +interface VerificationEmailProps { + readonly verifyLink: string; +} + +export async function NewEmailVerification({ + verifyLink, +}: VerificationEmailProps): Promise { + const t = await getTranslate(); + return ( + + + {t("emails.verification_email_heading")} + {t("emails.new_email_verification_text")} + {t("emails.verification_security_notice")} + + {t("emails.verification_email_click_on_this_link")} + + {verifyLink} + + {t("emails.verification_email_link_valid_for_24_hours")} + + + + ); +} + +export default NewEmailVerification; diff --git a/apps/web/modules/email/index.tsx b/apps/web/modules/email/index.tsx index 5cecef4828..0233234d33 100644 --- a/apps/web/modules/email/index.tsx +++ b/apps/web/modules/email/index.tsx @@ -12,8 +12,9 @@ import { WEBAPP_URL, } from "@/lib/constants"; import { getSurveyDomain } from "@/lib/getSurveyUrl"; -import { createInviteToken, createToken, createTokenForLinkSurvey } from "@/lib/jwt"; +import { createEmailChangeToken, createInviteToken, createToken, createTokenForLinkSurvey } from "@/lib/jwt"; import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; +import NewEmailVerification from "@/modules/email/emails/auth/new-email-verification"; import { EmailCustomizationPreviewEmail } from "@/modules/email/emails/general/email-customization-preview-email"; import { getTranslate } from "@/tolgee/server"; import { render } from "@react-email/render"; @@ -86,6 +87,25 @@ export const sendEmail = async (emailData: SendEmailDataProps): Promise } }; +export const sendVerificationNewEmail = async (id: string, email: string): Promise => { + try { + const t = await getTranslate(); + const token = createEmailChangeToken(id, email); + const verifyLink = `${WEBAPP_URL}/verify-email-change?token=${encodeURIComponent(token)}`; + + const html = await render(await NewEmailVerification({ verifyLink })); + + return await sendEmail({ + to: email, + subject: t("emails.verification_new_email_subject"), + html, + }); + } catch (error) { + logger.error(error, "Error in sendVerificationNewEmail"); + throw error; + } +}; + export const sendVerificationEmail = async ({ id, email, diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 3d26326978..1ebba4ba07 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -190,8 +190,8 @@ x-environment: &environment # Configure the minimum role for user management from UI(owner, manager, disabled) # USER_MANAGEMENT_MINIMUM_ROLE="manager" - # Configure the maximum age for the session in seconds. Default is 43200 (12 hours) - # SESSION_MAX_AGE=43200 + # Configure the maximum age for the session in seconds. Default is 86400 (24 hours) + # SESSION_MAX_AGE=86400 services: postgres: diff --git a/packages/types/errors.ts b/packages/types/errors.ts index d85e4887d9..7c240c6949 100644 --- a/packages/types/errors.ts +++ b/packages/types/errors.ts @@ -85,6 +85,14 @@ class AuthorizationError extends Error { } } +class TooManyRequestsError extends Error { + statusCode = 429; + constructor(message: string) { + super(message); + this.name = "TooManyRequestsError"; + } +} + interface NetworkError { code: "network_error"; message: string; @@ -116,6 +124,7 @@ export { OperationNotAllowedError, AuthenticationError, AuthorizationError, + TooManyRequestsError, }; export type { NetworkError, ForbiddenError };