diff --git a/.env.example b/.env.example index 428a8a016b..6c5eed3fb2 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 86400 (24 hours) +# SESSION_MAX_AGE=86400 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/(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 }> }) => { - + ({ 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/app/(auth)/email-change-without-verification-success/page.test.tsx b/apps/web/app/(auth)/email-change-without-verification-success/page.test.tsx new file mode 100644 index 0000000000..df6aa37986 --- /dev/null +++ b/apps/web/app/(auth)/email-change-without-verification-success/page.test.tsx @@ -0,0 +1,20 @@ +import { cleanup, render } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import EmailChangeWithoutVerificationSuccessPage from "./page"; + +vi.mock("@/modules/auth/email-change-without-verification-success/page", () => ({ + 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/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/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..179c5a59f3 100644 --- a/apps/web/lib/env.ts +++ b/apps/web/lib/env.ts @@ -105,6 +105,10 @@ 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 +204,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/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/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/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/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 e535991464..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/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/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/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/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..1ebba4ba07 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 86400 (24 hours) + # SESSION_MAX_AGE=86400 + 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/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]) 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 ? ( { + 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 ? ( @@ -138,7 +139,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" title={question.inputType === "phone" ? "Please enter a valid phone number" : undefined} 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 }; 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",