mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-31 10:50:35 -06:00
Compare commits
11 Commits
buggy-long
...
v3.12.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87870919ca | ||
|
|
ce2fdde474 | ||
|
|
6e2f30c6ed | ||
|
|
5c8040008a | ||
|
|
639e25d679 | ||
|
|
f7e5ef96d2 | ||
|
|
745f5487e9 | ||
|
|
0e7f3adf53 | ||
|
|
342d2b1fc4 | ||
|
|
15279685f7 | ||
|
|
12aa959f50 |
@@ -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
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,17 +1,87 @@
|
||||
"use server";
|
||||
|
||||
import {
|
||||
getIsEmailUnique,
|
||||
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 { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
||||
import { sendVerificationNewEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZUserUpdateInput } from "@formbricks/types/user";
|
||||
import {
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
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 = {
|
||||
...(parsedInput.name && { name: parsedInput.name }),
|
||||
...(parsedInput.locale && { locale: parsedInput.locale }),
|
||||
};
|
||||
|
||||
// Only process email update if a new email is provided and it's different from current email
|
||||
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");
|
||||
}
|
||||
|
||||
// Check if the new email is unique, no user exists with the new email
|
||||
const isEmailUnique = await getIsEmailUnique(inputEmail);
|
||||
|
||||
// If the new email is unique, proceed with the email update
|
||||
if (isEmailUnique) {
|
||||
if (EMAIL_VERIFICATION_DISABLED) {
|
||||
payload.email = inputEmail;
|
||||
await updateBrevoCustomer({ id: ctx.user.id, email: inputEmail });
|
||||
} else {
|
||||
await sendVerificationNewEmail(ctx.user.id, inputEmail);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only proceed with updateUser if we have actual changes to make
|
||||
if (Object.keys(payload).length > 0) {
|
||||
await updateUser(ctx.user.id, payload);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const ZUpdateAvatarAction = z.object({
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,211 @@ 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, ZUserEmail } 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 }).extend({
|
||||
email: ZUserEmail.transform((val) => val?.trim().toLowerCase()),
|
||||
});
|
||||
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.new_email_verification_success"));
|
||||
} else {
|
||||
toast.success(t("environments.settings.profile.email_change_initiated"));
|
||||
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) {
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 { getIsEmailUnique, 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("getIsEmailUnique", () => {
|
||||
const email = "test@example.com";
|
||||
|
||||
test("should return false if user exists", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
id: "some-user-id",
|
||||
} as any);
|
||||
|
||||
const result = await getIsEmailUnique(email);
|
||||
expect(result).toBe(false);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { email },
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("should return true if user does not exist", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await getIsEmailUnique(email);
|
||||
expect(result).toBe(true);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { email },
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 getIsEmailUnique = reactCache(
|
||||
async (email: string): Promise<boolean> =>
|
||||
cache(
|
||||
async () => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: email.toLowerCase(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return !user;
|
||||
},
|
||||
[`getIsEmailUnique-${email}`],
|
||||
{
|
||||
tags: [userCache.tag.byEmail(email)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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(),
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { EmailChangeWithoutVerificationSuccessPage } from "@/modules/auth/email-change-without-verification-success/page";
|
||||
|
||||
export default EmailChangeWithoutVerificationSuccessPage;
|
||||
3
apps/web/app/(auth)/verify-email-change/page.tsx
Normal file
3
apps/web/app/(auth)/verify-email-change/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { VerifyEmailChangePage } from "@/modules/auth/verify-email-change/page";
|
||||
|
||||
export default VerifyEmailChangePage;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,18 @@
|
||||
"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",
|
||||
"email_verification_loading": "E-Mail-Bestätigung läuft...",
|
||||
"email_verification_loading_description": "Wir aktualisieren Ihre E-Mail-Adresse in unserem System. Dies kann einige Sekunden dauern.",
|
||||
"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": {
|
||||
@@ -78,11 +90,12 @@
|
||||
"verification-requested": {
|
||||
"invalid_email_address": "Ungültige E-Mail-Adresse",
|
||||
"invalid_token": "Ungültiges Token ☹️",
|
||||
"new_email_verification_success": "Wenn die Adresse gültig ist, wurde eine Bestätigungs-E-Mail gesendet.",
|
||||
"no_email_provided": "Keine E-Mail bereitgestellt",
|
||||
"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 +464,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 +514,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:",
|
||||
@@ -1136,11 +1152,13 @@
|
||||
"disable_two_factor_authentication": "Zwei-Faktor-Authentifizierung deaktivieren",
|
||||
"disable_two_factor_authentication_description": "Wenn Du die Zwei-Faktor-Authentifizierung deaktivieren musst, empfehlen wir, sie so schnell wie möglich wieder zu aktivieren.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Jeder Backup-Code kann genau einmal verwendet werden, um Zugang ohne deinen Authenticator zu gewähren.",
|
||||
"email_change_initiated": "Deine Anfrage zur Änderung der E-Mail wurde eingeleitet.",
|
||||
"enable_two_factor_authentication": "Zwei-Faktor-Authentifizierung aktivieren",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Gib den Code aus deiner Authentifizierungs-App unten ein.",
|
||||
"file_size_must_be_less_than_10mb": "Dateigröße muss weniger als 10MB sein.",
|
||||
"invalid_file_type": "Ungültiger Dateityp. Nur JPEG-, PNG- und WEBP-Dateien sind erlaubt.",
|
||||
"lost_access": "Zugriff verloren",
|
||||
"new_email_update_success": "Deine Anfrage zur Änderung der E-Mail wurde erhalten.",
|
||||
"or_enter_the_following_code_manually": "Oder gib den folgenden Code manuell ein:",
|
||||
"organization_identification": "Hilf deiner Organisation, Dich auf Formbricks zu identifizieren",
|
||||
"organizations_delete_message": "Du bist der einzige Besitzer dieser Organisationen, also werden sie <b>auch gelöscht.</b>",
|
||||
|
||||
@@ -7,6 +7,18 @@
|
||||
"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",
|
||||
"email_verification_loading": "Email verification in progress...",
|
||||
"email_verification_loading_description": "We are updating your email address in our system. This may take a few seconds.",
|
||||
"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": {
|
||||
@@ -78,11 +90,12 @@
|
||||
"verification-requested": {
|
||||
"invalid_email_address": "Invalid email address",
|
||||
"invalid_token": "Invalid token ☹️",
|
||||
"new_email_verification_success": "If the address is valid, a verification email has been sent.",
|
||||
"no_email_provided": "No email provided",
|
||||
"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 +464,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 +514,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:",
|
||||
@@ -971,7 +987,7 @@
|
||||
"2000_monthly_identified_users": "2000 Monthly Identified Users",
|
||||
"30000_monthly_identified_users": "30000 Monthly Identified Users",
|
||||
"3_projects": "3 Projects",
|
||||
"5000_monthly_responses": "5000 Monthly Responses",
|
||||
"5000_monthly_responses": "5,000 Monthly Responses",
|
||||
"5_projects": "5 Projects",
|
||||
"7500_monthly_identified_users": "7500 Monthly Identified Users",
|
||||
"advanced_targeting": "Advanced Targeting",
|
||||
@@ -1136,11 +1152,13 @@
|
||||
"disable_two_factor_authentication": "Disable two factor authentication",
|
||||
"disable_two_factor_authentication_description": "If you need to disable 2FA, we recommend re-enabling it as soon as possible.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Each backup code can be used exactly once to grant access without your authenticator.",
|
||||
"email_change_initiated": "Your email change request has been initiated.",
|
||||
"enable_two_factor_authentication": "Enable two factor authentication",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Enter the code from your authenticator app below.",
|
||||
"file_size_must_be_less_than_10mb": "File size must be less than 10MB.",
|
||||
"invalid_file_type": "Invalid file type. Only JPEG, PNG, and WEBP files are allowed.",
|
||||
"lost_access": "Lost access",
|
||||
"new_email_update_success": "Your email change request was received.",
|
||||
"or_enter_the_following_code_manually": "Or enter the following code manually:",
|
||||
"organization_identification": "Assist your organization in identifying you on Formbricks",
|
||||
"organizations_delete_message": "You are the only owner of these organizations, so they <b>will be deleted as well.</b>",
|
||||
|
||||
@@ -7,6 +7,18 @@
|
||||
"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",
|
||||
"email_verification_loading": "Vérification de l'email en cours...",
|
||||
"email_verification_loading_description": "Nous mettons à jour votre adresse email dans notre système. Cela peut prendre quelques secondes.",
|
||||
"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": {
|
||||
@@ -78,11 +90,12 @@
|
||||
"verification-requested": {
|
||||
"invalid_email_address": "Adresse e-mail invalide",
|
||||
"invalid_token": "Jeton non valide ☹️",
|
||||
"new_email_verification_success": "Si l'adresse est valide, un email de vérification a été envoyé.",
|
||||
"no_email_provided": "Aucun e-mail fourni",
|
||||
"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 +464,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 +514,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 :",
|
||||
@@ -971,7 +987,7 @@
|
||||
"2000_monthly_identified_users": "2000 Utilisateurs Identifiés Mensuels",
|
||||
"30000_monthly_identified_users": "30000 Utilisateurs Identifiés Mensuels",
|
||||
"3_projects": "3 Projets",
|
||||
"5000_monthly_responses": "5000 Réponses Mensuelles",
|
||||
"5000_monthly_responses": "5,000 Réponses Mensuelles",
|
||||
"5_projects": "5 Projets",
|
||||
"7500_monthly_identified_users": "7500 Utilisateurs Identifiés Mensuels",
|
||||
"advanced_targeting": "Ciblage Avancé",
|
||||
@@ -1136,11 +1152,13 @@
|
||||
"disable_two_factor_authentication": "Désactiver l'authentification à deux facteurs",
|
||||
"disable_two_factor_authentication_description": "Si vous devez désactiver l'authentification à deux facteurs, nous vous recommandons de la réactiver dès que possible.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Chaque code de sauvegarde peut être utilisé exactement une fois pour accorder l'accès sans votre authentificateur.",
|
||||
"email_change_initiated": "Votre demande de changement d'email a été initiée.",
|
||||
"enable_two_factor_authentication": "Activer l'authentification à deux facteurs",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Entrez le code de votre application d'authentification ci-dessous.",
|
||||
"file_size_must_be_less_than_10mb": "La taille du fichier doit être inférieure à 10 Mo.",
|
||||
"invalid_file_type": "Type de fichier invalide. Seuls les fichiers JPEG, PNG et WEBP sont autorisés.",
|
||||
"lost_access": "Accès perdu",
|
||||
"new_email_update_success": "Votre demande de changement d'email a été reçue.",
|
||||
"or_enter_the_following_code_manually": "Ou entrez le code suivant manuellement :",
|
||||
"organization_identification": "Aidez votre organisation à vous identifier sur Formbricks",
|
||||
"organizations_delete_message": "Tu es le seul propriétaire de ces organisations, elles <b>seront aussi supprimées.</b>",
|
||||
|
||||
@@ -7,6 +7,18 @@
|
||||
"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",
|
||||
"email_verification_loading": "Verificação de e-mail em andamento...",
|
||||
"email_verification_loading_description": "Estamos atualizando seu endereço de e-mail em nosso sistema. Isso pode levar alguns segundos.",
|
||||
"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": {
|
||||
@@ -78,11 +90,12 @@
|
||||
"verification-requested": {
|
||||
"invalid_email_address": "Endereço de email inválido",
|
||||
"invalid_token": "Token inválido ☹️",
|
||||
"new_email_verification_success": "Se o endereço for válido, um email de verificação foi enviado.",
|
||||
"no_email_provided": "Nenhum e-mail fornecido",
|
||||
"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 +464,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 +514,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:",
|
||||
@@ -971,7 +987,7 @@
|
||||
"2000_monthly_identified_users": "2000 Usuários Identificados Mensalmente",
|
||||
"30000_monthly_identified_users": "30000 Usuários Identificados Mensalmente",
|
||||
"3_projects": "3 Projetos",
|
||||
"5000_monthly_responses": "5000 Respostas Mensais",
|
||||
"5000_monthly_responses": "5,000 Respostas Mensais",
|
||||
"5_projects": "5 Projetos",
|
||||
"7500_monthly_identified_users": "7500 Usuários Identificados Mensalmente",
|
||||
"advanced_targeting": "Mira Avançada",
|
||||
@@ -1136,11 +1152,13 @@
|
||||
"disable_two_factor_authentication": "Desativar a autenticação de dois fatores",
|
||||
"disable_two_factor_authentication_description": "Se você precisar desativar a 2FA, recomendamos reativá-la o mais rápido possível.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Cada código de backup pode ser usado exatamente uma vez para conceder acesso sem o seu autenticador.",
|
||||
"email_change_initiated": "Sua solicitação de alteração de e-mail foi iniciada.",
|
||||
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Digite o código do seu app autenticador abaixo.",
|
||||
"file_size_must_be_less_than_10mb": "O tamanho do arquivo deve ser menor que 10MB.",
|
||||
"invalid_file_type": "Tipo de arquivo inválido. Só são permitidos arquivos JPEG, PNG e WEBP.",
|
||||
"lost_access": "Perdi o acesso",
|
||||
"new_email_update_success": "Sua solicitação de alteração de e-mail foi recebida.",
|
||||
"or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:",
|
||||
"organization_identification": "Ajude sua organização a te identificar no Formbricks",
|
||||
"organizations_delete_message": "Você é o único dono dessas organizações, então elas <b>também serão apagadas.</b>",
|
||||
|
||||
@@ -7,6 +7,18 @@
|
||||
"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",
|
||||
"email_verification_loading": "Verificação do email em progresso...",
|
||||
"email_verification_loading_description": "Estamos a atualizar o seu endereço de email no nosso sistema. Isto pode demorar alguns segundos.",
|
||||
"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": {
|
||||
@@ -78,11 +90,12 @@
|
||||
"verification-requested": {
|
||||
"invalid_email_address": "Endereço de email inválido",
|
||||
"invalid_token": "Token inválido ☹️",
|
||||
"new_email_verification_success": "Se o endereço for válido, um email de verificação foi enviado.",
|
||||
"no_email_provided": "Nenhum email fornecido",
|
||||
"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 +464,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 +514,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:",
|
||||
@@ -971,7 +987,7 @@
|
||||
"2000_monthly_identified_users": "2000 Utilizadores Identificados Mensalmente",
|
||||
"30000_monthly_identified_users": "30000 Utilizadores Identificados Mensalmente",
|
||||
"3_projects": "3 Projetos",
|
||||
"5000_monthly_responses": "5000 Respostas Mensais",
|
||||
"5000_monthly_responses": "5,000 Respostas Mensais",
|
||||
"5_projects": "5 Projetos",
|
||||
"7500_monthly_identified_users": "7500 Utilizadores Identificados Mensalmente",
|
||||
"advanced_targeting": "Segmentação Avançada",
|
||||
@@ -1136,11 +1152,13 @@
|
||||
"disable_two_factor_authentication": "Desativar autenticação de dois fatores",
|
||||
"disable_two_factor_authentication_description": "Se precisar de desativar a autenticação de dois fatores, recomendamos que a reative o mais rapidamente possível.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Cada código de backup pode ser usado exatamente uma vez para conceder acesso sem o seu autenticador.",
|
||||
"email_change_initiated": "O seu pedido de alteração de email foi iniciado.",
|
||||
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Introduza o código da sua aplicação de autenticação abaixo.",
|
||||
"file_size_must_be_less_than_10mb": "O tamanho do ficheiro deve ser inferior a 10MB.",
|
||||
"invalid_file_type": "Tipo de ficheiro inválido. Apenas são permitidos ficheiros JPEG, PNG e WEBP.",
|
||||
"lost_access": "Perdeu o acesso",
|
||||
"new_email_update_success": "O seu pedido de alteração de email foi recebido.",
|
||||
"or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:",
|
||||
"organization_identification": "Ajude a sua organização a identificá-lo no Formbricks",
|
||||
"organizations_delete_message": "É o único proprietário destas organizações, por isso <b>também serão eliminadas.</b>",
|
||||
|
||||
@@ -7,6 +7,18 @@
|
||||
"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": "電子郵件驗證失敗",
|
||||
"email_verification_loading": "電子郵件驗證進行中...",
|
||||
"email_verification_loading_description": "我們正在系統中更新您的電子郵件地址。這可能需要幾秒鐘。",
|
||||
"invalid_or_expired_token": "電子郵件更改失敗。您的 token 無效或已過期。",
|
||||
"new_email": "新 電子郵件",
|
||||
"old_email": "舊 電子郵件"
|
||||
},
|
||||
"forgot-password": {
|
||||
"back_to_login": "返回登入",
|
||||
"email-sent": {
|
||||
@@ -78,11 +90,12 @@
|
||||
"verification-requested": {
|
||||
"invalid_email_address": "無效的電子郵件地址",
|
||||
"invalid_token": "無效的權杖 ☹️",
|
||||
"new_email_verification_success": "如果地址有效,驗證電子郵件已發送。",
|
||||
"no_email_provided": "未提供電子郵件",
|
||||
"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 +464,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 +514,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": "不要讓一週過去而沒有了解您的使用者:",
|
||||
@@ -1136,11 +1152,13 @@
|
||||
"disable_two_factor_authentication": "停用雙重驗證",
|
||||
"disable_two_factor_authentication_description": "如果您需要停用 2FA,我們建議您盡快重新啟用它。",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "每個備份碼只能使用一次,以便在沒有驗證器的情況下授予存取權限。",
|
||||
"email_change_initiated": "您的 email 更改請求已啟動。",
|
||||
"enable_two_factor_authentication": "啟用雙重驗證",
|
||||
"enter_the_code_from_your_authenticator_app_below": "在下方輸入您驗證器應用程式中的程式碼。",
|
||||
"file_size_must_be_less_than_10mb": "檔案大小必須小於 10MB。",
|
||||
"invalid_file_type": "無效的檔案類型。僅允許 JPEG、PNG 和 WEBP 檔案。",
|
||||
"lost_access": "無法存取",
|
||||
"new_email_update_success": "您的 email 更改請求已收到。",
|
||||
"or_enter_the_following_code_manually": "或手動輸入下列程式碼:",
|
||||
"organization_identification": "協助您的組織在 Formbricks 上識別您",
|
||||
"organizations_delete_message": "您是這些組織的唯一擁有者,因此它們也 <b>將被刪除。</b>",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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("/");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Response } from "node-fetch";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { createBrevoCustomer } from "./brevo";
|
||||
import { createBrevoCustomer, updateBrevoCustomer } from "./brevo";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
BREVO_API_KEY: "mock_api_key",
|
||||
@@ -42,18 +41,87 @@ describe("createBrevoCustomer", () => {
|
||||
|
||||
await createBrevoCustomer({ id: "123", email: "test@example.com" });
|
||||
|
||||
expect(validateInputs).toHaveBeenCalled();
|
||||
expect(loggerSpy).toHaveBeenCalledWith(expect.any(Error), "Error sending user to Brevo");
|
||||
});
|
||||
|
||||
test("should log the error response if fetch status is not 200", async () => {
|
||||
test("should log the error response if fetch status is not 201", async () => {
|
||||
const loggerSpy = vi.spyOn(logger, "error");
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce(
|
||||
new Response("Bad Request", { status: 400, statusText: "Bad Request" })
|
||||
new global.Response("Bad Request", { status: 400, statusText: "Bad Request" })
|
||||
);
|
||||
|
||||
await createBrevoCustomer({ id: "123", email: "test@example.com" });
|
||||
|
||||
expect(validateInputs).toHaveBeenCalled();
|
||||
expect(loggerSpy).toHaveBeenCalledWith({ errorText: "Bad Request" }, "Error sending user to Brevo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateBrevoCustomer", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should return early if BREVO_API_KEY is not defined", async () => {
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
BREVO_API_KEY: undefined,
|
||||
BREVO_LIST_ID: "123",
|
||||
}));
|
||||
|
||||
const { updateBrevoCustomer } = await import("./brevo"); // Re-import to get the mocked version
|
||||
|
||||
const result = await updateBrevoCustomer({ id: "user123", email: "test@example.com" });
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
expect(validateInputs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should log an error if fetch fails", async () => {
|
||||
const loggerSpy = vi.spyOn(logger, "error");
|
||||
|
||||
vi.mocked(global.fetch).mockRejectedValueOnce(new Error("Fetch failed"));
|
||||
|
||||
await updateBrevoCustomer({ id: "user123", email: "test@example.com" });
|
||||
|
||||
expect(validateInputs).toHaveBeenCalled();
|
||||
expect(loggerSpy).toHaveBeenCalledWith(expect.any(Error), "Error updating user in Brevo");
|
||||
});
|
||||
|
||||
test("should log the error response if fetch status is not 204", async () => {
|
||||
const loggerSpy = vi.spyOn(logger, "error");
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce(
|
||||
new global.Response("Bad Request", { status: 400, statusText: "Bad Request" })
|
||||
);
|
||||
|
||||
await updateBrevoCustomer({ id: "user123", email: "test@example.com" });
|
||||
|
||||
expect(validateInputs).toHaveBeenCalled();
|
||||
expect(loggerSpy).toHaveBeenCalledWith({ errorText: "Bad Request" }, "Error updating user in Brevo");
|
||||
});
|
||||
|
||||
test("should successfully update a Brevo customer", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce(new global.Response(null, { status: 204 }));
|
||||
|
||||
await updateBrevoCustomer({ id: "user123", email: "test@example.com" });
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://api.brevo.com/v3/contacts/user123?identifierType=ext_id",
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"api-key": "mock_api_key",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
attributes: { EMAIL: "test@example.com" },
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(validateInputs).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,26 @@ import { logger } from "@formbricks/logger";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { TUserEmail, ZUserEmail } from "@formbricks/types/user";
|
||||
|
||||
type BrevoCreateContact = {
|
||||
email?: string;
|
||||
ext_id?: string;
|
||||
attributes?: Record<string, string | string[]>;
|
||||
emailBlacklisted?: boolean;
|
||||
smsBlacklisted?: boolean;
|
||||
listIds?: number[];
|
||||
updateEnabled?: boolean;
|
||||
smtpBlacklistSender?: string[];
|
||||
};
|
||||
|
||||
type BrevoUpdateContact = {
|
||||
attributes?: Record<string, string>;
|
||||
emailBlacklisted?: boolean;
|
||||
smsBlacklisted?: boolean;
|
||||
listIds?: number[];
|
||||
unlinkListIds?: number[];
|
||||
smtpBlacklistSender?: string[];
|
||||
};
|
||||
|
||||
export const createBrevoCustomer = async ({ id, email }: { id: string; email: TUserEmail }) => {
|
||||
if (!BREVO_API_KEY) {
|
||||
return;
|
||||
@@ -12,7 +32,7 @@ export const createBrevoCustomer = async ({ id, email }: { id: string; email: TU
|
||||
validateInputs([id, ZId], [email, ZUserEmail]);
|
||||
|
||||
try {
|
||||
const requestBody: any = {
|
||||
const requestBody: BrevoCreateContact = {
|
||||
email,
|
||||
ext_id: id,
|
||||
updateEnabled: false,
|
||||
@@ -34,7 +54,7 @@ export const createBrevoCustomer = async ({ id, email }: { id: string; email: TU
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
if (res.status !== 201) {
|
||||
const errorText = await res.text();
|
||||
logger.error({ errorText }, "Error sending user to Brevo");
|
||||
}
|
||||
@@ -42,3 +62,36 @@ export const createBrevoCustomer = async ({ id, email }: { id: string; email: TU
|
||||
logger.error(error, "Error sending user to Brevo");
|
||||
}
|
||||
};
|
||||
|
||||
export const updateBrevoCustomer = async ({ id, email }: { id: string; email: TUserEmail }) => {
|
||||
if (!BREVO_API_KEY) {
|
||||
return;
|
||||
}
|
||||
|
||||
validateInputs([id, ZId], [email, ZUserEmail]);
|
||||
|
||||
try {
|
||||
const requestBody: BrevoUpdateContact = {
|
||||
attributes: {
|
||||
EMAIL: email,
|
||||
},
|
||||
};
|
||||
|
||||
const res = await fetch(`https://api.brevo.com/v3/contacts/${id}?identifierType=ext_id`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
"api-key": BREVO_API_KEY,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (res.status !== 204) {
|
||||
const errorText = await res.text();
|
||||
logger.error({ errorText }, "Error updating user in Brevo");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error, "Error updating user in Brevo");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
23
apps/web/modules/auth/verify-email-change/actions.ts
Normal file
23
apps/web/modules/auth/verify-email-change/actions.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
"use server";
|
||||
|
||||
import { verifyEmailChangeToken } from "@/lib/jwt";
|
||||
import { actionClient } from "@/lib/utils/action-client";
|
||||
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
||||
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");
|
||||
}
|
||||
await updateBrevoCustomer({ id: user.id, email: user.email });
|
||||
return user;
|
||||
});
|
||||
@@ -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_loading")).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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
"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";
|
||||
|
||||
interface EmailChangeSignInProps {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export const EmailChangeSignIn = ({ token }: EmailChangeSignInProps) => {
|
||||
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]);
|
||||
|
||||
const text = {
|
||||
heading: {
|
||||
success: t("auth.email-change.email_change_success"),
|
||||
error: t("auth.email-change.email_verification_failed"),
|
||||
loading: t("auth.email-change.email_verification_loading"),
|
||||
},
|
||||
description: {
|
||||
success: t("auth.email-change.email_change_success_description"),
|
||||
error: t("auth.email-change.invalid_or_expired_token"),
|
||||
loading: t("auth.email-change.email_verification_loading_description"),
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className={`leading-2 mb-4 text-center font-bold ${status === "error" ? "text-red-600" : ""}`}>
|
||||
{text.heading[status]}
|
||||
</h1>
|
||||
<p className="text-center text-sm">{text.description[status]}</p>
|
||||
<hr className="my-4" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
47
apps/web/modules/auth/verify-email-change/page.test.tsx
Normal file
47
apps/web/modules/auth/verify-email-change/page.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
16
apps/web/modules/auth/verify-email-change/page.tsx
Normal file
16
apps/web/modules/auth/verify-email-change/page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -200,11 +200,11 @@ export const TeamSettingsModal = ({
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
noPadding
|
||||
className="overflow-visible"
|
||||
className="flex max-h-[90dvh] flex-col overflow-visible"
|
||||
size="md"
|
||||
hideCloseButton
|
||||
closeOnOutsideClick={true}>
|
||||
<div className="sticky top-0 flex h-full flex-col rounded-lg">
|
||||
<div className="sticky top-0 z-10 rounded-t-lg bg-slate-100">
|
||||
<button
|
||||
className={cn(
|
||||
"absolute right-0 top-0 hidden pr-4 pt-4 text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-0 sm:block"
|
||||
@@ -213,27 +213,27 @@ export const TeamSettingsModal = ({
|
||||
<XIcon className="h-6 w-6 rounded-md bg-white" />
|
||||
<span className="sr-only">Close</span>
|
||||
</button>
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
<div className="flex w-full items-center justify-between p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>
|
||||
<H4>
|
||||
{t("environments.settings.teams.team_name_settings_title", {
|
||||
teamName: team.name,
|
||||
})}
|
||||
</H4>
|
||||
<Muted className="text-slate-500">
|
||||
{t("environments.settings.teams.team_settings_description")}
|
||||
</Muted>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>
|
||||
<H4>
|
||||
{t("environments.settings.teams.team_name_settings_title", {
|
||||
teamName: team.name,
|
||||
})}
|
||||
</H4>
|
||||
<Muted className="text-slate-500">
|
||||
{t("environments.settings.teams.team_settings_description")}
|
||||
</Muted>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FormProvider {...form}>
|
||||
<form className="w-full" onSubmit={handleSubmit(handleUpdateTeam)}>
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<div className="max-h-[500px] space-y-6 overflow-y-auto">
|
||||
<form
|
||||
className="flex w-full flex-grow flex-col overflow-hidden"
|
||||
onSubmit={handleSubmit(handleUpdateTeam)}>
|
||||
<div className="flex-grow space-y-6 overflow-y-auto p-6">
|
||||
<div className="space-y-6">
|
||||
<FormField
|
||||
control={control}
|
||||
name="name"
|
||||
@@ -512,6 +512,8 @@ export const TeamSettingsModal = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sticky bottom-0 z-10 border-slate-200 p-6">
|
||||
<div className="flex justify-between">
|
||||
<Button size="default" type="button" variant="outline" onClick={closeSettingsModal}>
|
||||
{t("common.cancel")}
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -75,6 +75,18 @@ describe("getMetadataForLinkSurvey", () => {
|
||||
title: mockSurveyName,
|
||||
images: [mockOgImageUrl],
|
||||
},
|
||||
alternates: {
|
||||
canonical: `/s/${mockSurveyId}`,
|
||||
},
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: false,
|
||||
follow: true,
|
||||
noimageindex: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,6 +159,18 @@ describe("getMetadataForLinkSurvey", () => {
|
||||
title: mockSurveyName,
|
||||
images: [mockOgImageUrl],
|
||||
},
|
||||
alternates: {
|
||||
canonical: `/s/${mockSurveyId}`,
|
||||
},
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: false,
|
||||
follow: true,
|
||||
noimageindex: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -174,6 +198,18 @@ describe("getMetadataForLinkSurvey", () => {
|
||||
title: mockSurveyName,
|
||||
images: [mockOgImageUrl],
|
||||
},
|
||||
alternates: {
|
||||
canonical: `/s/${mockSurveyId}`,
|
||||
},
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: false,
|
||||
follow: true,
|
||||
noimageindex: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,8 +27,22 @@ export const getMetadataForLinkSurvey = async (surveyId: string): Promise<Metada
|
||||
baseMetadata.twitter.images = [ogImgURL];
|
||||
}
|
||||
|
||||
const canonicalPath = `/s/${surveyId}`;
|
||||
|
||||
return {
|
||||
title: survey.name,
|
||||
...baseMetadata,
|
||||
alternates: {
|
||||
canonical: canonicalPath,
|
||||
},
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: false,
|
||||
follow: true,
|
||||
noimageindex: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as React from "react";
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & { blur?: boolean }
|
||||
>(({ className, blur, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
@@ -35,7 +35,7 @@ const sizeClassName = {
|
||||
};
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & DialogContentProps
|
||||
>(
|
||||
(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Response_created_at_idx" ON "Response"("created_at");
|
||||
@@ -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])
|
||||
|
||||
@@ -5,7 +5,7 @@ interface LabelProps {
|
||||
|
||||
export function Label({ text, htmlForId }: Readonly<LabelProps>) {
|
||||
return (
|
||||
<label htmlFor={htmlForId} className="fb-text-subheading fb-font-normal fb-text-sm">
|
||||
<label htmlFor={htmlForId} className="fb-text-subheading fb-font-normal fb-text-sm fb-block" dir="auto">
|
||||
{text}
|
||||
</label>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -65,6 +65,7 @@ vi.mock("@/lib/utils", () => ({
|
||||
}
|
||||
return choices.map((choice: { id: string }) => choice.id);
|
||||
}),
|
||||
isRTL: vi.fn((text) => text.includes("rtl")),
|
||||
}));
|
||||
|
||||
describe("MultipleChoiceMultiQuestion", () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Subheader } from "@/components/general/subheader";
|
||||
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
|
||||
import { getLocalizedValue } from "@/lib/i18n";
|
||||
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
||||
import { cn, getShuffledChoicesIds } from "@/lib/utils";
|
||||
import { cn, getShuffledChoicesIds, isRTL } from "@/lib/utils";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import type { TSurveyMultipleChoiceQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
@@ -140,6 +140,12 @@ export function MultipleChoiceMultiQuestion({
|
||||
: question.required;
|
||||
};
|
||||
|
||||
const otherOptionDir = useMemo(() => {
|
||||
const placeholder = getLocalizedValue(question.otherOptionPlaceholder, languageCode);
|
||||
if (!otherValue) return isRTL(placeholder) ? "rtl" : "ltr";
|
||||
return "auto";
|
||||
}, [languageCode, question.otherOptionPlaceholder, otherValue]);
|
||||
|
||||
return (
|
||||
<form
|
||||
key={question.id}
|
||||
@@ -267,7 +273,7 @@ export function MultipleChoiceMultiQuestion({
|
||||
{otherSelected ? (
|
||||
<input
|
||||
ref={otherSpecify}
|
||||
dir="auto"
|
||||
dir={otherOptionDir}
|
||||
id={`${otherOption.id}-label`}
|
||||
maxLength={250}
|
||||
name={question.id}
|
||||
@@ -279,7 +285,9 @@ export function MultipleChoiceMultiQuestion({
|
||||
}}
|
||||
className="placeholder:fb-text-placeholder fb-border-border fb-bg-survey-bg fb-text-heading focus:fb-ring-focus fb-rounded-custom fb-mt-3 fb-flex fb-h-10 fb-w-full fb-border fb-px-3 fb-py-2 fb-text-sm focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 disabled:fb-cursor-not-allowed disabled:fb-opacity-50"
|
||||
placeholder={
|
||||
getLocalizedValue(question.otherOptionPlaceholder, languageCode) ?? "Please specify"
|
||||
getLocalizedValue(question.otherOptionPlaceholder, languageCode).length > 0
|
||||
? getLocalizedValue(question.otherOptionPlaceholder, languageCode)
|
||||
: "Please specify"
|
||||
}
|
||||
required={question.required}
|
||||
aria-labelledby={`${otherOption.id}-label`}
|
||||
|
||||
@@ -62,6 +62,7 @@ vi.mock("@/lib/ttc", () => ({
|
||||
vi.mock("@/lib/utils", () => ({
|
||||
cn: vi.fn((...args) => args.filter(Boolean).join(" ")),
|
||||
getShuffledChoicesIds: vi.fn((choices) => choices.map((choice: any) => choice.id)),
|
||||
isRTL: vi.fn((text) => text.includes("rtl")),
|
||||
}));
|
||||
|
||||
// Test data
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Subheader } from "@/components/general/subheader";
|
||||
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
|
||||
import { getLocalizedValue } from "@/lib/i18n";
|
||||
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
||||
import { cn, getShuffledChoicesIds } from "@/lib/utils";
|
||||
import { cn, getShuffledChoicesIds, isRTL } from "@/lib/utils";
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import type { TSurveyMultipleChoiceQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
@@ -100,6 +100,12 @@ export function MultipleChoiceSingleQuestion({
|
||||
}
|
||||
}, [otherSelected]);
|
||||
|
||||
const otherOptionDir = useMemo(() => {
|
||||
const placeholder = getLocalizedValue(question.otherOptionPlaceholder, languageCode);
|
||||
if (!value) return isRTL(placeholder) ? "rtl" : "ltr";
|
||||
return "auto";
|
||||
}, [languageCode, question.otherOptionPlaceholder, value]);
|
||||
|
||||
return (
|
||||
<form
|
||||
key={question.id}
|
||||
@@ -196,7 +202,7 @@ export function MultipleChoiceSingleQuestion({
|
||||
document.getElementById(otherOption.id)?.focus();
|
||||
}
|
||||
}}>
|
||||
<span className="fb-flex fb-items-center fb-text-sm">
|
||||
<span className="fb-flex fb-items-center fb-text-sm" dir="auto">
|
||||
<input
|
||||
tabIndex={-1}
|
||||
dir="auto"
|
||||
@@ -212,10 +218,7 @@ export function MultipleChoiceSingleQuestion({
|
||||
}}
|
||||
checked={otherSelected}
|
||||
/>
|
||||
<span
|
||||
id={`${otherOption.id}-label`}
|
||||
className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium"
|
||||
dir="auto">
|
||||
<span id={`${otherOption.id}-label`} className="fb-ml-3 fb-mr-3 fb-grow fb-font-medium">
|
||||
{getLocalizedValue(otherOption.label, languageCode)}
|
||||
</span>
|
||||
</span>
|
||||
@@ -223,7 +226,7 @@ export function MultipleChoiceSingleQuestion({
|
||||
<input
|
||||
ref={otherSpecify}
|
||||
id={`${otherOption.id}-label`}
|
||||
dir="auto"
|
||||
dir={otherOptionDir}
|
||||
name={question.id}
|
||||
pattern=".*\S+.*"
|
||||
value={value}
|
||||
|
||||
@@ -6,8 +6,9 @@ import { Subheader } from "@/components/general/subheader";
|
||||
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
|
||||
import { getLocalizedValue } from "@/lib/i18n";
|
||||
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
||||
import { isRTL } from "@/lib/utils";
|
||||
import { type RefObject } from "preact";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { type TResponseData, type TResponseTtc } from "@formbricks/types/responses";
|
||||
import type { TSurveyOpenTextQuestion, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
|
||||
@@ -58,29 +59,36 @@ 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);
|
||||
};
|
||||
|
||||
const dir = useMemo(() => {
|
||||
const placeholder = getLocalizedValue(question.placeholder, languageCode);
|
||||
if (!value) return isRTL(placeholder) ? "rtl" : "ltr";
|
||||
return "auto";
|
||||
}, [value, languageCode, question.placeholder]);
|
||||
|
||||
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 ? (
|
||||
@@ -104,7 +112,7 @@ export function OpenTextQuestion({
|
||||
name={question.id}
|
||||
id={question.id}
|
||||
placeholder={getLocalizedValue(question.placeholder, languageCode)}
|
||||
dir="auto"
|
||||
dir={dir}
|
||||
step="any"
|
||||
required={question.required}
|
||||
value={value ? value : ""}
|
||||
@@ -134,12 +142,11 @@ export function OpenTextQuestion({
|
||||
aria-label="textarea"
|
||||
id={question.id}
|
||||
placeholder={getLocalizedValue(question.placeholder, languageCode)}
|
||||
dir="auto"
|
||||
dir={dir}
|
||||
required={question.required}
|
||||
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}
|
||||
|
||||
@@ -199,7 +199,7 @@ export function RankingQuestion({
|
||||
)}>
|
||||
{(idx + 1).toString()}
|
||||
</span>
|
||||
<div className="fb-grow fb-shrink fb-font-medium fb-text-sm">
|
||||
<div className="fb-grow fb-shrink fb-font-medium fb-text-sm fb-text-start" dir="auto">
|
||||
{getLocalizedValue(item.label, languageCode)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -168,3 +168,12 @@ export const getDefaultLanguageCode = (survey: TJsEnvironmentStateSurvey): strin
|
||||
|
||||
// Function to convert file extension to its MIME type
|
||||
export const getMimeType = (extension: TAllowedFileExtension): string => mimeTypes[extension];
|
||||
|
||||
/**
|
||||
* Returns true if the string contains any RTL character.
|
||||
* @param text The input string to test
|
||||
*/
|
||||
export function isRTL(text: string): boolean {
|
||||
const rtlCharRegex = /[\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC]/;
|
||||
return rtlCharRegex.test(text);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -139,6 +139,7 @@
|
||||
"S3_REGION",
|
||||
"S3_SECRET_KEY",
|
||||
"SAML_DATABASE_URL",
|
||||
"SESSION_MAX_AGE",
|
||||
"SENTRY_DSN",
|
||||
"SLACK_CLIENT_ID",
|
||||
"SLACK_CLIENT_SECRET",
|
||||
|
||||
Reference in New Issue
Block a user