Compare commits

...

7 Commits

Author SHA1 Message Date
Dhruwang
e3be480374 fix: prevent language switch from breaking survey orientation and resetting language on auto-save
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 15:22:57 +05:30
Anshuman Pandey
44d5530b48 fix: adds formbricks instance on window (#7630) 2026-04-02 07:26:48 +00:00
Matti Nannt
a314eb391e chore: add Codex environment config (#7589) 2026-04-02 07:24:02 +00:00
Matti Nannt
6c34c316d0 docs: remove non-official self-hosting options from README.md 2026-04-01 14:16:47 +02:00
Matti Nannt
4f26278f16 docs: add German README summary (#7641) 2026-04-01 11:04:15 +02:00
Tiago
b975e7fa2e feat: Make password reset links single-use and revocable (#7627) 2026-04-01 07:12:37 +00:00
Johannes
6c3052f9e4 fix: correct CSAT template option order for question 2 (#7636)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-01 07:11:27 +00:00
50 changed files with 1562 additions and 181 deletions

View File

@@ -0,0 +1,9 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "formbricks"
[setup]
script = '''
pnpm install
pnpm dev:setup
'''

View File

@@ -94,6 +94,12 @@ EMAIL_VERIFICATION_DISABLED=1
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
PASSWORD_RESET_DISABLED=1
# Password reset token lifetime in minutes. Must be between 5 and 120 if set.
# PASSWORD_RESET_TOKEN_LIFETIME_MINUTES=30
# Development-only helper: log the password reset link to the server console instead of sending reset emails.
# DEBUG_SHOW_RESET_LINK=1
# Email login. Disable the ability for users to login with email.
# EMAIL_AUTH_DISABLED=1

2
.gitignore vendored
View File

@@ -45,7 +45,7 @@ yarn-error.log*
.direnv
# Playwright
/test-results/
**/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

View File

@@ -127,34 +127,10 @@ Formbricks has a hosted cloud offering with a generous free plan to get you up a
Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription.
If you opt for self-hosting Formbricks, here are a few options to consider:
#### Docker
To get started with self-hosting with Docker, take a look at our [self-hosting docs](https://formbricks.com/docs/self-hosting/deployment).
#### Community-managed One Click Hosting
##### Railway
You can deploy Formbricks on [Railway](https://railway.app) using the button below.
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template/PPDzCd)
##### RepoCloud
Or you can also deploy Formbricks on [RepoCloud](https://repocloud.io) using the button below.
[![Deploy on RepoCloud](https://d16t0pc4846x52.cloudfront.net/deploy.png)](https://repocloud.io/details/?app_id=254)
##### Zeabur
Or you can also deploy Formbricks on [Zeabur](https://zeabur.com) using the button below.
[![Deploy to Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/G4TUJL)
<a id="development"></a>
## 👨‍💻 Development
### Prerequisites
@@ -247,4 +223,4 @@ We currently do not offer Formbricks white-labeled. That means that we don't sel
The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come.
<p align="right"><a href="#top">🔼 Back to top</a></p>
<a id="readme-de"></a>

View File

@@ -10,15 +10,16 @@ import {
getIsEmailUnique,
verifyUserPassword,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { requestPasswordReset } from "@/modules/auth/forgot-password/lib/password-reset-service";
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
import { sendVerificationNewEmail } from "@/modules/email";
function buildUserUpdatePayload(parsedInput: TUserPersonalInfoUpdateInput): TUserUpdateInput {
return {
@@ -85,11 +86,15 @@ export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPerso
export const resetPasswordAction = authenticatedActionClient.action(
withAuditLogging("passwordReset", "user", async ({ ctx }) => {
if (PASSWORD_RESET_DISABLED) {
throw new OperationNotAllowedError("Password reset is disabled");
}
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
}
await sendForgotPasswordEmail(ctx.user);
await requestPasswordReset(ctx.user, "profile");
ctx.auditLoggingCtx.userId = ctx.user.id;

View File

@@ -2464,8 +2464,8 @@ checksums:
templates/csat_question_1_headline: bd4894e95695ce5bc9fc5d326c79bc90
templates/csat_question_1_lower_label: 54d464343c0bc17231fd51aa2d73623f
templates/csat_question_1_upper_label: 9f000f63949d875ae628fc354a2a7f6a
templates/csat_question_2_choice_1: a0cf57bc571c95c43924a3c641d1355e
templates/csat_question_2_choice_2: a3a49eb9cc86972bce6dc41a107f472d
templates/csat_question_2_choice_1: 0cb1260dd25e94f56c2da7ab21b0e0ae
templates/csat_question_2_choice_2: f12ed9d98c7965ab949efcc25f8ca85e
templates/csat_question_2_choice_3: a7c58d9b8afdaefadeb1f5fdf4d5ad3f
templates/csat_question_2_choice_4: d09723c4bc1d85d99c2a9248ed0d4578
templates/csat_question_2_choice_5: a89ca2602a3322e89adf17b3349e03ab

View File

@@ -27,7 +27,9 @@ export const IMPRINT_URL = env.IMPRINT_URL;
export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
export const DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS = env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS === "1";
export const DEBUG_SHOW_RESET_LINK = !IS_PRODUCTION && env.DEBUG_SHOW_RESET_LINK === "1";
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
export const PASSWORD_RESET_TOKEN_LIFETIME_MINUTES = env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES;
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
export const GOOGLE_OAUTH_ENABLED = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET);

77
apps/web/lib/env.test.ts Normal file
View File

@@ -0,0 +1,77 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
const ORIGINAL_ENV = process.env;
const setTestEnv = (overrides: Record<string, string | undefined> = {}) => {
process.env = {
...ORIGINAL_ENV,
NODE_ENV: "test",
DATABASE_URL: "https://example.com/db",
ENCRYPTION_KEY: "12345678901234567890123456789012",
...overrides,
};
};
describe("env", () => {
beforeEach(() => {
vi.resetModules();
});
afterEach(() => {
process.env = ORIGINAL_ENV;
});
test("uses the default password reset token lifetime when env var is not set", async () => {
setTestEnv({
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: undefined,
});
const { env } = await import("./env");
expect(env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES).toBe(30);
});
test("uses the configured password reset token lifetime", async () => {
setTestEnv({
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: "45",
});
const { env } = await import("./env");
expect(env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES).toBe(45);
});
test("fails to load when the password reset token lifetime is not an integer", async () => {
setTestEnv({
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: "30minutes",
});
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
});
test("fails to load when the password reset token lifetime is out of range", async () => {
setTestEnv({
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: "121",
});
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
});
test("allows enabling DEBUG_SHOW_RESET_LINK", async () => {
setTestEnv({
DEBUG_SHOW_RESET_LINK: "1",
});
const { env } = await import("./env");
expect(env.DEBUG_SHOW_RESET_LINK).toBe("1");
});
test("fails to load when DEBUG_SHOW_RESET_LINK is invalid", async () => {
setTestEnv({
DEBUG_SHOW_RESET_LINK: "true",
});
await expect(import("./env")).rejects.toThrow("Invalid environment variables");
});
});

View File

@@ -17,6 +17,7 @@ export const env = createEnv({
DATABASE_URL: z.url(),
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
DEBUG: z.enum(["1", "0"]).optional(),
DEBUG_SHOW_RESET_LINK: z.enum(["1", "0"]).optional(),
AUTH_DEFAULT_TEAM_ID: z.string().optional(),
AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(),
E2E_TESTING: z.enum(["1", "0"]).optional(),
@@ -61,6 +62,7 @@ export const env = createEnv({
? z.string().optional()
: z.url("REDIS_URL is required for caching, rate limiting, and audit logging"),
PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: z.coerce.number().int().min(5).max(120).optional().default(30),
PRIVACY_URL: z
.url()
.optional()
@@ -144,6 +146,7 @@ export const env = createEnv({
DATABASE_URL: process.env.DATABASE_URL,
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: process.env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS,
DEBUG: process.env.DEBUG,
DEBUG_SHOW_RESET_LINK: process.env.DEBUG_SHOW_RESET_LINK,
AUTH_DEFAULT_TEAM_ID: process.env.AUTH_SSO_DEFAULT_TEAM_ID,
AUTH_SKIP_INVITE_FOR_SSO: process.env.AUTH_SKIP_INVITE_FOR_SSO,
E2E_TESTING: process.env.E2E_TESTING,
@@ -183,6 +186,7 @@ export const env = createEnv({
OIDC_SIGNING_ALGORITHM: process.env.OIDC_SIGNING_ALGORITHM,
REDIS_URL: process.env.REDIS_URL,
PASSWORD_RESET_DISABLED: process.env.PASSWORD_RESET_DISABLED,
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: process.env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES,
PRIVACY_URL: process.env.PRIVACY_URL,
RATE_LIMITING_DISABLED: process.env.RATE_LIMITING_DISABLED,
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY,

View File

@@ -6,7 +6,9 @@ import {
AuthenticationError,
AuthorizationError,
EXPECTED_ERROR_NAMES,
INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
InvalidInputError,
InvalidPasswordResetTokenError,
OperationNotAllowedError,
ResourceNotFoundError,
TooManyRequestsError,
@@ -71,6 +73,7 @@ describe("isExpectedError (shared helper)", () => {
"AuthenticationError",
"OperationNotAllowedError",
"TooManyRequestsError",
"InvalidPasswordResetTokenError",
];
expect(EXPECTED_ERROR_NAMES.size).toBe(expected.length);
@@ -87,6 +90,7 @@ describe("isExpectedError (shared helper)", () => {
{ ErrorClass: InvalidInputError, args: ["Invalid input"] },
{ ErrorClass: ValidationError, args: ["Invalid data"] },
{ ErrorClass: OperationNotAllowedError, args: ["Not allowed"] },
{ ErrorClass: InvalidPasswordResetTokenError, args: [INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE] },
])("returns true for $ErrorClass.name", ({ ErrorClass, args }) => {
const error = new (ErrorClass as any)(...args);
expect(isExpectedError(error)).toBe(true);
@@ -174,6 +178,14 @@ describe("actionClient handleServerError", () => {
expect(result?.serverError).toBe("Not allowed");
expect(Sentry.captureException).not.toHaveBeenCalled();
});
test("InvalidPasswordResetTokenError returns its message and is not sent to Sentry", async () => {
const result = await executeThrowingAction(
new InvalidPasswordResetTokenError(INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE)
);
expect(result?.serverError).toBe(INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE);
expect(Sentry.captureException).not.toHaveBeenCalled();
});
});
describe("unexpected errors SHOULD be reported to Sentry", () => {

View File

@@ -505,7 +505,7 @@
"forgot_password_email_change_password": "Passwort ändern",
"forgot_password_email_did_not_request": "Wenn Du sie nicht angefordert hast, ignoriere bitte diese E-Mail.",
"forgot_password_email_heading": "Passwort ändern",
"forgot_password_email_link_valid_for_24_hours": "Der Link ist 24 Stunden gültig.",
"forgot_password_email_link_valid_for_24_hours": "Der Link ist {minutes} Minuten gültig.",
"forgot_password_email_subject": "Setz dein Formbricks-Passwort zurück",
"forgot_password_email_text": "Du hast einen Link angefordert, um dein Passwort zu ändern. Du kannst dies tun, indem Du auf den untenstehenden Link klickst:",
"hidden_field": "Verstecktes Feld",
@@ -2619,8 +2619,8 @@
"csat_question_1_headline": "Wie wahrscheinlich ist es, dass Du dieses $[projectName] einem Freund oder Kollegen empfehlen würdest?",
"csat_question_1_lower_label": "Nicht wahrscheinlich",
"csat_question_1_upper_label": "Sehr wahrscheinlich",
"csat_question_2_choice_1": "Etwas zufrieden",
"csat_question_2_choice_2": "Sehr zufrieden",
"csat_question_2_choice_1": "Sehr zufrieden",
"csat_question_2_choice_2": "Etwas zufrieden",
"csat_question_2_choice_3": "Weder zufrieden noch unzufrieden",
"csat_question_2_choice_4": "Etwas unzufrieden",
"csat_question_2_choice_5": "Sehr unzufrieden",

View File

@@ -505,7 +505,7 @@
"forgot_password_email_change_password": "Change password",
"forgot_password_email_did_not_request": "If you did not request this, please ignore this email.",
"forgot_password_email_heading": "Change password",
"forgot_password_email_link_valid_for_24_hours": "The link is valid for 24 hours.",
"forgot_password_email_link_valid_for_24_hours": "The link is valid for {minutes} minutes.",
"forgot_password_email_subject": "Reset your Formbricks password",
"forgot_password_email_text": "You have requested a link to change your password. You can do this by clicking the link below:",
"hidden_field": "Hidden field",
@@ -2619,8 +2619,8 @@
"csat_question_1_headline": "How likely is it that you would recommend this $[projectName] to a friend or colleague?",
"csat_question_1_lower_label": "Not likely",
"csat_question_1_upper_label": "Very likely",
"csat_question_2_choice_1": "Somewhat satisfied",
"csat_question_2_choice_2": "Very satisfied",
"csat_question_2_choice_1": "Very satisfied",
"csat_question_2_choice_2": "Somewhat satisfied",
"csat_question_2_choice_3": "Neither satisfied nor dissatisfied",
"csat_question_2_choice_4": "Somewhat dissatisfied",
"csat_question_2_choice_5": "Very dissatisfied",

View File

@@ -505,7 +505,7 @@
"forgot_password_email_change_password": "Cambiar contraseña",
"forgot_password_email_did_not_request": "Si no has solicitado esto, por favor ignora este correo electrónico.",
"forgot_password_email_heading": "Cambiar contraseña",
"forgot_password_email_link_valid_for_24_hours": "El enlace es válido durante 24 horas.",
"forgot_password_email_link_valid_for_24_hours": "El enlace es válido durante {minutes} minutos.",
"forgot_password_email_subject": "Restablece tu contraseña de Formbricks",
"forgot_password_email_text": "Has solicitado un enlace para cambiar tu contraseña. Puedes hacerlo haciendo clic en el enlace a continuación:",
"hidden_field": "Campo oculto",
@@ -2619,8 +2619,8 @@
"csat_question_1_headline": "¿Qué probabilidad hay de que recomiendes este $[projectName] a un amigo o colega?",
"csat_question_1_lower_label": "Poco probable",
"csat_question_1_upper_label": "Muy probable",
"csat_question_2_choice_1": "Algo satisfecho",
"csat_question_2_choice_2": "Muy satisfecho",
"csat_question_2_choice_1": "Muy satisfecho",
"csat_question_2_choice_2": "Algo satisfecho",
"csat_question_2_choice_3": "Ni satisfecho ni insatisfecho",
"csat_question_2_choice_4": "Algo insatisfecho",
"csat_question_2_choice_5": "Muy insatisfecho",

View File

@@ -505,7 +505,7 @@
"forgot_password_email_change_password": "Changer le mot de passe",
"forgot_password_email_did_not_request": "Si vous n'avez pas demandé cela, veuillez ignorer cet e-mail.",
"forgot_password_email_heading": "Changer le mot de passe",
"forgot_password_email_link_valid_for_24_hours": "Le lien est valable pendant 24 heures.",
"forgot_password_email_link_valid_for_24_hours": "Le lien est valable pendant {minutes} minutes.",
"forgot_password_email_subject": "Réinitialise ton mot de passe Formbricks",
"forgot_password_email_text": "Vous avez demandé un lien pour changer votre mot de passe. Vous pouvez le faire en cliquant sur le lien ci-dessous :",
"hidden_field": "Champ caché",
@@ -2619,8 +2619,8 @@
"csat_question_1_headline": "Quelle est la probabilité que vous recommandiez ce $[projectName] à un ami ou un collègue ?",
"csat_question_1_lower_label": "Peu probable",
"csat_question_1_upper_label": "Très probable",
"csat_question_2_choice_1": "Un peu satisfait",
"csat_question_2_choice_2": "Très satisfait",
"csat_question_2_choice_1": "Très satisfait",
"csat_question_2_choice_2": "Un peu satisfait",
"csat_question_2_choice_3": "Ni satisfait ni insatisfait",
"csat_question_2_choice_4": "Un peu insatisfait",
"csat_question_2_choice_5": "Très insatisfait",

View File

@@ -505,7 +505,7 @@
"forgot_password_email_change_password": "Jelszó megváltoztatása",
"forgot_password_email_did_not_request": "Ha Ön nem kérte ezt, akkor hagyja figyelmen kívül ezt a levelet.",
"forgot_password_email_heading": "Jelszó megváltoztatása",
"forgot_password_email_link_valid_for_24_hours": "A hivatkozás 24 órán keresztül érvényes.",
"forgot_password_email_link_valid_for_24_hours": "A hivatkozás {minutes} percig érvényes.",
"forgot_password_email_subject": "A Formbricks-jelszó visszaállítása",
"forgot_password_email_text": "Hivatkozást kért a jelszava megváltoztatásához. Ezt a lenti hivatkozásra kattintva teheti meg:",
"hidden_field": "Rejtett mező",
@@ -2619,8 +2619,8 @@
"csat_question_1_headline": "Mennyire valószínű, hogy ezt a(z) $[projectName] projektet ajánlaná egy ismerősnek vagy kollégának?",
"csat_question_1_lower_label": "Nem valószínű",
"csat_question_1_upper_label": "Nagyon valószínű",
"csat_question_2_choice_1": "Valamelyest elégedett",
"csat_question_2_choice_2": "Nagyon elégedett",
"csat_question_2_choice_1": "Nagyon elégedett",
"csat_question_2_choice_2": "Valamelyest elégedett",
"csat_question_2_choice_3": "Sem elégedett, sem elégedetlen",
"csat_question_2_choice_4": "Valamelyest elégedetlen",
"csat_question_2_choice_5": "Nagyon elégedetlen",

View File

@@ -505,7 +505,7 @@
"forgot_password_email_change_password": "パスワードを変更",
"forgot_password_email_did_not_request": "このリクエストに心当たりのない場合は、このメールを無視してください。",
"forgot_password_email_heading": "パスワードを変更",
"forgot_password_email_link_valid_for_24_hours": "このリンクは24時間有効です。",
"forgot_password_email_link_valid_for_24_hours": "このリンクは{minutes}分間有効です。",
"forgot_password_email_subject": "Formbricksのパスワードをリセットしてください",
"forgot_password_email_text": "パスワード変更のリンクがリクエストされました。以下のリンクをクリックして変更できます。",
"hidden_field": "非表示フィールド",
@@ -2619,8 +2619,8 @@
"csat_question_1_headline": "この$[projectName]を友人や同僚に勧める可能性はどのくらいありますか?",
"csat_question_1_lower_label": "可能性が低い",
"csat_question_1_upper_label": "可能性が非常に高い",
"csat_question_2_choice_1": "やや満足",
"csat_question_2_choice_2": "非常に満足",
"csat_question_2_choice_1": "非常に満足",
"csat_question_2_choice_2": "やや満足",
"csat_question_2_choice_3": "満足も不満もない",
"csat_question_2_choice_4": "やや不満",
"csat_question_2_choice_5": "非常に不満",

View File

@@ -505,7 +505,7 @@
"forgot_password_email_change_password": "Wachtwoord wijzigen",
"forgot_password_email_did_not_request": "Als u dit niet heeft aangevraagd, kunt u deze e-mail negeren.",
"forgot_password_email_heading": "Wachtwoord wijzigen",
"forgot_password_email_link_valid_for_24_hours": "De link is 24 uur geldig.",
"forgot_password_email_link_valid_for_24_hours": "De link is {minutes} minuten geldig.",
"forgot_password_email_subject": "Reset uw Formbricks-wachtwoord",
"forgot_password_email_text": "U heeft een link aangevraagd om uw wachtwoord te wijzigen. Dit kunt u doen door op onderstaande link te klikken:",
"hidden_field": "Verborgen veld",
@@ -2619,8 +2619,8 @@
"csat_question_1_headline": "Hoe waarschijnlijk is het dat u deze $[projectName] zou aanbevelen aan een vriend of collega?",
"csat_question_1_lower_label": "Niet waarschijnlijk",
"csat_question_1_upper_label": "Zeer waarschijnlijk",
"csat_question_2_choice_1": "Enigszins tevreden",
"csat_question_2_choice_2": "Zeer tevreden",
"csat_question_2_choice_1": "Zeer tevreden",
"csat_question_2_choice_2": "Enigszins tevreden",
"csat_question_2_choice_3": "Noch tevreden, noch ontevreden",
"csat_question_2_choice_4": "Enigszins ontevreden",
"csat_question_2_choice_5": "Zeer ontevreden",

View File

@@ -505,7 +505,7 @@
"forgot_password_email_change_password": "Mudar senha",
"forgot_password_email_did_not_request": "Se você não solicitou isso, por favor ignore este e-mail.",
"forgot_password_email_heading": "Mudar senha",
"forgot_password_email_link_valid_for_24_hours": "O link é válido por 24 horas.",
"forgot_password_email_link_valid_for_24_hours": "O link é válido por {minutes} minutos.",
"forgot_password_email_subject": "Redefinir sua senha Formbricks",
"forgot_password_email_text": "Você pediu um link pra trocar sua senha. Você pode fazer isso clicando no link abaixo:",
"hidden_field": "Campo oculto",
@@ -2619,8 +2619,8 @@
"csat_question_1_headline": "Qual a probabilidade de você recomendar este $[projectName] para um amigo ou colega?",
"csat_question_1_lower_label": "Pouco provável",
"csat_question_1_upper_label": "Muito provável",
"csat_question_2_choice_1": "Meio satisfeito",
"csat_question_2_choice_2": "Muito satisfeito",
"csat_question_2_choice_1": "Muito satisfeito",
"csat_question_2_choice_2": "Meio satisfeito",
"csat_question_2_choice_3": "Nem satisfeito nem insatisfeito",
"csat_question_2_choice_4": "Um pouco insatisfeito",
"csat_question_2_choice_5": "Muito insatisfeito",

View File

@@ -505,7 +505,7 @@
"forgot_password_email_change_password": "Alterar palavra-passe",
"forgot_password_email_did_not_request": "Se não solicitou isto, por favor ignore este email.",
"forgot_password_email_heading": "Alterar palavra-passe",
"forgot_password_email_link_valid_for_24_hours": "O link é válido por 24 horas.",
"forgot_password_email_link_valid_for_24_hours": "O link é válido por {minutes} minutos.",
"forgot_password_email_subject": "Redefina a sua palavra-passe do Formbricks",
"forgot_password_email_text": "Solicitou um link para alterar a sua palavra-passe. Pode fazê-lo clicando no link abaixo:",
"hidden_field": "Campo oculto",
@@ -2619,8 +2619,8 @@
"csat_question_1_headline": "Qual a probabilidade de recomendar este $[projectName] a um amigo ou colega?",
"csat_question_1_lower_label": "Pouco provável",
"csat_question_1_upper_label": "Muito provável",
"csat_question_2_choice_1": "Algo satisfeito",
"csat_question_2_choice_2": "Muito satisfeito",
"csat_question_2_choice_1": "Muito satisfeito",
"csat_question_2_choice_2": "Algo satisfeito",
"csat_question_2_choice_3": "Nem satisfeito nem insatisfeito",
"csat_question_2_choice_4": "Algo insatisfeito",
"csat_question_2_choice_5": "Muito insatisfeito",

View File

@@ -505,7 +505,7 @@
"forgot_password_email_change_password": "Schimbați parola",
"forgot_password_email_did_not_request": "Dacă nu ați solicitat acest lucru, vă rugăm să ignorați acest email.",
"forgot_password_email_heading": "Schimbați parola",
"forgot_password_email_link_valid_for_24_hours": "Linkul este valabil timp de 24 de ore.",
"forgot_password_email_link_valid_for_24_hours": "Linkul este valabil timp de {minutes} minute.",
"forgot_password_email_subject": "Resetați parola dumneavoastră Formbricks",
"forgot_password_email_text": "Ați solicitat un link pentru a vă schimba parola. Puteți face acest lucru făcând clic pe linkul de mai jos:",
"hidden_field": "Câmp ascuns",
@@ -2619,8 +2619,8 @@
"csat_question_1_headline": "Cât de probabil este ca să recomandați acest $[projectName] unui prieten sau coleg?",
"csat_question_1_lower_label": "Puțin probabil",
"csat_question_1_upper_label": "Foarte probabil",
"csat_question_2_choice_1": "Puțin mulțumit",
"csat_question_2_choice_2": "Foarte mulțumit",
"csat_question_2_choice_1": "Foarte mulțumit",
"csat_question_2_choice_2": "Puțin mulțumit",
"csat_question_2_choice_3": "Nici mulțumit, nici nemulțumit",
"csat_question_2_choice_4": "Ușor nemulțumit",
"csat_question_2_choice_5": "Foarte nemulțumit",

View File

@@ -505,7 +505,7 @@
"forgot_password_email_change_password": "Сменить пароль",
"forgot_password_email_did_not_request": "Если вы не запрашивали это, просто проигнорируйте это письмо.",
"forgot_password_email_heading": "Сменить пароль",
"forgot_password_email_link_valid_for_24_hours": "Ссылка действительна в течение 24 часов.",
"forgot_password_email_link_valid_for_24_hours": "Ссылка действительна в течение {minutes} минут.",
"forgot_password_email_subject": "Сбросьте свой пароль Formbricks",
"forgot_password_email_text": "Вы запросили ссылку для смены пароля. Вы можете сделать это, перейдя по ссылке ниже:",
"hidden_field": "Скрытое поле",
@@ -2619,8 +2619,8 @@
"csat_question_1_headline": "Насколько вероятно, что вы порекомендуете $[projectName] другу или коллеге?",
"csat_question_1_lower_label": "Маловероятно",
"csat_question_1_upper_label": "Очень вероятно",
"csat_question_2_choice_1": "В целом доволен",
"csat_question_2_choice_2": "Очень доволен",
"csat_question_2_choice_1": "Очень доволен",
"csat_question_2_choice_2": "В целом доволен",
"csat_question_2_choice_3": "Ни доволен, ни недоволен",
"csat_question_2_choice_4": "В целом недоволен",
"csat_question_2_choice_5": "Очень недоволен",

View File

@@ -505,7 +505,7 @@
"forgot_password_email_change_password": "Ändra lösenord",
"forgot_password_email_did_not_request": "Om du inte begärde detta, vänligen ignorera detta e-postmeddelande.",
"forgot_password_email_heading": "Ändra lösenord",
"forgot_password_email_link_valid_for_24_hours": "Länken är giltig i 24 timmar.",
"forgot_password_email_link_valid_for_24_hours": "Länken är giltig i {minutes} minuter.",
"forgot_password_email_subject": "Återställ ditt Formbricks-lösenord",
"forgot_password_email_text": "Du har begärt en länk för att ändra ditt lösenord. Du kan göra detta genom att klicka på länken nedan:",
"hidden_field": "Dolt fält",
@@ -2619,8 +2619,8 @@
"csat_question_1_headline": "Hur troligt är det att du skulle rekommendera $[projectName] till en vän eller kollega?",
"csat_question_1_lower_label": "Inte troligt",
"csat_question_1_upper_label": "Mycket troligt",
"csat_question_2_choice_1": "Ganska nöjd",
"csat_question_2_choice_2": "Mycket nöjd",
"csat_question_2_choice_1": "Mycket nöjd",
"csat_question_2_choice_2": "Ganska nöjd",
"csat_question_2_choice_3": "Varken nöjd eller missnöjd",
"csat_question_2_choice_4": "Ganska missnöjd",
"csat_question_2_choice_5": "Mycket missnöjd",

View File

@@ -505,7 +505,7 @@
"forgot_password_email_change_password": "更改 密码",
"forgot_password_email_did_not_request": "如果您 未 请求此 项 ,请 忽略 此邮件 。",
"forgot_password_email_heading": "更改 密码",
"forgot_password_email_link_valid_for_24_hours": "链接在 24 小时 内有效。",
"forgot_password_email_link_valid_for_24_hours": "链接在{minutes}分钟内有效。",
"forgot_password_email_subject": "重置您的 Formbricks 密码",
"forgot_password_email_text": "您 已 请求 一个 链接 来 更改 您的 密码。 您 可以 点击 下方 链接 完成 这个 操作:",
"hidden_field": "隐藏字段",
@@ -2619,8 +2619,8 @@
"csat_question_1_headline": "您有多大可能向朋友或同事推荐这款 $[projectName] ",
"csat_question_1_lower_label": "不可能",
"csat_question_1_upper_label": "非常 可能",
"csat_question_2_choice_1": "有点 满意",
"csat_question_2_choice_2": "非常 满意",
"csat_question_2_choice_1": "非常 满意",
"csat_question_2_choice_2": "有点 满意",
"csat_question_2_choice_3": "既不 满意 也 不 不满意",
"csat_question_2_choice_4": "有点 不满意",
"csat_question_2_choice_5": "非常 不 满意",

View File

@@ -505,7 +505,7 @@
"forgot_password_email_change_password": "變更密碼",
"forgot_password_email_did_not_request": "如果您沒有要求此操作,請忽略此電子郵件。",
"forgot_password_email_heading": "變更密碼",
"forgot_password_email_link_valid_for_24_hours": "此連結有效期為 24 小時。",
"forgot_password_email_link_valid_for_24_hours": "此連結有效期為 {minutes} 分鐘。",
"forgot_password_email_subject": "重設您的 Formbricks 密碼",
"forgot_password_email_text": "您已請求變更密碼的連結。您可以點擊以下連結來執行此操作:",
"hidden_field": "隱藏欄位",
@@ -2619,8 +2619,8 @@
"csat_question_1_headline": "您向朋友或同事推薦此 {projectName} 的可能性有多高?",
"csat_question_1_lower_label": "不太可能",
"csat_question_1_upper_label": "非常可能",
"csat_question_2_choice_1": "有點滿意",
"csat_question_2_choice_2": "非常滿意",
"csat_question_2_choice_1": "非常滿意",
"csat_question_2_choice_2": "有點滿意",
"csat_question_2_choice_3": "既不滿意也不不滿意",
"csat_question_2_choice_4": "有點不滿意",
"csat_question_2_choice_5": "非常不滿意",

View File

@@ -1,9 +1,10 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { requestPasswordReset } from "@/modules/auth/forgot-password/lib/password-reset-service";
import { getUserByEmail } from "@/modules/auth/lib/user";
// Import mocked functions
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { sendForgotPasswordEmail } from "@/modules/email";
import { forgotPasswordAction } from "./actions";
// Mock dependencies
@@ -27,8 +28,14 @@ vi.mock("@/modules/auth/lib/user", () => ({
getUserByEmail: vi.fn(),
}));
vi.mock("@/modules/email", () => ({
sendForgotPasswordEmail: vi.fn(),
vi.mock("@/modules/auth/forgot-password/lib/password-reset-service", () => ({
requestPasswordReset: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("@/lib/utils/action-client", () => ({
@@ -77,7 +84,7 @@ describe("forgotPasswordAction", () => {
);
expect(getUserByEmail).not.toHaveBeenCalled();
expect(sendForgotPasswordEmail).not.toHaveBeenCalled();
expect(requestPasswordReset).not.toHaveBeenCalled();
});
test("should use correct rate limit configuration", async () => {
@@ -104,39 +111,39 @@ describe("forgotPasswordAction", () => {
describe("Password Reset Flow", () => {
test("should send password reset email when user exists with email identity provider", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
expect(applyIPRateLimit).toHaveBeenCalled();
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
expect(sendForgotPasswordEmail).toHaveBeenCalledWith(mockUser);
expect(requestPasswordReset).toHaveBeenCalledWith(mockUser, "public");
expect(result).toEqual({ success: true });
});
test("should not send email when user doesn't exist", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
vi.mocked(getUserByEmail).mockResolvedValue(null);
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
expect(applyIPRateLimit).toHaveBeenCalled();
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
expect(sendForgotPasswordEmail).not.toHaveBeenCalled();
expect(requestPasswordReset).not.toHaveBeenCalled();
expect(result).toEqual({ success: true });
});
test("should not send email when user has non-email identity provider", async () => {
const ssoUser = { ...mockUser, identityProvider: "google" };
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
vi.mocked(getUserByEmail).mockResolvedValue(ssoUser as any);
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
expect(applyIPRateLimit).toHaveBeenCalled();
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
expect(sendForgotPasswordEmail).not.toHaveBeenCalled();
expect(requestPasswordReset).not.toHaveBeenCalled();
expect(result).toEqual({ success: true });
});
});
@@ -146,7 +153,7 @@ describe("forgotPasswordAction", () => {
// This test verifies that password reset is enabled by default
// The actual PASSWORD_RESET_DISABLED check is part of the implementation
// and we've mocked it as false, so rate limiting should work normally
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
@@ -168,7 +175,7 @@ describe("forgotPasswordAction", () => {
});
test("should handle user lookup errors after rate limiting", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
vi.mocked(getUserByEmail).mockRejectedValue(new Error("Database error"));
await expect(forgotPasswordAction({ parsedInput: validInput } as any)).rejects.toThrow(
@@ -178,23 +185,30 @@ describe("forgotPasswordAction", () => {
expect(applyIPRateLimit).toHaveBeenCalled();
});
test("should handle email sending errors after rate limiting", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
test("should propagate unexpected password reset request errors after rate limiting", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
vi.mocked(sendForgotPasswordEmail).mockRejectedValue(new Error("Email service error"));
vi.mocked(requestPasswordReset).mockRejectedValue(new Error("Password reset request failed"));
await expect(forgotPasswordAction({ parsedInput: validInput } as any)).rejects.toThrow(
"Email service error"
);
await expect(forgotPasswordAction({ parsedInput: validInput } as any)).resolves.toEqual({
success: true,
});
expect(applyIPRateLimit).toHaveBeenCalled();
expect(getUserByEmail).toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
stage: "dispatch",
userId: mockUser.id,
}),
"Password reset request failed"
);
});
});
describe("Security Considerations", () => {
test("should always return success even for non-existent users to prevent email enumeration", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
vi.mocked(getUserByEmail).mockResolvedValue(null);
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
@@ -204,17 +218,17 @@ describe("forgotPasswordAction", () => {
test("should always return success even for SSO users to prevent identity provider enumeration", async () => {
const ssoUser = { ...mockUser, identityProvider: "github" };
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
vi.mocked(getUserByEmail).mockResolvedValue(ssoUser as any);
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
expect(result).toEqual({ success: true });
expect(sendForgotPasswordEmail).not.toHaveBeenCalled();
expect(requestPasswordReset).not.toHaveBeenCalled();
});
test("should rate limit all requests regardless of user existence", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
// Test with existing user
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);

View File

@@ -1,14 +1,15 @@
"use server";
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { ZUserEmail } from "@formbricks/types/user";
import { PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { actionClient } from "@/lib/utils/action-client";
import { requestPasswordReset } from "@/modules/auth/forgot-password/lib/password-reset-service";
import { getUserByEmail } from "@/modules/auth/lib/user";
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { sendForgotPasswordEmail } from "@/modules/email";
const ZForgotPasswordAction = z.object({
email: ZUserEmail,
@@ -26,7 +27,11 @@ export const forgotPasswordAction = actionClient
const user = await getUserByEmail(parsedInput.email);
if (user && user.identityProvider === "email") {
await sendForgotPasswordEmail(user);
try {
await requestPasswordReset(user, "public");
} catch (error) {
logger.error({ error, stage: "dispatch", userId: user.id }, "Password reset request failed");
}
}
return { success: true };

View File

@@ -0,0 +1,477 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import {
INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
InvalidPasswordResetTokenError,
} from "@formbricks/types/errors";
import type { TUser } from "@formbricks/types/user";
import { hashPassword } from "@/lib/auth";
import { hashString } from "@/lib/hash-string";
import { sendPasswordResetLinkEmail, sendPasswordResetNotifyEmail } from "@/modules/email";
import {
ACCOUNT_RECOVERY_LINK_EMAIL_ERROR_CODE,
completePasswordReset,
getPasswordResetTokenLifetimeInMinutes,
requestPasswordReset,
} from "./password-reset-service";
import type { TPasswordResetTokenRecord } from "./password-reset-token-repository";
type TPasswordResetTestUser = Pick<TUser, "id" | "email" | "locale" | "emailVerified"> & {
password: string;
};
type TPasswordResetAuditUserFixture = Pick<
TPasswordResetTestUser,
"id" | "email" | "locale" | "emailVerified"
>;
type TPasswordResetTransactionStub = {
user: {
findUnique: (args: { where: { id: string } }) => Promise<TPasswordResetAuditUserFixture | null>;
update: (args: {
where: { id: string };
data: { password: string };
}) => Promise<TPasswordResetAuditUserFixture>;
};
};
const testState = vi.hoisted(() => {
const tokenStore = new Map<string, TPasswordResetTokenRecord>();
const users = new Map<string, TPasswordResetTestUser>();
const selectAuditUser = (user: TPasswordResetTestUser): TPasswordResetAuditUserFixture => ({
id: user.id,
email: user.email,
locale: user.locale,
emailVerified: user.emailVerified,
});
const mockUpsertActiveToken = vi.fn(async (userId: string, tokenHash: string, expiresAt: Date) => {
const existingRecord = tokenStore.get(userId);
const now = new Date();
const record = {
id: existingRecord?.id ?? `prt_${userId}`,
userId,
tokenHash,
expiresAt,
createdAt: existingRecord?.createdAt ?? now,
updatedAt: now,
};
tokenStore.set(userId, record);
return record;
});
const mockFindByTokenHash = vi.fn(async (tokenHash: string) => {
return [...tokenStore.values()].find((record) => record.tokenHash === tokenHash) ?? null;
});
const mockDeleteByTokenHash = vi.fn(async (tokenHash: string) => {
const existingRecord = [...tokenStore.values()].find((record) => record.tokenHash === tokenHash);
if (!existingRecord) {
return 0;
}
tokenStore.delete(existingRecord.userId);
return 1;
});
const mockConsumeActiveToken = vi.fn(async (tokenHash: string, now: Date) => {
const record = [...tokenStore.values()].find(
(storedRecord) => storedRecord.tokenHash === tokenHash && storedRecord.expiresAt > now
);
if (!record) {
return 0;
}
tokenStore.delete(record.userId);
return 1;
});
const mockTransaction = vi.fn(async <T>(callback: (tx: TPasswordResetTransactionStub) => Promise<T>) => {
const tx: TPasswordResetTransactionStub = {
user: {
findUnique: vi.fn(async ({ where }) => {
const user = users.get(where.id);
return user ? selectAuditUser(user) : null;
}),
update: vi.fn(async ({ where, data }) => {
const user = users.get(where.id);
if (!user) {
throw new Error("User not found");
}
const updatedUser = {
...user,
password: data.password,
};
users.set(where.id, updatedUser);
return selectAuditUser(updatedUser);
}),
},
};
return await callback(tx);
});
return {
tokenStore,
users,
mockUpsertActiveToken,
mockFindByTokenHash,
mockDeleteByTokenHash,
mockConsumeActiveToken,
mockTransaction,
};
});
const constantsState = vi.hoisted(() => ({
debugShowResetLink: false,
}));
vi.mock("@/lib/hash-string", () => ({
hashString: vi.fn((value: string) => `hash:${value}`),
}));
vi.mock("@/lib/constants", () => ({
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: 30,
WEBAPP_URL: "http://localhost:3000",
get DEBUG_SHOW_RESET_LINK() {
return constantsState.debugShowResetLink;
},
}));
vi.mock("@/lib/auth", () => ({
hashPassword: vi.fn(async (password: string) => `hashed:${password}`),
}));
vi.mock("@/modules/email", () => ({
sendPasswordResetLinkEmail: vi.fn(async () => true),
sendPasswordResetNotifyEmail: vi.fn(async () => true),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
},
}));
vi.mock("@formbricks/database", () => ({
prisma: {
$transaction: testState.mockTransaction,
},
}));
vi.mock("./password-reset-token-repository", () => ({
upsertActiveToken: testState.mockUpsertActiveToken,
findByTokenHash: testState.mockFindByTokenHash,
deleteByTokenHash: testState.mockDeleteByTokenHash,
consumeActiveToken: testState.mockConsumeActiveToken,
}));
describe("password-reset-service", () => {
const user = {
id: "cm8z6bn2q000008l34h8g7k9m",
email: "user@example.com",
locale: "en-US" as const,
};
const parseTokenFromResetLink = (): string => {
const lastCall = vi.mocked(sendPasswordResetLinkEmail).mock.calls.at(-1);
const verifyLink = lastCall?.[0]?.verifyLink;
if (!verifyLink) {
throw new Error("No verify link found");
}
const url = new URL(verifyLink);
const token = url.searchParams.get("token");
if (!token) {
throw new Error("No token found in verify link");
}
return token;
};
const parseTokenFromDebugLog = (): string => {
const verifyLink = vi
.mocked(logger.info)
.mock.calls.map(([payload]) => payload?.verifyLink)
.find((loggedVerifyLink): loggedVerifyLink is string => typeof loggedVerifyLink === "string");
if (!verifyLink) {
throw new Error("No debug verify link found");
}
const url = new URL(verifyLink);
const token = url.searchParams.get("token");
if (!token) {
throw new Error("No token found in debug verify link");
}
return token;
};
const getStoredToken = (userId: string): TPasswordResetTokenRecord => {
const storedToken = testState.tokenStore.get(userId);
if (!storedToken) {
throw new Error("No stored token found");
}
return storedToken;
};
const getStoredUser = (userId: string): TPasswordResetTestUser => {
const storedUser = testState.users.get(userId);
if (!storedUser) {
throw new Error("No stored user found");
}
return storedUser;
};
beforeEach(() => {
constantsState.debugShowResetLink = false;
testState.tokenStore.clear();
testState.users.clear();
testState.users.set(user.id, {
...user,
emailVerified: null,
password: "old-password-hash",
});
});
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});
test("issues a hashed token with the configured default lifetime", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-30T12:00:00.000Z"));
await requestPasswordReset(user, "public");
const rawToken = parseTokenFromResetLink();
const storedToken = getStoredToken(user.id);
expect(getPasswordResetTokenLifetimeInMinutes()).toBe(30);
expect(storedToken.tokenHash).toBe(`hash:${rawToken}`);
expect(storedToken.tokenHash).not.toBe(rawToken);
expect(storedToken.expiresAt).toEqual(new Date("2026-03-30T12:30:00.000Z"));
expect(sendPasswordResetLinkEmail).toHaveBeenCalledWith(
expect.objectContaining({
email: user.email,
locale: user.locale,
linkValidityInMinutes: 30,
})
);
});
test("invalidates the previous token when a new reset request is issued", async () => {
await requestPasswordReset(user, "public");
const firstToken = parseTokenFromResetLink();
await requestPasswordReset(user, "public");
const secondToken = parseTokenFromResetLink();
await expect(completePasswordReset(firstToken, "Password123")).rejects.toMatchObject({
name: "InvalidPasswordResetTokenError",
message: INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
});
const result = await completePasswordReset(secondToken, "Password123");
expect(result.userId).toBe(user.id);
expect(getStoredUser(user.id).password).toBe("hashed:Password123");
});
test("rejects expired reset tokens", async () => {
await requestPasswordReset(user, "public");
const token = parseTokenFromResetLink();
testState.tokenStore.set(user.id, {
...getStoredToken(user.id),
expiresAt: new Date(Date.now() - 60 * 1000),
});
await expect(completePasswordReset(token, "Password123")).rejects.toMatchObject({
name: "InvalidPasswordResetTokenError",
reason: "expired",
message: INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
});
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({
stage: "consume",
reason: "expired",
userId: user.id,
}),
"Rejected password reset token"
);
});
test("rejects unknown and legacy jwt reset tokens", async () => {
await expect(completePasswordReset("unknown-token", "Password123")).rejects.toBeInstanceOf(
InvalidPasswordResetTokenError
);
await expect(completePasswordReset("legacy.jwt.token", "Password123")).rejects.toMatchObject({
reason: "legacy_jwt",
message: INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
});
});
test("consumes a token only once", async () => {
await requestPasswordReset(user, "public");
const token = parseTokenFromResetLink();
await expect(completePasswordReset(token, "Password123")).resolves.toMatchObject({
userId: user.id,
});
await expect(completePasswordReset(token, "Password123")).rejects.toBeInstanceOf(
InvalidPasswordResetTokenError
);
});
test("allows only one successful result for concurrent token submissions", async () => {
await requestPasswordReset(user, "public");
const token = parseTokenFromResetLink();
const results = await Promise.allSettled([
completePasswordReset(token, "Password123"),
completePasswordReset(token, "Password123"),
]);
const fulfilledResults = results.filter((result) => result.status === "fulfilled");
const rejectedResults = results.filter((result) => result.status === "rejected");
expect(fulfilledResults).toHaveLength(1);
expect(rejectedResults).toHaveLength(1);
expect((rejectedResults[0] as PromiseRejectedResult).reason).toBeInstanceOf(
InvalidPasswordResetTokenError
);
});
test("revokes the issued token when email delivery fails for a public request", async () => {
vi.mocked(sendPasswordResetLinkEmail).mockResolvedValueOnce(false);
await expect(requestPasswordReset(user, "public")).resolves.toBeUndefined();
const revokedToken = parseTokenFromResetLink();
expect(testState.tokenStore.size).toBe(0);
expect(testState.mockDeleteByTokenHash).toHaveBeenCalledWith(`hash:${revokedToken}`);
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
source: "public",
stage: "send",
userId: user.id,
}),
"Password reset request failed"
);
});
test("logs the reset link instead of sending an email when DEBUG_SHOW_RESET_LINK is enabled", async () => {
constantsState.debugShowResetLink = true;
await requestPasswordReset(user, "public");
expect(sendPasswordResetLinkEmail).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.objectContaining({
verifyLink: expect.stringMatching(/^http:\/\/localhost:3000\/auth\/forgot-password\/reset\?token=/),
}),
"DEBUG_SHOW_RESET_LINK is enabled; password reset email delivery skipped"
);
});
test("logs and suppresses token issuance failures for public requests", async () => {
testState.mockUpsertActiveToken.mockRejectedValueOnce(new Error("Database unavailable"));
await expect(requestPasswordReset(user, "public")).resolves.toBeUndefined();
expect(sendPasswordResetLinkEmail).not.toHaveBeenCalled();
expect(testState.mockDeleteByTokenHash).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
source: "public",
stage: "issue",
userId: user.id,
}),
"Password reset request failed"
);
});
test("surfaces profile reset request failures after revoking the token", async () => {
vi.mocked(sendPasswordResetLinkEmail).mockResolvedValueOnce(false);
await expect(requestPasswordReset(user, "profile")).rejects.toThrow(
ACCOUNT_RECOVERY_LINK_EMAIL_ERROR_CODE
);
expect(testState.tokenStore.size).toBe(0);
});
test("does not roll back a successful password reset when the notification email fails", async () => {
await requestPasswordReset(user, "public");
const token = parseTokenFromResetLink();
vi.mocked(sendPasswordResetNotifyEmail).mockResolvedValueOnce(false);
const result = await completePasswordReset(token, "Password123");
expect(result.userId).toBe(user.id);
expect(getStoredUser(user.id).password).toBe("hashed:Password123");
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
stage: "notify_email",
userId: user.id,
}),
"Failed to send password reset notification email"
);
});
test("skips notification email delivery when DEBUG_SHOW_RESET_LINK is enabled", async () => {
constantsState.debugShowResetLink = true;
await requestPasswordReset(user, "public");
const token = parseTokenFromDebugLog();
await completePasswordReset(token, "Password123");
expect(sendPasswordResetNotifyEmail).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith(
expect.objectContaining({
userId: user.id,
}),
"DEBUG_SHOW_RESET_LINK is enabled; password reset notification delivery skipped"
);
});
test("validates the reset token before hashing the new password", async () => {
await requestPasswordReset(user, "public");
const token = parseTokenFromResetLink();
await completePasswordReset(token, "Password123");
expect(testState.mockFindByTokenHash).toHaveBeenCalledBefore(vi.mocked(hashPassword));
expect(vi.mocked(hashPassword)).toHaveBeenCalledBefore(vi.mocked(prisma.$transaction));
expect(hashString).toHaveBeenCalledWith(token);
});
test("rejects invalid reset tokens before hashing the new password", async () => {
await expect(completePasswordReset("unknown-token", "Password123")).rejects.toBeInstanceOf(
InvalidPasswordResetTokenError
);
expect(hashPassword).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,325 @@
import "server-only";
import { Prisma } from "@prisma/client";
import crypto from "node:crypto";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId } from "@formbricks/types/common";
import {
INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
InvalidPasswordResetTokenError,
} from "@formbricks/types/errors";
import type { TUserEmail, TUserLocale } from "@formbricks/types/user";
import { ZUserEmail, ZUserLocale, ZUserPassword } from "@formbricks/types/user";
import { hashPassword } from "@/lib/auth";
import { DEBUG_SHOW_RESET_LINK, PASSWORD_RESET_TOKEN_LIFETIME_MINUTES, WEBAPP_URL } from "@/lib/constants";
import { hashString } from "@/lib/hash-string";
import { validateInputs } from "@/lib/utils/validate";
import { sendPasswordResetLinkEmail, sendPasswordResetNotifyEmail } from "@/modules/email";
import {
consumeActiveToken,
deleteByTokenHash,
findByTokenHash,
upsertActiveToken,
} from "./password-reset-token-repository";
export const ACCOUNT_RECOVERY_LINK_EMAIL_ERROR_CODE = "ERR_RECOVERY_RESET_LINK_EMAIL_FAILED";
export const ACCOUNT_RECOVERY_NOTIFICATION_EMAIL_ERROR_CODE = "ERR_RECOVERY_RESET_NOTIFICATION_EMAIL_FAILED";
const ZPasswordResetSource = z.enum(["public", "profile"]);
const passwordResetAuditSelection = {
id: true,
email: true,
locale: true,
emailVerified: true,
} satisfies Prisma.UserSelect;
type TPasswordResetRequestSource = z.infer<typeof ZPasswordResetSource>;
type TPasswordResetRecipient = {
id: string;
email: TUserEmail;
locale: TUserLocale;
};
type TPasswordResetAuditUser = Prisma.UserGetPayload<{
select: typeof passwordResetAuditSelection;
}>;
class PasswordResetLinkEmailError extends Error {
code = ACCOUNT_RECOVERY_LINK_EMAIL_ERROR_CODE;
constructor() {
super(ACCOUNT_RECOVERY_LINK_EMAIL_ERROR_CODE);
this.name = "PasswordResetLinkEmailError";
}
}
class PasswordResetNotificationEmailError extends Error {
code = ACCOUNT_RECOVERY_NOTIFICATION_EMAIL_ERROR_CODE;
constructor() {
super(ACCOUNT_RECOVERY_NOTIFICATION_EMAIL_ERROR_CODE);
this.name = "PasswordResetNotificationEmailError";
}
}
export const getPasswordResetTokenLifetimeInMinutes = (): number => PASSWORD_RESET_TOKEN_LIFETIME_MINUTES;
const buildPasswordResetLink = (token: string): string =>
`${WEBAPP_URL}/auth/forgot-password/reset?token=${encodeURIComponent(token)}`;
const isLegacyPasswordResetToken = (token: string): boolean => token.split(".").length === 3;
const logPasswordResetRequestFailure = ({
error,
source,
stage,
userId,
}: {
error: unknown;
source: TPasswordResetRequestSource;
stage: "issue" | "send" | "revoke";
userId: string;
}) => {
logger.error({ error, source, stage, userId }, "Password reset request failed");
};
const logPasswordResetTokenRejection = (error: InvalidPasswordResetTokenError) => {
logger.warn(
{
stage: "consume",
reason: error.reason ?? "invalid_or_superseded",
userId: error.userId,
},
"Rejected password reset token"
);
};
const createInvalidPasswordResetTokenError = (
reason: string,
userId?: string
): InvalidPasswordResetTokenError =>
new InvalidPasswordResetTokenError(INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE, reason, userId);
const getPasswordResetExpiry = (): Date =>
new Date(Date.now() + getPasswordResetTokenLifetimeInMinutes() * 60 * 1000);
const assertEmailWasSent = (didSendEmail: boolean, error: Error): void => {
if (!didSendEmail) {
throw error;
}
};
const revokeIssuedPasswordResetToken = async (
userId: string,
tokenHash: string,
source: TPasswordResetRequestSource
): Promise<void> => {
try {
await deleteByTokenHash(tokenHash);
} catch (error) {
logPasswordResetRequestFailure({
error,
source,
stage: "revoke",
userId,
});
}
};
const sendPasswordResetLink = async (user: TPasswordResetRecipient, verifyLink: string): Promise<void> => {
if (DEBUG_SHOW_RESET_LINK) {
logger.info({ verifyLink }, "DEBUG_SHOW_RESET_LINK is enabled; password reset email delivery skipped");
return;
}
const didSendEmail = await sendPasswordResetLinkEmail({
email: user.email,
locale: user.locale,
verifyLink,
linkValidityInMinutes: getPasswordResetTokenLifetimeInMinutes(),
});
assertEmailWasSent(didSendEmail, new PasswordResetLinkEmailError());
};
const updatePasswordWithActiveResetToken = async (
tokenHash: string,
hashedPassword: string,
now: Date
): Promise<{
userId: string;
oldUser: TPasswordResetAuditUser;
updatedUser: TPasswordResetAuditUser;
}> =>
prisma.$transaction(async (tx) => {
const tokenRecord = await findByTokenHash(tokenHash, tx);
if (!tokenRecord) {
throw createInvalidPasswordResetTokenError("invalid_or_superseded");
}
if (tokenRecord.expiresAt <= now) {
throw createInvalidPasswordResetTokenError("expired", tokenRecord.userId);
}
const oldUser = await tx.user.findUnique({
where: {
id: tokenRecord.userId,
},
select: passwordResetAuditSelection,
});
if (!oldUser) {
throw createInvalidPasswordResetTokenError("invalid_or_superseded", tokenRecord.userId);
}
const consumedTokenCount = await consumeActiveToken(tokenHash, now, tx);
if (consumedTokenCount !== 1) {
throw createInvalidPasswordResetTokenError("replay", tokenRecord.userId);
}
const updatedUser = await tx.user.update({
where: {
id: tokenRecord.userId,
},
data: {
password: hashedPassword,
},
select: passwordResetAuditSelection,
});
return {
userId: tokenRecord.userId,
oldUser,
updatedUser,
};
});
const assertResetTokenCanStillBeUsed = async (tokenHash: string, now: Date): Promise<void> => {
const tokenRecord = await findByTokenHash(tokenHash);
if (!tokenRecord) {
throw createInvalidPasswordResetTokenError("invalid_or_superseded");
}
if (tokenRecord.expiresAt <= now) {
throw createInvalidPasswordResetTokenError("expired", tokenRecord.userId);
}
};
const sendPasswordResetNotification = async ({
userId,
email,
locale,
}: {
userId: string;
email: string;
locale: TUserLocale;
}): Promise<void> => {
if (DEBUG_SHOW_RESET_LINK) {
logger.info({ userId }, "DEBUG_SHOW_RESET_LINK is enabled; password reset notification delivery skipped");
return;
}
try {
const didSendNotificationEmail = await sendPasswordResetNotifyEmail({
email,
locale,
});
assertEmailWasSent(didSendNotificationEmail, new PasswordResetNotificationEmailError());
} catch (error) {
logger.error(
{
error,
stage: "notify_email",
userId,
},
"Failed to send password reset notification email"
);
}
};
export const requestPasswordReset = async (
user: TPasswordResetRecipient,
source: TPasswordResetRequestSource
): Promise<void> => {
validateInputs(
[user.id, ZId],
[user.email, ZUserEmail],
[user.locale, ZUserLocale],
[source, ZPasswordResetSource]
);
const rawToken = crypto.randomBytes(32).toString("base64url");
const tokenHash = hashString(rawToken);
const expiresAt = getPasswordResetExpiry();
const verifyLink = buildPasswordResetLink(rawToken);
let tokenIssued = false;
try {
await upsertActiveToken(user.id, tokenHash, expiresAt);
tokenIssued = true;
await sendPasswordResetLink(user, verifyLink);
} catch (error) {
logPasswordResetRequestFailure({
error,
source,
stage: tokenIssued ? "send" : "issue",
userId: user.id,
});
if (tokenIssued) {
await revokeIssuedPasswordResetToken(user.id, tokenHash, source);
}
if (source === "profile") {
throw error;
}
}
};
export const completePasswordReset = async (
rawToken: string,
password: string
): Promise<{
userId: string;
oldUser: TPasswordResetAuditUser;
updatedUser: TPasswordResetAuditUser;
}> => {
validateInputs([rawToken, z.string().min(1)], [password, ZUserPassword]);
if (isLegacyPasswordResetToken(rawToken)) {
const error = createInvalidPasswordResetTokenError("legacy_jwt");
logPasswordResetTokenRejection(error);
throw error;
}
const tokenHash = hashString(rawToken);
const now = new Date();
try {
await assertResetTokenCanStillBeUsed(tokenHash, now);
const hashedPassword = await hashPassword(password);
const result = await updatePasswordWithActiveResetToken(tokenHash, hashedPassword, now);
await sendPasswordResetNotification({
userId: result.userId,
email: result.updatedUser.email,
locale: result.updatedUser.locale,
});
return result;
} catch (error) {
if (error instanceof InvalidPasswordResetTokenError) {
logPasswordResetTokenRejection(error);
throw error;
}
logger.error({ error, stage: "password_update" }, "Password reset completion failed");
throw error;
}
};

View File

@@ -0,0 +1,114 @@
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import {
consumeActiveToken,
deleteByTokenHash,
findByTokenHash,
upsertActiveToken,
} from "./password-reset-token-repository";
vi.mock("@formbricks/database", () => ({
prisma: {
passwordResetToken: {
upsert: vi.fn(),
findUnique: vi.fn(),
deleteMany: vi.fn(),
},
},
}));
describe("password-reset-token-repository", () => {
const userId = "cm8z6bn2q000008l34h8g7k9m";
const mockTokenRecord = {
id: "prt_123",
userId,
tokenHash: "hashed-token",
expiresAt: new Date("2026-03-30T12:30:00.000Z"),
createdAt: new Date("2026-03-30T12:00:00.000Z"),
updatedAt: new Date("2026-03-30T12:00:00.000Z"),
};
afterEach(() => {
vi.clearAllMocks();
});
test("upserts the active token for a user", async () => {
vi.mocked(prisma.passwordResetToken.upsert).mockResolvedValue(mockTokenRecord as any);
const result = await upsertActiveToken(userId, "hashed-token", mockTokenRecord.expiresAt);
expect(result).toEqual(mockTokenRecord);
expect(prisma.passwordResetToken.upsert).toHaveBeenCalledWith({
where: { userId },
create: {
userId,
tokenHash: "hashed-token",
expiresAt: mockTokenRecord.expiresAt,
},
update: {
tokenHash: "hashed-token",
expiresAt: mockTokenRecord.expiresAt,
},
select: expect.any(Object),
});
});
test("finds a token by hash", async () => {
vi.mocked(prisma.passwordResetToken.findUnique).mockResolvedValue(mockTokenRecord as any);
const result = await findByTokenHash("hashed-token");
expect(result).toEqual(mockTokenRecord);
expect(prisma.passwordResetToken.findUnique).toHaveBeenCalledWith({
where: { tokenHash: "hashed-token" },
select: expect.any(Object),
});
});
test("deletes by token hash", async () => {
vi.mocked(prisma.passwordResetToken.deleteMany).mockResolvedValue({ count: 1 } as any);
const result = await deleteByTokenHash("hashed-token");
expect(result).toBe(1);
expect(prisma.passwordResetToken.deleteMany).toHaveBeenCalledWith({
where: { tokenHash: "hashed-token" },
});
});
test("consumes only a non-expired token inside a transaction", async () => {
const tx = {
passwordResetToken: {
deleteMany: vi.fn().mockResolvedValue({ count: 1 }),
},
} as any;
const now = new Date("2026-03-30T12:10:00.000Z");
const result = await consumeActiveToken("hashed-token", now, tx);
expect(result).toBe(1);
expect(tx.passwordResetToken.deleteMany).toHaveBeenCalledWith({
where: {
tokenHash: "hashed-token",
expiresAt: {
gt: now,
},
},
});
});
test("wraps prisma known errors in DatabaseError", async () => {
vi.mocked(prisma.passwordResetToken.upsert).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("database failed", {
code: "P2002",
clientVersion: "test",
})
);
await expect(upsertActiveToken(userId, "hashed-token", mockTokenRecord.expiresAt)).rejects.toThrow(
DatabaseError
);
});
});

View File

@@ -0,0 +1,123 @@
import "server-only";
import { Prisma, PrismaClient } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
const passwordResetTokenSelection = {
id: true,
userId: true,
tokenHash: true,
expiresAt: true,
createdAt: true,
updatedAt: true,
} satisfies Prisma.PasswordResetTokenSelect;
const ZTokenHash = z.string().min(1);
type TPasswordResetTokenDbClient = PrismaClient | Prisma.TransactionClient;
export type TPasswordResetTokenRecord = Prisma.PasswordResetTokenGetPayload<{
select: typeof passwordResetTokenSelection;
}>;
const getDbClient = (tx?: Prisma.TransactionClient): TPasswordResetTokenDbClient => tx ?? prisma;
const handleDatabaseError = (error: unknown): never => {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
};
export const upsertActiveToken = async (
userId: string,
tokenHash: string,
expiresAt: Date,
tx?: Prisma.TransactionClient
): Promise<TPasswordResetTokenRecord> => {
validateInputs([userId, ZId], [tokenHash, ZTokenHash], [expiresAt, z.date()]);
try {
return await getDbClient(tx).passwordResetToken.upsert({
where: {
userId,
},
create: {
userId,
tokenHash,
expiresAt,
},
update: {
tokenHash,
expiresAt,
},
select: passwordResetTokenSelection,
});
} catch (error) {
return handleDatabaseError(error);
}
};
export const findByTokenHash = async (
tokenHash: string,
tx?: Prisma.TransactionClient
): Promise<TPasswordResetTokenRecord | null> => {
validateInputs([tokenHash, ZTokenHash]);
try {
return await getDbClient(tx).passwordResetToken.findUnique({
where: {
tokenHash,
},
select: passwordResetTokenSelection,
});
} catch (error) {
return handleDatabaseError(error);
}
};
export const deleteByTokenHash = async (
tokenHash: string,
tx?: Prisma.TransactionClient
): Promise<number> => {
validateInputs([tokenHash, ZTokenHash]);
try {
const result = await getDbClient(tx).passwordResetToken.deleteMany({
where: {
tokenHash,
},
});
return result.count;
} catch (error) {
return handleDatabaseError(error);
}
};
export const consumeActiveToken = async (
tokenHash: string,
now: Date,
tx: Prisma.TransactionClient
): Promise<number> => {
validateInputs([tokenHash, ZTokenHash], [now, z.date()]);
try {
const result = await tx.passwordResetToken.deleteMany({
where: {
tokenHash,
expiresAt: {
gt: now,
},
},
});
return result.count;
} catch (error) {
return handleDatabaseError(error);
}
};

View File

@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE,
InvalidPasswordResetTokenError,
OperationNotAllowedError,
} from "@formbricks/types/errors";
import { completePasswordReset } from "@/modules/auth/forgot-password/lib/password-reset-service";
import { resetPasswordAction } from "./actions";
const constantsState = vi.hoisted(() => ({
passwordResetDisabled: false,
}));
vi.mock("@/modules/auth/forgot-password/lib/password-reset-service", () => ({
completePasswordReset: vi.fn(),
}));
vi.mock("@/lib/constants", () => ({
get PASSWORD_RESET_DISABLED() {
return constantsState.passwordResetDisabled;
},
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
withAuditLogging: vi.fn((_event: string, _object: string, fn: Function) => fn),
}));
vi.mock("@/lib/utils/action-client", () => ({
actionClient: {
inputSchema: vi.fn().mockReturnThis(),
action: vi.fn((fn) => fn),
},
}));
describe("resetPasswordAction", () => {
const mockCtx = {
auditLoggingCtx: {
userId: "",
oldObject: null,
newObject: null,
},
};
const parsedInput = {
token: "opaque-reset-token",
password: "Password123",
};
beforeEach(() => {
vi.resetAllMocks();
constantsState.passwordResetDisabled = false;
});
afterEach(() => {
vi.restoreAllMocks();
});
test("delegates to completePasswordReset and populates audit context on success", async () => {
const oldUser = {
id: "user_123",
email: "user@example.com",
locale: "en-US",
emailVerified: null,
};
const updatedUser = {
...oldUser,
emailVerified: new Date(),
};
vi.mocked(completePasswordReset).mockResolvedValue({
userId: "user_123",
oldUser,
updatedUser,
});
const result = await resetPasswordAction({
ctx: mockCtx,
parsedInput,
} as any);
expect(result).toEqual({ success: true });
expect(completePasswordReset).toHaveBeenCalledWith(parsedInput.token, parsedInput.password);
expect(mockCtx.auditLoggingCtx.userId).toBe("user_123");
expect(mockCtx.auditLoggingCtx.oldObject).toEqual({ ...oldUser, passwordResetMarker: false });
expect(mockCtx.auditLoggingCtx.newObject).toEqual({ ...updatedUser, passwordResetMarker: true });
});
test("propagates generic invalid password reset failures", async () => {
vi.mocked(completePasswordReset).mockRejectedValue(
new InvalidPasswordResetTokenError(INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE, "expired")
);
await expect(
resetPasswordAction({
ctx: mockCtx,
parsedInput,
} as any)
).rejects.toThrow(INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE);
});
test("rejects reset attempts when password reset is disabled", async () => {
constantsState.passwordResetDisabled = true;
await expect(
resetPasswordAction({
ctx: mockCtx,
parsedInput,
} as any)
).rejects.toThrow(OperationNotAllowedError);
expect(completePasswordReset).not.toHaveBeenCalled();
});
});

View File

@@ -1,35 +1,30 @@
"use server";
import { z } from "zod";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { ZUserPassword } from "@formbricks/types/user";
import { hashPassword } from "@/lib/auth";
import { verifyToken } from "@/lib/jwt";
import { PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { actionClient } from "@/lib/utils/action-client";
import { getUser, updateUser } from "@/modules/auth/lib/user";
import { completePasswordReset } from "@/modules/auth/forgot-password/lib/password-reset-service";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendPasswordResetNotifyEmail } from "@/modules/email";
const ZResetPasswordAction = z.object({
token: z.string(),
token: z.string().min(1),
password: ZUserPassword,
});
export const resetPasswordAction = actionClient.inputSchema(ZResetPasswordAction).action(
withAuditLogging("updated", "user", async ({ ctx, parsedInput }) => {
const hashedPassword = await hashPassword(parsedInput.password);
const { id } = await verifyToken(parsedInput.token);
const oldObject = await getUser(id);
if (!oldObject) {
throw new ResourceNotFoundError("user", id);
if (PASSWORD_RESET_DISABLED) {
throw new OperationNotAllowedError("Password reset is disabled");
}
const updatedUser = await updateUser(id, { password: hashedPassword });
ctx.auditLoggingCtx.userId = id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = updatedUser;
const result = await completePasswordReset(parsedInput.token, parsedInput.password);
ctx.auditLoggingCtx.userId = result.userId;
ctx.auditLoggingCtx.oldObject = { ...result.oldUser, passwordResetMarker: false };
ctx.auditLoggingCtx.newObject = { ...result.updatedUser, passwordResetMarker: true };
await sendPasswordResetNotifyEmail({ email: updatedUser.email, locale: updatedUser.locale });
return { success: true };
})
);

View File

@@ -6,6 +6,7 @@ import { SubmitHandler, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE } from "@formbricks/types/errors";
import { ZUserPassword } from "@formbricks/types/user";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { resetPasswordAction } from "@/modules/auth/forgot-password/reset/actions";
@@ -22,7 +23,7 @@ const ZPasswordResetForm = z.object({
type TPasswordResetForm = z.infer<typeof ZPasswordResetForm>;
const passwordInputProps = {
autoComplete: "current-password",
autoComplete: "new-password",
placeholder: "*******",
required: true,
className:
@@ -57,50 +58,50 @@ export const ResetPasswordForm = () => {
router.push("/auth/forgot-password/reset/success");
} else {
const errorMessage = getFormattedErrorMessage(resetPasswordResponse);
toast.error(errorMessage);
toast.error(
errorMessage === INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE
? t("c.link_expired_description")
: errorMessage
);
}
};
return (
<>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
<div className="space-y-4">
<div>
<label htmlFor="password" className="block text-sm font-medium text-slate-800">
{t("auth.forgot-password.reset.new_password")}
</label>
<FormField
control={form.control}
name="password"
render={({ field }) => <PasswordInput {...passwordInputProps} {...field} id="password" />}
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-slate-800">
{t("auth.forgot-password.reset.confirm_password")}
</label>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<PasswordInput {...passwordInputProps} {...field} id="confirmPassword" />
)}
/>
</div>
<PasswordChecks password={form.watch("password")} />
</div>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
<div className="space-y-4">
<div>
<Button
type="submit"
disabled={!form.formState.isValid}
className="w-full justify-center"
loading={form.formState.isSubmitting}>
{t("auth.forgot-password.reset_password")}
</Button>
<label htmlFor="password" className="block text-sm font-medium text-slate-800">
{t("auth.forgot-password.reset.new_password")}
</label>
<FormField
control={form.control}
name="password"
render={({ field }) => <PasswordInput {...passwordInputProps} {...field} id="password" />}
/>
</div>
</form>
</>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-slate-800">
{t("auth.forgot-password.reset.confirm_password")}
</label>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => <PasswordInput {...passwordInputProps} {...field} id="confirmPassword" />}
/>
</div>
<PasswordChecks password={form.watch("password")} />
</div>
<div>
<Button
type="submit"
disabled={!form.formState.isValid}
className="w-full justify-center"
loading={form.formState.isSubmitting}>
{t("auth.forgot-password.reset_password")}
</Button>
</div>
</form>
);
};

View File

@@ -0,0 +1,34 @@
import { describe, expect, test } from "vitest";
import { renderForgotPasswordEmail } from "@formbricks/email";
const t = (key: string, replacements?: Record<string, string>): string => {
if (key === "emails.forgot_password_email_link_valid_for_24_hours") {
return `The link is valid for ${replacements?.minutes} minutes.`;
}
const translations: Record<string, string> = {
"emails.forgot_password_email_heading": "Change password",
"emails.forgot_password_email_text":
"You have requested a link to change your password. You can do this by clicking the link below:",
"emails.forgot_password_email_change_password": "Change password",
"emails.forgot_password_email_did_not_request": "If you didn't request this, please ignore this email.",
"emails.email_footer_text_1": "Have a great day!",
"emails.email_footer_text_2": "The Formbricks Team",
"emails.email_template_text_1": "This email was sent via Formbricks.",
};
return translations[key] ?? key;
};
describe("renderForgotPasswordEmail", () => {
test("renders the configurable link lifetime in minutes", async () => {
const html = await renderForgotPasswordEmail({
verifyLink: "https://app.formbricks.com/auth/forgot-password/reset?token=test-token",
linkValidityInMinutes: 30,
t,
});
expect(html).toContain("The link is valid for 30 minutes.");
expect(html).not.toContain("24 hours");
});
});

View File

@@ -157,17 +157,19 @@ export const sendVerificationEmail = async ({
}
};
export const sendForgotPasswordEmail = async (user: {
id: string;
export const sendPasswordResetLinkEmail = async (user: {
email: TUserEmail;
locale: TUserLocale;
verifyLink: string;
linkValidityInMinutes: number;
}): Promise<boolean> => {
const t = await getTranslate(user.locale);
const token = createToken(user.id, {
expiresIn: "1d",
const html = await renderForgotPasswordEmail({
verifyLink: user.verifyLink,
linkValidityInMinutes: user.linkValidityInMinutes,
t,
...legalProps,
});
const verifyLink = `${WEBAPP_URL}/auth/forgot-password/reset?token=${encodeURIComponent(token)}`;
const html = await renderForgotPasswordEmail({ verifyLink, t, ...legalProps });
return await sendEmail({
to: user.email,
subject: t("emails.forgot_password_email_subject"),

View File

@@ -200,7 +200,7 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
WEBAPP_URL: "https://test-webapp-url.com",
STRIPE_API_VERSION: "2026-01-28.clover",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
@@ -240,5 +240,6 @@ vi.mock("@/lib/constants", () => ({
MAIL_FROM: "mock@mail.com",
MAIL_FROM_NAME: "Mock Mail",
RATE_LIMITING_DISABLED: false,
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: 30,
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
}));

View File

@@ -30,6 +30,7 @@ These variables are present inside your machine's docker-compose file. Restart t
| IMPRINT_ADDRESS | Address for imprint. | optional | |
| EMAIL_AUTH_DISABLED | Disables the ability for users to signup or login via email and password if set to 1. | optional | |
| PASSWORD_RESET_DISABLED | Disables password reset functionality if set to 1. | optional | |
| PASSWORD_RESET_TOKEN_LIFETIME_MINUTES | Configures how long password reset links remain valid in minutes. Accepted values are integers from 5 to 120. | optional | 30 |
| EMAIL_VERIFICATION_DISABLED | Disables email verification if set to 1. | optional | |
| RATE_LIMITING_DISABLED | Disables rate limiting if set to 1. | optional | |
| DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS | Allows webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x) if set to 1. Useful for self-hosted instances that need to send webhooks to internal services. | optional | |

View File

@@ -64,6 +64,9 @@ EMAIL_VERIFICATION_DISABLED=0
# Set to 0 to enable password reset functionality (requires working SMTP)
PASSWORD_RESET_DISABLED=0
# Optional: configure the password reset link lifetime in minutes (5-120, default 30)
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES=30
```
## Configuration for One-Click Setup
@@ -83,6 +86,7 @@ environment:
SMTP_PASSWORD: your_password
EMAIL_VERIFICATION_DISABLED: 0
PASSWORD_RESET_DISABLED: 0
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: 30
```
2. Or during the setup, answer "Yes" when prompted to set up the email service:

View File

@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "PasswordResetToken" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"token_hash" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "PasswordResetToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PasswordResetToken_token_hash_key" ON "PasswordResetToken"("token_hash");
-- CreateIndex
CREATE UNIQUE INDEX "PasswordResetToken_userId_key" ON "PasswordResetToken"("userId");
-- CreateIndex
CREATE INDEX "PasswordResetToken_expires_at_idx" ON "PasswordResetToken"("expires_at");
-- AddForeignKey
ALTER TABLE "PasswordResetToken" ADD CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -881,6 +881,20 @@ model VerificationToken {
@@unique([identifier, token])
}
/// Stores the active password reset token for a user.
/// Tokens are opaque, hashed at rest, revocable, and single-use.
model PasswordResetToken {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
tokenHash String @unique @map(name: "token_hash")
expiresAt DateTime @map(name: "expires_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String @unique
@@index([expiresAt])
}
/// Represents a user in the Formbricks system.
/// Central model for user authentication and profile management.
///
@@ -899,25 +913,26 @@ model User {
emailVerified DateTime? @map(name: "email_verified")
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
twoFactorEnabled Boolean @default(false)
backupCodes String?
password String?
identityProvider IdentityProvider @default(email)
identityProvider IdentityProvider @default(email)
identityProviderAccountId String?
memberships Membership[]
accounts Account[]
sessions Session[]
passwordResetToken PasswordResetToken?
groupId String?
invitesCreated Invite[] @relation("inviteCreatedBy")
invitesAccepted Invite[] @relation("inviteAcceptedBy")
invitesCreated Invite[] @relation("inviteCreatedBy")
invitesAccepted Invite[] @relation("inviteAcceptedBy")
/// [UserNotificationSettings]
notificationSettings Json @default("{}")
notificationSettings Json @default("{}")
/// [Locale]
locale String @default("en-US")
locale String @default("en-US")
surveys Survey[]
teamUsers TeamUser[]
lastLoginAt DateTime?
isActive Boolean @default(true)
isActive Boolean @default(true)
}
/// Defines a segment of contacts based on attributes.

View File

@@ -9,21 +9,27 @@ import { TFunction } from "../../src/types/translations";
interface ForgotPasswordEmailProps extends TEmailTemplateLegalProps {
readonly verifyLink: string;
readonly linkValidityInMinutes: number;
readonly t?: TFunction;
}
export function ForgotPasswordEmail({
verifyLink,
linkValidityInMinutes,
t = mockT,
...legalProps
}: ForgotPasswordEmailProps): React.JSX.Element {
}: Readonly<ForgotPasswordEmailProps>): React.JSX.Element {
return (
<EmailTemplate t={t} {...legalProps}>
<Container>
<Heading>{t("emails.forgot_password_email_heading")}</Heading>
<Text className="text-sm">{t("emails.forgot_password_email_text")}</Text>
<EmailButton href={verifyLink} label={t("emails.forgot_password_email_change_password")} />
<Text className="text-sm font-bold">{t("emails.forgot_password_email_link_valid_for_24_hours")}</Text>
<Text className="text-sm font-bold">
{t("emails.forgot_password_email_link_valid_for_24_hours", {
minutes: String(linkValidityInMinutes),
})}
</Text>
<Text className="mb-0 text-sm">{t("emails.forgot_password_email_did_not_request")}</Text>
<EmailFooter t={t} />
</Container>

View File

@@ -12,6 +12,7 @@ export const exampleData = {
forgotPasswordEmail: {
verifyLink: "https://app.formbricks.com/auth/forgot-password/reset?token=example-reset-token",
linkValidityInMinutes: 30,
},
newEmailVerification: {

View File

@@ -24,7 +24,7 @@ const translations: Record<TranslationKey, TranslationValue> = {
"emails.forgot_password_email_change_password": "Change password",
"emails.forgot_password_email_did_not_request": "If you didn't request this, please ignore this email.",
"emails.forgot_password_email_heading": "Change password",
"emails.forgot_password_email_link_valid_for_24_hours": "The link is valid for 24 hours.",
"emails.forgot_password_email_link_valid_for_24_hours": "The link is valid for {minutes} minutes.",
"emails.forgot_password_email_subject": "Reset your Formbricks password",
"emails.forgot_password_email_text":
"You have requested a link to change your password. You can do this by clicking the link below:",

View File

@@ -29,6 +29,7 @@ export async function renderVerificationEmail(
export async function renderForgotPasswordEmail(
props: {
verifyLink: string;
linkValidityInMinutes: number;
t: TFunction;
} & TEmailTemplateLegalProps
): Promise<string> {

View File

@@ -111,6 +111,13 @@ const formbricks = {
setNonce,
};
// Explicitly assign to globalThis so the wrapper SDK (@formbricks/js) can
// find us even when the UMD environment detection is fooled by a leaked
// `exports` or `module` global on the page (e.g. from another UMD bundle,
// a tag manager, or a browser extension). This runs inside the UMD factory,
// so it executes regardless of which branch the wrapper picks.
(globalThis as unknown as Record<string, unknown>).formbricks = formbricks;
type TFormbricks = typeof formbricks;
export type { TFormbricks };
export default formbricks;

View File

@@ -59,7 +59,7 @@ export function LanguageSwitch({
handleI18nLanguage(calculatedLanguageCode);
if (setDir) {
const calculateDir = isRTLLanguage(survey, calculatedLanguageCode) ? "rtl" : "auto";
const calculateDir = isRTLLanguage(survey, calculatedLanguageCode) ? "rtl" : "ltr";
setDir?.(calculateDir);
}

View File

@@ -9,12 +9,13 @@ export function RenderSurvey(props: SurveyContainerProps) {
const onFinishedTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isRTL = isRTLLanguage(props.survey, props.languageCode);
const [dir, setDir] = useState<"ltr" | "rtl" | "auto">(isRTL ? "rtl" : "auto");
const [dir, setDir] = useState<"ltr" | "rtl" | "auto">(isRTL ? "rtl" : "ltr");
useEffect(() => {
const isRTL = isRTLLanguage(props.survey, props.languageCode);
setDir(isRTL ? "rtl" : "auto");
}, [props.languageCode, props.survey]);
setDir(isRTL ? "rtl" : "ltr");
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only recalculate direction when languageCode changes, not on survey auto-save
}, [props.languageCode]);
const close = () => {
if (onFinishedTimeoutRef.current) {

View File

@@ -1,20 +1,28 @@
import { ComponentChildren } from "preact";
import { useEffect } from "preact/hooks";
import { useEffect, useRef } from "preact/hooks";
import { I18nextProvider } from "react-i18next";
import i18n from "../../lib/i18n.config";
export const I18nProvider = ({ language, children }: { language: string; children?: ComponentChildren }) => {
const isFirstRender = useRef(true);
const prevLanguage = useRef(language);
// Set language synchronously on initial render so children get the correct translations immediately.
// This is safe because all translations are pre-loaded (bundled) in i18n.config.ts.
if (i18n.language !== language) {
i18n.changeLanguage(language);
}
// Handle language prop changes after initial render
useEffect(() => {
// On subsequent renders, skip this to avoid overriding language changes made by the user via LanguageSwitch.
if (isFirstRender.current) {
if (i18n.language !== language) {
i18n.changeLanguage(language);
}
isFirstRender.current = false;
}
// Only update language when the prop itself changes, not when i18n was changed internally by user action
useEffect(() => {
if (prevLanguage.current !== language) {
i18n.changeLanguage(language);
prevLanguage.current = language;
}
}, [language]);
// work around for react-i18next not supporting preact

View File

@@ -1,5 +1,7 @@
import { z } from "zod";
export const INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE = "ERR_INVALID_PASSWORD_RESET_TOKEN";
class ResourceNotFoundError extends Error {
statusCode = 404;
resourceId: string | null;
@@ -95,6 +97,20 @@ class TooManyRequestsError extends Error {
}
}
class InvalidPasswordResetTokenError extends Error {
statusCode = 400;
code: string;
reason?: string;
userId?: string;
constructor(code = INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE, reason?: string, userId?: string) {
super(code);
this.name = "InvalidPasswordResetTokenError";
this.code = code;
this.reason = reason;
this.userId = userId;
}
}
interface NetworkError {
code: "network_error";
message: string;
@@ -127,6 +143,7 @@ export {
AuthenticationError,
AuthorizationError,
TooManyRequestsError,
InvalidPasswordResetTokenError,
};
export type { NetworkError, ForbiddenError };
@@ -142,6 +159,7 @@ export const EXPECTED_ERROR_NAMES = new Set([
"AuthenticationError",
"OperationNotAllowedError",
"TooManyRequestsError",
"InvalidPasswordResetTokenError",
]);
/**

View File

@@ -144,6 +144,7 @@
"DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS",
"DATABASE_URL",
"DEBUG",
"DEBUG_SHOW_RESET_LINK",
"E2E_TESTING",
"EMAIL_AUTH_DISABLED",
"EMAIL_VERIFICATION_DISABLED",
@@ -199,6 +200,7 @@
"OIDC_ISSUER",
"OIDC_SIGNING_ALGORITHM",
"PASSWORD_RESET_DISABLED",
"PASSWORD_RESET_TOKEN_LIFETIME_MINUTES",
"PLAYWRIGHT_CI",
"PRIVACY_URL",
"RATE_LIMITING_DISABLED",