Compare commits

...

20 Commits

Author SHA1 Message Date
Piyush Gupta
93c72df4d9 fix: changes 2025-06-30 19:04:50 +05:30
Piyush Gupta
49560ccba8 fix: reset password email enumeration 2025-06-30 18:30:07 +05:30
Piyush Gupta
3f98283d4d fix: review changes 2025-06-30 17:10:30 +05:30
Piyush Gupta
7b64422a3f Merge branch 'main' of https://github.com/formbricks/formbricks into feat-resetpassword 2025-06-30 17:09:32 +05:30
Piyush Gupta
a7ee1f189f fix: docker build validation workflow 2025-05-13 17:04:41 +05:30
Piyush Gupta
46a590311b Merge branch 'main' of https://github.com/formbricks/formbricks into feat-resetpassword 2025-05-13 17:03:50 +05:30
Piyush Gupta
0faeffb624 Merge branch 'main' of https://github.com/formbricks/formbricks into feat-resetpassword 2025-05-12 17:10:02 +05:30
Piyush Gupta
d9727a336a Merge branch 'main' of https://github.com/formbricks/formbricks into feat-resetpassword 2025-05-12 13:50:29 +05:30
Piyush Gupta
330e0db668 Merge branch 'main' of https://github.com/formbricks/formbricks into feat-resetpassword 2025-05-12 10:58:53 +05:30
Piyush Gupta
f5b7f73199 test: enhance EditProfileDetailsForm tests with password reset functionality 2025-05-09 16:02:39 +05:30
Piyush Gupta
c02f070307 fix: functionality 2025-05-09 15:41:00 +05:30
Piyush Gupta
bc489e050a Merge branch 'main' of https://github.com/formbricks/formbricks into feat-resetpassword 2025-05-09 11:41:59 +05:30
Kunal Garg
3062059ed5 feat: added description and logout flow 2025-04-19 13:45:22 +05:30
Johannes
f27ede6b2c fix button 2025-04-15 08:48:31 +07:00
Piyush Gupta
e460ff5100 fix: error handling 2025-04-08 19:02:41 +05:30
Piyush Gupta
4699c0014b fix: reset password 2025-04-08 18:45:24 +05:30
Piyush Gupta
52f69be05d Merge branch 'main' of https://github.com/formbricks/formbricks into feat-resetpassword 2025-04-08 18:37:31 +05:30
Kunal Garg
619c0983a4 fix: input type fixed 2025-04-04 12:09:17 +05:30
Kunal Garg
964fb8d4f4 fix: html tag type 2025-04-03 15:44:52 +05:30
Kunal Garg
5391c60bba feat: reset password in accounts page 2025-04-03 15:29:58 +05:30
18 changed files with 205 additions and 37 deletions

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
import { 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")}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,7 +34,8 @@
"text": "您現在可以使用新密碼登入"
}
},
"reset_password": "重設密碼"
"reset_password": "重設密碼",
"reset_password_description": "您將被登出以重設您的密碼。"
},
"invite": {
"create_account": "建立帳戶",

View File

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

View File

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

View File

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

View File

@@ -78,6 +78,7 @@ export const getUserByEmail = reactCache(async (email: string) => {
email: true,
emailVerified: true,
isActive: true,
identityProvider: true,
},
});

View File

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

View File

@@ -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()}`}

View File

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

View File

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