Compare commits

...

7 Commits

Author SHA1 Message Date
Harsh Bhat
639e25d679 chore: canonical seo issue (#5852) 2025-05-21 13:38:41 +00:00
Piyush Gupta
f7e5ef96d2 feat: added email change feature (#5837)
Co-authored-by: Paribesh01 <nepalparibesh01@gmail.com>
Co-authored-by: Paribesh Nepal <100255987+Paribesh01@users.noreply.github.com>
2025-05-21 11:23:12 +00:00
Dhruwang Jariwala
745f5487e9 fix: tweaks in open text question (#5841)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-21 06:20:40 +00:00
devin-ai-integration[bot]
0e7f3adf53 feat: Make session maxAge configurable with environment variable (#5830)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <mail@matti.sh>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-21 05:49:18 +00:00
Dhruwang Jariwala
342d2b1fc4 fix: response getting stuck (#5849) 2025-05-21 05:33:13 +00:00
Piyush Gupta
15279685f7 fix: delete pre-filled value (#5839) 2025-05-21 04:23:05 +00:00
Matti Nannt
12aa959f50 fix: slow responses query slowing down database (#5846) 2025-05-21 04:13:31 +00:00
79 changed files with 1506 additions and 264 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,7 @@ export const env = createEnv({
PROMETHEUS_EXPORTER_PORT: z.string().optional(),
PROMETHEUS_ENABLED: z.enum(["1", "0"]).optional(),
USER_MANAGEMENT_MINIMUM_ROLE: z.enum(["owner", "manager", "disabled"]).optional(),
SESSION_MAX_AGE: z.string().transform((val) => parseInt(val)).optional(),
},
/*
@@ -200,5 +201,6 @@ export const env = createEnv({
PROMETHEUS_ENABLED: process.env.PROMETHEUS_ENABLED,
PROMETHEUS_EXPORTER_PORT: process.env.PROMETHEUS_EXPORTER_PORT,
USER_MANAGEMENT_MINIMUM_ROLE: process.env.USER_MANAGEMENT_MINIMUM_ROLE,
SESSION_MAX_AGE: process.env.SESSION_MAX_AGE,
},
});

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

@@ -7,6 +7,16 @@
"continue_with_oidc": "Weiter mit {oidcDisplayName}",
"continue_with_openid": "Login mit OpenID",
"continue_with_saml": "Login mit SAML SSO",
"email-change": {
"confirm_password_description": "Bitte bestätige dein Passwort, bevor du deine E-Mail-Adresse änderst",
"email_already_exists": "Diese E-Mail wird bereits verwendet",
"email_change_success": "E-Mail erfolgreich geändert",
"email_change_success_description": "Du hast deine E-Mail-Adresse erfolgreich geändert. Bitte logge dich mit deiner neuen E-Mail-Adresse ein.",
"email_verification_failed": "E-Mail-Bestätigung fehlgeschlagen",
"invalid_or_expired_token": "E-Mail-Änderung fehlgeschlagen. Dein Token ist ungültig oder abgelaufen.",
"new_email": "Neue E-Mail",
"old_email": "Alte E-Mail"
},
"forgot-password": {
"back_to_login": "Zurück zum Login",
"email-sent": {
@@ -82,7 +92,7 @@
"please_click_the_link_in_the_email_to_activate_your_account": "Bitte klicke auf den Link in der E-Mail, um dein Konto zu aktivieren.",
"please_confirm_your_email_address": "Bitte bestätige deine E-Mail-Adresse",
"resend_verification_email": "Bestätigungs-E-Mail erneut senden",
"verification_email_successfully_sent": "Bestätigungs-E-Mail erfolgreich gesendet. Bitte überprüfe dein Postfach.",
"verification_email_successfully_sent": "Bestätigungs-E-Mail an {email} gesendet. Bitte überprüfen Sie, um das Update abzuschließen.",
"we_sent_an_email_to": "Wir haben eine E-Mail an {email} gesendet",
"you_didnt_receive_an_email_or_your_link_expired": "Hast Du keine E-Mail erhalten oder ist dein Link abgelaufen?"
},
@@ -451,6 +461,7 @@
"live_survey_notification_view_more_responses": "Zeige {responseCount} weitere Antworten",
"live_survey_notification_view_previous_responses": "Vorherige Antworten anzeigen",
"live_survey_notification_view_response": "Antwort anzeigen",
"new_email_verification_text": "Um Ihre neue E-Mail-Adresse zu bestätigen, klicken Sie bitte auf die Schaltfläche unten:",
"notification_footer_all_the_best": "Alles Gute,",
"notification_footer_in_your_settings": "in deinen Einstellungen \uD83D\uDE4F",
"notification_footer_please_turn_them_off": "Bitte ausstellen",
@@ -500,6 +511,8 @@
"verification_email_thanks": "Danke, dass Du deine E-Mail bestätigt hast!",
"verification_email_to_fill_survey": "Um die Umfrage auszufüllen, klicke bitte auf den untenstehenden Button:",
"verification_email_verify_email": "E-Mail bestätigen",
"verification_new_email_subject": "E-Mail-Änderungsbestätigung",
"verification_security_notice": "Wenn du diese E-Mail-Änderung nicht angefordert hast, ignoriere bitte diese E-Mail oder kontaktiere sofort den Support.",
"verified_link_survey_email_subject": "Deine Umfrage ist bereit zum Ausfüllen.",
"weekly_summary_create_reminder_notification_body_cal_slot": "Wähle einen 15-minütigen Termin im Kalender unseres Gründers aus.",
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Lass keine Woche vergehen, ohne etwas über deine Nutzer zu lernen:",

View File

@@ -7,6 +7,16 @@
"continue_with_oidc": "Continue with {oidcDisplayName}",
"continue_with_openid": "Continue with OpenID",
"continue_with_saml": "Continue with SAML SSO",
"email-change": {
"confirm_password_description": "Please confirm your password before changing your email address",
"email_already_exists": "This email is already in use",
"email_change_success": "Email changed successfully",
"email_change_success_description": "You have successfully changed your email address. Please log in with your new email address.",
"email_verification_failed": "Email verification failed",
"invalid_or_expired_token": "Email change failed. Your token is invalid or expired.",
"new_email": "New Email",
"old_email": "Old Email"
},
"forgot-password": {
"back_to_login": "Back to login",
"email-sent": {
@@ -82,7 +92,7 @@
"please_click_the_link_in_the_email_to_activate_your_account": "Please click the link in the email to activate your account.",
"please_confirm_your_email_address": "Please confirm your email address",
"resend_verification_email": "Resend verification email",
"verification_email_successfully_sent": "Verification email successfully sent. Please check your inbox.",
"verification_email_successfully_sent": "Verification email sent to {email}. Please verify to complete the update.",
"we_sent_an_email_to": "We sent an email to {email}. ",
"you_didnt_receive_an_email_or_your_link_expired": "You didn't receive an email or your link expired?"
},
@@ -451,6 +461,7 @@
"live_survey_notification_view_more_responses": "View {responseCount} more Responses",
"live_survey_notification_view_previous_responses": "View previous responses",
"live_survey_notification_view_response": "View Response",
"new_email_verification_text": "To verify your new email address, please click the button below:",
"notification_footer_all_the_best": "All the best,",
"notification_footer_in_your_settings": "in your settings \uD83D\uDE4F",
"notification_footer_please_turn_them_off": "please turn them off",
@@ -500,6 +511,8 @@
"verification_email_thanks": "Thanks for validating your email!",
"verification_email_to_fill_survey": "To fill out the survey please click on the button below:",
"verification_email_verify_email": "Verify email",
"verification_new_email_subject": "Email change verification",
"verification_security_notice": "If you did not request this email change, please ignore this email or contact support immediately.",
"verified_link_survey_email_subject": "Your survey is ready to be filled out.",
"weekly_summary_create_reminder_notification_body_cal_slot": "Pick a 15-minute slot in our CEOs calendar",
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Don't let a week pass without learning about your users:",

View File

@@ -7,6 +7,16 @@
"continue_with_oidc": "Continuer avec {oidcDisplayName}",
"continue_with_openid": "Continuer avec OpenID",
"continue_with_saml": "Continuer avec SAML SSO",
"email-change": {
"confirm_password_description": "Veuillez confirmer votre mot de passe avant de changer votre adresse e-mail",
"email_already_exists": "Cet e-mail est déjà utilisé",
"email_change_success": "E-mail changé avec succès",
"email_change_success_description": "Vous avez changé votre adresse e-mail avec succès. Veuillez vous connecter avec votre nouvelle adresse e-mail.",
"email_verification_failed": "Échec de la vérification de l'email",
"invalid_or_expired_token": "Échec du changement d'email. Votre jeton est invalide ou expiré.",
"new_email": "Nouvel Email",
"old_email": "Ancien Email"
},
"forgot-password": {
"back_to_login": "Retour à la connexion",
"email-sent": {
@@ -82,7 +92,7 @@
"please_click_the_link_in_the_email_to_activate_your_account": "Veuillez cliquer sur le lien dans l'e-mail pour activer votre compte.",
"please_confirm_your_email_address": "Veuillez confirmer votre adresse e-mail.",
"resend_verification_email": "Renvoyer l'email de vérification",
"verification_email_successfully_sent": "Email de vérification envoyé avec succès. Veuillez vérifier votre boîte de réception.",
"verification_email_successfully_sent": "Email de vérification envoyé à {email}. Veuillez vérifier pour compléter la mise à jour.",
"we_sent_an_email_to": "Nous avons envoyé un email à {email}",
"you_didnt_receive_an_email_or_your_link_expired": "Vous n'avez pas reçu d'email ou votre lien a expiré ?"
},
@@ -451,6 +461,7 @@
"live_survey_notification_view_more_responses": "Voir {responseCount} réponses supplémentaires",
"live_survey_notification_view_previous_responses": "Voir les réponses précédentes",
"live_survey_notification_view_response": "Voir la réponse",
"new_email_verification_text": "Pour confirmer votre nouvelle adresse e-mail, veuillez cliquer sur le bouton ci-dessous :",
"notification_footer_all_the_best": "Tous mes vœux,",
"notification_footer_in_your_settings": "dans vos paramètres \uD83D\uDE4F",
"notification_footer_please_turn_them_off": "veuillez les éteindre",
@@ -500,6 +511,8 @@
"verification_email_thanks": "Merci de valider votre email !",
"verification_email_to_fill_survey": "Pour remplir le questionnaire, veuillez cliquer sur le bouton ci-dessous :",
"verification_email_verify_email": "Vérifier l'email",
"verification_new_email_subject": "Vérification du changement d'email",
"verification_security_notice": "Si vous n'avez pas demandé ce changement d'email, veuillez ignorer cet email ou contacter le support immédiatement.",
"verified_link_survey_email_subject": "Votre enquête est prête à être remplie.",
"weekly_summary_create_reminder_notification_body_cal_slot": "Choisissez un créneau de 15 minutes dans le calendrier de notre PDG.",
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Ne laissez pas une semaine passer sans en apprendre davantage sur vos utilisateurs :",

View File

@@ -7,6 +7,16 @@
"continue_with_oidc": "Continuar com {oidcDisplayName}",
"continue_with_openid": "Continuar com OpenID",
"continue_with_saml": "Continuar com SAML SSO",
"email-change": {
"confirm_password_description": "Por favor, confirme sua senha antes de mudar seu endereço de e-mail",
"email_already_exists": "Este e-mail já está em uso",
"email_change_success": "E-mail alterado com sucesso",
"email_change_success_description": "Você alterou seu endereço de e-mail com sucesso. Por favor, faça login com seu novo endereço de e-mail.",
"email_verification_failed": "Falha na verificação do e-mail",
"invalid_or_expired_token": "Falha na alteração do e-mail. Seu token é inválido ou expirou.",
"new_email": "Novo Email",
"old_email": "Email Antigo"
},
"forgot-password": {
"back_to_login": "Voltar para o login",
"email-sent": {
@@ -82,7 +92,7 @@
"please_click_the_link_in_the_email_to_activate_your_account": "Por favor, clica no link do e-mail pra ativar sua conta.",
"please_confirm_your_email_address": "Por favor, confirme seu endereço de e-mail",
"resend_verification_email": "Reenviar e-mail de verificação",
"verification_email_successfully_sent": "Email de verificação enviado com sucesso. Por favor, verifique sua caixa de entrada.",
"verification_email_successfully_sent": "E-mail de verificação enviado para {email}. Verifique para concluir a atualização.",
"we_sent_an_email_to": "Enviamos um email para {email}",
"you_didnt_receive_an_email_or_your_link_expired": "Você não recebeu um e-mail ou seu link expirou?"
},
@@ -451,6 +461,7 @@
"live_survey_notification_view_more_responses": "Ver mais {responseCount} respostas",
"live_survey_notification_view_previous_responses": "Ver respostas anteriores",
"live_survey_notification_view_response": "Ver Resposta",
"new_email_verification_text": "Para verificar seu novo endereço de e-mail, clique no botão abaixo:",
"notification_footer_all_the_best": "Tudo de bom,",
"notification_footer_in_your_settings": "nas suas configurações \uD83D\uDE4F",
"notification_footer_please_turn_them_off": "por favor, desliga eles",
@@ -500,6 +511,8 @@
"verification_email_thanks": "Valeu por validar seu e-mail!",
"verification_email_to_fill_survey": "Para preencher a pesquisa, por favor clique no botão abaixo:",
"verification_email_verify_email": "Verificar e-mail",
"verification_new_email_subject": "Verificação de alteração de e-mail",
"verification_security_notice": "Se você não solicitou essa mudança de email, por favor ignore este email ou entre em contato com o suporte imediatamente.",
"verified_link_survey_email_subject": "Sua pesquisa está pronta para ser preenchida.",
"weekly_summary_create_reminder_notification_body_cal_slot": "Escolha um horário de 15 minutos na agenda do nosso CEO",
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Não deixe uma semana passar sem aprender sobre seus usuários:",

View File

@@ -7,6 +7,16 @@
"continue_with_oidc": "Continuar com {oidcDisplayName}",
"continue_with_openid": "Continuar com OpenID",
"continue_with_saml": "Continuar com SAML SSO",
"email-change": {
"confirm_password_description": "Por favor, confirme a sua palavra-passe antes de alterar o seu endereço de email",
"email_already_exists": "Este email já está a ser utilizado",
"email_change_success": "Email alterado com sucesso",
"email_change_success_description": "Alterou com sucesso o seu endereço de email. Por favor, inicie sessão com o seu novo endereço de email.",
"email_verification_failed": "Falha na verificação do email",
"invalid_or_expired_token": "Falha na alteração do email. O seu token é inválido ou expirou.",
"new_email": "Novo Email",
"old_email": "Email Antigo"
},
"forgot-password": {
"back_to_login": "Voltar ao login",
"email-sent": {
@@ -82,7 +92,7 @@
"please_click_the_link_in_the_email_to_activate_your_account": "Por favor, clique no link no email para ativar a sua conta.",
"please_confirm_your_email_address": "Por favor, confirme o seu endereço de email",
"resend_verification_email": "Reenviar email de verificação",
"verification_email_successfully_sent": "Email de verificação enviado com sucesso. Por favor, verifique a sua caixa de entrada.",
"verification_email_successfully_sent": "Email de verificação enviado para {email}. Por favor, verifique para completar a atualização.",
"we_sent_an_email_to": "Enviámos um email para {email}. ",
"you_didnt_receive_an_email_or_your_link_expired": "Não recebeu um email ou o seu link expirou?"
},
@@ -451,6 +461,7 @@
"live_survey_notification_view_more_responses": "Ver mais {responseCount} respostas",
"live_survey_notification_view_previous_responses": "Ver respostas anteriores",
"live_survey_notification_view_response": "Ver Resposta",
"new_email_verification_text": "Para verificar o seu novo endereço de email, por favor clique no botão abaixo:",
"notification_footer_all_the_best": "Tudo de bom,",
"notification_footer_in_your_settings": "nas suas definições \uD83D\uDE4F",
"notification_footer_please_turn_them_off": "por favor, desative-os",
@@ -500,6 +511,8 @@
"verification_email_thanks": "Obrigado por validar o seu email!",
"verification_email_to_fill_survey": "Para preencher o questionário, clique no botão abaixo:",
"verification_email_verify_email": "Verificar email",
"verification_new_email_subject": "Verificação de alteração de email",
"verification_security_notice": "Se não solicitou esta alteração de email, ignore este email ou contacte o suporte imediatamente.",
"verified_link_survey_email_subject": "O seu inquérito está pronto para ser preenchido.",
"weekly_summary_create_reminder_notification_body_cal_slot": "Escolha um intervalo de 15 minutos no calendário do nosso CEO",
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Não deixe passar uma semana sem aprender sobre os seus utilizadores:",

View File

@@ -7,6 +7,16 @@
"continue_with_oidc": "使用 '{'oidcDisplayName'}' 繼續",
"continue_with_openid": "使用 OpenID 繼續",
"continue_with_saml": "使用 SAML SSO 繼續",
"email-change": {
"confirm_password_description": "在更改您的電子郵件地址之前,請確認您的密碼",
"email_already_exists": "此電子郵件地址已被使用",
"email_change_success": "電子郵件已成功更改",
"email_change_success_description": "您已成功更改電子郵件地址。請使用您的新電子郵件地址登入。",
"email_verification_failed": "電子郵件驗證失敗",
"invalid_or_expired_token": "電子郵件更改失敗。您的 token 無效或已過期。",
"new_email": "新 電子郵件",
"old_email": "舊 電子郵件"
},
"forgot-password": {
"back_to_login": "返回登入",
"email-sent": {
@@ -82,7 +92,7 @@
"please_click_the_link_in_the_email_to_activate_your_account": "請點擊電子郵件中的連結以啟用您的帳戶。",
"please_confirm_your_email_address": "請確認您的電子郵件地址",
"resend_verification_email": "重新發送驗證電子郵件",
"verification_email_successfully_sent": "驗證電子郵件已成功發送。請檢查您的收件匣。",
"verification_email_successfully_sent": "验证电子邮件已发送至 {email}。请验证以完成更新。",
"we_sent_an_email_to": "我們已發送一封電子郵件至 <email>'{'email'}'</email>。",
"you_didnt_receive_an_email_or_your_link_expired": "您沒有收到電子郵件或您的連結已過期?"
},
@@ -451,6 +461,7 @@
"live_survey_notification_view_more_responses": "檢視另外 '{'responseCount'}' 個回應",
"live_survey_notification_view_previous_responses": "檢視先前的回應",
"live_survey_notification_view_response": "檢視回應",
"new_email_verification_text": "要驗證您的新電子郵件地址,請點擊下面的按鈕:",
"notification_footer_all_the_best": "祝您一切順利,",
"notification_footer_in_your_settings": "在您的設定中 \uD83D\uDE4F",
"notification_footer_please_turn_them_off": "請關閉它們",
@@ -500,6 +511,8 @@
"verification_email_thanks": "感謝您驗證您的電子郵件!",
"verification_email_to_fill_survey": "若要填寫問卷,請點擊下方的按鈕:",
"verification_email_verify_email": "驗證電子郵件",
"verification_new_email_subject": "電子郵件更改驗證",
"verification_security_notice": "如果您沒有要求更改此電子郵件,請忽略此電子郵件或立即聯繫支援。",
"verified_link_survey_email_subject": "您的 survey 已準備好填寫。",
"weekly_summary_create_reminder_notification_body_cal_slot": "在我們 CEO 的日曆中選擇一個 15 分鐘的時段",
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "不要讓一週過去而沒有了解您的使用者:",

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

@@ -75,6 +75,9 @@ describe("getMetadataForLinkSurvey", () => {
title: mockSurveyName,
images: [mockOgImageUrl],
},
alternates: {
canonical: `/s/${mockSurveyId}`,
},
});
});
@@ -147,6 +150,9 @@ describe("getMetadataForLinkSurvey", () => {
title: mockSurveyName,
images: [mockOgImageUrl],
},
alternates: {
canonical: `/s/${mockSurveyId}`,
},
});
});
@@ -174,6 +180,9 @@ describe("getMetadataForLinkSurvey", () => {
title: mockSurveyName,
images: [mockOgImageUrl],
},
alternates: {
canonical: `/s/${mockSurveyId}`,
},
});
});
});

View File

@@ -27,8 +27,13 @@ export const getMetadataForLinkSurvey = async (surveyId: string): Promise<Metada
baseMetadata.twitter.images = [ogImgURL];
}
const canonicalPath = `/s/${surveyId}`;
return {
title: survey.name,
...baseMetadata,
alternates: {
canonical: canonicalPath,
},
};
};

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

@@ -58,29 +58,30 @@ export function OpenTextQuestion({
}, [isCurrent, autoFocusEnabled]);
const handleInputChange = (inputValue: string) => {
inputRef.current?.setCustomValidity("");
setCurrentLength(inputValue.length);
onChange({ [question.id]: inputValue });
};
const handleInputResize = (event: { target: any }) => {
const maxHeight = 160; // 8 lines
const textarea = event.target;
textarea.style.height = "auto";
const newHeight = Math.min(textarea.scrollHeight, maxHeight);
textarea.style.height = `${newHeight}px`;
textarea.style.overflow = newHeight >= maxHeight ? "auto" : "hidden";
const handleOnSubmit = (e: Event) => {
e.preventDefault();
const input = inputRef.current;
input?.setCustomValidity("");
if (question.required && (!value || value.trim() === "")) {
input?.setCustomValidity("Please fill out this field.");
input?.reportValidity();
return;
}
// at this point, validity is clean
const updatedTtc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtc);
onSubmit({ [question.id]: value }, updatedTtc);
};
return (
<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 ? (
@@ -139,7 +140,6 @@ export function OpenTextQuestion({
value={value}
onInput={(e) => {
handleInputChange(e.currentTarget.value);
handleInputResize(e);
}}
className="fb-border-border placeholder:fb-text-placeholder fb-bg-input-bg fb-text-subheading focus:fb-border-brand fb-rounded-custom fb-block fb-w-full fb-border fb-p-2 fb-shadow-sm focus:fb-ring-0 sm:fb-text-sm"
title={question.inputType === "phone" ? "Please enter a valid phone number" : undefined}

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