mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-22 06:00:51 -06:00
Compare commits
20 Commits
fix-data-r
...
feat-reset
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93c72df4d9 | ||
|
|
49560ccba8 | ||
|
|
3f98283d4d | ||
|
|
7b64422a3f | ||
|
|
a7ee1f189f | ||
|
|
46a590311b | ||
|
|
0faeffb624 | ||
|
|
d9727a336a | ||
|
|
330e0db668 | ||
|
|
f5b7f73199 | ||
|
|
c02f070307 | ||
|
|
bc489e050a | ||
|
|
3062059ed5 | ||
|
|
f27ede6b2c | ||
|
|
e460ff5100 | ||
|
|
4699c0014b | ||
|
|
52f69be05d | ||
|
|
619c0983a4 | ||
|
|
964fb8d4f4 | ||
|
|
5391c60bba |
@@ -1,3 +1,4 @@
|
||||
import { forgotPasswordAction } from "@/modules/auth/forgot-password/actions";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -24,6 +25,8 @@ const mockUser = {
|
||||
objective: "other",
|
||||
} as unknown as TUser;
|
||||
|
||||
vi.mock("next-auth/react", () => ({ signOut: vi.fn() }));
|
||||
|
||||
// Mock window.location.reload
|
||||
const originalLocation = window.location;
|
||||
beforeEach(() => {
|
||||
@@ -37,6 +40,10 @@ vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/act
|
||||
updateUserAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/forgot-password/actions", () => ({
|
||||
forgotPasswordAction: vi.fn(),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -50,7 +57,13 @@ 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} emailVerificationDisabled={true} />);
|
||||
render(
|
||||
<EditProfileDetailsForm
|
||||
user={mockUser}
|
||||
emailVerificationDisabled={true}
|
||||
isPasswordResetEnabled={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("common.full_name");
|
||||
expect(nameInput).toHaveValue(mockUser.name);
|
||||
@@ -91,7 +104,13 @@ describe("EditProfileDetailsForm", () => {
|
||||
const errorMessage = "Update failed";
|
||||
vi.mocked(updateUserAction).mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />);
|
||||
render(
|
||||
<EditProfileDetailsForm
|
||||
user={mockUser}
|
||||
emailVerificationDisabled={false}
|
||||
isPasswordResetEnabled={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("common.full_name");
|
||||
await userEvent.clear(nameInput);
|
||||
@@ -109,7 +128,13 @@ describe("EditProfileDetailsForm", () => {
|
||||
});
|
||||
|
||||
test("update button is disabled initially and enables on change", async () => {
|
||||
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />);
|
||||
render(
|
||||
<EditProfileDetailsForm
|
||||
user={mockUser}
|
||||
emailVerificationDisabled={false}
|
||||
isPasswordResetEnabled={false}
|
||||
/>
|
||||
);
|
||||
const updateButton = screen.getByText("common.update");
|
||||
expect(updateButton).toBeDisabled();
|
||||
|
||||
@@ -117,4 +142,63 @@ describe("EditProfileDetailsForm", () => {
|
||||
await userEvent.type(nameInput, " updated");
|
||||
expect(updateButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test("reset password button works", async () => {
|
||||
vi.mocked(forgotPasswordAction).mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<EditProfileDetailsForm
|
||||
user={mockUser}
|
||||
emailVerificationDisabled={false}
|
||||
isPasswordResetEnabled={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" });
|
||||
await userEvent.click(resetButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(forgotPasswordAction).toHaveBeenCalledWith({ email: mockUser.email });
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith("auth.forgot-password.email-sent.heading");
|
||||
});
|
||||
});
|
||||
|
||||
test("reset password button handles error correctly", async () => {
|
||||
const errorMessage = "Reset failed";
|
||||
vi.mocked(forgotPasswordAction).mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
render(
|
||||
<EditProfileDetailsForm
|
||||
user={mockUser}
|
||||
emailVerificationDisabled={false}
|
||||
isPasswordResetEnabled={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" });
|
||||
await userEvent.click(resetButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(forgotPasswordAction).toHaveBeenCalledWith({ email: mockUser.email });
|
||||
});
|
||||
});
|
||||
|
||||
test("reset password button shows loading state", async () => {
|
||||
vi.mocked(forgotPasswordAction).mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||
|
||||
render(
|
||||
<EditProfileDetailsForm
|
||||
user={mockUser}
|
||||
emailVerificationDisabled={false}
|
||||
isPasswordResetEnabled={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const resetButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" });
|
||||
await userEvent.click(resetButton);
|
||||
|
||||
expect(resetButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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 { forgotPasswordAction } from "@/modules/auth/forgot-password/actions";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
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";
|
||||
@@ -30,13 +32,17 @@ const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email:
|
||||
});
|
||||
type TEditProfileNameForm = z.infer<typeof ZEditProfileNameFormSchema>;
|
||||
|
||||
interface IEditProfileDetailsFormProps {
|
||||
user: TUser;
|
||||
isPasswordResetEnabled?: boolean;
|
||||
emailVerificationDisabled: boolean;
|
||||
}
|
||||
|
||||
export const EditProfileDetailsForm = ({
|
||||
user,
|
||||
isPasswordResetEnabled,
|
||||
emailVerificationDisabled,
|
||||
}: {
|
||||
user: TUser;
|
||||
emailVerificationDisabled: boolean;
|
||||
}) => {
|
||||
}: IEditProfileDetailsFormProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
const form = useForm<TEditProfileNameForm>({
|
||||
@@ -50,6 +56,8 @@ export const EditProfileDetailsForm = ({
|
||||
});
|
||||
|
||||
const { isSubmitting, isDirty } = form.formState;
|
||||
|
||||
const [isResettingPassword, setIsResettingPassword] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
|
||||
@@ -121,6 +129,24 @@ export const EditProfileDetailsForm = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (!user.email) return;
|
||||
|
||||
setIsResettingPassword(true);
|
||||
|
||||
await forgotPasswordAction({ email: user.email });
|
||||
|
||||
toast.success(t("auth.forgot-password.email-sent.heading"));
|
||||
await signOutWithAudit({
|
||||
reason: "password_reset",
|
||||
redirectUrl: "/auth/login",
|
||||
redirect: true,
|
||||
callbackUrl: "/auth/login",
|
||||
});
|
||||
|
||||
setIsResettingPassword(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormProvider {...form}>
|
||||
@@ -205,6 +231,26 @@ export const EditProfileDetailsForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{isPasswordResetEnabled && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label htmlFor="reset-password">{t("auth.forgot-password.reset_password")}</Label>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{t("auth.forgot-password.reset_password_description")}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Input type="email" id="reset-password" defaultValue={user.email} disabled />
|
||||
<Button
|
||||
onClick={handleResetPassword}
|
||||
loading={isResettingPassword}
|
||||
disabled={isResettingPassword}
|
||||
size="default"
|
||||
variant="secondary">
|
||||
{t("auth.forgot-password.reset_password")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-4"
|
||||
|
||||
@@ -12,7 +12,8 @@ import Page from "./page";
|
||||
|
||||
// Mock services and utils
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: true,
|
||||
IS_FORMBRICKS_CLOUD: 1,
|
||||
PASSWORD_RESET_DISABLED: 1,
|
||||
EMAIL_VERIFICATION_DISABLED: true,
|
||||
}));
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
|
||||
@@ -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 { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } 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";
|
||||
@@ -32,6 +32,8 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
throw new Error(t("common.user_not_found"));
|
||||
}
|
||||
|
||||
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.account_settings")}>
|
||||
@@ -42,7 +44,11 @@ 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 emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED} user={user} />
|
||||
<EditProfileDetailsForm
|
||||
user={user}
|
||||
emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED}
|
||||
isPasswordResetEnabled={isPasswordResetEnabled}
|
||||
/>
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title={t("common.avatar")}
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"text": "Du kannst Dich jetzt mit deinem neuen Passwort einloggen"
|
||||
}
|
||||
},
|
||||
"reset_password": "Passwort zurücksetzen"
|
||||
"reset_password": "Passwort zurücksetzen",
|
||||
"reset_password_description": "Du wirst abgemeldet, um dein Passwort zurückzusetzen."
|
||||
},
|
||||
"invite": {
|
||||
"create_account": "Konto erstellen",
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"text": "You can now log in with your new password"
|
||||
}
|
||||
},
|
||||
"reset_password": "Reset password"
|
||||
"reset_password": "Reset password",
|
||||
"reset_password_description": "You will be logged out to reset your password."
|
||||
},
|
||||
"invite": {
|
||||
"create_account": "Create an account",
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"text": "Vous pouvez maintenant vous connecter avec votre nouveau mot de passe."
|
||||
}
|
||||
},
|
||||
"reset_password": "Réinitialiser le mot de passe"
|
||||
"reset_password": "Réinitialiser le mot de passe",
|
||||
"reset_password_description": "Vous serez déconnecté pour réinitialiser votre mot de passe."
|
||||
},
|
||||
"invite": {
|
||||
"create_account": "Créer un compte",
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"text": "Agora você pode fazer login com sua nova senha"
|
||||
}
|
||||
},
|
||||
"reset_password": "Redefinir senha"
|
||||
"reset_password": "Redefinir senha",
|
||||
"reset_password_description": "Você será desconectado para redefinir sua senha."
|
||||
},
|
||||
"invite": {
|
||||
"create_account": "Cria uma conta",
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"text": "Pode agora iniciar sessão com a sua nova palavra-passe"
|
||||
}
|
||||
},
|
||||
"reset_password": "Redefinir palavra-passe"
|
||||
"reset_password": "Redefinir palavra-passe",
|
||||
"reset_password_description": "Será desconectado para redefinir a sua palavra-passe."
|
||||
},
|
||||
"invite": {
|
||||
"create_account": "Criar uma conta",
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"text": "您現在可以使用新密碼登入"
|
||||
}
|
||||
},
|
||||
"reset_password": "重設密碼"
|
||||
"reset_password": "重設密碼",
|
||||
"reset_password_description": "您將被登出以重設您的密碼。"
|
||||
},
|
||||
"invite": {
|
||||
"create_account": "建立帳戶",
|
||||
|
||||
@@ -13,7 +13,13 @@ export const logSignOutAction = async (
|
||||
userId: string,
|
||||
userEmail: string,
|
||||
context: {
|
||||
reason?: "user_initiated" | "account_deletion" | "email_change" | "session_timeout" | "forced_logout";
|
||||
reason?:
|
||||
| "user_initiated"
|
||||
| "account_deletion"
|
||||
| "email_change"
|
||||
| "session_timeout"
|
||||
| "forced_logout"
|
||||
| "password_reset";
|
||||
redirectUrl?: string;
|
||||
organizationId?: string;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||
import { actionClient } from "@/lib/utils/action-client";
|
||||
import { getUserByEmail } from "@/modules/auth/lib/user";
|
||||
import { sendForgotPasswordEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { ZUserEmail } from "@formbricks/types/user";
|
||||
|
||||
const ZForgotPasswordAction = z.object({
|
||||
@@ -13,9 +15,15 @@ const ZForgotPasswordAction = z.object({
|
||||
export const forgotPasswordAction = actionClient
|
||||
.schema(ZForgotPasswordAction)
|
||||
.action(async ({ parsedInput }) => {
|
||||
const user = await getUserByEmail(parsedInput.email);
|
||||
if (user) {
|
||||
await sendForgotPasswordEmail(user);
|
||||
if (PASSWORD_RESET_DISABLED) {
|
||||
throw new OperationNotAllowedError("Password reset is disabled");
|
||||
}
|
||||
return { success: true };
|
||||
|
||||
const user = await getUserByEmail(parsedInput.email);
|
||||
|
||||
if (!user || user.identityProvider !== "email") {
|
||||
return;
|
||||
}
|
||||
|
||||
await sendForgotPasswordEmail(user);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,13 @@ import { signOut } from "next-auth/react";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
interface UseSignOutOptions {
|
||||
reason?: "user_initiated" | "account_deletion" | "email_change" | "session_timeout" | "forced_logout";
|
||||
reason?:
|
||||
| "user_initiated"
|
||||
| "account_deletion"
|
||||
| "email_change"
|
||||
| "session_timeout"
|
||||
| "forced_logout"
|
||||
| "password_reset";
|
||||
redirectUrl?: string;
|
||||
organizationId?: string;
|
||||
redirect?: boolean;
|
||||
|
||||
@@ -78,6 +78,7 @@ export const getUserByEmail = reactCache(async (email: string) => {
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
isActive: true,
|
||||
identityProvider: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -283,7 +283,13 @@ export const logSignOut = (
|
||||
userId: string,
|
||||
userEmail: string,
|
||||
context?: {
|
||||
reason?: "user_initiated" | "account_deletion" | "email_change" | "session_timeout" | "forced_logout";
|
||||
reason?:
|
||||
| "user_initiated"
|
||||
| "account_deletion"
|
||||
| "email_change"
|
||||
| "session_timeout"
|
||||
| "forced_logout"
|
||||
| "password_reset";
|
||||
redirectUrl?: string;
|
||||
organizationId?: string;
|
||||
}
|
||||
|
||||
@@ -60,11 +60,7 @@ const getRatingContent = (scale: string, i: number, range: number, isColorCoding
|
||||
);
|
||||
}
|
||||
if (scale === "number") {
|
||||
return (
|
||||
<Text className="m-0 h-[44px] text-center text-[14px] leading-[44px]">
|
||||
{i + 1}
|
||||
</Text>
|
||||
);
|
||||
return <Text className="m-0 h-[44px] text-center text-[14px] leading-[44px]">{i + 1}</Text>;
|
||||
}
|
||||
if (scale === "star") {
|
||||
return <Text className="m-auto text-3xl">⭐</Text>;
|
||||
@@ -232,8 +228,8 @@ export async function PreviewEmailTemplate({
|
||||
{ "rounded-l-lg border-l": i === 0 },
|
||||
{ "rounded-r-lg": i === firstQuestion.range - 1 },
|
||||
firstQuestion.isColorCodingEnabled &&
|
||||
firstQuestion.scale === "number" &&
|
||||
`border border-t-[6px] border-t-${getRatingNumberOptionColor(firstQuestion.range, i + 1)}`,
|
||||
firstQuestion.scale === "number" &&
|
||||
`border border-t-[6px] border-t-${getRatingNumberOptionColor(firstQuestion.range, i + 1)}`,
|
||||
firstQuestion.scale === "star" && "border-transparent"
|
||||
)}
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${(i + 1).toString()}`}
|
||||
|
||||
@@ -129,9 +129,9 @@ export const QuestionFormInput = ({
|
||||
(question &&
|
||||
(id.includes(".")
|
||||
? // Handle nested properties
|
||||
(question[id.split(".")[0] as keyof TSurveyQuestion] as any)?.[id.split(".")[1]]
|
||||
(question[id.split(".")[0] as keyof TSurveyQuestion] as any)?.[id.split(".")[1]]
|
||||
: // Original behavior
|
||||
(question[id as keyof TSurveyQuestion] as TI18nString))) ||
|
||||
(question[id as keyof TSurveyQuestion] as TI18nString))) ||
|
||||
createI18nString("", surveyLanguageCodes)
|
||||
);
|
||||
}, [
|
||||
@@ -351,8 +351,9 @@ export const QuestionFormInput = ({
|
||||
<div className="h-10 w-full"></div>
|
||||
<div
|
||||
ref={highlightContainerRef}
|
||||
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent ${localSurvey.languages?.length > 1 ? "pr-24" : ""
|
||||
}`}
|
||||
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent ${
|
||||
localSurvey.languages?.length > 1 ? "pr-24" : ""
|
||||
}`}
|
||||
dir="auto"
|
||||
key={highlightedJSX.toString()}>
|
||||
{highlightedJSX}
|
||||
@@ -379,8 +380,9 @@ export const QuestionFormInput = ({
|
||||
maxLength={maxLength}
|
||||
ref={inputRef}
|
||||
onBlur={onBlur}
|
||||
className={`absolute top-0 text-black caret-black ${localSurvey.languages?.length > 1 ? "pr-24" : ""
|
||||
} ${className}`}
|
||||
className={`absolute top-0 text-black caret-black ${
|
||||
localSurvey.languages?.length > 1 ? "pr-24" : ""
|
||||
} ${className}`}
|
||||
isInvalid={
|
||||
isInvalid &&
|
||||
text[usedLanguageCode]?.trim() === "" &&
|
||||
|
||||
@@ -42,7 +42,7 @@ export const EndScreenForm = ({
|
||||
|
||||
const [showEndingCardCTA, setshowEndingCardCTA] = useState<boolean>(
|
||||
endingCard.type === "endScreen" &&
|
||||
(!!getLocalizedValue(endingCard.buttonLabel, selectedLanguageCode) || !!endingCard.buttonLink)
|
||||
(!!getLocalizedValue(endingCard.buttonLabel, selectedLanguageCode) || !!endingCard.buttonLink)
|
||||
);
|
||||
return (
|
||||
<form>
|
||||
|
||||
Reference in New Issue
Block a user