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

This commit is contained in:
Jakob Schott
2025-05-21 15:25:56 +02:00
71 changed files with 1411 additions and 258 deletions

View File

@@ -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"
# USER_MANAGEMENT_MINIMUM_ROLE="manager"
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
# SESSION_MAX_AGE=86400

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(<EditProfileDetailsForm user={mockUser} />);
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={true} />);
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(<EditProfileDetailsForm user={mockUser} />);
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />);
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(<EditProfileDetailsForm user={mockUser} />);
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />);
const updateButton = screen.getByText("common.update");
expect(updateButton).toBeDisabled();

View File

@@ -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<typeof ZEditProfileNameFormSchema>;
export const EditProfileDetailsForm = ({ user }: { user: TUser }) => {
export const EditProfileDetailsForm = ({
user,
emailVerificationDisabled,
}: {
user: TUser;
emailVerificationDisabled: boolean;
}) => {
const { t } = useTranslate();
const router = useRouter();
const form = useForm<TEditProfileNameForm>({
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<TEditProfileNameForm> = 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 (
<FormProvider {...form}>
<form className="w-full max-w-sm items-center" onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("common.full_name")}</FormLabel>
<FormControl>
<Input
{...field}
type="text"
placeholder={t("common.full_name")}
required
isInvalid={!!form.formState.errors.name}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
<>
<FormProvider {...form}>
<form className="w-full max-w-sm" onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("common.full_name")}</FormLabel>
<FormControl>
<Input
{...field}
type="text"
required
placeholder={t("common.full_name")}
isInvalid={!!form.formState.errors.name}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
{/* disabled email field */}
<div className="mt-4 space-y-2">
<Label htmlFor="email">{t("common.email")}</Label>
<Input type="email" id="email" defaultValue={user.email} disabled />
</div>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="mt-4">
<FormLabel>{t("common.email")}</FormLabel>
<FormControl>
<Input
{...field}
type="email"
required
isInvalid={!!form.formState.errors.email}
disabled={user.identityProvider !== "email"}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
<FormField
control={form.control}
name="locale"
render={({ field }) => (
<FormItem className="mt-4">
<FormLabel>{t("common.language")}</FormLabel>
<FormControl>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
className="h-10 w-full border border-slate-300 px-3 text-left"
variant="ghost">
<div className="flex w-full items-center justify-between">
{appLanguages.find((language) => language.code === field.value)?.label[field.value] ||
"NA"}
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-40 bg-slate-50 text-slate-700"
align="start"
side="bottom">
{appLanguages.map((language) => (
<DropdownMenuItem
key={language.code}
onClick={() => field.onChange(language.code)}
className="min-h-8 cursor-pointer">
{language.label[field.value]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
<FormError />
</FormItem>
)}
/>
<FormField
control={form.control}
name="locale"
render={({ field }) => (
<FormItem className="mt-4">
<FormLabel>{t("common.language")}</FormLabel>
<FormControl>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
className="h-10 w-full border border-slate-300 px-3 text-left">
<div className="flex w-full items-center justify-between">
{appLanguages.find((l) => l.code === field.value)?.label[field.value] ?? "NA"}
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-40 bg-slate-50 text-slate-700" align="start">
{appLanguages.map((lang) => (
<DropdownMenuItem
key={lang.code}
onClick={() => field.onChange(lang.code)}
className="min-h-8 cursor-pointer">
{lang.label[field.value]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
<FormError />
</FormItem>
)}
/>
<Button
type="submit"
className="mt-4"
size="sm"
loading={isSubmitting}
disabled={isSubmitting || !isDirty}>
{t("common.update")}
</Button>
</form>
</FormProvider>
<Button
type="submit"
className="mt-4"
size="sm"
loading={isSubmitting}
disabled={isSubmitting || !isDirty}>
{t("common.update")}
</Button>
</form>
</FormProvider>
<PasswordConfirmationModal
open={showModal}
setOpen={setShowModal}
oldEmail={user.email}
newEmail={form.getValues("email") || user.email}
onConfirm={handleConfirmPassword}
/>
</>
);
};

View File

@@ -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 ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
{children}
<button data-testid="modal-close" onClick={() => setOpen(false)}>
Close
</button>
</div>
) : null,
}));
// Mock the PasswordInput component
vi.mock("@/modules/ui/components/password-input", () => ({
PasswordInput: ({ onChange, value, placeholder }: any) => (
<input
type="password"
value={value || ""}
onChange={(e) => 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(<PasswordConfirmationModal {...defaultProps} open={false} />);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
});
test("renders modal content when open is true", () => {
render(<PasswordConfirmationModal {...defaultProps} />);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("modal-title")).toBeInTheDocument();
});
test("displays old and new email addresses", () => {
render(<PasswordConfirmationModal {...defaultProps} />);
expect(screen.getByText("old@example.com")).toBeInTheDocument();
expect(screen.getByText("new@example.com")).toBeInTheDocument();
});
test("shows password input field", () => {
render(<PasswordConfirmationModal {...defaultProps} />);
const passwordInput = screen.getByTestId("password-input");
expect(passwordInput).toBeInTheDocument();
expect(passwordInput).toHaveAttribute("placeholder", "*******");
});
test("disables confirm button when form is not dirty", () => {
render(<PasswordConfirmationModal {...defaultProps} />);
const confirmButton = screen.getByText("common.confirm");
expect(confirmButton).toBeDisabled();
});
test("disables confirm button when old and new emails are the same", () => {
render(
<PasswordConfirmationModal {...defaultProps} oldEmail="same@example.com" newEmail="same@example.com" />
);
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(<PasswordConfirmationModal {...defaultProps} />);
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(<PasswordConfirmationModal {...defaultProps} />);
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(<PasswordConfirmationModal {...defaultProps} />);
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("");
});
});
});

View File

@@ -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<void>;
}
const PasswordConfirmationSchema = z.object({
password: ZUserPassword,
});
type FormValues = z.infer<typeof PasswordConfirmationSchema>;
export const PasswordConfirmationModal = ({
open,
setOpen,
oldEmail,
newEmail,
onConfirm,
}: PasswordConfirmationModalProps) => {
const { t } = useTranslate();
const form = useForm<FormValues>({
resolver: zodResolver(PasswordConfirmationSchema),
});
const { isSubmitting, isDirty } = form.formState;
const onSubmit: SubmitHandler<FormValues> = 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 (
<Modal open={open} setOpen={setOpen} title={t("auth.forgot-password.reset.confirm_password")}>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<p className="text-muted-foreground text-sm">
{t("auth.email-change.confirm_password_description")}
</p>
<div className="flex flex-col gap-2 text-sm sm:flex-row sm:justify-between sm:gap-4">
<p>
<strong>{t("auth.email-change.old_email")}:</strong>
<br /> {oldEmail.toLowerCase()}
</p>
<p>
<strong>{t("auth.email-change.new_email")}:</strong>
<br /> {newEmail.toLowerCase()}
</p>
</div>
<FormField
control={form.control}
name="password"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full">
<FormControl>
<div>
<PasswordInput
id="password"
autoComplete="current-password"
placeholder="*******"
aria-placeholder="password"
aria-label="password"
aria-required="true"
required
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
value={field.value}
onChange={(password) => field.onChange(password)}
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>
</FormControl>
</FormItem>
)}
/>
<div className="mt-4 space-x-2 text-right">
<Button type="button" variant="secondary" onClick={handleCancel}>
{t("common.cancel")}
</Button>
<Button
type="submit"
variant="default"
loading={isSubmitting}
disabled={isSubmitting || !isDirty || oldEmail.toLowerCase() === newEmail.toLowerCase()}>
{t("common.confirm")}
</Button>
</div>
</form>
</FormProvider>
</Modal>
);
};

View File

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

View File

@@ -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<Pick<User, "password" | "identityProvider">> =>
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<boolean> => {
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<boolean> =>
cache(
async () => {
const user = await prisma.user.findUnique({
where: {
email: email.toLowerCase(),
},
select: {
id: true,
},
});
return !!user;
},
[`checkUserExistsByEmail-${email}`],
{
tags: [userCache.tag.byEmail(email)],
}
)()
);

View File

@@ -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(),

View File

@@ -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 }> }) => {
<SettingsCard
title={t("environments.settings.profile.personal_information")}
description={t("environments.settings.profile.update_personal_info")}>
<EditProfileDetailsForm user={user} />
<EditProfileDetailsForm emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED} user={user} />
</SettingsCard>
<SettingsCard
title={t("common.avatar")}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 }) => (
<div data-testid="email-change-success-page">{children}</div>
),
}));
describe("EmailChangeWithoutVerificationSuccessPage", () => {
afterEach(() => {
cleanup();
});
test("renders EmailChangeWithoutVerificationSuccessPage", () => {
const { getByTestId } = render(<EmailChangeWithoutVerificationSuccessPage />);
expect(getByTestId("email-change-success-page")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,3 @@
import { EmailChangeWithoutVerificationSuccessPage } from "@/modules/auth/email-change-without-verification-success/page";
export default EmailChangeWithoutVerificationSuccessPage;

View File

@@ -0,0 +1,3 @@
import { VerifyEmailChangePage } from "@/modules/auth/verify-email-change/page";
export default VerifyEmailChangePage;

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<JwtPayload> => {
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<JwtPayload> => {
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;

View File

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

View File

@@ -135,14 +135,11 @@ export const getResponses = async (
): Promise<Result<ApiResponseWithMeta<Response[]>, 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,
},

View File

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

View File

@@ -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: () => <div data-testid="back-to-login">Back to Login</div>,
}));
vi.mock("@/modules/auth/components/form-wrapper", () => ({
FormWrapper: ({ children }: { children: React.ReactNode }) => (
<div data-testid="form-wrapper">{children}</div>
),
}));
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("/");
});
});

View File

@@ -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 (
<div className="bg-gradient-radial flex min-h-screen from-slate-200 to-slate-50">
<FormWrapper>
<h1 className="leading-2 mb-4 text-center font-bold">
{t("auth.email-change.email_change_success")}
</h1>
<p className="text-center text-sm">{t("auth.email-change.email_change_success_description")}</p>
<hr className="my-4" />
<BackToLoginButton />
</FormWrapper>
</div>
);
};

View File

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

View File

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

View File

@@ -5,15 +5,17 @@ import { getTranslate } from "@/tolgee/server";
export const SignupWithoutVerificationSuccessPage = async () => {
const t = await getTranslate();
return (
<FormWrapper>
<h1 className="leading-2 mb-4 text-center font-bold">
{t("auth.signup_without_verification_success.user_successfully_created")}
</h1>
<p className="text-center text-sm">
{t("auth.signup_without_verification_success.user_successfully_created_description")}
</p>
<hr className="my-4" />
<BackToLoginButton />
</FormWrapper>
<div className="bg-gradient-radial flex min-h-screen from-slate-200 to-slate-50">
<FormWrapper>
<h1 className="leading-2 mb-4 text-center font-bold">
{t("auth.signup_without_verification_success.user_successfully_created")}
</h1>
<p className="text-center text-sm">
{t("auth.signup_without_verification_success.user_successfully_created_description")}
</p>
<hr className="my-4" />
<BackToLoginButton />
</FormWrapper>
</div>
);
};

View File

@@ -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(<RequestVerificationEmail email="test@example.com" />);
expect(screen.getByText("Resend verification email")).toBeInTheDocument();
});
test("shows error toast when no email is provided", async () => {
render(<RequestVerificationEmail email={null} />);
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(<RequestVerificationEmail email={mockEmail} />);
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(<RequestVerificationEmail email="test@example.com" />);
// Simulate visibility change
document.dispatchEvent(new Event("visibilitychange"));
expect(mockReload).toHaveBeenCalled();
});
});

View File

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

View File

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

View File

@@ -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(<EmailChangeSignIn token="valid-token" />);
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(<EmailChangeSignIn token="valid-token" />);
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(<EmailChangeSignIn token="invalid-token" />);
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(<EmailChangeSignIn token="" />);
expect(screen.getByText("auth.email-change.email_verification_failed")).toBeInTheDocument();
expect(screen.getByText("auth.email-change.invalid_or_expired_token")).toBeInTheDocument();
});
});

View File

@@ -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 (
<>
<h1 className={`leading-2 mb-4 text-center font-bold ${status === "error" ? "text-red-600" : ""}`}>
{status === "success"
? t("auth.email-change.email_change_success")
: t("auth.email-change.email_verification_failed")}
</h1>
<p className="text-center text-sm">
{status === "success"
? t("auth.email-change.email_change_success_description")
: t("auth.email-change.invalid_or_expired_token")}
</p>
<hr className="my-4" />
</>
);
};

View File

@@ -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: () => <div data-testid="back-to-login">Back to Login</div>,
}));
vi.mock("@/modules/auth/components/form-wrapper", () => ({
FormWrapper: ({ children }: { children: React.ReactNode }) => (
<div data-testid="form-wrapper">{children}</div>
),
}));
vi.mock("@/modules/auth/verify-email-change/components/email-change-sign-in", () => ({
EmailChangeSignIn: ({ token }: { token: string }) => (
<div data-testid="email-change-sign-in">Email Change Sign In with token: {token}</div>
),
}));
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();
});
});

View File

@@ -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 (
<div className="bg-gradient-radial flex min-h-screen from-slate-200 to-slate-50">
<FormWrapper>
<EmailChangeSignIn token={token} />
<BackToLoginButton />
</FormWrapper>
</div>
);
};

View File

@@ -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<React.JSX.Element> {
const t = await getTranslate();
return (
<EmailTemplate t={t}>
<Container>
<Heading>{t("emails.verification_email_heading")}</Heading>
<Text>{t("emails.new_email_verification_text")}</Text>
<Text>{t("emails.verification_security_notice")}</Text>
<EmailButton href={verifyLink} label={t("emails.verification_email_verify_email")} />
<Text>{t("emails.verification_email_click_on_this_link")}</Text>
<Link className="break-all text-black" href={verifyLink}>
{verifyLink}
</Link>
<Text className="font-bold">{t("emails.verification_email_link_valid_for_24_hours")}</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
export default NewEmailVerification;

View File

@@ -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<boolean>
}
};
export const sendVerificationNewEmail = async (id: string, email: string): Promise<boolean> => {
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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "Response_created_at_idx" ON "Response"("created_at");

View File

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

View File

@@ -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", () => {
<QuestionConditional
{...baseProps}
question={question}
value=""
value={undefined as any}
prefilledQuestionValue="John"
skipPrefilled={true}
/>
@@ -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

View File

@@ -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 ? (
<OpenTextQuestion

View File

@@ -57,29 +57,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 (
<form
key={question.id}
onSubmit={(e) => {
e.preventDefault();
const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedttc);
onSubmit({ [question.id]: value }, updatedttc);
}}
className="fb-w-full">
<form key={question.id} onSubmit={handleOnSubmit} className="fb-w-full">
<ScrollableContainer>
<div>
{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}

View File

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

View File

@@ -139,6 +139,7 @@
"S3_REGION",
"S3_SECRET_KEY",
"SAML_DATABASE_URL",
"SESSION_MAX_AGE",
"SENTRY_DSN",
"SLACK_CLIENT_ID",
"SLACK_CLIENT_SECRET",