feat: added email change feature (#5837)

Co-authored-by: Paribesh01 <nepalparibesh01@gmail.com>
Co-authored-by: Paribesh Nepal <100255987+Paribesh01@users.noreply.github.com>
This commit is contained in:
Piyush Gupta
2025-05-21 16:53:12 +05:30
committed by GitHub
parent 745f5487e9
commit f7e5ef96d2
34 changed files with 1286 additions and 205 deletions

View File

@@ -214,5 +214,5 @@ UNKEY_ROOT_KEY=
# Configure the minimum role for user management from UI(owner, manager, disabled)
# USER_MANAGEMENT_MINIMUM_ROLE="manager"
# Configure the maximum age for the session in seconds. Default is 43200 (12 hours)
# SESSION_MAX_AGE=43200
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
# SESSION_MAX_AGE=86400

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

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

@@ -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": {
@@ -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": {
@@ -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": {
@@ -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": {
@@ -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": {
@@ -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": {
@@ -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

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

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

@@ -190,8 +190,8 @@ x-environment: &environment
# Configure the minimum role for user management from UI(owner, manager, disabled)
# USER_MANAGEMENT_MINIMUM_ROLE="manager"
# Configure the maximum age for the session in seconds. Default is 43200 (12 hours)
# SESSION_MAX_AGE=43200
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
# SESSION_MAX_AGE=86400
services:
postgres:

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