Compare commits

...

22 Commits

Author SHA1 Message Date
Niels Kaspers 66b5185e33 fix: add terms_of_service translation key to all locales 2026-04-04 10:31:46 +03:00
Niels Kaspers 319523131f fix: add TERMS_URL support to survey link footers
The TERMS_URL environment variable was documented in docker-compose.yml
and defined in constants but never threaded through the survey link
footer components. This adds TERMS_URL prop passing through
survey-renderer, pin-screen, survey-client-wrapper, link-survey-wrapper,
and legal-footer, matching the existing pattern used by PRIVACY_URL and
IMPRINT_URL.
2026-04-04 10:26:46 +03:00
Anshuman Pandey b5eaa4c7fd fix: merge epic/improve-telemetry into main (#7666) 2026-04-03 10:12:51 +00:00
Tiago 995c03bc01 chore: Revoke all active sessions after password reset (#7628) 2026-04-03 06:10:28 +00:00
Johannes b4395a48c5 fix: multi-lang toggle covering arabic text (#7657)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-02 13:09:16 +00:00
Johannes 461e3893fe fix: 7549 multilang button overflow (#7656)
Co-authored-by: Niels Kaspers <kaspersniels@gmail.com>
2026-04-02 12:53:57 +00:00
Tiago 735a9f84ec fix: harden api error reporting for v2/v1 Sentry observability (#7633) 2026-04-02 12:08:44 +00:00
Dhruwang Jariwala 8cb8d734cf fix: prevent language switch from breaking survey orientation and resetting language on auto-save (#7654)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 12:08:12 +00:00
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
Dhruwang Jariwala 5bb8119ebf feat: split AI toggle into smart tools and data analysis settings (#7563) 2026-03-31 11:23:51 +00:00
Johannes 02411277d4 revert: remove fake-door workflows experiment (#7392) (#7631)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-03-31 10:47:33 +00:00
Dhruwang Jariwala 4cfb8c6d7b fix: resolve language code case mismatch in link survey rendering (#7624)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 11:34:20 +00:00
Anshuman Pandey e74a51a5ff fix: sync segment state after auto-save to prevent stale reference on publish (#7619) 2026-03-30 06:51:44 +00:00
Dhruwang Jariwala 29cc6a10fe fix: prevent auto-save from overwriting survey status during publish (#7618) 2026-03-30 06:34:20 +00:00
Bhagya Amarasinghe 01f765e969 fix: migrate auth sessions to database-backed storage (#7594) 2026-03-27 07:15:06 +00:00
Anshuman Pandey 9366960f18 feat: adds support for internal webhook urls (#7577) 2026-03-27 07:04:14 +00:00
IllimarR 697dc9cc99 feat: add Estonian language support for surveys (#7574)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-27 06:12:40 +00:00
152 changed files with 6579 additions and 1518 deletions
+9
View File
@@ -0,0 +1,9 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "formbricks"
[setup]
script = '''
pnpm install
pnpm dev:setup
'''
+14
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
@@ -185,6 +191,14 @@ ENTERPRISE_LICENSE_KEY=
# Ignore Rate Limiting across the Formbricks app
# RATE_LIMITING_DISABLED=1
# Disable telemetry reporting (usage stats sent to Formbricks). Ignored when an EE license is active.
# TELEMETRY_DISABLED=1
# Allow webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x)
# WARNING: Only enable this if you understand the SSRF risks. Useful for self-hosted instances
# that need to send webhooks to internal services.
# DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
+1 -1
View File
@@ -45,7 +45,7 @@ yarn-error.log*
.direnv
# Playwright
/test-results/
**/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
+1 -25
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>
@@ -11,7 +11,6 @@ import {
RocketIcon,
UserCircleIcon,
UserIcon,
WorkflowIcon,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
@@ -116,13 +115,6 @@ export const MainNavigation = ({
pathname?.includes("/segments") ||
pathname?.includes("/attributes"),
},
{
name: t("common.workflows"),
href: `/environments/${environment.id}/workflows`,
icon: WorkflowIcon,
isActive: pathname?.includes("/workflows"),
isHidden: !isFormbricksCloud,
},
{
name: t("common.configuration"),
href: `/environments/${environment.id}/workspace/general`,
@@ -130,7 +122,7 @@ export const MainNavigation = ({
isActive: pathname?.includes("/workspace"),
},
],
[t, environment.id, pathname, isFormbricksCloud]
[t, environment.id, pathname]
);
const dropdownNavigation = [
@@ -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;
@@ -3,13 +3,41 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import type { TOrganizationRole } from "@formbricks/types/memberships";
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
async function updateOrganizationAction<T extends z.ZodRawShape>({
ctx,
organizationId,
schema,
data,
roles,
}: {
ctx: AuthenticatedActionClientCtx;
organizationId: string;
schema: z.ZodObject<T>;
data: z.infer<z.ZodObject<T>>;
roles: TOrganizationRole[];
}) {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [{ type: "organization", schema, data, roles }],
});
ctx.auditLoggingCtx.organizationId = organizationId;
const oldObject = await getOrganization(organizationId);
const result = await updateOrganization(organizationId, data);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
const ZUpdateOrganizationNameAction = z.object({
organizationId: ZId,
data: ZOrganizationUpdateInput.pick({ name: true }),
@@ -18,26 +46,55 @@ const ZUpdateOrganizationNameAction = z.object({
export const updateOrganizationNameAction = authenticatedActionClient
.inputSchema(ZUpdateOrganizationNameAction)
.action(
withAuditLogging("updated", "organization", async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
schema: ZOrganizationUpdateInput.pick({ name: true }),
data: parsedInput.data,
roles: ["owner"],
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
})
withAuditLogging(
"updated",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateOrganizationNameAction>;
}) =>
updateOrganizationAction({
ctx,
organizationId: parsedInput.organizationId,
schema: ZOrganizationUpdateInput.pick({ name: true }),
data: parsedInput.data,
roles: ["owner"],
})
)
);
const ZUpdateOrganizationAISettingsAction = z.object({
organizationId: ZId,
data: ZOrganizationUpdateInput.pick({ isAISmartToolsEnabled: true, isAIDataAnalysisEnabled: true }),
});
export const updateOrganizationAISettingsAction = authenticatedActionClient
.inputSchema(ZUpdateOrganizationAISettingsAction)
.action(
withAuditLogging(
"updated",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateOrganizationAISettingsAction>;
}) =>
updateOrganizationAction({
ctx,
organizationId: parsedInput.organizationId,
schema: ZOrganizationUpdateInput.pick({
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
}),
data: parsedInput.data,
roles: ["owner", "manager"],
})
)
);
const ZDeleteOrganizationAction = z.object({
@@ -0,0 +1,84 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { updateOrganizationAISettingsAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
interface AISettingsToggleProps {
organization: TOrganization;
membershipRole?: TOrganizationRole;
}
export const AISettingsToggle = ({ organization, membershipRole }: Readonly<AISettingsToggleProps>) => {
const [loadingField, setLoadingField] = useState<string | null>(null);
const { t } = useTranslation();
const router = useRouter();
const { isOwner, isManager } = getAccessFlags(membershipRole);
const canEdit = isOwner || isManager;
const handleToggle = async (
field: "isAISmartToolsEnabled" | "isAIDataAnalysisEnabled",
checked: boolean
) => {
setLoadingField(field);
try {
const response = await updateOrganizationAISettingsAction({
organizationId: organization.id,
data: { [field]: checked },
});
if (response?.data) {
toast.success(t("environments.settings.general.ai_settings_updated_successfully"));
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(response);
toast.error(errorMessage);
}
} catch {
toast.error(t("common.something_went_wrong"));
} finally {
setLoadingField(null);
}
};
return (
<div>
<AdvancedOptionToggle
isChecked={organization.isAISmartToolsEnabled}
onToggle={(checked) => handleToggle("isAISmartToolsEnabled", checked)}
htmlId="ai-smart-tools-toggle"
title={t("environments.settings.general.ai_smart_tools_enabled")}
description={t("environments.settings.general.ai_smart_tools_enabled_description")}
disabled={loadingField !== null || !canEdit}
customContainerClass="px-0"
/>
<AdvancedOptionToggle
isChecked={organization.isAIDataAnalysisEnabled}
onToggle={(checked) => handleToggle("isAIDataAnalysisEnabled", checked)}
htmlId="ai-data-analysis-toggle"
title={t("environments.settings.general.ai_data_analysis_enabled")}
description={t("environments.settings.general.ai_data_analysis_enabled_description")}
disabled={loadingField !== null || !canEdit}
customContainerClass="px-0"
/>
{!canEdit && (
<Alert variant="warning">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>
</Alert>
)}
</div>
);
};
@@ -11,6 +11,7 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header";
import packageJson from "@/package.json";
import { SettingsCard } from "../../components/SettingsCard";
import { AISettingsToggle } from "./components/AISettingsToggle";
import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
import { SecurityListTip } from "./components/SecurityListTip";
@@ -60,6 +61,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
membershipRole={currentUserMembership?.role}
/>
</SettingsCard>
<SettingsCard
title={t("environments.settings.general.ai_enabled")}
description={t("environments.settings.general.ai_enabled_description")}>
<AISettingsToggle organization={organization} membershipRole={currentUserMembership?.role} />
</SettingsCard>
<EmailCustomizationSettings
organization={organization}
hasWhiteLabelPermission={hasWhiteLabelPermission}
@@ -1,208 +0,0 @@
"use client";
import { CheckCircle2, Sparkles } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
const FORMBRICKS_HOST = "https://app.formbricks.com";
const SURVEY_ID = "cr9r4b2r73x6hlmn5aa2ha44";
const ENVIRONMENT_ID = "cmk41i8bi92bdad01svi74dec";
interface WorkflowsPageProps {
userEmail: string;
organizationName: string;
billingPlan: string;
}
type Step = "prompt" | "followup" | "thankyou";
export const WorkflowsPage = ({ userEmail, organizationName, billingPlan }: WorkflowsPageProps) => {
const { t } = useTranslation();
const [step, setStep] = useState<Step>("prompt");
const [promptValue, setPromptValue] = useState("");
const [detailsValue, setDetailsValue] = useState("");
const [responseId, setResponseId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleGenerateWorkflow = async () => {
if (promptValue.trim().length < 100 || isSubmitting) return;
setIsSubmitting(true);
try {
const res = await fetch(`${FORMBRICKS_HOST}/api/v2/client/${ENVIRONMENT_ID}/responses`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
surveyId: SURVEY_ID,
finished: false,
data: {
workflow: promptValue.trim(),
useremail: userEmail,
orgname: organizationName,
billingplan: billingPlan,
},
}),
});
if (res.ok) {
const json = await res.json();
setResponseId(json.data?.id ?? null);
}
setStep("followup");
} catch {
setStep("followup");
} finally {
setIsSubmitting(false);
}
};
const handleSubmitFeedback = async () => {
if (isSubmitting) return;
setIsSubmitting(true);
if (responseId) {
try {
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
finished: true,
data: {
details: detailsValue.trim(),
},
}),
});
} catch {
// silently fail
}
}
setIsSubmitting(false);
setStep("thankyou");
};
const handleSkipFeedback = async () => {
if (!responseId) {
setStep("thankyou");
return;
}
try {
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
finished: true,
data: {},
}),
});
} catch {
// silently fail
}
setStep("thankyou");
};
if (step === "prompt") {
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-2xl space-y-8">
<div className="space-y-3 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-brand-light to-brand-dark shadow-md">
<Sparkles className="h-6 w-6 text-white" />
</div>
<h1 className="text-4xl font-bold tracking-tight text-slate-800">{t("workflows.heading")}</h1>
<p className="text-lg text-slate-500">{t("workflows.subheading")}</p>
</div>
<div className="relative">
<textarea
value={promptValue}
onChange={(e) => setPromptValue(e.target.value)}
placeholder={t("workflows.placeholder")}
rows={5}
className="w-full resize-none rounded-xl border border-slate-200 bg-white px-5 py-4 text-base text-slate-800 shadow-sm transition-all placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-brand-light/20"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
handleGenerateWorkflow();
}
}}
/>
<div className="mt-3 flex items-center justify-between">
<span
className={`text-xs ${promptValue.trim().length >= 100 ? "text-slate-400" : "text-amber-500"}`}>
{promptValue.trim().length} / 100
</span>
<Button
onClick={handleGenerateWorkflow}
disabled={promptValue.trim().length < 100 || isSubmitting}
loading={isSubmitting}
size="lg">
<Sparkles className="h-4 w-4" />
{t("workflows.generate_button")}
</Button>
</div>
</div>
</div>
</div>
);
}
if (step === "followup") {
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-2xl space-y-8">
<div className="space-y-3 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100">
<Sparkles className="h-6 w-6 text-brand-dark" />
</div>
<h1 className="text-3xl font-bold tracking-tight text-slate-800">
{t("workflows.coming_soon_title")}
</h1>
<p className="mx-auto max-w-md text-base text-slate-500">
{t("workflows.coming_soon_description")}
</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<label className="text-md mb-2 block font-medium text-slate-700">
{t("workflows.follow_up_label")}
</label>
<textarea
value={detailsValue}
onChange={(e) => setDetailsValue(e.target.value)}
placeholder={t("workflows.follow_up_placeholder")}
rows={4}
className="w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-800 transition-all placeholder:text-slate-400 focus:border-brand-dark focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand-light/20"
/>
<div className="mt-4 flex items-center justify-end gap-3">
<Button variant="ghost" onClick={handleSkipFeedback} className="text-slate-500">
{t("common.skip")}
</Button>
<Button
onClick={handleSubmitFeedback}
disabled={!detailsValue.trim() || isSubmitting}
loading={isSubmitting}>
{t("workflows.submit_button")}
</Button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-md space-y-6 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-50">
<CheckCircle2 className="h-8 w-8 text-green-500" />
</div>
<h1 className="text-2xl font-bold text-slate-800">{t("workflows.thank_you_title")}</h1>
<p className="text-base text-slate-500">{t("workflows.thank_you_description")}</p>
</div>
</div>
);
};
@@ -1,42 +0,0 @@
import { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getCloudBillingDisplayContext } from "@/modules/ee/billing/lib/cloud-billing-display";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { WorkflowsPage } from "./components/workflows-page";
export const metadata: Metadata = {
title: "Workflows",
};
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
if (!IS_FORMBRICKS_CLOUD) {
return notFound();
}
const { session, organization, isBilling } = await getEnvironmentAuth(params.environmentId);
if (isBilling) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
}
const user = await getUser(session.user.id);
if (!user) {
return redirect("/auth/login");
}
const cloudBillingDisplayContext = await getCloudBillingDisplayContext(organization.id);
return (
<WorkflowsPage
userEmail={user.email}
organizationName={organization.name}
billingPlan={cloudBillingDisplayContext.currentCloudPlan}
/>
);
};
export default Page;
@@ -51,8 +51,20 @@ vi.mock("@/lib/env", () => ({
RECAPTCHA_SECRET_KEY: "secret-key",
GITHUB_ID: "github-id",
SAML_DATABASE_URL: "postgresql://saml.example.com/formbricks",
ENTERPRISE_LICENSE_KEY: "test-license-key",
},
}));
vi.mock("@/lib/constants", () => ({
E2E_TESTING: false,
IS_DEVELOPMENT: false,
TELEMETRY_DISABLED: false,
}));
vi.mock("@/lib/hash-string", () => ({
hashString: vi.fn((s: string) => `hashed-${s}`),
}));
vi.mock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn(),
}));
// Mock fetch
const fetchMock = vi.fn();
@@ -199,6 +211,14 @@ describe("sendTelemetryEvents", () => {
test("should handle telemetry send failure and apply cooldown", async () => {
// Reset module to clear nextTelemetryCheck state from previous tests
vi.resetModules();
vi.doMock("@/lib/constants", () => ({
E2E_TESTING: false,
IS_DEVELOPMENT: false,
TELEMETRY_DISABLED: false,
}));
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: false }),
}));
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
// Ensure we can acquire lock by setting last sent time far in the past
@@ -221,6 +241,7 @@ describe("sendTelemetryEvents", () => {
expect.objectContaining({
error: networkError,
message: "Network error",
hashedLicenseKey: "hashed-test-license-key",
}),
"Failed to send telemetry - applying 1h cooldown"
);
@@ -242,6 +263,14 @@ describe("sendTelemetryEvents", () => {
test("should skip if no organization exists", async () => {
// Reset module to clear nextTelemetryCheck state from previous tests
vi.resetModules();
vi.doMock("@/lib/constants", () => ({
E2E_TESTING: false,
IS_DEVELOPMENT: false,
TELEMETRY_DISABLED: false,
}));
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: false }),
}));
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
// Ensure we can acquire lock by setting last sent time far in the past
@@ -276,4 +305,113 @@ describe("sendTelemetryEvents", () => {
// This might be a bug, but we test the actual behavior
expect(mockCacheService.set).toHaveBeenCalled();
});
test("should skip telemetry when TELEMETRY_DISABLED is true and no active EE license", async () => {
vi.resetModules();
vi.doMock("@/lib/constants", () => ({
E2E_TESTING: false,
IS_DEVELOPMENT: false,
TELEMETRY_DISABLED: true,
}));
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: false }),
}));
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
await freshSendTelemetryEvents();
// Should return early without touching cache or sending telemetry
expect(getCacheService).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
test("should send telemetry when TELEMETRY_DISABLED is true but EE license is active", async () => {
vi.resetModules();
vi.doMock("@/lib/constants", () => ({
E2E_TESTING: false,
IS_DEVELOPMENT: false,
TELEMETRY_DISABLED: true,
}));
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: true }),
}));
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
// Re-setup mocks after resetModules
vi.mocked(getCacheService).mockResolvedValue({
ok: true,
data: mockCacheService as any,
});
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: true });
mockCacheService.del.mockResolvedValue({ ok: true, data: undefined });
mockCacheService.get.mockResolvedValue({ ok: true, data: null });
mockCacheService.set.mockResolvedValue({ ok: true, data: undefined });
vi.mocked(prisma.organization.findFirst).mockResolvedValue({
id: "org-123",
createdAt: new Date("2023-01-01"),
} as any);
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{
organizationCount: BigInt(1),
userCount: BigInt(5),
teamCount: BigInt(2),
projectCount: BigInt(3),
surveyCount: BigInt(10),
inProgressSurveyCount: BigInt(4),
completedSurveyCount: BigInt(6),
responseCountAllTime: BigInt(100),
responseCountSinceLastUpdate: BigInt(10),
displayCount: BigInt(50),
contactCount: BigInt(20),
segmentCount: BigInt(4),
newestResponseAt: new Date("2024-01-01T00:00:00.000Z"),
},
] as any);
vi.mocked(prisma.integration.findMany).mockResolvedValue([{ type: IntegrationType.notion }] as any);
vi.mocked(prisma.account.findMany).mockResolvedValue([{ provider: "github" }] as any);
fetchMock.mockResolvedValue({ ok: true });
await freshSendTelemetryEvents();
// EE license active — telemetry should bypass TELEMETRY_DISABLED and send
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test("should unconditionally skip when E2E_TESTING is true even with active EE license", async () => {
vi.resetModules();
vi.doMock("@/lib/constants", () => ({
E2E_TESTING: true,
IS_DEVELOPMENT: false,
TELEMETRY_DISABLED: false,
}));
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: true }),
}));
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
await freshSendTelemetryEvents();
// E2E_TESTING is a hard skip — no EE bypass, no cache, no fetch
expect(getCacheService).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
test("should unconditionally skip when IS_DEVELOPMENT is true", async () => {
vi.resetModules();
vi.doMock("@/lib/constants", () => ({
E2E_TESTING: false,
IS_DEVELOPMENT: true,
TELEMETRY_DISABLED: false,
}));
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: true }),
}));
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
await freshSendTelemetryEvents();
expect(getCacheService).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
});
@@ -2,8 +2,11 @@ import { IntegrationType } from "@prisma/client";
import { createCacheKey, getCacheService } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { E2E_TESTING, IS_DEVELOPMENT, TELEMETRY_DISABLED } from "@/lib/constants";
import { env } from "@/lib/env";
import { hashString } from "@/lib/hash-string";
import { getInstanceInfo } from "@/lib/instance";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import packageJson from "@/package.json";
const TELEMETRY_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
@@ -24,8 +27,31 @@ let nextTelemetryCheck = 0;
* 2. Redis check (shared across instances, persists across restarts)
* 3. Distributed lock (prevents concurrent execution in multi-instance deployments)
*/
// Hashed license key for log context — allows correlating log entries to a specific license
// without exposing the raw key. Computed once at module load.
const hashedLicenseKey = env.ENTERPRISE_LICENSE_KEY ? hashString(env.ENTERPRISE_LICENSE_KEY) : null;
/**
* Returns true if telemetry is disabled via env var AND there is no active EE license.
* EE customers cannot opt out — telemetry is always enforced for license compliance.
*/
const isTelemetryDisabledForCE = async (): Promise<boolean> => {
if (!TELEMETRY_DISABLED) return false;
const license = await getEnterpriseLicense();
return !license.active;
};
export const sendTelemetryEvents = async () => {
try {
// ============================================================
// CHECK 0: Non-Production Hard Skip
// ============================================================
// Purpose: Unconditionally skip telemetry in dev and test/CI environments.
// No EE bypass — these are internal flags, not customer-facing.
if (E2E_TESTING || IS_DEVELOPMENT) {
return;
}
const now = Date.now();
// ============================================================
@@ -39,7 +65,18 @@ export const sendTelemetryEvents = async () => {
}
// ============================================================
// CHECK 2: Redis Check (Shared State)
// CHECK 2: Telemetry Disabled Check
// ============================================================
// Purpose: Allow CE self-hosters to opt out of telemetry via env var.
// EE bypass: If an active Enterprise License is detected, telemetry is always sent
// regardless of the TELEMETRY_DISABLED setting to enforce license compliance.
// Placed after in-memory check to avoid calling getEnterpriseLicense() on every invocation.
if (await isTelemetryDisabledForCE()) {
return;
}
// ============================================================
// CHECK 3: Redis Check (Shared State)
// ============================================================
// Purpose: Check if telemetry was sent recently by ANY instance (shared across cluster).
// This persists across restarts and works in multi-instance deployments.
@@ -66,7 +103,7 @@ export const sendTelemetryEvents = async () => {
}
// ============================================================
// CHECK 3: Distributed Lock (Prevent Concurrent Execution)
// CHECK 4: Distributed Lock (Prevent Concurrent Execution)
// ============================================================
// Purpose: Ensure only ONE instance executes telemetry at a time in a cluster.
// How it works:
@@ -100,7 +137,7 @@ export const sendTelemetryEvents = async () => {
// Log as warning since telemetry is non-essential
const errorMessage = e instanceof Error ? e.message : String(e);
logger.warn(
{ error: e, message: errorMessage, lastSent, now },
{ error: e, message: errorMessage, lastSent, now, hashedLicenseKey },
"Failed to send telemetry - applying 1h cooldown"
);
@@ -118,7 +155,7 @@ export const sendTelemetryEvents = async () => {
// Log as warning since telemetry is non-essential functionality
const errorMessage = error instanceof Error ? error.message : String(error);
logger.warn(
{ error, message: errorMessage, timestamp: Date.now() },
{ error, message: errorMessage, timestamp: Date.now(), hashedLicenseKey },
"Unexpected error in sendTelemetryEvents wrapper - telemetry check skipped"
);
}
@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { GET } from "./route";
const mocks = vi.hoisted(() => {
const nextAuthHandler = vi.fn(async () => new Response(null, { status: 200 }));
const nextAuth = vi.fn(() => nextAuthHandler);
return {
nextAuth,
nextAuthHandler,
baseSignIn: vi.fn(async () => true),
baseSession: vi.fn(async ({ session }: { session: unknown }) => session),
baseEventSignIn: vi.fn(),
queueAuditEventBackground: vi.fn(),
captureException: vi.fn(),
loggerError: vi.fn(),
};
});
vi.mock("next-auth", () => ({
default: mocks.nextAuth,
}));
vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: undefined,
}));
vi.mock("@sentry/nextjs", () => ({
captureException: mocks.captureException,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: mocks.loggerError,
})),
},
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {
callbacks: {
signIn: mocks.baseSignIn,
session: mocks.baseSession,
},
events: {
signIn: mocks.baseEventSignIn,
},
},
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventBackground: mocks.queueAuditEventBackground,
}));
const getWrappedAuthOptions = async (requestId: string = "req-123") => {
const request = new Request("http://localhost/api/auth/signin", {
headers: { "x-request-id": requestId },
});
await GET(request, {} as any);
expect(mocks.nextAuth).toHaveBeenCalledTimes(1);
return mocks.nextAuth.mock.calls[0][0];
};
describe("auth route audit logging", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
test("logs successful sign-in from the NextAuth signIn event after session creation", async () => {
const authOptions = await getWrappedAuthOptions();
const user = { id: "user_1", email: "user@example.com", name: "User Example" };
const account = { provider: "keycloak" };
await expect(authOptions.callbacks.signIn({ user, account })).resolves.toBe(true);
expect(mocks.queueAuditEventBackground).not.toHaveBeenCalled();
await authOptions.events.signIn({ user, account, isNewUser: false });
expect(mocks.baseEventSignIn).toHaveBeenCalledWith({ user, account, isNewUser: false });
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "signedIn",
targetType: "user",
userId: "user_1",
targetId: "user_1",
organizationId: "unknown",
status: "success",
userType: "user",
newObject: expect.objectContaining({
email: "user@example.com",
authMethod: "sso",
provider: "keycloak",
sessionStrategy: "database",
isNewUser: false,
}),
})
);
});
test("logs failed sign-in attempts from the callback stage with the request event id", async () => {
const error = new Error("Access denied");
mocks.baseSignIn.mockRejectedValueOnce(error);
const authOptions = await getWrappedAuthOptions("req-failure");
const user = { id: "user_2", email: "user2@example.com" };
const account = { provider: "credentials" };
await expect(authOptions.callbacks.signIn({ user, account })).rejects.toThrow("Access denied");
expect(mocks.baseEventSignIn).not.toHaveBeenCalled();
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "signedIn",
targetType: "user",
userId: "user_2",
targetId: "user_2",
organizationId: "unknown",
status: "failure",
userType: "user",
eventId: "req-failure",
newObject: expect.objectContaining({
email: "user2@example.com",
authMethod: "password",
provider: "credentials",
errorMessage: "Access denied",
}),
})
);
});
});
+67 -68
View File
@@ -6,10 +6,26 @@ import { logger } from "@formbricks/logger";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { authOptions as baseAuthOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
export const fetchCache = "force-no-store";
const getAuthMethod = (account: Account | null) => {
if (account?.provider === "credentials") {
return "password";
}
if (account?.provider === "token") {
return "email_verification";
}
if (account?.provider) {
return "sso";
}
return "unknown";
};
const handler = async (req: Request, ctx: any) => {
const eventId = req.headers.get("x-request-id") ?? undefined;
@@ -17,44 +33,6 @@ const handler = async (req: Request, ctx: any) => {
...baseAuthOptions,
callbacks: {
...baseAuthOptions.callbacks,
async jwt(params: any) {
let result: any = params.token;
let error: any = undefined;
try {
if (baseAuthOptions.callbacks?.jwt) {
result = await baseAuthOptions.callbacks.jwt(params);
}
} catch (err) {
error = err;
logger.withContext({ eventId, err }).error("JWT callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
// Audit JWT operations (token refresh, updates)
if (params.trigger && params.token?.profile?.id) {
const status: TAuditStatus = error ? "failure" : "success";
const auditLog = {
action: "jwtTokenCreated" as const,
targetType: "user" as const,
userId: params.token.profile.id,
targetId: params.token.profile.id,
organizationId: UNKNOWN_DATA,
status,
userType: "user" as const,
newObject: { trigger: params.trigger, tokenType: "jwt" },
...(error ? { eventId } : {}),
};
queueAuditEventBackground(auditLog);
}
if (error) throw error;
return result;
},
async session(params: any) {
let result: any = params.session;
let error: any = undefined;
@@ -90,7 +68,7 @@ const handler = async (req: Request, ctx: any) => {
}) {
let result: boolean | string = true;
let error: any = undefined;
let authMethod = "unknown";
const authMethod = getAuthMethod(account);
try {
if (baseAuthOptions.callbacks?.signIn) {
@@ -102,15 +80,6 @@ const handler = async (req: Request, ctx: any) => {
credentials,
});
}
// Determine authentication method for more detailed logging
if (account?.provider === "credentials") {
authMethod = "password";
} else if (account?.provider === "token") {
authMethod = "email_verification";
} else if (account?.provider && account.provider !== "credentials") {
authMethod = "sso";
}
} catch (err) {
error = err;
result = false;
@@ -122,30 +91,60 @@ const handler = async (req: Request, ctx: any) => {
}
}
const status: TAuditStatus = result === false ? "failure" : "success";
const auditLog = {
action: "signedIn" as const,
targetType: "user" as const,
userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA,
status,
userType: "user" as const,
newObject: {
...user,
authMethod,
provider: account?.provider,
...(error ? { errorMessage: error.message } : {}),
},
...(status === "failure" ? { eventId } : {}),
};
queueAuditEventBackground(auditLog);
if (result === false) {
queueAuditEventBackground({
action: "signedIn",
targetType: "user",
userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA,
status: "failure",
userType: "user",
newObject: {
...user,
authMethod,
provider: account?.provider,
...(error instanceof Error ? { errorMessage: error.message } : {}),
},
eventId,
});
}
if (error) throw error;
return result;
},
},
events: {
...baseAuthOptions.events,
async signIn({ user, account, isNewUser }: any) {
try {
await baseAuthOptions.events?.signIn?.({ user, account, isNewUser });
} catch (err) {
logger.withContext({ eventId, err }).error("Sign-in event callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
queueAuditEventBackground({
action: "signedIn",
targetType: "user",
userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA,
status: "success",
userType: "user",
newObject: {
...user,
authMethod: getAuthMethod(account),
provider: account?.provider,
sessionStrategy: "database",
isNewUser: isNewUser ?? false,
},
});
},
},
};
return NextAuth(authOptions)(req, ctx);
@@ -76,7 +76,8 @@ const mockOrganization: TOrganization = {
},
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
};
const mockSurveys: TSurvey[] = [
@@ -86,9 +86,11 @@ export const GET = withV1ApiWrapper({
};
}
const error = err instanceof Error ? err : new Error(String(err));
logger.error(
{
error: err,
error,
url: req.url,
environmentId: params.environmentId,
},
@@ -96,9 +98,10 @@ export const GET = withV1ApiWrapper({
);
return {
response: responses.internalServerErrorResponse(
err instanceof Error ? err.message : "Unknown error occurred",
"An error occurred while processing your request.",
true
),
error,
};
}
},
@@ -0,0 +1,488 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { putResponseHandler } from "./put-response-handler";
const mocks = vi.hoisted(() => ({
formatValidationErrorsForV1Api: vi.fn((errors) => errors),
getResponse: vi.fn(),
getSurvey: vi.fn(),
getValidatedResponseUpdateInput: vi.fn(),
loggerError: vi.fn(),
sendToPipeline: vi.fn(),
updateResponseWithQuotaEvaluation: vi.fn(),
validateFileUploads: vi.fn(),
validateOtherOptionLengthForMultipleChoice: vi.fn(),
validateResponseData: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: mocks.loggerError,
},
}));
vi.mock("@/app/lib/pipelines", () => ({
sendToPipeline: mocks.sendToPipeline,
}));
vi.mock("@/lib/response/service", () => ({
getResponse: mocks.getResponse,
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: mocks.getSurvey,
}));
vi.mock("@/modules/api/lib/validation", () => ({
formatValidationErrorsForV1Api: mocks.formatValidationErrorsForV1Api,
validateResponseData: mocks.validateResponseData,
}));
vi.mock("@/modules/api/v2/lib/element", () => ({
validateOtherOptionLengthForMultipleChoice: mocks.validateOtherOptionLengthForMultipleChoice,
}));
vi.mock("@/modules/storage/utils", () => ({
validateFileUploads: mocks.validateFileUploads,
}));
vi.mock("./response", () => ({
updateResponseWithQuotaEvaluation: mocks.updateResponseWithQuotaEvaluation,
}));
vi.mock("./validated-response-update-input", () => ({
getValidatedResponseUpdateInput: mocks.getValidatedResponseUpdateInput,
}));
const environmentId = "environment_a";
const responseId = "response_123";
const surveyId = "survey_123";
const createRequest = () =>
new Request(`https://api.test/api/v1/client/${environmentId}/responses/${responseId}`, {
method: "PUT",
});
const createHandlerParams = (params?: Partial<{ environmentId: string; responseId: string }>) =>
({
req: createRequest(),
props: {
params: Promise.resolve({
environmentId,
responseId,
...params,
}),
},
}) as never;
const getBaseResponseUpdateInput = () => ({
data: {
q1: "updated-answer",
},
language: "en",
});
const getBaseExistingResponse = () =>
({
id: responseId,
surveyId,
data: {
q0: "existing-answer",
},
finished: false,
language: "en",
}) as const;
const getBaseSurvey = () =>
({
id: surveyId,
environmentId,
blocks: [],
questions: [],
}) as const;
const getBaseUpdatedResponse = () =>
({
id: responseId,
surveyId,
data: {
q0: "existing-answer",
q1: "updated-answer",
},
finished: false,
quotaFull: undefined,
}) as const;
describe("putResponseHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getValidatedResponseUpdateInput.mockResolvedValue({
responseUpdateInput: getBaseResponseUpdateInput(),
});
mocks.getResponse.mockResolvedValue(getBaseExistingResponse());
mocks.getSurvey.mockResolvedValue(getBaseSurvey());
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue(getBaseUpdatedResponse());
mocks.validateFileUploads.mockReturnValue(true);
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue(null);
mocks.validateResponseData.mockReturnValue(null);
});
test("returns a bad request response when the response id is missing", async () => {
const result = await putResponseHandler(createHandlerParams({ responseId: "" }));
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toEqual({
code: "bad_request",
message: "Response ID is missing",
details: {},
});
expect(mocks.getValidatedResponseUpdateInput).not.toHaveBeenCalled();
});
test("returns the validation response from the parsed request input", async () => {
const validationResponse = responses.badRequestResponse(
"Malformed JSON in request body",
undefined,
true
);
mocks.getValidatedResponseUpdateInput.mockResolvedValue({
response: validationResponse,
});
const result = await putResponseHandler(createHandlerParams());
expect(result.response).toBe(validationResponse);
expect(mocks.getResponse).not.toHaveBeenCalled();
});
test("returns not found when the response does not exist", async () => {
mocks.getResponse.mockResolvedValue(null);
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(404);
await expect(result.response.json()).resolves.toEqual({
code: "not_found",
message: "Response not found",
details: {
resource_id: responseId,
resource_type: "Response",
},
});
});
test("maps resource lookup errors to a not found response", async () => {
mocks.getResponse.mockRejectedValue(new ResourceNotFoundError("Response", responseId));
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(404);
await expect(result.response.json()).resolves.toEqual({
code: "not_found",
message: "Response not found",
details: {
resource_id: responseId,
resource_type: "Response",
},
});
});
test("maps invalid lookup input errors to a bad request response", async () => {
mocks.getResponse.mockRejectedValue(new InvalidInputError("Invalid response id"));
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toEqual({
code: "bad_request",
message: "Invalid response id",
details: {},
});
});
test("maps database lookup errors to a reported internal server error", async () => {
const error = new DatabaseError("Lookup failed");
mocks.getResponse.mockRejectedValue(error);
const result = await putResponseHandler(createHandlerParams());
expect(result.error).toBe(error);
expect(result.response.status).toBe(500);
await expect(result.response.json()).resolves.toEqual({
code: "internal_server_error",
message: "Lookup failed",
details: {},
});
expect(mocks.loggerError).toHaveBeenCalledWith(
{
error,
url: createRequest().url,
},
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
});
test("maps unknown lookup failures to a generic internal server error", async () => {
const error = new Error("boom");
mocks.getResponse.mockRejectedValue(error);
const result = await putResponseHandler(createHandlerParams());
expect(result.error).toBe(error);
expect(result.response.status).toBe(500);
await expect(result.response.json()).resolves.toEqual({
code: "internal_server_error",
message: "Unknown error occurred",
details: {},
});
});
test("rejects updates when the response survey does not belong to the requested environment", async () => {
mocks.getSurvey.mockResolvedValue({
...getBaseSurvey(),
environmentId: "different_environment",
});
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(404);
await expect(result.response.json()).resolves.toEqual({
code: "not_found",
message: "Response not found",
details: {
resource_id: responseId,
resource_type: "Response",
},
});
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
});
test("rejects updates when the response is already finished", async () => {
mocks.getResponse.mockResolvedValue({
...getBaseExistingResponse(),
finished: true,
});
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toEqual({
code: "bad_request",
message: "Response is already finished",
details: {},
});
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
});
test("rejects invalid file upload updates", async () => {
mocks.validateFileUploads.mockReturnValue(false);
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toEqual({
code: "bad_request",
message: "Invalid file upload response",
details: {},
});
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
});
test("rejects updates when an other-option response exceeds the character limit", async () => {
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue("question_123");
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toEqual({
code: "bad_request",
message: "Response exceeds character limit",
details: {
questionId: "question_123",
},
});
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
});
test("returns validation details when merged response data is invalid", async () => {
mocks.validateResponseData.mockReturnValue([{ field: "q1", message: "Required" }]);
mocks.formatValidationErrorsForV1Api.mockReturnValue({
q1: "Required",
});
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toEqual({
code: "bad_request",
message: "Validation failed",
details: {
q1: "Required",
},
});
expect(mocks.formatValidationErrorsForV1Api).toHaveBeenCalledWith([{ field: "q1", message: "Required" }]);
});
test("returns not found when the response disappears during update", async () => {
mocks.updateResponseWithQuotaEvaluation.mockRejectedValue(
new ResourceNotFoundError("Response", responseId)
);
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(404);
await expect(result.response.json()).resolves.toEqual({
code: "not_found",
message: "Response not found",
details: {
resource_id: responseId,
resource_type: "Response",
},
});
});
test("returns a bad request response for invalid update input during persistence", async () => {
mocks.updateResponseWithQuotaEvaluation.mockRejectedValue(
new InvalidInputError("Response update payload is invalid")
);
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toEqual({
code: "bad_request",
message: "Response update payload is invalid",
details: {},
});
});
test("returns a reported internal server error for database update failures", async () => {
const error = new DatabaseError("Update failed");
mocks.updateResponseWithQuotaEvaluation.mockRejectedValue(error);
const result = await putResponseHandler(createHandlerParams());
expect(result.error).toBe(error);
expect(result.response.status).toBe(500);
await expect(result.response.json()).resolves.toEqual({
code: "internal_server_error",
message: "Update failed",
details: {},
});
expect(mocks.loggerError).toHaveBeenCalledWith(
{
error,
url: createRequest().url,
},
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
});
test("returns a generic internal server error for unexpected update failures", async () => {
const error = new Error("Unexpected persistence failure");
mocks.updateResponseWithQuotaEvaluation.mockRejectedValue(error);
const result = await putResponseHandler(createHandlerParams());
expect(result.error).toBe(error);
expect(result.response.status).toBe(500);
await expect(result.response.json()).resolves.toEqual({
code: "internal_server_error",
message: "Something went wrong",
details: {},
});
expect(mocks.loggerError).toHaveBeenCalledWith(
{
error,
url: createRequest().url,
},
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
});
test("returns a success payload and emits a responseUpdated pipeline event", async () => {
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(200);
await expect(result.response.json()).resolves.toEqual({
data: {
id: responseId,
quotaFull: false,
},
});
expect(mocks.sendToPipeline).toHaveBeenCalledTimes(1);
expect(mocks.sendToPipeline).toHaveBeenCalledWith({
event: "responseUpdated",
environmentId,
surveyId,
response: {
id: responseId,
surveyId,
data: {
q0: "existing-answer",
q1: "updated-answer",
},
finished: false,
},
});
});
test("emits both pipeline events and includes quota metadata when the response finishes", async () => {
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue({
...getBaseUpdatedResponse(),
finished: true,
quotaFull: {
id: "quota_123",
action: "endSurvey",
endingCardId: "ending_card_123",
},
});
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(200);
await expect(result.response.json()).resolves.toEqual({
data: {
id: responseId,
quotaFull: true,
quota: {
id: "quota_123",
action: "endSurvey",
endingCardId: "ending_card_123",
},
},
});
expect(mocks.sendToPipeline).toHaveBeenCalledTimes(2);
expect(mocks.sendToPipeline).toHaveBeenNthCalledWith(1, {
event: "responseUpdated",
environmentId,
surveyId,
response: {
id: responseId,
surveyId,
data: {
q0: "existing-answer",
q1: "updated-answer",
},
finished: true,
},
});
expect(mocks.sendToPipeline).toHaveBeenNthCalledWith(2, {
event: "responseFinished",
environmentId,
surveyId,
response: {
id: responseId,
surveyId,
data: {
q0: "existing-answer",
q1: "updated-answer",
},
finished: true,
},
});
});
});
@@ -0,0 +1,283 @@
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseUpdateInput } from "@formbricks/types/responses";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { THandlerParams } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./response";
import { getValidatedResponseUpdateInput } from "./validated-response-update-input";
type TRouteResult = {
response: Response;
error?: unknown;
};
type TExistingResponseResult = { existingResponse: TResponse } | TRouteResult;
type TSurveyResult = { survey: TSurvey } | TRouteResult;
type TUpdatedResponseResult =
| { updatedResponse: Awaited<ReturnType<typeof updateResponseWithQuotaEvaluation>> }
| TRouteResult;
export type TPutRouteParams = {
params: Promise<{
environmentId: string;
responseId: string;
}>;
};
const handleDatabaseError = (
error: Error,
url: string,
endpoint: string,
responseId: string
): TRouteResult => {
if (error instanceof ResourceNotFoundError) {
return { response: responses.notFoundResponse("Response", responseId, true) };
}
if (error instanceof InvalidInputError) {
return { response: responses.badRequestResponse(error.message, undefined, true) };
}
if (error instanceof DatabaseError) {
logger.error({ error, url }, `Error in ${endpoint}`);
return {
response: responses.internalServerErrorResponse(error.message, true),
error,
};
}
return {
response: responses.internalServerErrorResponse("Unknown error occurred", true),
error,
};
};
const validateResponse = (
response: TResponse,
survey: TSurvey,
responseUpdateInput: TResponseUpdateInput
) => {
const mergedData = {
...response.data,
...responseUpdateInput.data,
};
const validationErrors = validateResponseData(
survey.blocks,
mergedData,
responseUpdateInput.language ?? response.language ?? "en",
survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
),
};
}
};
const getExistingResponse = async (req: Request, responseId: string): Promise<TExistingResponseResult> => {
try {
const existingResponse = await getResponse(responseId);
return existingResponse
? { existingResponse }
: { response: responses.notFoundResponse("Response", responseId, true) };
} catch (error) {
return handleDatabaseError(
error instanceof Error ? error : new Error(String(error)),
req.url,
"PUT /api/v1/client/[environmentId]/responses/[responseId]",
responseId
);
}
};
const getSurveyForResponse = async (
req: Request,
responseId: string,
surveyId: string
): Promise<TSurveyResult> => {
try {
const survey = await getSurvey(surveyId);
return survey ? { survey } : { response: responses.notFoundResponse("Survey", surveyId, true) };
} catch (error) {
return handleDatabaseError(
error instanceof Error ? error : new Error(String(error)),
req.url,
"PUT /api/v1/client/[environmentId]/responses/[responseId]",
responseId
);
}
};
const validateUpdateRequest = (
existingResponse: TResponse,
survey: TSurvey,
responseUpdateInput: TResponseUpdateInput
): TRouteResult | undefined => {
if (existingResponse.finished) {
return {
response: responses.badRequestResponse("Response is already finished", undefined, true),
};
}
if (!validateFileUploads(responseUpdateInput.data, survey.questions)) {
return {
response: responses.badRequestResponse("Invalid file upload response", undefined, true),
};
}
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: responseUpdateInput.data,
surveyQuestions: survey.questions as unknown as TSurveyElement[],
responseLanguage: responseUpdateInput.language,
});
if (otherResponseInvalidQuestionId) {
return {
response: responses.badRequestResponse(
`Response exceeds character limit`,
{
questionId: otherResponseInvalidQuestionId,
},
true
),
};
}
return validateResponse(existingResponse, survey, responseUpdateInput);
};
const getUpdatedResponse = async (
req: Request,
responseId: string,
responseUpdateInput: TResponseUpdateInput
): Promise<TUpdatedResponseResult> => {
try {
const updatedResponse = await updateResponseWithQuotaEvaluation(responseId, responseUpdateInput);
return { updatedResponse };
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Response", responseId, true),
};
}
if (error instanceof InvalidInputError) {
return {
response: responses.badRequestResponse(error.message),
};
}
if (error instanceof DatabaseError) {
logger.error(
{ error, url: req.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return {
response: responses.internalServerErrorResponse(error.message),
error,
};
}
const unexpectedError = error instanceof Error ? error : new Error(String(error));
logger.error(
{ error: unexpectedError, url: req.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return {
response: responses.internalServerErrorResponse("Something went wrong"),
error: unexpectedError,
};
}
};
export const putResponseHandler = async ({
req,
props,
}: THandlerParams<TPutRouteParams>): Promise<TRouteResult> => {
const params = await props.params;
const { environmentId, responseId } = params;
if (!responseId) {
return {
response: responses.badRequestResponse("Response ID is missing", undefined, true),
};
}
const validatedUpdateInput = await getValidatedResponseUpdateInput(req);
if ("response" in validatedUpdateInput) {
return validatedUpdateInput;
}
const { responseUpdateInput } = validatedUpdateInput;
const existingResponseResult = await getExistingResponse(req, responseId);
if ("response" in existingResponseResult) {
return existingResponseResult;
}
const { existingResponse } = existingResponseResult;
const surveyResult = await getSurveyForResponse(req, responseId, existingResponse.surveyId);
if ("response" in surveyResult) {
return surveyResult;
}
const { survey } = surveyResult;
if (survey.environmentId !== environmentId) {
return {
response: responses.notFoundResponse("Response", responseId, true),
};
}
const validationResult = validateUpdateRequest(existingResponse, survey, responseUpdateInput);
if (validationResult) {
return validationResult;
}
const updatedResponseResult = await getUpdatedResponse(req, responseId, responseUpdateInput);
if ("response" in updatedResponseResult) {
return updatedResponseResult;
}
const { updatedResponse } = updatedResponseResult;
const { quotaFull, ...responseData } = updatedResponse;
sendToPipeline({
event: "responseUpdated",
environmentId: survey.environmentId,
surveyId: survey.id,
response: responseData,
});
if (updatedResponse.finished) {
sendToPipeline({
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: survey.id,
response: responseData,
});
}
const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = {
id: responseData.id,
...quotaObj,
};
return {
response: responses.successResponse(responseDataWithQuota, true),
};
};
@@ -0,0 +1,84 @@
import { describe, expect, test } from "vitest";
import { getValidatedResponseUpdateInput } from "./validated-response-update-input";
describe("getValidatedResponseUpdateInput", () => {
test("returns a bad request response for malformed JSON", async () => {
const request = new Request("http://localhost/api/v1/client/test/responses/response-id", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: "{invalid-json",
});
const result = await getValidatedResponseUpdateInput(request);
expect("response" in result).toBe(true);
if (!("response" in result)) {
throw new Error("Expected a response result");
}
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toEqual(
expect.objectContaining({
code: "bad_request",
message: "Malformed JSON in request body",
details: {
error: expect.any(String),
},
})
);
});
test("returns parsed response update input for valid JSON", async () => {
const request = new Request("http://localhost/api/v1/client/test/responses/response-id", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
finished: true,
}),
});
const result = await getValidatedResponseUpdateInput(request);
expect(result).toEqual({
responseUpdateInput: {
finished: true,
},
});
});
test("returns a bad request response for schema-invalid JSON", async () => {
const request = new Request("http://localhost/api/v1/client/test/responses/response-id", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
finished: "not-boolean",
}),
});
const result = await getValidatedResponseUpdateInput(request);
expect("response" in result).toBe(true);
if (!("response" in result)) {
throw new Error("Expected a response result");
}
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toEqual(
expect.objectContaining({
code: "bad_request",
message: "Fields are missing or incorrectly formatted",
details: expect.objectContaining({
finished: expect.any(String),
}),
})
);
});
});
@@ -0,0 +1,28 @@
import { TResponseUpdateInput, ZResponseUpdateInput } from "@formbricks/types/responses";
import {
TParseAndValidateJsonBodyResult,
parseAndValidateJsonBody,
} from "@/app/lib/api/parse-and-validate-json-body";
export type TValidatedResponseUpdateInputResult =
| { response: Response }
| { responseUpdateInput: TResponseUpdateInput };
export const getValidatedResponseUpdateInput = async (
req: Request
): Promise<TValidatedResponseUpdateInputResult> => {
const validatedInput: TParseAndValidateJsonBodyResult<TResponseUpdateInput> =
await parseAndValidateJsonBody({
request: req,
schema: ZResponseUpdateInput,
malformedJsonMessage: "Malformed JSON in request body",
});
if ("response" in validatedInput) {
return {
response: validatedInput.response,
};
}
return { responseUpdateInput: validatedInput.data };
};
@@ -1,235 +1,11 @@
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseUpdateInput, ZResponseUpdateInput } from "@formbricks/types/responses";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { putResponseHandler } from "./lib/put-response-handler";
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
const handleDatabaseError = (error: Error, url: string, endpoint: string, responseId: string): Response => {
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse("Response", responseId, true);
}
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message, undefined, true);
}
if (error instanceof DatabaseError) {
logger.error({ error, url }, `Error in ${endpoint}`);
return responses.internalServerErrorResponse(error.message, true);
}
return responses.internalServerErrorResponse("Unknown error occurred", true);
};
const validateResponse = (
response: TResponse,
survey: TSurvey,
responseUpdateInput: TResponseUpdateInput
) => {
// Validate response data against validation rules
const mergedData = {
...response.data,
...responseUpdateInput.data,
};
const validationErrors = validateResponseData(
survey.blocks,
mergedData,
responseUpdateInput.language ?? response.language ?? "en",
survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
),
};
}
};
export const PUT = withV1ApiWrapper({
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ responseId: string }> }>) => {
const params = await props.params;
const { responseId } = params;
if (!responseId) {
return {
response: responses.badRequestResponse("Response ID is missing", undefined, true),
};
}
const responseUpdate = await req.json();
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
),
};
}
let response;
try {
response = await getResponse(responseId);
} catch (error) {
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return {
response: handleDatabaseError(
error instanceof Error ? error : new Error(String(error)),
req.url,
endpoint,
responseId
),
};
}
if (!response) {
return {
response: responses.notFoundResponse("Response", responseId, true),
};
}
if (response.finished) {
return {
response: responses.badRequestResponse("Response is already finished", undefined, true),
};
}
// get survey to get environmentId
let survey;
try {
survey = await getSurvey(response.surveyId);
} catch (error) {
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return {
response: handleDatabaseError(
error instanceof Error ? error : new Error(String(error)),
req.url,
endpoint,
responseId
),
};
}
if (!survey) {
return {
response: responses.notFoundResponse("Survey", response.surveyId, true),
};
}
if (!validateFileUploads(inputValidation.data.data, survey.questions)) {
return {
response: responses.badRequestResponse("Invalid file upload response", undefined, true),
};
}
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: inputValidation.data.data,
surveyQuestions: survey.questions as unknown as TSurveyElement[],
responseLanguage: inputValidation.data.language,
});
if (otherResponseInvalidQuestionId) {
return {
response: responses.badRequestResponse(
`Response exceeds character limit`,
{
questionId: otherResponseInvalidQuestionId,
},
true
),
};
}
const validationResult = validateResponse(response, survey, inputValidation.data);
if (validationResult) {
return validationResult;
}
// update response with quota evaluation
let updatedResponse;
try {
updatedResponse = await updateResponseWithQuotaEvaluation(responseId, inputValidation.data);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Response", responseId, true),
};
}
if (error instanceof InvalidInputError) {
return {
response: responses.badRequestResponse(error.message),
};
}
if (error instanceof DatabaseError) {
logger.error(
{ error, url: req.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return {
response: responses.internalServerErrorResponse(error.message),
};
}
logger.error(
{ error, url: req.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return {
response: responses.internalServerErrorResponse("Something went wrong"),
};
}
const { quotaFull, ...responseData } = updatedResponse;
// send response update to pipeline
// don't await to not block the response
sendToPipeline({
event: "responseUpdated",
environmentId: survey.environmentId,
surveyId: survey.id,
response: responseData,
});
if (updatedResponse.finished) {
// send response to pipeline
// don't await to not block the response
sendToPipeline({
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: survey.id,
response: responseData,
});
}
const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = {
id: responseData.id,
...quotaObj,
};
return {
response: responses.successResponse(responseDataWithQuota, true),
};
},
handler: putResponseHandler,
});
@@ -1,7 +1,7 @@
import { logger } from "@formbricks/logger";
import { TUploadPrivateFileRequest, ZUploadPrivateFileRequest } from "@formbricks/types/storage";
import { ZUploadPrivateFileRequest } from "@formbricks/types/storage";
import { parseAndValidateJsonBody } from "@/app/lib/api/parse-and-validate-json-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { MAX_FILE_UPLOAD_SIZES } from "@/lib/constants";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
@@ -30,33 +30,27 @@ export const POST = withV1ApiWrapper({
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ environmentId: string }> }>) => {
const params = await props.params;
const { environmentId } = params;
let jsonInput: TUploadPrivateFileRequest;
try {
jsonInput = await req.json();
} catch (error) {
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
}
const parsedInputResult = ZUploadPrivateFileRequest.safeParse({
...jsonInput,
environmentId,
const parsedInputResult = await parseAndValidateJsonBody({
request: req,
schema: ZUploadPrivateFileRequest,
buildInput: (jsonInput) => ({
...(jsonInput !== null && typeof jsonInput === "object" ? jsonInput : {}),
environmentId,
}),
});
if (!parsedInputResult.success) {
const errorDetails = transformErrorToDetails(parsedInputResult.error);
logger.error({ error: errorDetails }, "Fields are missing or incorrectly formatted");
if ("response" in parsedInputResult) {
if (parsedInputResult.issue === "invalid_json") {
logger.error({ error: parsedInputResult.details, url: req.url }, "Error parsing JSON input");
} else {
logger.error(
{ error: parsedInputResult.details, url: req.url },
"Fields are missing or incorrectly formatted"
);
}
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
errorDetails,
true
),
response: parsedInputResult.response,
};
}
@@ -105,9 +99,14 @@ export const POST = withV1ApiWrapper({
if (!signedUrlResponse.ok) {
logger.error({ error: signedUrlResponse.error }, "Error getting signed url for upload");
const errorResponse = getErrorResponseFromStorageError(signedUrlResponse.error, { fileName });
return {
response: errorResponse,
};
return errorResponse.status >= 500
? {
response: errorResponse,
error: signedUrlResponse.error,
}
: {
response: errorResponse,
};
}
return {
@@ -49,7 +49,8 @@ const mockOrganization: TOrganization = {
},
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
};
const mockFollowUp: TSurveyCreateInputWithEnvironmentId["followUps"][number] = {
@@ -0,0 +1,126 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createDisplay: vi.fn(),
getIsContactsEnabled: vi.fn(),
getOrganizationIdFromEnvironmentId: vi.fn(),
reportApiError: vi.fn(),
}));
vi.mock("./lib/display", () => ({
createDisplay: mocks.createDisplay,
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsContactsEnabled: mocks.getIsContactsEnabled,
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromEnvironmentId: mocks.getOrganizationIdFromEnvironmentId,
}));
vi.mock("@/app/lib/api/api-error-reporter", () => ({
reportApiError: mocks.reportApiError,
}));
const environmentId = "cld1234567890abcdef123456";
const surveyId = "clg123456789012345678901234";
describe("api/v2 client displays route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getOrganizationIdFromEnvironmentId.mockResolvedValue("org_123");
mocks.getIsContactsEnabled.mockResolvedValue(true);
});
test("returns a v2 bad request response for malformed JSON without reporting an internal error", async () => {
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: "{",
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ environmentId }),
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual(
expect.objectContaining({
code: "bad_request",
message: "Invalid JSON in request body",
})
);
expect(mocks.createDisplay).not.toHaveBeenCalled();
expect(mocks.reportApiError).not.toHaveBeenCalled();
});
test("reports unexpected createDisplay failures while keeping the response payload unchanged", async () => {
const underlyingError = new Error("display persistence failed");
mocks.createDisplay.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
surveyId,
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
});
test("reports unexpected contact-license lookup failures with the same generic public response", async () => {
const underlyingError = new Error("license lookup failed");
mocks.getOrganizationIdFromEnvironmentId.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
surveyId,
contactId: "clh123456789012345678901234",
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
expect(mocks.createDisplay).not.toHaveBeenCalled();
});
});
@@ -1,8 +1,11 @@
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display";
import {
TDisplayCreateInputV2,
ZDisplayCreateInputV2,
} from "@/app/api/v2/client/[environmentId]/displays/types/display";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
import { parseAndValidateJsonBody } from "@/app/lib/api/parse-and-validate-json-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createDisplay } from "./lib/display";
@@ -13,6 +16,29 @@ interface Context {
}>;
}
type TValidatedDisplayInputResult = { displayInputData: TDisplayCreateInputV2 } | { response: Response };
const parseAndValidateDisplayInput = async (
request: Request,
environmentId: string
): Promise<TValidatedDisplayInputResult> => {
const inputValidation = await parseAndValidateJsonBody({
request,
schema: ZDisplayCreateInputV2,
buildInput: (jsonInput) => ({
...(jsonInput !== null && typeof jsonInput === "object" ? jsonInput : {}),
environmentId,
}),
malformedJsonMessage: "Invalid JSON in request body",
});
if ("response" in inputValidation) {
return inputValidation;
}
return { displayInputData: inputValidation.data };
};
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
{},
@@ -25,38 +51,40 @@ export const OPTIONS = async (): Promise<Response> => {
export const POST = async (request: Request, context: Context): Promise<Response> => {
const params = await context.params;
const jsonInput = await request.json();
const inputValidation = ZDisplayCreateInputV2.safeParse({
...jsonInput,
environmentId: params.environmentId,
});
const validatedInput = await parseAndValidateDisplayInput(request, params.environmentId);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
if ("response" in validatedInput) {
return validatedInput.response;
}
if (inputValidation.data.contactId) {
const organizationId = await getOrganizationIdFromEnvironmentId(params.environmentId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
}
}
const { displayInputData } = validatedInput;
try {
const response = await createDisplay(inputValidation.data);
if (displayInputData.contactId) {
const organizationId = await getOrganizationIdFromEnvironmentId(params.environmentId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return responses.forbiddenResponse(
"User identification is only available for enterprise users.",
true
);
}
}
const response = await createDisplay(displayInputData);
return responses.successResponse(response, true);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse("Survey", inputValidation.data.surveyId);
} else {
logger.error({ error, url: request.url }, "Error creating display");
return responses.internalServerErrorResponse("Something went wrong. Please try again.");
return responses.notFoundResponse("Survey", displayInputData.surveyId, true);
}
const response = responses.internalServerErrorResponse("Something went wrong. Please try again.", true);
reportApiError({
request,
status: response.status,
error,
});
return response;
}
};
@@ -0,0 +1,135 @@
import * as Sentry from "@sentry/nextjs";
import { type NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
applyIPRateLimit: vi.fn(),
getEnvironmentState: vi.fn(),
contextualLoggerError: vi.fn(),
}));
vi.mock("@/app/api/v1/client/[environmentId]/environment/lib/environmentState", () => ({
getEnvironmentState: mocks.getEnvironmentState,
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: mocks.applyIPRateLimit,
applyRateLimit: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
api: {
client: { windowMs: 60000, max: 100 },
v1: { windowMs: 60000, max: 1000 },
},
},
}));
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: mocks.contextualLoggerError,
warn: vi.fn(),
info: vi.fn(),
})),
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
},
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
AUDIT_LOG_ENABLED: false,
IS_PRODUCTION: true,
SENTRY_DSN: "test-dsn",
ENCRYPTION_KEY: "test-key",
REDIS_URL: "redis://localhost:6379",
};
});
const createMockRequest = (url: string, headers = new Map<string, string>()): NextRequest => {
const parsedUrl = new URL(url);
return {
method: "GET",
url,
headers: {
get: (key: string) => headers.get(key),
},
nextUrl: {
pathname: parsedUrl.pathname,
},
} as unknown as NextRequest;
};
describe("api/v2 client environment route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.applyIPRateLimit.mockResolvedValue(undefined);
});
test("reports v1-backed failures as v2 and keeps the response payload unchanged", async () => {
const underlyingError = new Error("Environment load failed");
mocks.getEnvironmentState.mockRejectedValue(underlyingError);
const request = createMockRequest(
"https://api.test/api/v2/client/ck12345678901234567890123/environment",
new Map([["x-request-id", "req-v2-env"]])
);
const { GET } = await import("./route");
const response = await GET(request, {
params: Promise.resolve({
environmentId: "ck12345678901234567890123",
}),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "An error occurred while processing your request.",
details: {},
});
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
underlyingError,
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v2",
correlationId: "req-v2-env",
method: "GET",
path: "/api/v2/client/ck12345678901234567890123/environment",
}),
extra: expect.objectContaining({
error: expect.objectContaining({
name: "Error",
message: "Environment load failed",
}),
originalError: expect.objectContaining({
name: "Error",
message: "Environment load failed",
}),
}),
contexts: expect.objectContaining({
apiRequest: expect.objectContaining({
apiVersion: "v2",
correlationId: "req-v2-env",
method: "GET",
path: "/api/v2/client/ck12345678901234567890123/environment",
status: 500,
}),
}),
})
);
});
});
@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
checkSurveyValidity: vi.fn(),
createResponseWithQuotaEvaluation: vi.fn(),
getClientIpFromHeaders: vi.fn(),
getIsContactsEnabled: vi.fn(),
getOrganizationIdFromEnvironmentId: vi.fn(),
getSurvey: vi.fn(),
reportApiError: vi.fn(),
sendToPipeline: vi.fn(),
validateResponseData: vi.fn(),
}));
vi.mock("@/app/api/v2/client/[environmentId]/responses/lib/utils", () => ({
checkSurveyValidity: mocks.checkSurveyValidity,
}));
vi.mock("./lib/response", () => ({
createResponseWithQuotaEvaluation: mocks.createResponseWithQuotaEvaluation,
}));
vi.mock("@/app/lib/api/api-error-reporter", () => ({
reportApiError: mocks.reportApiError,
}));
vi.mock("@/app/lib/pipelines", () => ({
sendToPipeline: mocks.sendToPipeline,
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: mocks.getSurvey,
}));
vi.mock("@/lib/utils/client-ip", () => ({
getClientIpFromHeaders: mocks.getClientIpFromHeaders,
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromEnvironmentId: mocks.getOrganizationIdFromEnvironmentId,
}));
vi.mock("@/modules/api/lib/validation", () => ({
formatValidationErrorsForV1Api: vi.fn((errors) => errors),
validateResponseData: mocks.validateResponseData,
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsContactsEnabled: mocks.getIsContactsEnabled,
}));
const environmentId = "cld1234567890abcdef123456";
const surveyId = "clg123456789012345678901234";
describe("api/v2 client responses route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.checkSurveyValidity.mockResolvedValue(null);
mocks.getSurvey.mockResolvedValue({
id: surveyId,
environmentId,
blocks: [],
questions: [],
isCaptureIpEnabled: false,
});
mocks.validateResponseData.mockReturnValue(null);
mocks.getOrganizationIdFromEnvironmentId.mockResolvedValue("org_123");
mocks.getIsContactsEnabled.mockResolvedValue(true);
mocks.getClientIpFromHeaders.mockResolvedValue("127.0.0.1");
});
test("reports unexpected response creation failures while keeping the public payload generic", async () => {
const underlyingError = new Error("response persistence failed");
mocks.createResponseWithQuotaEvaluation.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-request-id": "req-v2-response",
},
body: JSON.stringify({
surveyId,
finished: false,
data: {},
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
});
test("reports unexpected pre-persistence failures with the same generic public response", async () => {
const underlyingError = new Error("survey lookup failed");
mocks.getSurvey.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-request-id": "req-v2-response-pre-check",
},
body: JSON.stringify({
surveyId,
finished: false,
data: {},
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
expect(mocks.createResponseWithQuotaEvaluation).not.toHaveBeenCalled();
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
});
});
@@ -1,10 +1,10 @@
import { headers } from "next/headers";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
import { parseAndValidateJsonBody } from "@/app/lib/api/parse-and-validate-json-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
@@ -25,78 +25,86 @@ interface Context {
}>;
}
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
{},
true,
// Cache CORS preflight responses for 1 hour (conservative approach)
// Balances performance gains with flexibility for CORS policy changes
"public, s-maxage=3600, max-age=3600"
);
};
type TResponseSurvey = NonNullable<Awaited<ReturnType<typeof getSurvey>>>;
export const POST = async (request: Request, context: Context): Promise<Response> => {
const params = await context.params;
const requestHeaders = await headers();
let responseInput;
try {
responseInput = await request.json();
} catch (error) {
return responses.badRequestResponse(
"Invalid JSON in request body",
{ error: error instanceof Error ? error.message : "Unknown error occurred" },
true
);
}
type TValidatedResponseInputResult =
| {
environmentId: string;
responseInputData: TResponseInputV2;
}
| { response: Response };
const { environmentId } = params;
const getCountry = (requestHeaders: Headers): string | undefined =>
requestHeaders.get("CF-IPCountry") ||
requestHeaders.get("X-Vercel-IP-Country") ||
requestHeaders.get("CloudFront-Viewer-Country") ||
undefined;
const getUnexpectedPublicErrorResponse = (): Response =>
responses.internalServerErrorResponse("Something went wrong. Please try again.", true);
const parseAndValidateResponseInput = async (
request: Request,
environmentId: string
): Promise<TValidatedResponseInputResult> => {
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
const responseInputValidation = ZResponseInputV2.safeParse({ ...responseInput, environmentId });
if (!environmentIdValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(environmentIdValidation.error),
true
);
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(environmentIdValidation.error),
true
),
};
}
if (!responseInputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(responseInputValidation.error),
true
);
const responseInputValidation = await parseAndValidateJsonBody({
request,
schema: ZResponseInputV2,
buildInput: (jsonInput) => ({
...(jsonInput !== null && typeof jsonInput === "object" ? jsonInput : {}),
environmentId,
}),
malformedJsonMessage: "Invalid JSON in request body",
});
if ("response" in responseInputValidation) {
return responseInputValidation;
}
const userAgent = request.headers.get("user-agent") || undefined;
const agent = new UAParser(userAgent);
return {
environmentId,
responseInputData: responseInputValidation.data,
};
};
const country =
requestHeaders.get("CF-IPCountry") ||
requestHeaders.get("X-Vercel-IP-Country") ||
requestHeaders.get("CloudFront-Viewer-Country") ||
undefined;
const responseInputData = responseInputValidation.data;
if (responseInputData.contactId) {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
}
const getContactsDisabledResponse = async (
environmentId: string,
contactId: string | null | undefined
): Promise<Response | null> => {
if (!contactId) {
return null;
}
// get and check survey
const survey = await getSurvey(responseInputData.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", responseInput.surveyId, true);
}
const surveyCheckResult = await checkSurveyValidity(survey, environmentId, responseInput);
if (surveyCheckResult) return surveyCheckResult;
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
return isContactsEnabled
? null
: responses.forbiddenResponse("User identification is only available for enterprise users.", true);
};
const validateResponseSubmission = async (
environmentId: string,
responseInputData: TResponseInputV2,
survey: TResponseSurvey
): Promise<Response | null> => {
const surveyCheckResult = await checkSurveyValidity(survey, environmentId, responseInputData);
if (surveyCheckResult) {
return surveyCheckResult;
}
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: responseInputData.data,
surveyQuestions: getElementsFromBlocks(survey.blocks),
@@ -113,7 +121,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
);
}
// Validate response data against validation rules
const validationErrors = validateResponseData(
survey.blocks,
responseInputData.data,
@@ -121,15 +128,29 @@ export const POST = async (request: Request, context: Context): Promise<Response
survey.questions
);
if (validationErrors) {
return responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
);
}
return validationErrors
? responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
)
: null;
};
const createResponseForRequest = async ({
request,
survey,
responseInputData,
country,
}: {
request: Request;
survey: TResponseSurvey;
responseInputData: TResponseInputV2;
country: string | undefined;
}): Promise<TResponseWithQuotaFull | Response> => {
const userAgent = request.headers.get("user-agent") || undefined;
const agent = new UAParser(userAgent);
let response: TResponseWithQuotaFull;
try {
const meta: TResponseInputV2["meta"] = {
source: responseInputData?.meta?.source,
@@ -139,54 +160,115 @@ export const POST = async (request: Request, context: Context): Promise<Response
device: agent.getDevice().type || "desktop",
os: agent.getOS().name,
},
country: country,
country,
action: responseInputData?.meta?.action,
};
// Capture IP address if the survey has IP capture enabled
// Server-derived IP always overwrites any client-provided value
if (survey.isCaptureIpEnabled) {
const ipAddress = await getClientIpFromHeaders();
meta.ipAddress = ipAddress;
meta.ipAddress = await getClientIpFromHeaders();
}
response = await createResponseWithQuotaEvaluation({
return await createResponseWithQuotaEvaluation({
...responseInputData,
meta,
});
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
return responses.badRequestResponse(error.message, undefined, true);
}
logger.error({ error, url: request.url }, "Error creating response");
return responses.internalServerErrorResponse(
error instanceof Error ? error.message : "Unknown error occurred"
);
const response = getUnexpectedPublicErrorResponse();
reportApiError({
request,
status: response.status,
error,
});
return response;
}
const { quotaFull, ...responseData } = response;
};
sendToPipeline({
event: "responseCreated",
environmentId,
surveyId: responseData.surveyId,
response: responseData,
});
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
{},
true,
// Cache CORS preflight responses for 1 hour (conservative approach)
// Balances performance gains with flexibility for CORS policy changes
"public, s-maxage=3600, max-age=3600"
);
};
export const POST = async (request: Request, context: Context): Promise<Response> => {
const params = await context.params;
const validatedInput = await parseAndValidateResponseInput(request, params.environmentId);
if ("response" in validatedInput) {
return validatedInput.response;
}
const { environmentId, responseInputData } = validatedInput;
const country = getCountry(request.headers);
try {
const contactsDisabledResponse = await getContactsDisabledResponse(
environmentId,
responseInputData.contactId
);
if (contactsDisabledResponse) {
return contactsDisabledResponse;
}
const survey = await getSurvey(responseInputData.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", responseInputData.surveyId, true);
}
const validationResponse = await validateResponseSubmission(environmentId, responseInputData, survey);
if (validationResponse) {
return validationResponse;
}
const createdResponse = await createResponseForRequest({
request,
survey,
responseInputData,
country,
});
if (createdResponse instanceof Response) {
return createdResponse;
}
const { quotaFull, ...responseData } = createdResponse;
if (responseData.finished) {
sendToPipeline({
event: "responseFinished",
event: "responseCreated",
environmentId,
surveyId: responseData.surveyId,
response: responseData,
});
if (responseData.finished) {
sendToPipeline({
event: "responseFinished",
environmentId,
surveyId: responseData.surveyId,
response: responseData,
});
}
const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = {
id: responseData.id,
...quotaObj,
};
return responses.successResponse(responseDataWithQuota, true);
} catch (error) {
const response = getUnexpectedPublicErrorResponse();
reportApiError({
request,
status: response.status,
error,
});
return response;
}
const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = {
id: responseData.id,
...quotaObj,
};
return responses.successResponse(responseDataWithQuota, true);
};
@@ -0,0 +1,221 @@
import * as Sentry from "@sentry/nextjs";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { reportApiError } from "./api-error-reporter";
const loggerMocks = vi.hoisted(() => {
const contextualError = vi.fn();
const rootError = vi.fn();
const withContext = vi.fn(() => ({
error: contextualError,
}));
return {
contextualError,
rootError,
withContext,
};
});
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: loggerMocks.withContext,
error: loggerMocks.rootError,
warn: vi.fn(),
info: vi.fn(),
},
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
IS_PRODUCTION: true,
SENTRY_DSN: "dsn",
};
});
describe("reportApiError", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("captures real errors directly with structured context", () => {
const request = new Request("https://app.test/api/v2/client/environment", {
method: "POST",
headers: {
"x-request-id": "req-1",
},
});
const error = new Error("boom");
reportApiError({
request,
status: 500,
error,
});
expect(loggerMocks.withContext).toHaveBeenCalledWith(
expect.objectContaining({
apiVersion: "v2",
correlationId: "req-1",
method: "POST",
path: "/api/v2/client/environment",
status: 500,
error: expect.objectContaining({
name: "Error",
message: "boom",
}),
})
);
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
error,
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v2",
correlationId: "req-1",
method: "POST",
path: "/api/v2/client/environment",
}),
extra: expect.objectContaining({
error: expect.objectContaining({
name: "Error",
message: "boom",
}),
originalError: expect.objectContaining({
name: "Error",
message: "boom",
}),
}),
contexts: expect.objectContaining({
apiRequest: expect.objectContaining({
apiVersion: "v2",
correlationId: "req-1",
method: "POST",
path: "/api/v2/client/environment",
status: 500,
}),
}),
})
);
});
test("captures non-error payloads with a synthetic error while preserving additional data", () => {
const request = new Request("https://app.test/api/v1/management/surveys", {
headers: {
"x-request-id": "req-2",
},
});
const payload = {
type: "internal_server_error",
details: [{ field: "server", issue: "error occurred" }],
};
reportApiError({
request,
status: 500,
error: payload,
originalError: payload,
});
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.objectContaining({
message: "API V1 error, id: req-2",
}),
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v1",
correlationId: "req-2",
method: "GET",
path: "/api/v1/management/surveys",
}),
extra: expect.objectContaining({
error: payload,
originalError: payload,
}),
})
);
});
test("swallows Sentry failures after logging a fallback reporter error", () => {
vi.mocked(Sentry.captureException).mockImplementation(() => {
throw new Error("sentry down");
});
const request = new Request("https://app.test/api/v2/client/displays", {
headers: {
"x-request-id": "req-3",
},
});
expect(() =>
reportApiError({
request,
status: 500,
error: new Error("boom"),
})
).not.toThrow();
expect(loggerMocks.rootError).toHaveBeenCalledWith(
expect.objectContaining({
apiVersion: "v2",
correlationId: "req-3",
method: "GET",
path: "/api/v2/client/displays",
status: 500,
reportingError: expect.objectContaining({
name: "Error",
message: "sentry down",
}),
}),
"Failed to report API error"
);
});
test("serializes cyclic payloads without throwing", () => {
const request = new Request("https://app.test/api/v2/client/responses", {
headers: {
"x-request-id": "req-4",
},
});
const payload: Record<string, unknown> = {
type: "internal_server_error",
};
payload.self = payload;
expect(() =>
reportApiError({
request,
status: 500,
error: payload,
originalError: payload,
})
).not.toThrow();
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.objectContaining({
message: "API V2 error, id: req-4",
}),
expect.objectContaining({
extra: expect.objectContaining({
error: {
type: "internal_server_error",
self: "[Circular]",
},
originalError: {
type: "internal_server_error",
self: "[Circular]",
},
}),
})
);
});
});
+282
View File
@@ -0,0 +1,282 @@
import * as Sentry from "@sentry/nextjs";
import { logger } from "@formbricks/logger";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
type TRequestLike = Pick<Request, "method" | "url" | "headers">;
type TApiErrorContext = {
apiVersion: TApiVersion;
correlationId: string;
method: string;
path: string;
status: number;
};
type TSentryCaptureContext = NonNullable<Parameters<typeof Sentry.captureException>[1]>;
export type TApiVersion = "v1" | "v2" | "v3" | "unknown";
const getPathname = (url: string): string => {
if (url.startsWith("/")) {
return url;
}
try {
return new URL(url).pathname;
} catch {
return url;
}
};
export const getApiVersionFromPath = (pathname: string): TApiVersion => {
const match = /^\/api\/(v\d+)(?:\/|$)/.exec(pathname);
if (!match) {
return "unknown";
}
switch (match[1]) {
case "v1":
case "v2":
case "v3":
return match[1];
default:
return "unknown";
}
};
const serializeError = (value: unknown, seen = new WeakSet<object>()): unknown => {
if (value === null || value === undefined) {
return value;
}
if (typeof value !== "object") {
return value;
}
if (seen.has(value)) {
return "[Circular]";
}
seen.add(value);
if (value instanceof Error) {
const serializedError: Record<string, unknown> = {
name: value.name,
message: value.message,
};
if (value.stack) {
serializedError.stack = value.stack;
}
if ("cause" in value && value.cause !== undefined) {
serializedError.cause = serializeError(value.cause, seen);
}
for (const [key, entryValue] of Object.entries(value as unknown as Record<string, unknown>)) {
serializedError[key] = serializeError(entryValue, seen);
}
return serializedError;
}
if (Array.isArray(value)) {
return value.map((item) => serializeError(item, seen));
}
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, entryValue]) => [
key,
serializeError(entryValue, seen),
])
);
};
const getSerializedValueType = (value: unknown): string => {
if (value === null) {
return "null";
}
if (Array.isArray(value)) {
return "array";
}
if (value instanceof Error) {
return value.name;
}
return typeof value;
};
export const serializeErrorSafely = (value: unknown): unknown => {
try {
return serializeError(value);
} catch (serializationError) {
return {
name: "ErrorSerializationFailed",
message: "Failed to serialize API error payload",
originalType: getSerializedValueType(value),
serializationError:
serializationError instanceof Error ? serializationError.message : String(serializationError),
};
}
};
const getSyntheticError = (apiVersion: TApiVersion, correlationId: string): Error => {
if (apiVersion === "unknown") {
return new Error(`API error, id: ${correlationId}`);
}
return new Error(`API ${apiVersion.toUpperCase()} error, id: ${correlationId}`);
};
const getLogMessage = (apiVersion: TApiVersion): string => {
switch (apiVersion) {
case "v1":
return "API V1 Error Details";
case "v2":
return "API V2 Error Details";
case "v3":
return "API V3 Error Details";
default:
return "API Error Details";
}
};
const buildApiErrorContext = ({
request,
status,
apiVersion,
}: {
request: TRequestLike;
status: number;
apiVersion?: TApiVersion;
}): TApiErrorContext => {
const path = getPathname(request.url);
return {
apiVersion: apiVersion ?? getApiVersionFromPath(path),
correlationId: request.headers.get("x-request-id") ?? "",
method: request.method,
path,
status,
};
};
export const buildSentryCaptureContext = ({
context,
errorPayload,
originalErrorPayload,
}: {
context: TApiErrorContext;
errorPayload: unknown;
originalErrorPayload: unknown;
}): TSentryCaptureContext => ({
level: "error",
tags: {
apiVersion: context.apiVersion,
correlationId: context.correlationId,
method: context.method,
path: context.path,
},
extra: {
error: errorPayload,
originalError: originalErrorPayload,
},
contexts: {
apiRequest: {
apiVersion: context.apiVersion,
correlationId: context.correlationId,
method: context.method,
path: context.path,
status: context.status,
},
},
});
export const emitApiErrorLog = (context: TApiErrorContext, errorPayload?: unknown): void => {
const logContext =
errorPayload === undefined
? context
: {
...context,
error: errorPayload,
};
logger.withContext(logContext).error(getLogMessage(context.apiVersion));
};
export const emitApiErrorToSentry = ({
error,
captureContext,
}: {
error: Error;
captureContext: TSentryCaptureContext;
}): void => {
Sentry.captureException(error, captureContext);
};
const logReporterFailure = (context: TApiErrorContext, reportingError: unknown): void => {
try {
logger.error(
{
apiVersion: context.apiVersion,
correlationId: context.correlationId,
method: context.method,
path: context.path,
status: context.status,
reportingError: serializeErrorSafely(reportingError),
},
"Failed to report API error"
);
} catch {
// Swallow reporter failures so API responses are never affected by observability issues.
}
};
export const reportApiError = ({
request,
status,
error,
apiVersion,
originalError,
}: {
request: TRequestLike;
status: number;
error?: unknown;
apiVersion?: TApiVersion;
originalError?: unknown;
}): void => {
const context = buildApiErrorContext({
request,
status,
apiVersion,
});
const capturedError =
error instanceof Error ? error : getSyntheticError(context.apiVersion, context.correlationId);
const logErrorPayload = error === undefined ? undefined : serializeErrorSafely(error);
const errorPayload = serializeErrorSafely(error ?? capturedError);
const originalErrorPayload = serializeErrorSafely(originalError ?? error);
try {
emitApiErrorLog(context, logErrorPayload);
} catch (reportingError) {
logReporterFailure(context, reportingError);
}
if (SENTRY_DSN && IS_PRODUCTION && status >= 500) {
try {
emitApiErrorToSentry({
error: capturedError,
captureContext: buildSentryCaptureContext({
context,
errorPayload,
originalErrorPayload,
}),
});
} catch (reportingError) {
logReporterFailure(context, reportingError);
}
}
};
@@ -0,0 +1,111 @@
import { describe, expect, test } from "vitest";
import { z } from "zod";
import { parseAndValidateJsonBody } from "./parse-and-validate-json-body";
describe("parseAndValidateJsonBody", () => {
test("returns a malformed JSON response when request parsing fails", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: "{invalid-json",
});
const result = await parseAndValidateJsonBody({
request,
schema: z.object({
finished: z.boolean(),
}),
malformedJsonMessage: "Malformed JSON in request body",
});
expect("response" in result).toBe(true);
if (!("response" in result)) {
throw new Error("Expected a response result");
}
expect(result.issue).toBe("invalid_json");
expect(result.details).toEqual({
error: expect.any(String),
});
await expect(result.response.json()).resolves.toEqual({
code: "bad_request",
message: "Malformed JSON in request body",
details: {
error: expect.any(String),
},
});
});
test("returns a validation response when the parsed JSON does not match the schema", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
finished: "not-boolean",
}),
});
const result = await parseAndValidateJsonBody({
request,
schema: z.object({
finished: z.boolean(),
}),
});
expect("response" in result).toBe(true);
if (!("response" in result)) {
throw new Error("Expected a response result");
}
expect(result.issue).toBe("invalid_body");
expect(result.details).toEqual(
expect.objectContaining({
finished: expect.any(String),
})
);
await expect(result.response.json()).resolves.toEqual({
code: "bad_request",
message: "Fields are missing or incorrectly formatted",
details: expect.objectContaining({
finished: expect.any(String),
}),
});
});
test("returns parsed data when JSON parsing and schema validation succeed", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
finished: true,
}),
});
const result = await parseAndValidateJsonBody({
request,
schema: z.object({
finished: z.boolean(),
environmentId: z.string(),
}),
buildInput: (jsonInput) => ({
...(jsonInput as Record<string, unknown>),
environmentId: "env_123",
}),
});
expect(result).toEqual({
data: {
environmentId: "env_123",
finished: true,
},
});
});
});
@@ -0,0 +1,71 @@
import { z } from "zod";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
type TJsonBodyValidationIssue = "invalid_json" | "invalid_body";
type TJsonBodyValidationError = {
details: Record<string, string> | { error: string };
issue: TJsonBodyValidationIssue;
response: Response;
};
type TJsonBodyValidationSuccess<TData> = {
data: TData;
};
export type TParseAndValidateJsonBodyResult<TData> =
| TJsonBodyValidationError
| TJsonBodyValidationSuccess<TData>;
type TParseAndValidateJsonBodyOptions<TSchema extends z.ZodTypeAny> = {
request: Request;
schema: TSchema;
buildInput?: (jsonInput: unknown) => unknown;
malformedJsonMessage?: string;
validationMessage?: string;
};
const DEFAULT_MALFORMED_JSON_MESSAGE = "Malformed JSON input, please check your request body";
const DEFAULT_VALIDATION_MESSAGE = "Fields are missing or incorrectly formatted";
const getErrorMessage = (error: unknown): string =>
error instanceof Error ? error.message : "Unknown error occurred";
export const parseAndValidateJsonBody = async <TSchema extends z.ZodTypeAny>({
request,
schema,
buildInput,
malformedJsonMessage = DEFAULT_MALFORMED_JSON_MESSAGE,
validationMessage = DEFAULT_VALIDATION_MESSAGE,
}: TParseAndValidateJsonBodyOptions<TSchema>): Promise<
TParseAndValidateJsonBodyResult<z.output<TSchema>>
> => {
let jsonInput: unknown;
try {
jsonInput = await request.json();
} catch (error) {
const details = { error: getErrorMessage(error) };
return {
details,
issue: "invalid_json",
response: responses.badRequestResponse(malformedJsonMessage, details, true),
};
}
const inputValidation = schema.safeParse(buildInput ? buildInput(jsonInput) : jsonInput);
if (!inputValidation.success) {
const details = transformErrorToDetails(inputValidation.error);
return {
details,
issue: "invalid_body",
response: responses.badRequestResponse(validationMessage, details, true),
};
}
return { data: inputValidation.data };
};
+112 -37
View File
@@ -6,7 +6,6 @@ import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
import { responses } from "./response";
// Mocks
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
__esModule: true,
queueAuditEvent: vi.fn(),
@@ -14,24 +13,13 @@ vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: vi.fn((callback) => {
callback(mockSentryScope);
return mockSentryScope;
}),
withScope: vi.fn(),
}));
// Define these outside the mock factory so they can be referenced in tests and reset by clearAllMocks.
const mockContextualLoggerError = vi.fn();
const mockContextualLoggerWarn = vi.fn();
const mockContextualLoggerInfo = vi.fn();
// Mock Sentry scope that can be referenced in tests
const mockSentryScope = {
setTag: vi.fn(),
setExtra: vi.fn(),
setContext: vi.fn(),
setLevel: vi.fn(),
};
const V1_MANAGEMENT_SURVEYS_URL = "https://api.test/api/v1/management/surveys";
vi.mock("@formbricks/logger", () => {
const mockWithContextInstance = vi.fn(() => ({
@@ -86,7 +74,6 @@ vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
}));
function createMockRequest({ method = "GET", url = "https://api.test/endpoint", headers = new Map() } = {}) {
// Parse the URL to get the pathname
const parsedUrl = url.startsWith("/") ? new URL(url, "http://localhost:3000") : new URL(url);
return {
@@ -122,12 +109,6 @@ describe("withV1ApiWrapper", () => {
}));
vi.clearAllMocks();
// Reset mock Sentry scope calls
mockSentryScope.setTag.mockClear();
mockSentryScope.setExtra.mockClear();
mockSentryScope.setContext.mockClear();
mockSentryScope.setLevel.mockClear();
});
test("logs and audits on error response with API key authentication", async () => {
@@ -155,7 +136,7 @@ describe("withV1ApiWrapper", () => {
});
const req = createMockRequest({
url: "https://api.test/v1/management/surveys",
url: V1_MANAGEMENT_SURVEYS_URL,
headers: new Map([["x-request-id", "abc-123"]]),
});
const { withV1ApiWrapper } = await import("./with-api-logging");
@@ -177,9 +158,33 @@ describe("withV1ApiWrapper", () => {
organizationId: "org-1",
})
);
expect(Sentry.withScope).toHaveBeenCalled();
expect(mockSentryScope.setExtra).toHaveBeenCalledWith("originalError", undefined);
expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error));
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v1",
correlationId: "abc-123",
method: "GET",
path: "/api/v1/management/surveys",
}),
extra: expect.objectContaining({
error: expect.objectContaining({
message: "API V1 error, id: abc-123",
}),
originalError: undefined,
}),
contexts: expect.objectContaining({
apiRequest: expect.objectContaining({
apiVersion: "v1",
correlationId: "abc-123",
method: "GET",
path: "/api/v1/management/surveys",
status: 500,
}),
}),
})
);
});
test("does not log Sentry if not 500", async () => {
@@ -206,7 +211,7 @@ describe("withV1ApiWrapper", () => {
};
});
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
await wrapped(req, undefined);
@@ -251,7 +256,7 @@ describe("withV1ApiWrapper", () => {
});
const req = createMockRequest({
url: "https://api.test/v1/management/surveys",
url: V1_MANAGEMENT_SURVEYS_URL,
headers: new Map([["x-request-id", "err-1"]]),
});
const { withV1ApiWrapper } = await import("./with-api-logging");
@@ -280,8 +285,78 @@ describe("withV1ApiWrapper", () => {
organizationId: "org-1",
})
);
expect(Sentry.withScope).toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error));
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.objectContaining({
message: "fail!",
}),
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v1",
correlationId: "err-1",
method: "GET",
path: "/api/v1/management/surveys",
}),
extra: expect.objectContaining({
error: expect.objectContaining({
message: "fail!",
}),
originalError: expect.objectContaining({
message: "fail!",
}),
}),
})
);
});
test("uses handler result error for handled 500 responses", async () => {
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const handledError = new Error("handled failure");
const handler = vi.fn().mockResolvedValue({
response: responses.internalServerErrorResponse("fail"),
error: handledError,
});
const req = createMockRequest({
url: "https://api.test/api/v2/client/environment",
headers: new Map([["x-request-id", "handled-1"]]),
});
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(500);
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
handledError,
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v2",
correlationId: "handled-1",
method: "GET",
path: "/api/v2/client/environment",
}),
extra: expect.objectContaining({
error: expect.objectContaining({
message: "handled failure",
}),
originalError: expect.objectContaining({
message: "handled failure",
}),
}),
})
);
});
test("does not log on success response but still audits", async () => {
@@ -308,7 +383,7 @@ describe("withV1ApiWrapper", () => {
};
});
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
await wrapped(req, undefined);
@@ -358,7 +433,7 @@ describe("withV1ApiWrapper", () => {
response: responses.internalServerErrorResponse("fail"),
});
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
await wrapped(req, undefined);
@@ -378,7 +453,7 @@ describe("withV1ApiWrapper", () => {
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
@@ -412,7 +487,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(authenticateRequest).mockResolvedValue(null);
const handler = vi.fn();
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
@@ -471,7 +546,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
const handler = vi.fn();
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
@@ -499,7 +574,7 @@ describe("withV1ApiWrapper", () => {
response: responses.successResponse({ data: "test" }),
});
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
await wrapped(req, undefined);
@@ -518,7 +593,7 @@ describe("buildAuditLogBaseObject", () => {
test("creates audit log base object with correct structure", async () => {
const { buildAuditLogBaseObject } = await import("./with-api-logging");
const result = buildAuditLogBaseObject("created", "survey", "https://api.test/v1/management/surveys");
const result = buildAuditLogBaseObject("created", "survey", V1_MANAGEMENT_SURVEYS_URL);
expect(result).toEqual({
action: "created",
@@ -530,7 +605,7 @@ describe("buildAuditLogBaseObject", () => {
oldObject: undefined,
newObject: undefined,
userType: "api",
apiUrl: "https://api.test/v1/management/surveys",
apiUrl: V1_MANAGEMENT_SURVEYS_URL,
});
});
});
+17 -35
View File
@@ -1,9 +1,9 @@
import * as Sentry from "@sentry/nextjs";
import { Session, getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { authenticateRequest } from "@/app/api/v1/auth";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
import { responses } from "@/app/lib/api/response";
import {
AuthenticationMethod,
@@ -11,7 +11,7 @@ import {
isIntegrationRoute,
isManagementApiRoute,
} from "@/app/middleware/endpoint-validator";
import { AUDIT_LOG_ENABLED, IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { AUDIT_LOG_ENABLED } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyIPRateLimit, applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
@@ -33,7 +33,10 @@ export interface THandlerParams<TProps = unknown> {
}
// Interface for wrapper function parameters
export interface TWithV1ApiWrapperParams<TResult extends { response: Response }, TProps = unknown> {
export interface TWithV1ApiWrapperParams<
TResult extends { response: Response; error?: unknown },
TProps = unknown,
> {
handler: (params: THandlerParams<TProps>) => Promise<TResult>;
action?: TAuditAction;
targetType?: TAuditTarget;
@@ -93,7 +96,7 @@ const handleRateLimiting = async (
/**
* Execute handler with error handling
*/
const executeHandler = async <TResult extends { response: Response }, TProps>(
const executeHandler = async <TResult extends { response: Response; error?: unknown }, TProps>(
handler: (params: THandlerParams<TProps>) => Promise<TResult>,
req: NextRequest,
props: TProps,
@@ -158,34 +161,12 @@ const handleAuthentication = async (
/**
* Log error details to system logger and Sentry
*/
const logErrorDetails = (res: Response, req: NextRequest, correlationId: string, error?: any): void => {
const logContext = {
correlationId,
method: req.method,
path: req.url,
const logErrorDetails = (res: Response, req: NextRequest, error?: unknown): void => {
reportApiError({
request: req,
status: res.status,
...(error && { error }),
};
logger.withContext(logContext).error("V1 API Error Details");
if (SENTRY_DSN && IS_PRODUCTION && res.status >= 500) {
// Set correlation ID as a tag for easy filtering
Sentry.withScope((scope) => {
scope.setTag("correlationId", correlationId);
scope.setLevel("error");
// If we have an actual error, capture it with full stacktrace
// Otherwise, create a generic error with context
if (error instanceof Error) {
Sentry.captureException(error);
} else {
scope.setExtra("originalError", error);
const genericError = new Error(`API V1 error, id: ${correlationId}`);
Sentry.captureException(genericError);
}
});
}
error,
});
};
/**
@@ -195,7 +176,7 @@ const processResponse = async (
res: Response,
req: NextRequest,
auditLog?: TApiAuditLog,
error?: any
error?: unknown
): Promise<void> => {
const correlationId = req.headers.get("x-request-id") ?? "";
@@ -210,7 +191,7 @@ const processResponse = async (
// Handle error logging
if (!res.ok) {
logErrorDetails(res, req, correlationId, error);
logErrorDetails(res, req, error);
}
// Queue audit event if enabled and audit log exists
@@ -267,7 +248,7 @@ const getRouteType = (
* @returns Wrapped handler function that returns the final HTTP response
*
*/
export const withV1ApiWrapper = <TResult extends { response: Response }, TProps = unknown>(
export const withV1ApiWrapper = <TResult extends { response: Response; error?: unknown }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const { handler, action, targetType, customRateLimitConfig, unauthenticatedResponse } = params;
@@ -312,9 +293,10 @@ export const withV1ApiWrapper = <TResult extends { response: Response }, TProps
// === Handler Execution ===
const { result, error } = await executeHandler(handler, req, props, auditLog, authentication);
const res = result.response;
const reportedError = result.error ?? error;
// === Response Processing & Logging ===
await processResponse(res, req, auditLog, error);
await processResponse(res, req, auditLog, reportedError);
return res;
};
+9 -15
View File
@@ -373,7 +373,6 @@ checksums:
common/show_response_count: 609e5dc7c074d57e711a728fa2f8eb79
common/shown: 63e4ffb245c05e04b636446c3dbdd8df
common/size: 227fadeeff951e041ff42031a11a4626
common/skip: b7f28dfa2f58b80b149bb82b392d0291
common/skipped: d496f0f667e1b4364b954db71335d4ef
common/skips: 99de7579122a3fa6ec5e2a47f3fd8b34
common/some_files_failed_to_upload: a0e26efeb29ae905257ecf93b112dff0
@@ -444,7 +443,6 @@ checksums:
common/website_survey: 17513d25a07b6361768a15ec622b021b
common/weeks: 545de30df4f44d3f6d1d344af6a10815
common/welcome_card: 76081ebd5b2e35da9b0f080323704ae7
common/workflows: b0c9c8615a9ba7d9cb73e767290a7f72
common/workspace: b63ef0e99ee6f7fef6cbe4971ca6cf0f
common/workspace_configuration: d0a5812d6a97d7724d565b1017c34387
common/workspace_created_successfully: bf401ae83da954f1db48724e2a8e40f1
@@ -1070,6 +1068,13 @@ checksums:
environments/settings/enterprise/sso: 95e98e279bb89233d63549b202bd9112
environments/settings/enterprise/teams: 21ab78abcba0f16c3029741563f789ea
environments/settings/enterprise/unlock_the_full_power_of_formbricks_free_for_30_days: 104d07b63a42911c9673ceb08a4dbd43
environments/settings/general/ai_data_analysis_enabled: 45fabb594da6851f73fef50ca40fe525
environments/settings/general/ai_data_analysis_enabled_description: 46d4f0bdf4ebf89e78f79cc961a2de83
environments/settings/general/ai_enabled: 3cb1fce89c525e754448d5bd143eb6b5
environments/settings/general/ai_enabled_description: e8c3e9f362588898a6cea85e18c013a1
environments/settings/general/ai_settings_updated_successfully: 2a6f534dc3a246ced46becd8a4a9543d
environments/settings/general/ai_smart_tools_enabled: 1dda984f5262c5f9120ee9a409236758
environments/settings/general/ai_smart_tools_enabled_description: 1ceca6707746d3ab4a530712a06d91da
environments/settings/general/bulk_invite_warning_description: e8737a2fbd5ff353db5580d17b4b5a37
environments/settings/general/cannot_delete_only_organization: 833cc6848b28f2694a4552b4de91a6ba
environments/settings/general/cannot_leave_only_organization: dd8463262e4299fef7ad73512225c55b
@@ -2459,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
@@ -3182,14 +3187,3 @@ checksums:
templates/usability_question_9_headline: 5850229e97ae97698ce90b330ea49682
templates/usability_rating_description: 8c4f3818fe830ae544611f816265f1a1
templates/usability_score_name: 5cbf1172d24dfcb17d979dff6dfdf7e2
workflows/coming_soon_description: 1e0621d287924d84fb539afab7372b23
workflows/coming_soon_title: d79be80559c70c828cf20811d2ed5039
workflows/follow_up_label: ead918852c5840636a14baabfe94821e
workflows/follow_up_placeholder: f680918bec28192282e229c3d4b5e80a
workflows/generate_button: b194b6172a49af8374a19dd2cf39cfdc
workflows/heading: a98a6b14d3e955f38cc16386df9a4111
workflows/placeholder: f5d943582bf25e8734930844e598457b
workflows/subheading: ebf5e3b3aeb85e13e843358cc5476f42
workflows/submit_button: 7a062f2de02ce60b1d73e510ff1ca094
workflows/thank_you_description: 7623c1ba4f059c8d9e68aae3360b20b1
workflows/thank_you_title: 07edd8c50685a52c0969d711df26d768
+97
View File
@@ -0,0 +1,97 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { upsertAccount } from "./service";
const { mockUpsert } = vi.hoisted(() => ({
mockUpsert: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
account: {
upsert: mockUpsert,
},
},
}));
describe("account service", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("upsertAccount keeps user ownership immutable on update", async () => {
const accountData = {
userId: "user-1",
type: "oauth",
provider: "google",
providerAccountId: "provider-1",
access_token: "access-token",
refresh_token: "refresh-token",
expires_at: 123,
scope: "openid email",
token_type: "Bearer",
id_token: "id-token",
};
mockUpsert.mockResolvedValue({
id: "account-1",
createdAt: new Date(),
updatedAt: new Date(),
...accountData,
});
await upsertAccount(accountData);
expect(mockUpsert).toHaveBeenCalledWith({
where: {
provider_providerAccountId: {
provider: "google",
providerAccountId: "provider-1",
},
},
create: accountData,
update: {
access_token: "access-token",
refresh_token: "refresh-token",
expires_at: 123,
scope: "openid email",
token_type: "Bearer",
id_token: "id-token",
},
});
});
test("upsertAccount wraps Prisma known request errors", async () => {
const prismaError = Object.assign(Object.create(Prisma.PrismaClientKnownRequestError.prototype), {
message: "duplicate account",
});
mockUpsert.mockRejectedValue(prismaError);
await expect(
upsertAccount({
userId: "user-1",
type: "oauth",
provider: "google",
providerAccountId: "provider-1",
})
).rejects.toMatchObject({
name: "DatabaseError",
message: "duplicate account",
});
});
test("upsertAccount rethrows non-Prisma errors", async () => {
const error = new Error("unexpected failure");
mockUpsert.mockRejectedValue(error);
await expect(
upsertAccount({
userId: "user-1",
type: "oauth",
provider: "google",
providerAccountId: "provider-1",
})
).rejects.toThrow("unexpected failure");
});
});
+33
View File
@@ -20,3 +20,36 @@ export const createAccount = async (accountData: TAccountInput): Promise<TAccoun
throw error;
}
};
export const upsertAccount = async (accountData: TAccountInput): Promise<TAccount> => {
const [validatedAccountData] = validateInputs([accountData, ZAccountInput]);
const updateAccountData: Omit<TAccountInput, "userId" | "type" | "provider" | "providerAccountId"> = {
access_token: validatedAccountData.access_token,
refresh_token: validatedAccountData.refresh_token,
expires_at: validatedAccountData.expires_at,
scope: validatedAccountData.scope,
token_type: validatedAccountData.token_type,
id_token: validatedAccountData.id_token,
};
try {
const account = await prisma.account.upsert({
where: {
provider_providerAccountId: {
provider: validatedAccountData.provider,
providerAccountId: validatedAccountData.providerAccountId,
},
},
create: validatedAccountData,
update: updateAccountData,
});
return account;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
+4
View File
@@ -26,7 +26,10 @@ export const TERMS_URL = env.TERMS_URL;
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);
@@ -152,6 +155,7 @@ export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;
export const REDIS_URL = env.REDIS_URL;
export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1";
export const TELEMETRY_DISABLED = env.TELEMETRY_DISABLED === "1";
export const BREVO_API_KEY = env.BREVO_API_KEY;
export const BREVO_LIST_ID = env.BREVO_LIST_ID;
+77
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");
});
});
+8
View File
@@ -15,7 +15,9 @@ export const env = createEnv({
BREVO_API_KEY: z.string().optional(),
BREVO_LIST_ID: z.string().optional(),
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(),
@@ -60,11 +62,13 @@ 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()
.or(z.string().refine((str) => str === "")),
RATE_LIMITING_DISABLED: z.enum(["1", "0"]).optional(),
TELEMETRY_DISABLED: z.enum(["1", "0"]).optional(),
S3_ACCESS_KEY: z.string().optional(),
S3_BUCKET_NAME: z.string().optional(),
S3_REGION: z.string().optional(),
@@ -141,7 +145,9 @@ export const env = createEnv({
BREVO_LIST_ID: process.env.BREVO_LIST_ID,
CRON_SECRET: process.env.CRON_SECRET,
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,
@@ -181,8 +187,10 @@ 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,
TELEMETRY_DISABLED: process.env.TELEMETRY_DISABLED,
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY,
S3_BUCKET_NAME: process.env.S3_BUCKET_NAME,
S3_REGION: process.env.S3_REGION,
+3 -1
View File
@@ -84,7 +84,9 @@ export const extractLanguageIds = (languages: TLanguage[]): string[] => {
export const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: string | null) => {
if (!surveyLanguages?.length || !languageCode) return "default";
const language = surveyLanguages.find((surveyLanguage) => surveyLanguage.language.code === languageCode);
const language = surveyLanguages.find(
(surveyLanguage) => surveyLanguage.language.code.toLowerCase() === languageCode.toLowerCase()
);
return language?.default ? "default" : language?.language.code || "default";
};
+2 -1
View File
@@ -37,7 +37,8 @@ describe("auth", () => {
},
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
];
vi.mocked(getOrganizationsByUserId).mockResolvedValue(mockOrganizations);
+10 -5
View File
@@ -72,7 +72,8 @@ describe("Organization Service", () => {
stripeCustomerId: null,
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
};
@@ -124,7 +125,8 @@ describe("Organization Service", () => {
stripeCustomerId: null,
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
},
];
@@ -176,7 +178,8 @@ describe("Organization Service", () => {
createdAt: new Date(),
updatedAt: new Date(),
billing: expectedBilling,
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
};
@@ -235,7 +238,8 @@ describe("Organization Service", () => {
stripeCustomerId: null,
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
memberships: [{ userId: "user1" }, { userId: "user2" }],
projects: [
@@ -276,7 +280,8 @@ describe("Organization Service", () => {
stripeCustomerId: null,
usageCycleAnchor: expect.any(Date),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
});
expect(prisma.organization.update).toHaveBeenCalledWith({
+4 -2
View File
@@ -34,7 +34,8 @@ export const select = {
stripe: true,
},
},
isAIEnabled: true,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
whitelabel: true,
} satisfies Prisma.OrganizationSelect;
@@ -72,7 +73,8 @@ const mapOrganization = (organization: TOrganizationWithBilling): TOrganization
updatedAt: organization.updatedAt,
name: organization.name,
billing: mapOrganizationBilling(organization.billing),
isAIEnabled: organization.isAIEnabled,
isAISmartToolsEnabled: organization.isAISmartToolsEnabled,
isAIDataAnalysisEnabled: organization.isAIDataAnalysisEnabled,
whitelabel: organization.whitelabel as TOrganization["whitelabel"],
});
+2 -1
View File
@@ -232,7 +232,8 @@ export const mockOrganizationOutput: TOrganization = {
name: "mock Organization",
createdAt: currentDate,
updatedAt: currentDate,
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
billing: {
stripeCustomerId: null,
limits: {
+4 -2
View File
@@ -70,7 +70,8 @@ describe("User Service", () => {
},
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
{
id: "org2",
@@ -87,7 +88,8 @@ describe("User Service", () => {
},
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
];
@@ -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", () => {
@@ -9,6 +9,10 @@ vi.mock("node:dns", () => ({
},
}));
vi.mock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: false,
}));
const mockResolve = vi.mocked(dns.resolve);
const mockResolve6 = vi.mocked(dns.resolve6);
@@ -294,4 +298,78 @@ describe("validateWebhookUrl", () => {
});
});
});
describe("DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS", () => {
test("allows private IP URLs when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("http://127.0.0.1/")).resolves.toBeUndefined();
await expect(validateWithFlag("http://192.168.1.1/test")).resolves.toBeUndefined();
await expect(validateWithFlag("http://10.0.0.1/webhook")).resolves.toBeUndefined();
});
test("allows localhost when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("http://localhost/webhook")).resolves.toBeUndefined();
await expect(validateWithFlag("http://localhost:3333/webhook")).resolves.toBeUndefined();
});
test("allows localhost.localdomain when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("http://localhost.localdomain/path")).resolves.toBeUndefined();
});
test("allows hostname resolving to private IP when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
setupDnsResolution(["192.168.1.1"]);
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("https://internal.company.com/webhook")).resolves.toBeUndefined();
});
test("still rejects unresolvable hostnames when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
setupDnsResolution(null, null);
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("https://typo-gibberish.invalid/hook")).rejects.toThrow(
"Could not resolve webhook URL hostname"
);
});
test("still rejects invalid URL format when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("not-a-url")).rejects.toThrow("Invalid webhook URL format");
});
test("still rejects non-HTTP protocols when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("ftp://192.168.1.1/")).rejects.toThrow(
"Webhook URL must use HTTPS or HTTP protocol"
);
});
});
});
+16 -6
View File
@@ -1,6 +1,7 @@
import "server-only";
import dns from "node:dns";
import { InvalidInputError } from "@formbricks/types/errors";
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "../constants";
const BLOCKED_HOSTNAMES = new Set([
"localhost",
@@ -139,8 +140,10 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
const hostname = parsed.hostname;
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) {
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
}
}
// Direct IP literal — validate without DNS resolution
@@ -149,12 +152,17 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
if (isIPv4Literal || isIPv6Literal) {
const ip = isIPv6Literal ? stripIPv6Brackets(hostname) : hostname;
if (isPrivateIP(ip)) {
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
return;
}
// Skip DNS resolution for localhost-like hostnames when internal URLs are allowed since these are resolved via /etc/hosts and not DNS
if (DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
return;
}
// Domain name — resolve DNS and validate every resolved IP
let resolvedIPs: string[];
try {
@@ -168,9 +176,11 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
);
}
for (const ip of resolvedIPs) {
if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) {
for (const ip of resolvedIPs) {
if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
}
}
};
+11 -18
View File
@@ -400,7 +400,6 @@
"show_response_count": "Antwortanzahl anzeigen",
"shown": "Angezeigt",
"size": "Größe",
"skip": "Überspringen",
"skipped": "Übersprungen",
"skips": "Übersprungen",
"some_files_failed_to_upload": "Einige Dateien konnten nicht hochgeladen werden",
@@ -434,6 +433,7 @@
"team_name": "Teamname",
"team_role": "Team-Rolle",
"teams": "Teams",
"terms_of_service": "Nutzungsbedingungen",
"text": "Text",
"time": "Zeit",
"time_to_finish": "Zeit zum Fertigstellen",
@@ -471,7 +471,6 @@
"website_survey": "Website-Umfrage",
"weeks": "Wochen",
"welcome_card": "Willkommenskarte",
"workflows": "Workflows",
"workspace": "Arbeitsbereich",
"workspace_configuration": "Projektkonfiguration",
"workspace_created_successfully": "Projekt erfolgreich erstellt",
@@ -507,7 +506,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",
@@ -1131,6 +1130,13 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "Schalte die volle Power von Formbricks frei. 30 Tage kostenlos."
},
"general": {
"ai_data_analysis_enabled": "Datenanreicherung & Analyse (KI)",
"ai_data_analysis_enabled_description": "KI, um mehr aus deinen Daten herauszuholen, Dashboards, Diagramme, Berichte und mehr einzurichten. Greift auf deine Erfahrungsdaten zu.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Verwalte KI-gestützte Funktionen für diese Organisation.",
"ai_settings_updated_successfully": "KI-Einstellungen erfolgreich aktualisiert",
"ai_smart_tools_enabled": "Intelligente Funktionen (KI)",
"ai_smart_tools_enabled_description": "KI, um dir zu helfen, in kürzerer Zeit mehr zu erreichen. Greift niemals auf mit Formbricks gesammelte Daten zu. Wird nur verwendet, um z. B. Umfragen in andere Sprachen zu übersetzen.",
"bulk_invite_warning_description": "Bitte beachte, dass im Free-Plan alle Organisationsmitglieder automatisch die Rolle \"Owner\" zugewiesen bekommen, unabhängig von der im CSV-File angegebenen Rolle.",
"cannot_delete_only_organization": "Das ist deine einzige Organisation, sie kann nicht gelöscht werden. Erstelle zuerst eine neue Organisation.",
"cannot_leave_only_organization": "Du kannst diese Organisation nicht verlassen, da es deine einzige Organisation ist. Erstelle zuerst eine neue Organisation.",
@@ -2614,8 +2620,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",
@@ -3337,18 +3343,5 @@
"usability_question_9_headline": "Ich fühlte mich beim Benutzen des Systems sicher.",
"usability_rating_description": "Bewerte die wahrgenommene Benutzerfreundlichkeit, indem du die Nutzer bittest, ihre Erfahrung mit deinem Produkt mittels eines standardisierten 10-Fragen-Fragebogens zu bewerten.",
"usability_score_name": "System Usability Score Survey (SUS)"
},
"workflows": {
"coming_soon_description": "Danke, dass du deine Workflow-Idee mit uns geteilt hast! Wir arbeiten gerade an diesem Feature und dein Feedback hilft uns dabei, genau das zu entwickeln, was du brauchst.",
"coming_soon_title": "Wir sind fast da!",
"follow_up_label": "Möchten Sie noch etwas hinzufügen?",
"follow_up_placeholder": "Welche konkreten Aufgaben möchten Sie automatisieren? Gibt es Tools oder Integrationen, die Sie einbinden möchten?",
"generate_button": "Workflow generieren",
"heading": "Welchen Workflow möchtest du erstellen?",
"placeholder": "Beschreiben Sie den Workflow, den Sie erstellen möchten…",
"subheading": "Generiere deinen Workflow in Sekunden.",
"submit_button": "Details hinzufügen",
"thank_you_description": "Ihr Feedback hilft uns, die Workflows-Funktion so zu gestalten, wie Sie sie brauchen. Wir halten Sie über unseren Fortschritt auf dem Laufenden.",
"thank_you_title": "Danke für dein Feedback!"
}
}
+11 -18
View File
@@ -400,7 +400,6 @@
"show_response_count": "Show response count",
"shown": "Shown",
"size": "Size",
"skip": "Skip",
"skipped": "Skipped",
"skips": "Skips",
"some_files_failed_to_upload": "Some files failed to upload",
@@ -434,6 +433,7 @@
"team_name": "Team name",
"team_role": "Team role",
"teams": "Teams",
"terms_of_service": "Terms of Service",
"text": "Text",
"time": "Time",
"time_to_finish": "Time to finish",
@@ -471,7 +471,6 @@
"website_survey": "Website Survey",
"weeks": "weeks",
"welcome_card": "Welcome card",
"workflows": "Workflows",
"workspace": "Workspace",
"workspace_configuration": "Workspace Configuration",
"workspace_created_successfully": "Workspace created successfully",
@@ -507,7 +506,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",
@@ -1131,6 +1130,13 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "Unlock the full power of Formbricks. Free for 30 days."
},
"general": {
"ai_data_analysis_enabled": "Data enrichment & analysis (AI)",
"ai_data_analysis_enabled_description": "AI to get more out of your data, setup dashboards, charts, reports and more. Touches your experience data.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Manage AI-powered features for this organization.",
"ai_settings_updated_successfully": "AI settings updated successfully",
"ai_smart_tools_enabled": "Smart functionality (AI)",
"ai_smart_tools_enabled_description": "AI to help you achieve more in less time. Never touches data collected with Formbricks. Only used to e.g. translate surveys to other languages.",
"bulk_invite_warning_description": "On the free plan, all organization members are always assigned the “Owner” role.",
"cannot_delete_only_organization": "This is your only organization, it cannot be deleted. Create a new organization first.",
"cannot_leave_only_organization": "You cannot leave this organization as it is your only organization. Create a new organization first.",
@@ -2614,8 +2620,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",
@@ -3337,18 +3343,5 @@
"usability_question_9_headline": "I felt confident while using the system.",
"usability_rating_description": "Measure perceived usability by asking users to rate their experience with your product using a standardized 10-question survey.",
"usability_score_name": "System Usability Score (SUS)"
},
"workflows": {
"coming_soon_description": "Thank you for sharing your workflow idea with us! We are currently designing this feature and your feedback will help us build exactly what you need.",
"coming_soon_title": "We are almost there!",
"follow_up_label": "Is there anything else you would like to add?",
"follow_up_placeholder": "What specific tasks would you like to automate? Any tools or integrations you would want included?",
"generate_button": "Generate workflow",
"heading": "What workflow do you want to create?",
"placeholder": "Describe the workflow you want to generate…",
"subheading": "Generate your workflow in seconds.",
"submit_button": "Add details",
"thank_you_description": "Your input helps us build the Workflows feature you actually need. We will keep you posted on our progress.",
"thank_you_title": "Thank you for your feedback!"
}
}
+11 -18
View File
@@ -400,7 +400,6 @@
"show_response_count": "Mostrar recuento de respuestas",
"shown": "Mostrado",
"size": "Tamaño",
"skip": "Omitir",
"skipped": "Omitido",
"skips": "Omisiones",
"some_files_failed_to_upload": "Algunos archivos no se han podido subir",
@@ -434,6 +433,7 @@
"team_name": "Nombre del equipo",
"team_role": "Rol del equipo",
"teams": "Equipos",
"terms_of_service": "Términos de servicio",
"text": "Texto",
"time": "Hora",
"time_to_finish": "Tiempo para finalizar",
@@ -471,7 +471,6 @@
"website_survey": "Encuesta de sitio web",
"weeks": "semanas",
"welcome_card": "Tarjeta de bienvenida",
"workflows": "Flujos de trabajo",
"workspace": "Espacio de trabajo",
"workspace_configuration": "Configuración del proyecto",
"workspace_created_successfully": "Proyecto creado correctamente",
@@ -507,7 +506,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",
@@ -1131,6 +1130,13 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloquea todo el potencial de Formbricks. Gratis durante 30 días."
},
"general": {
"ai_data_analysis_enabled": "Enriquecimiento y análisis de datos (IA)",
"ai_data_analysis_enabled_description": "IA para sacar más partido a tus datos, configurar paneles, gráficos, informes y más. Accede a los datos de experiencia.",
"ai_enabled": "IA de Formbricks",
"ai_enabled_description": "Gestiona las funciones impulsadas por IA para esta organización.",
"ai_settings_updated_successfully": "Configuración de IA actualizada correctamente",
"ai_smart_tools_enabled": "Funcionalidad inteligente (IA)",
"ai_smart_tools_enabled_description": "IA para ayudarte a conseguir más en menos tiempo. Nunca accede a los datos recopilados con Formbricks. Solo se usa para, por ejemplo, traducir encuestas a otros idiomas.",
"bulk_invite_warning_description": "En el plan gratuito, a todos los miembros de la organización se les asigna siempre el rol de \"Propietario\".",
"cannot_delete_only_organization": "Esta es tu única organización, no se puede eliminar. Crea una nueva organización primero.",
"cannot_leave_only_organization": "No puedes abandonar esta organización ya que es tu única organización. Crea una nueva organización primero.",
@@ -2614,8 +2620,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",
@@ -3337,18 +3343,5 @@
"usability_question_9_headline": "Me sentí seguro mientras usaba el sistema.",
"usability_rating_description": "Mide la usabilidad percibida pidiendo a los usuarios que valoren su experiencia con tu producto mediante una encuesta estandarizada de 10 preguntas.",
"usability_score_name": "Puntuación de usabilidad del sistema (SUS)"
},
"workflows": {
"coming_soon_description": "¡Gracias por compartir tu idea de flujo de trabajo con nosotros! Actualmente estamos diseñando esta funcionalidad y tus comentarios nos ayudarán a construir exactamente lo que necesitas.",
"coming_soon_title": "¡Ya casi estamos!",
"follow_up_label": "¿Hay algo más que te gustaría añadir?",
"follow_up_placeholder": "¿Qué tareas específicas te gustaría automatizar? ¿Alguna herramienta o integración que quieras incluir?",
"generate_button": "Generar flujo de trabajo",
"heading": "¿Qué flujo de trabajo quieres crear?",
"placeholder": "Describe el flujo de trabajo que quieres generar…",
"subheading": "Genera tu flujo de trabajo en segundos.",
"submit_button": "Añadir detalles",
"thank_you_description": "Tu aportación nos ayuda a construir la funcionalidad de Flujos de trabajo que realmente necesitas. Te mantendremos informado sobre nuestro progreso.",
"thank_you_title": "¡Gracias por tus comentarios!"
}
}
+11 -18
View File
@@ -400,7 +400,6 @@
"show_response_count": "Afficher le nombre de réponses",
"shown": "Montré",
"size": "Taille",
"skip": "Ignorer",
"skipped": "Passé",
"skips": "Sauter",
"some_files_failed_to_upload": "Certains fichiers n'ont pas pu être téléchargés",
@@ -434,6 +433,7 @@
"team_name": "Nom de l'équipe",
"team_role": "Rôle dans l'équipe",
"teams": "Équipes",
"terms_of_service": "Conditions d'utilisation",
"text": "Texte",
"time": "Temps",
"time_to_finish": "Temps de finir",
@@ -471,7 +471,6 @@
"website_survey": "Sondage de site web",
"weeks": "semaines",
"welcome_card": "Carte de bienvenue",
"workflows": "Workflows",
"workspace": "Espace de travail",
"workspace_configuration": "Configuration du projet",
"workspace_created_successfully": "Projet créé avec succès",
@@ -507,7 +506,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é",
@@ -1131,6 +1130,13 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "Débloquez tout le potentiel de Formbricks. Gratuit pendant 30 jours."
},
"general": {
"ai_data_analysis_enabled": "Enrichissement et analyse des données (IA)",
"ai_data_analysis_enabled_description": "L'IA pour tirer le meilleur parti de vos données, configurer des tableaux de bord, des graphiques, des rapports et plus encore. Accède à vos données d'expérience.",
"ai_enabled": "IA Formbricks",
"ai_enabled_description": "Gérer les fonctionnalités alimentées par l'IA pour cette organisation.",
"ai_settings_updated_successfully": "Paramètres IA mis à jour avec succès",
"ai_smart_tools_enabled": "Fonctionnalités intelligentes (IA)",
"ai_smart_tools_enabled_description": "L'IA pour vous aider à accomplir plus en moins de temps. N'accède jamais aux données collectées avec Formbricks. Utilisée uniquement pour, par exemple, traduire les sondages dans d'autres langues.",
"bulk_invite_warning_description": "Dans le plan gratuit, tous les membres de l'organisation se voient toujours attribuer le rôle \"Owner\".",
"cannot_delete_only_organization": "C'est votre seule organisation, elle ne peut pas être supprimée. Créez d'abord une nouvelle organisation.",
"cannot_leave_only_organization": "Vous ne pouvez pas quitter cette organisation car c'est votre seule organisation. Créez d'abord une nouvelle organisation.",
@@ -2614,8 +2620,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",
@@ -3337,18 +3343,5 @@
"usability_question_9_headline": "Je me suis senti confiant en utilisant le système.",
"usability_rating_description": "Mesurez la convivialité perçue en demandant aux utilisateurs d'évaluer leur expérience avec votre produit via un sondage standardisé de 10 questions.",
"usability_score_name": "Score d'Utilisabilité du Système (SUS)"
},
"workflows": {
"coming_soon_description": "Merci d'avoir partagé votre idée de workflow avec nous! Nous concevons actuellement cette fonctionnalité et vos retours nous aideront à créer exactement ce dont vous avez besoin.",
"coming_soon_title": "Nous y sommes presque!",
"follow_up_label": "Souhaitez-vous ajouter quelque chose ?",
"follow_up_placeholder": "Quelles tâches spécifiques souhaitez-vous automatiser ? Y a-t-il des outils ou intégrations que vous aimeriez inclure ?",
"generate_button": "Générer le workflow",
"heading": "Quel workflow souhaitez-vous créer?",
"placeholder": "Décrivez le processus que vous souhaitez générer…",
"subheading": "Générez votre workflow en quelques secondes.",
"submit_button": "Ajouter des détails",
"thank_you_description": "Vos retours nous aident à construire la fonctionnalité Workflows dont vous avez réellement besoin. Nous vous tiendrons informé(e) de notre avancement.",
"thank_you_title": "Merci pour vos retours!"
}
}
+11 -18
View File
@@ -400,7 +400,6 @@
"show_response_count": "Válaszok számának megjelenítése",
"shown": "Megjelenítve",
"size": "Méret",
"skip": "Kihagyás",
"skipped": "Kihagyva",
"skips": "Kihagyja",
"some_files_failed_to_upload": "Néhány fájlt nem sikerült feltölteni",
@@ -434,6 +433,7 @@
"team_name": "Csapat neve",
"team_role": "Csapatszerep",
"teams": "Csapatok",
"terms_of_service": "Felhasználási feltételek",
"text": "Szöveg",
"time": "Idő",
"time_to_finish": "Idő a befejezésig",
@@ -471,7 +471,6 @@
"website_survey": "Webhely kérdőív",
"weeks": "hét",
"welcome_card": "Üdvözlő kártya",
"workflows": "Munkafolyamatok",
"workspace": "Munkaterület",
"workspace_configuration": "Munkaterület beállítása",
"workspace_created_successfully": "A munkaterület sikeresen létrehozva",
@@ -507,7 +506,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ő",
@@ -1131,6 +1130,13 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "A Formbricks teljes erejének feloldása. 30 napig ingyen."
},
"general": {
"ai_data_analysis_enabled": "Adatgazdagítás és elemzés (AI)",
"ai_data_analysis_enabled_description": "AI segítségével többet hozhat ki az adataiból, irányítópultokat, diagramokat, jelentéseket és egyebeket állíthat be. Hozzáfér az élményekhez kapcsolódó adatokhoz.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "AI-alapú funkciók kezelése ehhez a szervezethez.",
"ai_settings_updated_successfully": "AI beállítások sikeresen frissítve",
"ai_smart_tools_enabled": "Intelligens funkciók (AI)",
"ai_smart_tools_enabled_description": "AI segítségével kevesebb idő alatt többet érhet el. Soha nem fér hozzá a Formbricks által gyűjtött adatokhoz. Csak például felmérések más nyelvekre történő fordításához használatos.",
"bulk_invite_warning_description": "Az ingyenes csomagban az összes szervezeti tag mindig a „Tulajdonos” szerephez van hozzárendelve.",
"cannot_delete_only_organization": "Ez az egyetlen szervezete, nem lehet törölni. Először hozzon létre egy új szervezetet.",
"cannot_leave_only_organization": "Nem hagyhatja el ezt a szervezetet, mivel ez az egyetlen szervezete. Először hozzon létre egy új szervezetet.",
@@ -2614,8 +2620,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",
@@ -3337,18 +3343,5 @@
"usability_question_9_headline": "Magabiztosnak éreztem magam a rendszer használata során.",
"usability_rating_description": "Az érzékelt használhatóság mérése arra kérve a felhasználókat, hogy értékeljék a termékkel kapcsolatos tapasztalataikat egy szabványosított, 10 kérdésből álló kérdőív használatával.",
"usability_score_name": "Rendszer-használhatósági pontszám (SUS)"
},
"workflows": {
"coming_soon_description": "Köszönjük, hogy megosztotta velünk a munkafolyamatra vonatkozó ötletét! Jelenleg a funkció kialakításán dolgozunk, és a visszajelzése segít nekünk abban, hogy pontosan azt alkossuk meg, amire szüksége van.",
"coming_soon_title": "Már majdnem kész vagyunk!",
"follow_up_label": "Van még bármi egyéb, amit hozzá szeretne fűzni?",
"follow_up_placeholder": "Milyen konkrét feladatokat szeretne automatizálni? Vannak olyan eszközök vagy integrációk, amelyeket szívesen látna a rendszerben?",
"generate_button": "Munkafolyamat előállítása",
"heading": "Milyen munkafolyamatot szeretne létrehozni?",
"placeholder": "Mutassa be az előállítani kívánt munkafolyamatot…",
"subheading": "Munkafolyamat előállítása másodpercek alatt.",
"submit_button": "Részletek hozzáadása",
"thank_you_description": "A visszajelzése segít nekünk abban, hogy olyan Munkafolyamatok funkciót alakítsunk ki, amelyre valóban szüksége van. Folyamatosan tájékoztatni fogjuk Önt a fejlesztés előrehaladásáról.",
"thank_you_title": "Köszönjük a visszajelzését!"
}
}
+11 -18
View File
@@ -400,7 +400,6 @@
"show_response_count": "回答数を表示",
"shown": "表示済み",
"size": "サイズ",
"skip": "スキップ",
"skipped": "スキップ済み",
"skips": "スキップ数",
"some_files_failed_to_upload": "一部のファイルのアップロードに失敗しました",
@@ -434,6 +433,7 @@
"team_name": "チーム名",
"team_role": "チームの役割",
"teams": "チーム",
"terms_of_service": "利用規約",
"text": "テキスト",
"time": "時間",
"time_to_finish": "所要時間",
@@ -471,7 +471,6 @@
"website_survey": "ウェブサイトフォーム",
"weeks": "週間",
"welcome_card": "ウェルカムカード",
"workflows": "ワークフロー",
"workspace": "ワークスペース",
"workspace_configuration": "ワークスペース設定",
"workspace_created_successfully": "ワークスペースが正常に作成されました",
@@ -507,7 +506,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": "非表示フィールド",
@@ -1131,6 +1130,13 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "Formbricksの全機能をアンロック。30日間無料。"
},
"general": {
"ai_data_analysis_enabled": "データエンリッチメントと分析(AI)",
"ai_data_analysis_enabled_description": "AIを活用してデータから最大限の価値を引き出し、ダッシュボード、チャート、レポートなどを設定できます。エクスペリエンスデータに触れます。",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "この組織のAI機能を管理します。",
"ai_settings_updated_successfully": "AI設定が正常に更新されました",
"ai_smart_tools_enabled": "スマート機能(AI",
"ai_smart_tools_enabled_description": "AIを活用して、より短時間でより多くのことを達成できます。Formbricksで収集されたデータには一切触れません。アンケートを他の言語に翻訳するなどの用途にのみ使用されます。",
"bulk_invite_warning_description": "無料プランでは、すべての組織メンバーに常に「オーナー」ロールが割り当てられます。",
"cannot_delete_only_organization": "これはあなたの唯一の組織です。削除できません。まず新しい組織を作成してください。",
"cannot_leave_only_organization": "これはあなたの唯一の組織であるため、離れることはできません。まず新しい組織を作成してください。",
@@ -2614,8 +2620,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": "非常に不満",
@@ -3337,18 +3343,5 @@
"usability_question_9_headline": "システムを使っている間、自信がありました。",
"usability_rating_description": "標準化された10の質問アンケートを使用して、製品に対するユーザーの体験を評価し、知覚された使いやすさを測定する。",
"usability_score_name": "システムユーザビリティスコア(SUS)"
},
"workflows": {
"coming_soon_description": "ワークフローのアイデアを共有していただきありがとうございます!現在この機能を設計中で、あなたのフィードバックは私たちが必要とされる機能を構築するのに役立ちます。",
"coming_soon_title": "もうすぐ完成です!",
"follow_up_label": "他に追加したいことはありますか?",
"follow_up_placeholder": "自動化したい具体的な作業や使用したいツール・連携があればご記入ください。",
"generate_button": "ワークフローを生成",
"heading": "どのようなワークフローを作成しますか?",
"placeholder": "作成したいワークフローについて説明してください…",
"subheading": "数秒でワークフローを生成します。",
"submit_button": "詳細を追加",
"thank_you_description": "ご入力いただいた内容は、より実用的なWorkflows機能の開発に役立てます。進捗は順次ご案内します。",
"thank_you_title": "フィードバックありがとうございます!"
}
}
+11 -18
View File
@@ -400,7 +400,6 @@
"show_response_count": "Toon het aantal reacties",
"shown": "Getoond",
"size": "Maat",
"skip": "Overslaan",
"skipped": "Overgeslagen",
"skips": "Overslaan",
"some_files_failed_to_upload": "Sommige bestanden konden niet worden geüpload",
@@ -434,6 +433,7 @@
"team_name": "Teamnaam",
"team_role": "Teamrol",
"teams": "Teams",
"terms_of_service": "Gebruiksvoorwaarden",
"text": "Tekst",
"time": "Tijd",
"time_to_finish": "Tijd om af te ronden",
@@ -471,7 +471,6 @@
"website_survey": "Website-enquête",
"weeks": "weken",
"welcome_card": "Welkomstkaart",
"workflows": "Workflows",
"workspace": "Werkruimte",
"workspace_configuration": "Werkruimte-configuratie",
"workspace_created_successfully": "Project succesvol aangemaakt",
@@ -507,7 +506,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",
@@ -1131,6 +1130,13 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "Ontgrendel de volledige kracht van Formbricks. 30 dagen gratis."
},
"general": {
"ai_data_analysis_enabled": "Dataverrijking & analyse (AI)",
"ai_data_analysis_enabled_description": "AI om meer uit je data te halen, dashboards op te zetten, grafieken, rapporten en meer. Raakt je ervaringsdata aan.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Beheer AI-functies voor deze organisatie.",
"ai_settings_updated_successfully": "AI-instellingen succesvol bijgewerkt",
"ai_smart_tools_enabled": "Slimme functionaliteit (AI)",
"ai_smart_tools_enabled_description": "AI om je te helpen meer te bereiken in minder tijd. Raakt nooit data aan die met Formbricks is verzameld. Wordt alleen gebruikt om bijvoorbeeld enquêtes naar andere talen te vertalen.",
"bulk_invite_warning_description": "Bij het gratis abonnement krijgen alle organisatieleden altijd de rol 'Eigenaar' toegewezen.",
"cannot_delete_only_organization": "Dit is uw enige organisatie. Deze kan niet worden verwijderd. Maak eerst een nieuwe organisatie aan.",
"cannot_leave_only_organization": "U kunt deze organisatie niet verlaten, aangezien dit uw enige organisatie is. Maak eerst een nieuwe organisatie aan.",
@@ -2614,8 +2620,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",
@@ -3337,18 +3343,5 @@
"usability_question_9_headline": "Ik voelde me zelfverzekerd tijdens het gebruik van het systeem.",
"usability_rating_description": "Meet de waargenomen bruikbaarheid door gebruikers te vragen hun ervaring met uw product te beoordelen met behulp van een gestandaardiseerde enquête met tien vragen.",
"usability_score_name": "Systeembruikbaarheidsscore (SUS)"
},
"workflows": {
"coming_soon_description": "Bedankt voor het delen van je workflow-idee met ons! We zijn momenteel bezig met het ontwerpen van deze functie en jouw feedback helpt ons om precies te bouwen wat je nodig hebt.",
"coming_soon_title": "We zijn er bijna!",
"follow_up_label": "Is er nog iets dat u wilt toevoegen?",
"follow_up_placeholder": "Welke specifieke taken wil je automatiseren? Zijn er tools of integraties die je wilt meenemen?",
"generate_button": "Genereer workflow",
"heading": "Welke workflow wil je maken?",
"placeholder": "Beschrijf de workflow die je wilt genereren…",
"subheading": "Genereer je workflow in enkele seconden.",
"submit_button": "Voeg details toe",
"thank_you_description": "Jouw input helpt ons om de Workflows-functie te bouwen die jij echt nodig hebt. We houden je op de hoogte van onze voortgang.",
"thank_you_title": "Bedankt voor je feedback!"
}
}
+11 -18
View File
@@ -400,7 +400,6 @@
"show_response_count": "Mostrar contagem de respostas",
"shown": "mostrado",
"size": "Tamanho",
"skip": "Pular",
"skipped": "Pulou",
"skips": "Pula",
"some_files_failed_to_upload": "Alguns arquivos falharam ao enviar",
@@ -434,6 +433,7 @@
"team_name": "Nome da equipe",
"team_role": "Função na equipe",
"teams": "Equipes",
"terms_of_service": "Termos de Serviço",
"text": "Texto",
"time": "tempo",
"time_to_finish": "Hora de terminar",
@@ -471,7 +471,6 @@
"website_survey": "Pesquisa de Site",
"weeks": "semanas",
"welcome_card": "Cartão de boas-vindas",
"workflows": "Fluxos de trabalho",
"workspace": "Espaço de trabalho",
"workspace_configuration": "Configuração do projeto",
"workspace_created_successfully": "Projeto criado com sucesso",
@@ -507,7 +506,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",
@@ -1131,6 +1130,13 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloqueie todo o poder do Formbricks. Grátis por 30 dias."
},
"general": {
"ai_data_analysis_enabled": "Enriquecimento e análise de dados (IA)",
"ai_data_analysis_enabled_description": "IA para extrair mais dos seus dados, configurar dashboards, gráficos, relatórios e muito mais. Acessa os dados da sua experiência.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Gerencie recursos com IA para esta organização.",
"ai_settings_updated_successfully": "Configurações de IA atualizadas com sucesso",
"ai_smart_tools_enabled": "Funcionalidades inteligentes (IA)",
"ai_smart_tools_enabled_description": "IA para ajudar você a conquistar mais em menos tempo. Nunca acessa dados coletados com o Formbricks. Usado apenas para, por exemplo, traduzir pesquisas para outros idiomas.",
"bulk_invite_warning_description": "Por favor, note que no Plano Gratuito, todos os membros da organização são automaticamente atribuídos ao papel de 'Owner', independentemente do papel especificado no arquivo CSV.",
"cannot_delete_only_organization": "Essa é sua única organização, não pode ser deletada. Crie uma nova organização primeiro.",
"cannot_leave_only_organization": "Você não pode sair dessa organização porque é a sua única. Crie uma nova organização primeiro.",
@@ -2614,8 +2620,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",
@@ -3337,18 +3343,5 @@
"usability_question_9_headline": "Me senti confiante ao usar o sistema.",
"usability_rating_description": "Meça a usabilidade percebida perguntando aos usuários para avaliar sua experiência com seu produto usando uma pesquisa padronizada de 10 perguntas.",
"usability_score_name": "Pontuação de Usabilidade do Sistema (SUS)"
},
"workflows": {
"coming_soon_description": "Obrigado por compartilhar sua ideia de fluxo de trabalho conosco! Estamos atualmente projetando este recurso e seu feedback nos ajudará a construir exatamente o que você precisa.",
"coming_soon_title": "Estamos quase lá!",
"follow_up_label": "Há algo mais que você gostaria de acrescentar?",
"follow_up_placeholder": "Quais tarefas específicas você gostaria de automatizar? Alguma ferramenta ou integração que gostaria de incluir?",
"generate_button": "Gerar fluxo de trabalho",
"heading": "Qual fluxo de trabalho você quer criar?",
"placeholder": "Descreva o fluxo de trabalho que você quer gerar…",
"subheading": "Gere seu fluxo de trabalho em segundos.",
"submit_button": "Adicionar detalhes",
"thank_you_description": "Sua contribuição nos ajuda a construir a funcionalidade de Fluxos de Trabalho que você realmente precisa. Manteremos você informado sobre nosso progresso.",
"thank_you_title": "Obrigado pelo seu feedback!"
}
}
+11 -18
View File
@@ -400,7 +400,6 @@
"show_response_count": "Mostrar contagem de respostas",
"shown": "Mostrado",
"size": "Tamanho",
"skip": "Saltar",
"skipped": "Ignorado",
"skips": "Saltos",
"some_files_failed_to_upload": "Alguns ficheiros falharam ao carregar",
@@ -434,6 +433,7 @@
"team_name": "Nome da equipa",
"team_role": "Função na equipa",
"teams": "Equipas",
"terms_of_service": "Termos de Serviço",
"text": "Texto",
"time": "Tempo",
"time_to_finish": "Tempo para concluir",
@@ -471,7 +471,6 @@
"website_survey": "Inquérito do Website",
"weeks": "semanas",
"welcome_card": "Cartão de boas-vindas",
"workflows": "Fluxos de trabalho",
"workspace": "Espaço de trabalho",
"workspace_configuration": "Configuração do projeto",
"workspace_created_successfully": "Projeto criado com sucesso",
@@ -507,7 +506,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",
@@ -1131,6 +1130,13 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "Desbloqueie todo o poder do Formbricks. Grátis por 30 dias."
},
"general": {
"ai_data_analysis_enabled": "Enriquecimento e análise de dados (IA)",
"ai_data_analysis_enabled_description": "IA para tirar mais partido dos teus dados, configurar dashboards, gráficos, relatórios e muito mais. Acede aos dados da tua experiência.",
"ai_enabled": "IA da Formbricks",
"ai_enabled_description": "Gerir funcionalidades com IA para esta organização.",
"ai_settings_updated_successfully": "Definições de IA atualizadas com sucesso",
"ai_smart_tools_enabled": "Funcionalidade inteligente (IA)",
"ai_smart_tools_enabled_description": "IA para te ajudar a alcançar mais em menos tempo. Nunca acede aos dados recolhidos com o Formbricks. Apenas usado para, por exemplo, traduzir inquéritos para outros idiomas.",
"bulk_invite_warning_description": "No plano gratuito, todos os membros da organização são sempre atribuídos ao papel de \"Proprietário\".",
"cannot_delete_only_organization": "Esta é a sua única organização, não pode ser eliminada. Crie uma nova organização primeiro.",
"cannot_leave_only_organization": "Não pode sair desta organização, pois é a sua única organização. Crie uma nova organização primeiro.",
@@ -2614,8 +2620,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",
@@ -3337,18 +3343,5 @@
"usability_question_9_headline": "Eu senti-me confiante ao usar o sistema.",
"usability_rating_description": "Meça a usabilidade percebida ao solicitar que os utilizadores avaliem a sua experiência com o seu produto usando um questionário padronizado de 10 perguntas.",
"usability_score_name": "System Usability Score (SUS)"
},
"workflows": {
"coming_soon_description": "Obrigado por partilhar a sua ideia de fluxo de trabalho connosco! Estamos atualmente a desenhar esta funcionalidade e o seu feedback vai ajudar-nos a construir exatamente o que precisa.",
"coming_soon_title": "Estamos quase lá!",
"follow_up_label": "Há mais alguma coisa que gostaria de acrescentar?",
"follow_up_placeholder": "Que tarefas específicas gostaria de automatizar? Existe alguma ferramenta ou integração que queira incluir?",
"generate_button": "Gerar fluxo de trabalho",
"heading": "Que fluxo de trabalho quer criar?",
"placeholder": "Descreva o fluxo de trabalho que pretende gerar…",
"subheading": "Gere o seu fluxo de trabalho em segundos.",
"submit_button": "Adicionar detalhes",
"thank_you_description": "A sua contribuição ajuda-nos a criar a funcionalidade Workflows de que realmente precisa. Mantê-lo-emos informado sobre o nosso progresso.",
"thank_you_title": "Obrigado pelo seu feedback!"
}
}
+11 -18
View File
@@ -400,7 +400,6 @@
"show_response_count": "Afișează numărul de răspunsuri",
"shown": "Afișat",
"size": "Mărime",
"skip": "Omite",
"skipped": "Sărit",
"skips": "Salturi",
"some_files_failed_to_upload": "Unele fișiere nu au reușit să se încarce",
@@ -434,6 +433,7 @@
"team_name": "Nume echipă",
"team_role": "Rol în echipă",
"teams": "Echipe",
"terms_of_service": "Termeni și condiții",
"text": "Text",
"time": "Timp",
"time_to_finish": "Timp până la finalizare",
@@ -471,7 +471,6 @@
"website_survey": "Chestionar despre site",
"weeks": "săptămâni",
"welcome_card": "Card de bun venit",
"workflows": "Workflows",
"workspace": "Spațiu de lucru",
"workspace_configuration": "Configurare workspace",
"workspace_created_successfully": "Spațiul de lucru a fost creat cu succes",
@@ -507,7 +506,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",
@@ -1131,6 +1130,13 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "Deblocați puterea completă a Formbricks. Gratuit timp de 30 de zile."
},
"general": {
"ai_data_analysis_enabled": "Îmbogățire și analiză de date (AI)",
"ai_data_analysis_enabled_description": "AI pentru a obține mai mult din datele tale, configurare dashboard-uri, grafice, rapoarte și multe altele. Accesează datele tale de experiență.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Gestionează funcționalitățile bazate pe AI pentru această organizație.",
"ai_settings_updated_successfully": "Setările AI au fost actualizate cu succes",
"ai_smart_tools_enabled": "Funcționalitate inteligentă (AI)",
"ai_smart_tools_enabled_description": "AI care te ajută să faci mai mult în mai puțin timp. Nu accesează niciodată datele colectate cu Formbricks. Folosit doar, de exemplu, pentru a traduce chestionare în alte limbi.",
"bulk_invite_warning_description": "În planul gratuit, toți membrii organizației sunt întotdeauna alocați rolului „Proprietar”.",
"cannot_delete_only_organization": "Aceasta este singura ta organizație, nu poate fi ștearsă. Creează mai întâi o nouă organizație.",
"cannot_leave_only_organization": "Nu poți părăsi această organizație deoarece este singura ta organizație. Creează mai întâi o nouă organizație.",
@@ -2614,8 +2620,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",
@@ -3337,18 +3343,5 @@
"usability_question_9_headline": "M-am simțit încrezător în timp ce utilizam sistemul.",
"usability_rating_description": "Măsurați uzabilitatea percepută cerând utilizatorilor să își evalueze experiența cu produsul dumneavoastră folosind un chestionar standardizat din 10 întrebări.",
"usability_score_name": "Scor de Uzabilitate al Sistemului (SUS)"
},
"workflows": {
"coming_soon_description": "Îți mulțumim că ai împărtășit cu noi ideea ta de workflow! În prezent, lucrăm la această funcționalitate, iar feedback-ul tău ne ajută să construim exact ce ai nevoie.",
"coming_soon_title": "Suntem aproape gata!",
"follow_up_label": "Mai este ceva ce ați dori să adăugați?",
"follow_up_placeholder": "Ce sarcini specifice ați dori să automatizați? Există instrumente sau integrări pe care ați dori să le includem?",
"generate_button": "Generează workflow",
"heading": "Ce workflow vrei să creezi?",
"placeholder": "Descrieți fluxul de lucru pe care doriți să-l generați…",
"subheading": "Generează-ți workflow-ul în câteva secunde.",
"submit_button": "Adaugă detalii",
"thank_you_description": "Contribuția dvs. ne ajută să dezvoltăm funcția Workflows de care chiar aveți nevoie. Vă vom ține la curent cu progresul nostru.",
"thank_you_title": "Îți mulțumim pentru feedback!"
}
}
+11 -18
View File
@@ -400,7 +400,6 @@
"show_response_count": "Показать количество ответов",
"shown": "Показано",
"size": "Размер",
"skip": "Пропустить",
"skipped": "Пропущено",
"skips": "Пропуски",
"some_files_failed_to_upload": "Не удалось загрузить некоторые файлы",
@@ -434,6 +433,7 @@
"team_name": "Название команды",
"team_role": "Роль в команде",
"teams": "Команды",
"terms_of_service": "Условия использования",
"text": "Текст",
"time": "Время",
"time_to_finish": "Время до завершения",
@@ -471,7 +471,6 @@
"website_survey": "Опрос сайта",
"weeks": "недели",
"welcome_card": "Приветственная карточка",
"workflows": "Воркфлоу",
"workspace": "Рабочее пространство",
"workspace_configuration": "Настройка рабочего пространства",
"workspace_created_successfully": "Рабочий проект успешно создан",
@@ -507,7 +506,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": "Скрытое поле",
@@ -1131,6 +1130,13 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "Откройте все возможности Formbricks. Бесплатно на 30 дней."
},
"general": {
"ai_data_analysis_enabled": "Обогащение и анализ данных (ИИ)",
"ai_data_analysis_enabled_description": "ИИ для получения большего от твоих данных: настройка дашбордов, графиков, отчетов и не только. Работает с твоими данными об опыте.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Управляй функциями на базе ИИ для этой организации.",
"ai_settings_updated_successfully": "Настройки AI успешно обновлены",
"ai_smart_tools_enabled": "Умные функции (ИИ)",
"ai_smart_tools_enabled_description": "ИИ помогает тебе делать больше за меньшее время. Никогда не использует данные, собранные с помощью Formbricks. Применяется, например, для перевода опросов на другие языки.",
"bulk_invite_warning_description": "В бесплатном тарифе всем участникам организации всегда назначается роль \"Владелец\".",
"cannot_delete_only_organization": "Это ваша единственная организация, её нельзя удалить. Сначала создайте новую организацию.",
"cannot_leave_only_organization": "Вы не можете покинуть эту организацию, так как она у вас единственная. Сначала создайте новую организацию.",
@@ -2614,8 +2620,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": "Очень недоволен",
@@ -3337,18 +3343,5 @@
"usability_question_9_headline": "Я чувствовал себя уверенно, используя систему.",
"usability_rating_description": "Оцените воспринимаемую удобство, попросив пользователей оценить свой опыт работы с вашим продуктом с помощью стандартизированного опроса из 10 вопросов.",
"usability_score_name": "Индекс удобства системы (SUS)"
},
"workflows": {
"coming_soon_description": "Спасибо, что поделился своей идеей воркфлоу с нами! Сейчас мы разрабатываем эту функцию, и твой отзыв поможет нам сделать именно то, что тебе нужно.",
"coming_soon_title": "Мы почти готовы!",
"follow_up_label": "Хотите ли вы что-нибудь добавить?",
"follow_up_placeholder": "Какие конкретные задачи вы хотите автоматизировать? Какие инструменты или интеграции вам хотелось бы добавить?",
"generate_button": "Сгенерировать воркфлоу",
"heading": "Какой воркфлоу ты хочешь создать?",
"placeholder": "Опишите, какой сценарий (workflow) вы хотите создать…",
"subheading": "Сгенерируй свой воркфлоу за секунды.",
"submit_button": "Добавить детали",
"thank_you_description": "Ваш вклад помогает нам создавать функцию сценариев, которая действительно вам нужна. Мы будем держать вас в курсе нашего прогресса.",
"thank_you_title": "Спасибо за твой отзыв!"
}
}
+11 -18
View File
@@ -400,7 +400,6 @@
"show_response_count": "Visa antal svar",
"shown": "Visad",
"size": "Storlek",
"skip": "Hoppa över",
"skipped": "Överhoppad",
"skips": "Överhoppningar",
"some_files_failed_to_upload": "Några filer misslyckades att laddas upp",
@@ -434,6 +433,7 @@
"team_name": "Teamnamn",
"team_role": "Teamroll",
"teams": "Åtkomstkontroll",
"terms_of_service": "Användarvillkor",
"text": "Text",
"time": "Tid",
"time_to_finish": "Tid att slutföra",
@@ -471,7 +471,6 @@
"website_survey": "Webbplatsenkät",
"weeks": "veckor",
"welcome_card": "Välkomstkort",
"workflows": "Arbetsflöden",
"workspace": "Arbetsyta",
"workspace_configuration": "Arbetsytans konfiguration",
"workspace_created_successfully": "Arbetsytan har skapats",
@@ -507,7 +506,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",
@@ -1131,6 +1130,13 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "Lås upp Formbricks fulla kraft. Gratis i 30 dagar."
},
"general": {
"ai_data_analysis_enabled": "Dataförbättring & analys (AI)",
"ai_data_analysis_enabled_description": "AI för att få ut mer av din data, skapa dashboards, diagram, rapporter och mer. Använder din upplevelsedata.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "Hantera AI-drivna funktioner för den här organisationen.",
"ai_settings_updated_successfully": "AI-inställningarna har uppdaterats",
"ai_smart_tools_enabled": "Smarta funktioner (AI)",
"ai_smart_tools_enabled_description": "AI som hjälper dig att göra mer på kortare tid. Rör aldrig data som samlats in med Formbricks. Används bara till t.ex. att översätta enkäter till andra språk.",
"bulk_invite_warning_description": "På gratisplanen tilldelas alla organisationsmedlemmar alltid rollen \"Ägare\".",
"cannot_delete_only_organization": "Detta är din enda organisation, den kan inte tas bort. Skapa en ny organisation först.",
"cannot_leave_only_organization": "Du kan inte lämna denna organisation eftersom det är din enda organisation. Skapa en ny organisation först.",
@@ -2614,8 +2620,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",
@@ -3337,18 +3343,5 @@
"usability_question_9_headline": "Jag kände mig trygg när jag använde systemet.",
"usability_rating_description": "Mät upplevd användbarhet genom att be användare betygsätta sin upplevelse med din produkt med en standardiserad 10-frågors enkät.",
"usability_score_name": "System Usability Score (SUS)"
},
"workflows": {
"coming_soon_description": "Tack för att du delade din arbetsflödesidé med oss! Vi håller just nu på att designa den här funktionen och din feedback hjälper oss att bygga precis det du behöver.",
"coming_soon_title": "Vi är nästan där!",
"follow_up_label": "Finns det något annat du vill lägga till?",
"follow_up_placeholder": "Vilka specifika uppgifter vill du automatisera? Några verktyg eller integrationer du vill ha med?",
"generate_button": "Skapa arbetsflöde",
"heading": "Vilket arbetsflöde vill du skapa?",
"placeholder": "Beskriv det arbetsflöde du vill skapa…",
"subheading": "Skapa ditt arbetsflöde på några sekunder.",
"submit_button": "Lägg till detaljer",
"thank_you_description": "Din input hjälper oss att bygga arbetsflödesfunktionen du faktiskt behöver. Vi håller dig uppdaterad om våra framsteg.",
"thank_you_title": "Tack för din feedback!"
}
}
+11 -18
View File
@@ -400,7 +400,6 @@
"show_response_count": "显示 响应 计数",
"shown": "显示",
"size": "尺寸",
"skip": "跳过",
"skipped": "跳过",
"skips": "跳过",
"some_files_failed_to_upload": "某些文件上传失败",
@@ -434,6 +433,7 @@
"team_name": "团队 名称",
"team_role": "团队角色",
"teams": "团队",
"terms_of_service": "服务条款",
"text": "文本",
"time": "时间",
"time_to_finish": "完成 时间",
@@ -471,7 +471,6 @@
"website_survey": "网站 调查",
"weeks": "周",
"welcome_card": "欢迎 卡片",
"workflows": "工作流",
"workspace": "工作区",
"workspace_configuration": "工作区配置",
"workspace_created_successfully": "工作区创建成功",
@@ -507,7 +506,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": "隐藏字段",
@@ -1131,6 +1130,13 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "解锁 Formbricks 的全部功能。免费使用 30 天。"
},
"general": {
"ai_data_analysis_enabled": "数据增强与分析(AI",
"ai_data_analysis_enabled_description": "使用 AI 深度挖掘你的数据,设置仪表盘、图表、报告等。会处理你的体验数据。",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "管理该组织的 AI 驱动功能。",
"ai_settings_updated_successfully": "AI 设置已成功更新",
"ai_smart_tools_enabled": "智能功能(AI",
"ai_smart_tools_enabled_description": "AI 帮你更高效地完成更多任务。绝不会接触 Formbricks 收集的数据,仅用于如问卷翻译等功能。",
"bulk_invite_warning_description": "在免费计划中,所有组织成员都会被分配为 \"Owner \"角色。",
"cannot_delete_only_organization": "这是 您 唯一的 组织,不可 删除。请 先 创建一个新的 组织。",
"cannot_leave_only_organization": "您 不能 离开 此 组织,因为 这是 您 唯一的 组织。请 先 创建一个新的 组织。",
@@ -2614,8 +2620,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": "非常 不 满意",
@@ -3337,18 +3343,5 @@
"usability_question_9_headline": "使用 系统 时 我 感到 自信。",
"usability_rating_description": "通过要求用户使用标准化的 10 问 调查 来 评价 他们对您产品的体验,以 测量 感知 的 可用性。",
"usability_score_name": "系统 可用性 得分 ( SUS )"
},
"workflows": {
"coming_soon_description": "感谢你与我们分享你的工作流想法!我们目前正在设计这个功能,你的反馈将帮助我们打造真正适合你的工具。",
"coming_soon_title": "我们快完成啦!",
"follow_up_label": "还有其他想补充的内容吗?",
"follow_up_placeholder": "您希望自动化哪些具体任务?是否需要包含特定工具或集成?",
"generate_button": "生成工作流",
"heading": "你想创建什么样的工作流?",
"placeholder": "请描述您希望生成的工作流程……",
"subheading": "几秒钟生成你的工作流。",
"submit_button": "补充细节",
"thank_you_description": "您的反馈有助于我们打造真正适合您的工作流功能。我们会及时告知您进展。",
"thank_you_title": "感谢你的反馈!"
}
}
+11 -18
View File
@@ -400,7 +400,6 @@
"show_response_count": "顯示回應數",
"shown": "已顯示",
"size": "大小",
"skip": "略過",
"skipped": "已跳過",
"skips": "跳過次數",
"some_files_failed_to_upload": "部分檔案上傳失敗",
@@ -434,6 +433,7 @@
"team_name": "團隊名稱",
"team_role": "團隊角色",
"teams": "團隊",
"terms_of_service": "服務條款",
"text": "文字",
"time": "時間",
"time_to_finish": "完成時間",
@@ -471,7 +471,6 @@
"website_survey": "網站問卷",
"weeks": "週",
"welcome_card": "歡迎卡片",
"workflows": "工作流程",
"workspace": "工作區",
"workspace_configuration": "工作區設定",
"workspace_created_successfully": "工作區已成功建立",
@@ -507,7 +506,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": "隱藏欄位",
@@ -1131,6 +1130,13 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "免費解鎖 Formbricks 的全部功能,為期 30 天。"
},
"general": {
"ai_data_analysis_enabled": "資料增強與分析(AI",
"ai_data_analysis_enabled_description": "利用 AI 深入分析你的資料,建立儀表板、圖表、報告等。會處理你的體驗資料。",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "管理此組織的 AI 功能。",
"ai_settings_updated_successfully": "AI 設定已成功更新",
"ai_smart_tools_enabled": "智慧功能(AI",
"ai_smart_tools_enabled_description": "AI 幫你更快完成更多事。絕不會接觸 Formbricks 收集的資料,只用於像是將問卷翻譯成其他語言等用途。",
"bulk_invite_warning_description": "在免費方案中,所有組織成員始終會被指派「擁有者」角色。",
"cannot_delete_only_organization": "這是您唯一的組織,無法刪除。請先建立新組織。",
"cannot_leave_only_organization": "您無法離開此組織,因為它是您唯一的組織。請先建立新組織。",
@@ -2614,8 +2620,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": "非常不滿意",
@@ -3337,18 +3343,5 @@
"usability_question_9_headline": "使用 系統 時,我 感到 有 信心。",
"usability_rating_description": "透過使用標準化的 十個問題 問卷,要求使用者評估他們對 您 產品的使用體驗,來衡量感知的 可用性。",
"usability_score_name": "系統 可用性 分數 (SUS)"
},
"workflows": {
"coming_soon_description": "感謝你和我們分享你的工作流程想法!我們目前正在設計這個功能,你的回饋將幫助我們打造真正符合你需求的工具。",
"coming_soon_title": "快完成囉!",
"follow_up_label": "還有其他想補充的嗎?",
"follow_up_placeholder": "您希望自動化哪些具體任務?有沒有想要整合的工具或功能?",
"generate_button": "產生工作流程",
"heading": "你想建立什麼樣的工作流程?",
"placeholder": "請描述您想產生的工作流程……",
"subheading": "幾秒鐘就能產生你的工作流程。",
"submit_button": "補充細節",
"thank_you_description": "您的意見有助於我們打造真正符合您需求的工作流程功能。我們會持續向您更新開發進度。",
"thank_you_title": "感謝你的回饋!"
}
}
@@ -44,12 +44,22 @@ export const authenticatedApiClient = async <S extends ExtendedSchemas>({
return response;
} catch (err) {
if (err !== null && typeof err === "object" && "type" in err) {
return handleApiError(request, err as ApiErrorResponseV2);
return handleApiError(request, err as ApiErrorResponseV2, undefined, err);
}
return handleApiError(request, {
type: "internal_server_error",
details: [{ field: "error", issue: "An error occurred while processing your request." }],
});
return handleApiError(
request,
{
type: "internal_server_error",
details: [
{
field: "error",
issue: "An error occurred while processing your request. Please try again later.",
},
],
},
undefined,
err
);
}
};
@@ -1,5 +1,5 @@
import { describe, expect, test, vi } from "vitest";
import { logApiRequest } from "@/modules/api/v2/lib/utils";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { handleApiError, logApiRequest } from "@/modules/api/v2/lib/utils";
import { apiWrapper } from "../api-wrapper";
import { authenticatedApiClient } from "../authenticated-api-client";
@@ -8,10 +8,15 @@ vi.mock("../api-wrapper", () => ({
}));
vi.mock("@/modules/api/v2/lib/utils", () => ({
handleApiError: vi.fn(),
logApiRequest: vi.fn(),
}));
describe("authenticatedApiClient", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("should log request and return response", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
@@ -29,4 +34,60 @@ describe("authenticatedApiClient", () => {
expect(response.status).toBe(200);
expect(logApiRequest).toHaveBeenCalled();
});
test("passes the original ApiErrorResponseV2 through to handleApiError", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
const apiError = {
type: "internal_server_error",
details: [{ field: "error", issue: "boom" }],
} as const;
const handledResponse = new Response("error", { status: 500 });
vi.mocked(apiWrapper).mockRejectedValue(apiError);
vi.mocked(handleApiError).mockReturnValue(handledResponse);
const handler = vi.fn();
const response = await authenticatedApiClient({
request,
handler,
});
expect(response).toBe(handledResponse);
expect(handleApiError).toHaveBeenCalledWith(request, apiError, undefined, apiError);
});
test("passes unknown thrown errors to handleApiError as originalError", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
const thrownError = new Error("boom");
const handledResponse = new Response("error", { status: 500 });
vi.mocked(apiWrapper).mockRejectedValue(thrownError);
vi.mocked(handleApiError).mockReturnValue(handledResponse);
const handler = vi.fn();
const response = await authenticatedApiClient({
request,
handler,
});
expect(response).toBe(handledResponse);
expect(handleApiError).toHaveBeenCalledWith(
request,
{
type: "internal_server_error",
details: [
{
field: "error",
issue: "An error occurred while processing your request. Please try again later.",
},
],
},
undefined,
thrownError
);
});
});
+95 -67
View File
@@ -1,26 +1,18 @@
import * as Sentry from "@sentry/nextjs";
import { describe, expect, test, vi } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { ZodError } from "zod";
import { logger } from "@formbricks/logger";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { formatZodError, handleApiError, logApiError, logApiRequest } from "../utils";
const mockRequest = new Request("http://localhost");
const mockRequest = new Request("http://localhost/api/v2/test");
// Add the request id header
mockRequest.headers.set("x-request-id", "123");
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: vi.fn((callback: (scope: any) => void) => {
const mockScope = {
setTag: vi.fn(),
setContext: vi.fn(),
setLevel: vi.fn(),
setExtra: vi.fn(),
};
callback(mockScope);
}),
withScope: vi.fn(),
}));
// Mock SENTRY_DSN constant while preserving untouched exports.
@@ -37,6 +29,10 @@ vi.mock("@/lib/constants", async (importOriginal) => {
});
describe("utils", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("handleApiError", () => {
test('return bad request response for "bad_request" error', async () => {
const details = [{ field: "param", issue: "invalid" }];
@@ -122,6 +118,57 @@ describe("utils", () => {
expect(body.error.details).toEqual([
{ field: "error", issue: "An error occurred while processing your request. Please try again later." },
]);
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.objectContaining({
message: "API V2 error, id: 123",
}),
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v2",
correlationId: "123",
method: "GET",
path: "/api/v2/test",
}),
extra: expect.objectContaining({
error,
originalError: error,
}),
contexts: expect.objectContaining({
apiRequest: expect.objectContaining({
apiVersion: "v2",
correlationId: "123",
method: "GET",
path: "/api/v2/test",
status: 500,
}),
}),
})
);
});
test("preserves originalError separately when provided", () => {
const error: ApiErrorResponseV2 = {
type: "internal_server_error",
details: [{ field: "server", issue: "error occurred" }],
};
const originalError = new Error("boom");
handleApiError(mockRequest, error, undefined, originalError);
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.objectContaining({
message: "API V2 error, id: 123",
}),
expect.objectContaining({
extra: expect.objectContaining({
error,
originalError: expect.objectContaining({
message: "boom",
}),
}),
})
);
});
});
@@ -156,13 +203,11 @@ describe("utils", () => {
describe("logApiRequest", () => {
test("logs API request details", () => {
// Mock the withContext method and its returned info method
const infoMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
info: infoMock,
});
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
@@ -172,23 +217,18 @@ describe("utils", () => {
logApiRequest(mockRequest, 200);
// Verify withContext was called
expect(withContextMock).toHaveBeenCalled();
// Verify info was called on the child logger
expect(infoMock).toHaveBeenCalledWith("API Request Details");
// Restore the original method
logger.withContext = originalWithContext;
});
test("logs API request details without correlationId and without safe query params", () => {
// Mock the withContext method and its returned info method
const infoMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
info: infoMock,
});
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
@@ -198,7 +238,6 @@ describe("utils", () => {
logApiRequest(mockRequest, 200);
// Verify withContext was called with the expected context
expect(withContextMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "GET",
@@ -208,23 +247,19 @@ describe("utils", () => {
})
);
// Verify info was called on the child logger
expect(infoMock).toHaveBeenCalledWith("API Request Details");
// Restore the original method
logger.withContext = originalWithContext;
});
});
describe("logApiError", () => {
test("logs API error details with method and path", () => {
// Mock the withContext method and its returned error method
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
error: errorMock,
});
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
@@ -238,33 +273,30 @@ describe("utils", () => {
logApiError(mockRequest, error);
// Verify withContext was called with the expected context including method and path
expect(withContextMock).toHaveBeenCalledWith({
apiVersion: "v2",
correlationId: "123",
method: "POST",
path: "/api/v2/management/surveys",
error,
status: 500,
});
// Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Restore the original method
logger.withContext = originalWithContext;
});
test("logs API error details without correlationId", () => {
// Mock the withContext method and its returned error method
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
error: errorMock,
});
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
const mockRequest = new Request("http://localhost/api/test");
const mockRequest = new Request("http://localhost/api/v2/test");
mockRequest.headers.delete("x-request-id");
const error: ApiErrorResponseV2 = {
@@ -274,44 +306,26 @@ describe("utils", () => {
logApiError(mockRequest, error);
// Verify withContext was called with the expected context
expect(withContextMock).toHaveBeenCalledWith({
apiVersion: "v2",
correlationId: "",
method: "GET",
path: "/api/test",
path: "/api/v2/test",
error,
status: 500,
});
// Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Restore the original method
logger.withContext = originalWithContext;
});
test("log API error details with SENTRY_DSN set includes method and path tags", () => {
// Mock the withContext method and its returned error method
test("sends internal server errors to Sentry with direct capture context", () => {
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
error: errorMock,
});
// Mock Sentry's captureException method
vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any);
// Capture the scope mock for tag verification
const scopeSetTagMock = vi.fn();
vi.mocked(Sentry.withScope).mockImplementation((callback: (scope: any) => void) => {
const mockScope = {
setTag: scopeSetTagMock,
setContext: vi.fn(),
setLevel: vi.fn(),
setExtra: vi.fn(),
};
callback(mockScope);
});
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
@@ -325,31 +339,49 @@ describe("utils", () => {
logApiError(mockRequest, error);
// Verify withContext was called with the expected context including method and path
expect(withContextMock).toHaveBeenCalledWith({
apiVersion: "v2",
correlationId: "123",
method: "DELETE",
path: "/api/v2/management/surveys",
error,
status: 500,
});
// Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Verify Sentry scope tags include method and path
expect(scopeSetTagMock).toHaveBeenCalledWith("correlationId", "123");
expect(scopeSetTagMock).toHaveBeenCalledWith("method", "DELETE");
expect(scopeSetTagMock).toHaveBeenCalledWith("path", "/api/v2/management/surveys");
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.objectContaining({
message: "API V2 error, id: 123",
}),
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v2",
correlationId: "123",
method: "DELETE",
path: "/api/v2/management/surveys",
}),
extra: expect.objectContaining({
error,
originalError: error,
}),
contexts: expect.objectContaining({
apiRequest: expect.objectContaining({
apiVersion: "v2",
correlationId: "123",
method: "DELETE",
path: "/api/v2/management/surveys",
status: 500,
}),
}),
})
);
// Verify Sentry.captureException was called
expect(Sentry.captureException).toHaveBeenCalled();
// Restore the original method
logger.withContext = originalWithContext;
});
test("does not send to Sentry for non-internal_server_error types", () => {
// Mock the withContext method and its returned error method
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
error: errorMock,
@@ -357,7 +389,6 @@ describe("utils", () => {
vi.mocked(Sentry.captureException).mockClear();
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
@@ -371,13 +402,10 @@ describe("utils", () => {
logApiError(mockRequest, error);
// Verify Sentry.captureException was NOT called for non-500 errors
expect(Sentry.captureException).not.toHaveBeenCalled();
// But structured logging should still happen
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Restore the original method
logger.withContext = originalWithContext;
});
});
+33 -32
View File
@@ -1,37 +1,38 @@
// Function is this file can be used in edge runtime functions, like api routes.
import * as Sentry from "@sentry/nextjs";
import { logger } from "@formbricks/logger";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): void => {
const correlationId = request.headers.get("x-request-id") ?? "";
const method = request.method;
const url = new URL(request.url);
const path = url.pathname;
// Send the error to Sentry if the DSN is set and the error type is internal_server_error
// This is useful for tracking down issues without overloading Sentry with errors
if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") {
// Use Sentry scope to add correlation ID and request context as tags for easy filtering
Sentry.withScope((scope) => {
scope.setTag("correlationId", correlationId);
scope.setTag("method", method);
scope.setTag("path", path);
scope.setLevel("error");
scope.setExtra("originalError", error);
const err = new Error(`API V2 error, id: ${correlationId}`);
Sentry.captureException(err);
});
const getStatusFromApiError = (error: ApiErrorResponseV2): number => {
switch (error.type) {
case "bad_request":
return 400;
case "unauthorized":
return 401;
case "forbidden":
return 403;
case "not_found":
return 404;
case "conflict":
return 409;
case "unprocessable_entity":
return 422;
case "too_many_requests":
return 429;
case "internal_server_error":
default:
return 500;
}
logger
.withContext({
correlationId,
method,
path,
error,
})
.error("API V2 Error Details");
};
export const logApiErrorEdge = (
request: Request,
error: ApiErrorResponseV2,
originalError: unknown = error
): void => {
reportApiError({
request,
status: getStatusFromApiError(error),
error,
originalError,
});
};
+13 -7
View File
@@ -12,9 +12,10 @@ import { logApiErrorEdge } from "./utils-edge";
export const handleApiError = (
request: Request,
err: ApiErrorResponseV2,
auditLog?: TApiAuditLog
auditLog?: TApiAuditLog,
originalError: unknown = err
): Response => {
logApiError(request, err, auditLog);
logApiError(request, err, auditLog, originalError);
switch (err.type) {
case "bad_request":
@@ -64,9 +65,9 @@ export const logApiRequest = (request: Request, responseStatus: number, auditLog
const startTime = request.headers.get("x-start-time") || "";
const queryParams = Object.fromEntries(url.searchParams.entries());
const sensitiveParams = ["apikey", "token", "secret"];
const sensitiveParams = new Set(["apikey", "token", "secret"]);
const safeQueryParams = Object.fromEntries(
Object.entries(queryParams).filter(([key]) => !sensitiveParams.includes(key.toLowerCase()))
Object.entries(queryParams).filter(([key]) => !sensitiveParams.has(key.toLowerCase()))
);
logger
@@ -74,7 +75,7 @@ export const logApiRequest = (request: Request, responseStatus: number, auditLog
method,
path,
responseStatus,
duration: `${Date.now() - parseInt(startTime)} ms`,
duration: `${Date.now() - Number.parseInt(startTime, 10)} ms`,
correlationId,
queryParams: safeQueryParams,
})
@@ -83,8 +84,13 @@ export const logApiRequest = (request: Request, responseStatus: number, auditLog
logAuditLog(request, auditLog);
};
export const logApiError = (request: Request, error: ApiErrorResponseV2, auditLog?: TApiAuditLog): void => {
logApiErrorEdge(request, error);
export const logApiError = (
request: Request,
error: ApiErrorResponseV2,
auditLog?: TApiAuditLog,
originalError: unknown = error
): void => {
logApiErrorEdge(request, error, originalError);
logAuditLog(request, auditLog);
};
@@ -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);
@@ -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 };
@@ -0,0 +1,602 @@
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 { deleteSessionsByUserId } from "@/modules/auth/lib/auth-session-repository";
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 TPasswordResetSessionRecord = {
id: string;
createdAt: Date;
updatedAt: Date;
sessionToken: string;
userId: string;
expires: Date;
};
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 sessionStore = new Map<string, TPasswordResetSessionRecord>();
const cloneTokenRecord = (record: TPasswordResetTokenRecord): TPasswordResetTokenRecord => ({
...record,
expiresAt: new Date(record.expiresAt),
createdAt: new Date(record.createdAt),
updatedAt: new Date(record.updatedAt),
});
const cloneUser = (user: TPasswordResetTestUser): TPasswordResetTestUser => ({
...user,
emailVerified: user.emailVerified ? new Date(user.emailVerified) : null,
});
const cloneSessionRecord = (session: TPasswordResetSessionRecord): TPasswordResetSessionRecord => ({
...session,
createdAt: new Date(session.createdAt),
updatedAt: new Date(session.updatedAt),
expires: new Date(session.expires),
});
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 mockDeleteSessionsByUserId = vi.fn(async (userId: string) => {
const matchingSessionEntries = [...sessionStore.entries()].filter(
([, sessionRecord]) => sessionRecord.userId === userId
);
for (const [sessionId] of matchingSessionEntries) {
sessionStore.delete(sessionId);
}
return matchingSessionEntries.length;
});
const restoreMap = <TKey, TValue>(
store: Map<TKey, TValue>,
snapshot: ReadonlyArray<readonly [TKey, TValue]>
): void => {
store.clear();
snapshot.forEach(([key, value]) => store.set(key, value));
};
const mockTransaction = vi.fn(async <T>(callback: (tx: TPasswordResetTransactionStub) => Promise<T>) => {
const tokenSnapshot = [...tokenStore.entries()].map(
([key, record]) => [key, cloneTokenRecord(record)] as const
);
const userSnapshot = [...users.entries()].map(([key, user]) => [key, cloneUser(user)] as const);
const sessionSnapshot = [...sessionStore.entries()].map(
([key, session]) => [key, cloneSessionRecord(session)] as const
);
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);
}),
},
};
try {
return await callback(tx);
} catch (error) {
restoreMap(tokenStore, tokenSnapshot);
restoreMap(users, userSnapshot);
restoreMap(sessionStore, sessionSnapshot);
throw error;
}
});
return {
tokenStore,
users,
sessionStore,
mockUpsertActiveToken,
mockFindByTokenHash,
mockDeleteByTokenHash,
mockDeleteSessionsByUserId,
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,
}));
vi.mock("@/modules/auth/lib/auth-session-repository", () => ({
deleteSessionsByUserId: testState.mockDeleteSessionsByUserId,
}));
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;
};
const createSessionRecord = (userId: string, sessionId: string): TPasswordResetSessionRecord => {
const now = new Date("2026-03-30T12:00:00.000Z");
return {
id: sessionId,
userId,
sessionToken: `session-token-${sessionId}`,
expires: new Date("2026-03-31T12:00:00.000Z"),
createdAt: now,
updatedAt: now,
};
};
const getSessionsForUser = (userId: string): TPasswordResetSessionRecord[] =>
[...testState.sessionStore.values()].filter((session) => session.userId === userId);
beforeEach(() => {
constantsState.debugShowResetLink = false;
testState.tokenStore.clear();
testState.users.clear();
testState.sessionStore.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("revokes all active sessions for the user after a successful reset", async () => {
const otherUserId = "cm8z6bn2q000008l34h8g7k9n";
testState.sessionStore.set("session-user-1", createSessionRecord(user.id, "session-user-1"));
testState.sessionStore.set("session-user-2", createSessionRecord(user.id, "session-user-2"));
testState.sessionStore.set("session-other-1", createSessionRecord(otherUserId, "session-other-1"));
await requestPasswordReset(user, "public");
const token = parseTokenFromResetLink();
await completePasswordReset(token, "Password123");
expect(deleteSessionsByUserId).toHaveBeenCalledWith(user.id, expect.any(Object));
expect(getSessionsForUser(user.id)).toHaveLength(0);
expect(getSessionsForUser(otherUserId)).toHaveLength(1);
});
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();
testState.sessionStore.set("session-user-1", createSessionRecord(user.id, "session-user-1"));
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(getSessionsForUser(user.id)).toHaveLength(0);
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();
});
test("rolls back the reset when session revocation fails", async () => {
await requestPasswordReset(user, "public");
const token = parseTokenFromResetLink();
testState.sessionStore.set("session-user-1", createSessionRecord(user.id, "session-user-1"));
testState.mockDeleteSessionsByUserId.mockRejectedValueOnce(new Error("Session revoke failed"));
await expect(completePasswordReset(token, "Password123")).rejects.toThrow("Session revoke failed");
expect(getStoredUser(user.id).password).toBe("old-password-hash");
expect(getStoredToken(user.id).tokenHash).toBe(`hash:${token}`);
expect(getSessionsForUser(user.id)).toHaveLength(1);
expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
stage: "session_revoke",
userId: user.id,
}),
"Password reset completion failed"
);
await expect(completePasswordReset(token, "Password123")).resolves.toMatchObject({
userId: user.id,
});
expect(getStoredUser(user.id).password).toBe("hashed:Password123");
});
});
@@ -0,0 +1,353 @@
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 { deleteSessionsByUserId } from "@/modules/auth/lib/auth-session-repository";
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";
}
}
class PasswordResetSessionRevocationError extends Error {
userId: string;
cause: unknown;
constructor(userId: string, cause: unknown) {
super("ERR_ACCOUNT_RECOVERY_SESSION_REVOKE_FAILED");
this.name = "PasswordResetSessionRevocationError";
this.userId = userId;
this.cause = cause;
}
}
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,
});
try {
await deleteSessionsByUserId(tokenRecord.userId, tx);
} catch (error) {
throw new PasswordResetSessionRevocationError(tokenRecord.userId, error);
}
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;
}
if (error instanceof PasswordResetSessionRevocationError) {
logger.error(
{
error: error.cause instanceof Error ? error.cause : error,
stage: "session_revoke",
userId: error.userId,
},
"Password reset completion failed"
);
throw error.cause instanceof Error ? error.cause : error;
}
logger.error({ error, stage: "password_update" }, "Password reset completion failed");
throw error;
}
};
@@ -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
);
});
});
@@ -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);
}
};
@@ -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();
});
});
@@ -1,35 +1,29 @@
"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 };
})
);
@@ -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>
);
};
@@ -0,0 +1,68 @@
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 { deleteSessionsByUserId } from "./auth-session-repository";
vi.mock("@formbricks/database", () => ({
prisma: {
session: {
deleteMany: vi.fn(),
},
},
}));
describe("auth-session-repository", () => {
const userId = "cm8z6bn2q000008l34h8g7k9m";
afterEach(() => {
vi.clearAllMocks();
});
test("deletes all sessions for the target user", async () => {
vi.mocked(prisma.session.deleteMany).mockResolvedValue({ count: 2 });
const result = await deleteSessionsByUserId(userId);
expect(result).toBe(2);
expect(prisma.session.deleteMany).toHaveBeenCalledWith({
where: { userId },
});
});
test("returns zero when the user has no sessions", async () => {
vi.mocked(prisma.session.deleteMany).mockResolvedValue({ count: 0 });
const result = await deleteSessionsByUserId(userId);
expect(result).toBe(0);
});
test("uses the provided transaction client when available", async () => {
const txDeleteMany = vi.fn().mockResolvedValue({ count: 3 });
const tx = {
session: {
deleteMany: txDeleteMany,
},
} as unknown as Prisma.TransactionClient;
const result = await deleteSessionsByUserId(userId, tx);
expect(result).toBe(3);
expect(txDeleteMany).toHaveBeenCalledWith({
where: { userId },
});
expect(prisma.session.deleteMany).not.toHaveBeenCalled();
});
test("wraps prisma known errors in DatabaseError", async () => {
vi.mocked(prisma.session.deleteMany).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("database failed", {
code: "P2021",
clientVersion: "test",
})
);
await expect(deleteSessionsByUserId(userId)).rejects.toThrow(DatabaseError);
});
});
@@ -0,0 +1,37 @@
import "server-only";
import { Prisma, PrismaClient } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
type TAuthSessionDbClient = PrismaClient | Prisma.TransactionClient;
const getDbClient = (tx?: Prisma.TransactionClient): TAuthSessionDbClient => tx ?? prisma;
const handleDatabaseError = (error: unknown): never => {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
};
export const deleteSessionsByUserId = async (
userId: string,
tx?: Prisma.TransactionClient
): Promise<number> => {
validateInputs([userId, ZId]);
try {
const result = await getDbClient(tx).session.deleteMany({
where: {
userId,
},
});
return result.count;
} catch (error) {
return handleDatabaseError(error);
}
};
+28 -40
View File
@@ -10,6 +10,25 @@ import { authOptions } from "./authOptions";
import { mockUser } from "./mock-data";
import { hashPassword } from "./utils";
vi.mock("@next-auth/prisma-adapter", () => ({
PrismaAdapter: vi.fn(() => ({
createUser: vi.fn(),
getUser: vi.fn(),
getUserByEmail: vi.fn(),
getUserByAccount: vi.fn(),
updateUser: vi.fn(),
deleteUser: vi.fn(),
linkAccount: vi.fn(),
unlinkAccount: vi.fn(),
createSession: vi.fn(),
getSessionAndUser: vi.fn(),
updateSession: vi.fn(),
deleteSession: vi.fn(),
createVerificationToken: vi.fn(),
useVerificationToken: vi.fn(),
})),
}));
// Mock encryption utilities
vi.mock("@/lib/encryption", () => ({
symmetricEncrypt: vi.fn((value: string) => `encrypted_${value}`),
@@ -300,51 +319,20 @@ describe("authOptions", () => {
});
describe("Callbacks", () => {
describe("jwt callback", () => {
test("should add profile information to token if user is found", async () => {
vi.spyOn(prisma.user, "findFirst").mockResolvedValue({
id: mockUser.id,
locale: mockUser.locale,
email: mockUser.email,
emailVerified: mockUser.emailVerified,
} as any);
const token = { email: mockUser.email };
if (!authOptions.callbacks?.jwt) {
throw new Error("jwt callback is not defined");
}
const result = await authOptions.callbacks.jwt({ token } as any);
expect(result).toEqual({
...token,
profile: { id: mockUser.id },
});
});
test("should return token unchanged if no existing user is found", async () => {
vi.spyOn(prisma.user, "findFirst").mockResolvedValue(null);
const token = { email: "nonexistent@example.com" };
if (!authOptions.callbacks?.jwt) {
throw new Error("jwt callback is not defined");
}
const result = await authOptions.callbacks.jwt({ token } as any);
expect(result).toEqual(token);
});
});
describe("session callback", () => {
test("should add user profile to session", async () => {
const token = {
id: "user6",
profile: { id: "user6", email: "user6@example.com" },
};
test("should add user id and isActive to session from database user", async () => {
const session = { user: { email: "user6@example.com" } };
const user = { id: "user6", isActive: false };
const session = { user: {} };
if (!authOptions.callbacks?.session) {
throw new Error("session callback is not defined");
}
const result = await authOptions.callbacks.session({ session, token } as any);
expect(result.user).toEqual(token.profile);
const result = await authOptions.callbacks.session({ session, user } as any);
expect(result.user).toEqual({
email: "user6@example.com",
id: "user6",
isActive: false,
});
});
});
+10 -21
View File
@@ -1,3 +1,4 @@
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import type { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { cookies } from "next/headers";
@@ -13,7 +14,7 @@ import {
} from "@/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { verifyToken } from "@/lib/jwt";
import { getUserByEmail, updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
import { updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
import {
logAuthAttempt,
logAuthEvent,
@@ -31,6 +32,7 @@ import { handleSsoCallback } from "@/modules/ee/sso/lib/sso-handlers";
import { createBrevoCustomer } from "./brevo";
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
CredentialsProvider({
id: "credentials",
@@ -310,30 +312,17 @@ export const authOptions: NextAuthOptions = {
...(ENTERPRISE_LICENSE_KEY ? getSSOProviders() : []),
],
session: {
strategy: "database",
maxAge: SESSION_MAX_AGE,
},
callbacks: {
async jwt({ token }) {
const existingUser = await getUserByEmail(token?.email!);
if (!existingUser) {
return token;
async session({ session, user }) {
if (session.user) {
session.user.id = user.id;
if ("isActive" in user && typeof user.isActive === "boolean") {
session.user.isActive = user.isActive;
}
}
return {
...token,
profile: { id: existingUser.id },
isActive: existingUser.isActive,
};
},
async session({ session, token }) {
// @ts-expect-error
session.user.id = token?.id;
// @ts-expect-error
session.user = token.profile;
// @ts-expect-error
session.user.isActive = token.isActive;
return session;
},
async signIn({ user, account }) {
@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getProxySession, getSessionTokenFromRequest } from "./proxy-session";
const { mockFindUnique } = vi.hoisted(() => ({
mockFindUnique: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
session: {
findUnique: mockFindUnique,
},
},
}));
const createRequest = (cookies: Record<string, string> = {}) => ({
cookies: {
get: (name: string) => {
const value = cookies[name];
return value ? { value } : undefined;
},
},
});
describe("proxy-session", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("reads the secure session cookie when present", () => {
const request = createRequest({
"__Secure-next-auth.session-token": "secure-token",
});
expect(getSessionTokenFromRequest(request)).toBe("secure-token");
});
test("returns null when no session cookie is present", async () => {
const request = createRequest();
const session = await getProxySession(request);
expect(session).toBeNull();
expect(mockFindUnique).not.toHaveBeenCalled();
});
test("returns null when the session is expired", async () => {
mockFindUnique.mockResolvedValue({
userId: "user-1",
expires: new Date(Date.now() - 60_000),
user: {
isActive: true,
},
});
const request = createRequest({
"next-auth.session-token": "expired-token",
});
const session = await getProxySession(request);
expect(session).toBeNull();
expect(mockFindUnique).toHaveBeenCalledWith({
where: {
sessionToken: "expired-token",
},
select: {
userId: true,
expires: true,
user: {
select: {
isActive: true,
},
},
},
});
});
test("returns null when the session belongs to an inactive user", async () => {
mockFindUnique.mockResolvedValue({
userId: "user-1",
expires: new Date(Date.now() + 60_000),
user: {
isActive: false,
},
});
const request = createRequest({
"next-auth.session-token": "inactive-user-token",
});
const session = await getProxySession(request);
expect(session).toBeNull();
});
test("returns the session when the cookie maps to a valid session", async () => {
const validSession = {
userId: "user-1",
expires: new Date(Date.now() + 60_000),
user: {
isActive: true,
},
};
mockFindUnique.mockResolvedValue(validSession);
const request = createRequest({
"next-auth.session-token": "valid-token",
});
const session = await getProxySession(request);
expect(session).toEqual(validSession);
});
});
@@ -0,0 +1,54 @@
import { prisma } from "@formbricks/database";
const NEXT_AUTH_SESSION_COOKIE_NAMES = [
"__Secure-next-auth.session-token",
"next-auth.session-token",
] as const;
type TCookieStore = {
get: (name: string) => { value: string } | undefined;
};
type TRequestWithCookies = {
cookies: TCookieStore;
};
export const getSessionTokenFromRequest = (request: TRequestWithCookies): string | null => {
for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) {
const cookie = request.cookies.get(cookieName);
if (cookie?.value) {
return cookie.value;
}
}
return null;
};
export const getProxySession = async (request: TRequestWithCookies) => {
const sessionToken = getSessionTokenFromRequest(request);
if (!sessionToken) {
return null;
}
const session = await prisma.session.findUnique({
where: {
sessionToken,
},
select: {
userId: true,
expires: true,
user: {
select: {
isActive: true,
},
},
},
});
if (!session || session.expires <= new Date() || session.user.isActive === false) {
return null;
}
return session;
};
@@ -9,4 +9,6 @@ export const CLOUD_STRIPE_FEATURE_LOOKUP_KEYS = {
RBAC: "rbac",
SPAM_PROTECTION: "spam-protection",
CONTACTS: "contacts",
AI_SMART_TOOLS: "ai-smart-tools",
AI_DATA_ANALYSIS: "ai-data-analysis",
} as const;
@@ -10,7 +10,7 @@ import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { updateUser } from "./lib/update-user";
const handleError = (err: unknown, url: string): { response: Response } => {
const handleError = (err: unknown, url: string): { response: Response; error?: unknown } => {
if (err instanceof ResourceNotFoundError) {
return { response: responses.notFoundResponse(err.resourceType, err.resourceId) };
}
@@ -20,7 +20,10 @@ const handleError = (err: unknown, url: string): { response: Response } => {
}
logger.error({ error: err, url }, "Error in POST /api/v1/client/[environmentId]/user");
return { response: responses.internalServerErrorResponse("Unable to fetch user state", true) };
return {
response: responses.internalServerErrorResponse("Unable to fetch user state", true),
error: err,
};
};
export const OPTIONS = async (): Promise<Response> => {
@@ -146,7 +146,8 @@ describe("License Core Logic", () => {
sso: true,
saml: true,
spamProtection: true,
ai: false,
aiSmartTools: false,
aiDataAnalysis: false,
auditLogs: true,
accessControl: true,
quotas: true,
@@ -281,7 +282,8 @@ describe("License Core Logic", () => {
whitelabel: false,
removeBranding: false,
contacts: false,
ai: false,
aiSmartTools: false,
aiDataAnalysis: false,
saml: false,
spamProtection: false,
auditLogs: false,
@@ -302,7 +304,8 @@ describe("License Core Logic", () => {
whitelabel: false,
removeBranding: false,
contacts: false,
ai: false,
aiSmartTools: false,
aiDataAnalysis: false,
saml: false,
spamProtection: false,
auditLogs: false,
@@ -332,7 +335,8 @@ describe("License Core Logic", () => {
whitelabel: false,
removeBranding: false,
contacts: false,
ai: false,
aiSmartTools: false,
aiDataAnalysis: false,
saml: false,
spamProtection: false,
auditLogs: false,
@@ -521,7 +525,8 @@ describe("License Core Logic", () => {
sso: true,
saml: true,
spamProtection: true,
ai: false,
aiSmartTools: false,
aiDataAnalysis: false,
auditLogs: true,
accessControl: true,
quotas: true,
@@ -585,7 +590,8 @@ describe("License Core Logic", () => {
sso: true,
saml: true,
spamProtection: true,
ai: false,
aiSmartTools: false,
aiDataAnalysis: false,
auditLogs: true,
accessControl: true,
quotas: true,
@@ -640,7 +646,8 @@ describe("License Core Logic", () => {
sso: true,
saml: true,
spamProtection: true,
ai: false,
aiSmartTools: false,
aiDataAnalysis: false,
auditLogs: true,
accessControl: true,
quotas: true,
@@ -782,7 +789,8 @@ describe("License Core Logic", () => {
sso: true,
saml: true,
spamProtection: true,
ai: true,
aiSmartTools: true,
aiDataAnalysis: true,
auditLogs: true,
accessControl: true,
quotas: true,
@@ -810,7 +818,8 @@ describe("License Core Logic", () => {
sso: true,
saml: true,
spamProtection: true,
ai: true,
aiSmartTools: true,
aiDataAnalysis: true,
auditLogs: true,
accessControl: true,
quotas: true,
@@ -839,7 +848,8 @@ describe("License Core Logic", () => {
whitelabel: false,
removeBranding: false,
contacts: false,
ai: false,
aiSmartTools: false,
aiDataAnalysis: false,
saml: false,
spamProtection: false,
auditLogs: false,
@@ -910,7 +920,8 @@ describe("License Core Logic", () => {
whitelabel: true,
removeBranding: true,
contacts: true,
ai: true,
aiSmartTools: true,
aiDataAnalysis: true,
saml: true,
spamProtection: true,
auditLogs: true,
@@ -978,7 +989,8 @@ describe("License Core Logic", () => {
whitelabel: true,
removeBranding: true,
contacts: true,
ai: true,
aiSmartTools: true,
aiDataAnalysis: true,
saml: true,
spamProtection: true,
auditLogs: true,
@@ -1020,7 +1032,8 @@ describe("License Core Logic", () => {
sso: true,
saml: true,
spamProtection: true,
ai: false,
aiSmartTools: false,
aiDataAnalysis: false,
auditLogs: true,
accessControl: true,
quotas: true,
@@ -1146,7 +1159,8 @@ describe("License Core Logic", () => {
sso: true,
saml: true,
spamProtection: true,
ai: false,
aiSmartTools: false,
aiDataAnalysis: false,
auditLogs: true,
accessControl: true,
quotas: true,
@@ -1267,7 +1281,8 @@ describe("License Core Logic", () => {
whitelabel: true,
removeBranding: true,
contacts: true,
ai: true,
aiSmartTools: true,
aiDataAnalysis: true,
saml: true,
spamProtection: true,
auditLogs: true,
@@ -1322,7 +1337,8 @@ describe("License Core Logic", () => {
whitelabel: true,
removeBranding: true,
contacts: true,
ai: true,
aiSmartTools: true,
aiDataAnalysis: true,
saml: true,
spamProtection: true,
auditLogs: true,
@@ -1377,7 +1393,8 @@ describe("License Core Logic", () => {
whitelabel: true,
removeBranding: true,
contacts: true,
ai: true,
aiSmartTools: true,
aiDataAnalysis: true,
saml: true,
spamProtection: true,
auditLogs: true,
@@ -77,7 +77,8 @@ const LicenseFeaturesSchema = z.object({
whitelabel: z.boolean(),
removeBranding: z.boolean(),
contacts: z.boolean(),
ai: z.boolean(),
aiSmartTools: z.boolean(),
aiDataAnalysis: z.boolean(),
saml: z.boolean(),
spamProtection: z.boolean(),
auditLogs: z.boolean(),
@@ -144,7 +145,8 @@ const DEFAULT_FEATURES: TEnterpriseLicenseFeatures = {
whitelabel: false,
removeBranding: false,
contacts: false,
ai: false,
aiSmartTools: false,
aiDataAnalysis: false,
saml: false,
spamProtection: false,
auditLogs: false,
@@ -9,6 +9,8 @@ import { getEnterpriseLicense, getLicenseFeatures } from "./license";
import {
getAccessControlPermission,
getBiggerUploadFileSizePermission,
getIsAIDataAnalysisEnabled,
getIsAISmartToolsEnabled,
getIsAuditLogsEnabled,
getIsContactsEnabled,
getIsMultiOrgEnabled,
@@ -55,7 +57,8 @@ const defaultFeatures: TEnterpriseLicenseFeatures = {
sso: false,
saml: false,
spamProtection: false,
ai: false,
aiSmartTools: false,
aiDataAnalysis: false,
auditLogs: false,
accessControl: false,
quotas: false,
@@ -194,6 +197,72 @@ describe("License Utils", () => {
expect(access).toBe(true);
expect(quotas).toBe(true);
});
test("uses cloud AI smart tools entitlement", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(hasOrganizationEntitlementWithLicenseGuard).mockResolvedValueOnce(true);
const result = await getIsAISmartToolsEnabled("org_1");
expect(result).toBe(true);
expect(hasOrganizationEntitlementWithLicenseGuard).toHaveBeenCalledWith(
"org_1",
CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.AI_SMART_TOOLS
);
});
test("uses cloud AI data analysis entitlement", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(hasOrganizationEntitlementWithLicenseGuard).mockResolvedValueOnce(true);
const result = await getIsAIDataAnalysisEnabled("org_1");
expect(result).toBe(true);
expect(hasOrganizationEntitlementWithLicenseGuard).toHaveBeenCalledWith(
"org_1",
CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.AI_DATA_ANALYSIS
);
});
test("returns self-hosted AI features from license", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: {
...defaultFeatures,
aiSmartTools: true,
aiDataAnalysis: true,
},
});
const [smartTools, dataAnalysis] = await Promise.all([
getIsAISmartToolsEnabled("org_1"),
getIsAIDataAnalysisEnabled("org_1"),
]);
expect(smartTools).toBe(true);
expect(dataAnalysis).toBe(true);
});
test("returns false for self-hosted AI features when not enabled", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: {
...defaultFeatures,
aiSmartTools: false,
aiDataAnalysis: false,
},
});
const [smartTools, dataAnalysis] = await Promise.all([
getIsAISmartToolsEnabled("org_1"),
getIsAIDataAnalysisEnabled("org_1"),
]);
expect(smartTools).toBe(false);
expect(dataAnalysis).toBe(false);
});
});
describe("getBiggerUploadFileSizePermission", () => {
+14 -1
View File
@@ -29,13 +29,18 @@ const getFeaturePermission = async (
// On Self-hosted: requires active license AND feature enabled in license
const getCustomPlanFeaturePermission = async (
organizationId: string,
featureKey: keyof Pick<TEnterpriseLicenseFeatures, "accessControl" | "quotas" | "contacts">
featureKey: keyof Pick<
TEnterpriseLicenseFeatures,
"accessControl" | "quotas" | "contacts" | "aiSmartTools" | "aiDataAnalysis"
>
): Promise<boolean> => {
if (IS_FORMBRICKS_CLOUD) {
const featureLookupKeyMap: Record<string, string> = {
accessControl: CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.RBAC,
quotas: CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.QUOTA_MANAGEMENT,
contacts: CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.CONTACTS,
aiSmartTools: CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.AI_SMART_TOOLS,
aiDataAnalysis: CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.AI_DATA_ANALYSIS,
};
const lookupKey = featureLookupKeyMap[featureKey];
if (lookupKey) {
@@ -109,6 +114,14 @@ export const getIsQuotasEnabled = async (organizationId: string): Promise<boolea
return getCustomPlanFeaturePermission(organizationId, "quotas");
};
export const getIsAISmartToolsEnabled = async (organizationId: string): Promise<boolean> => {
return getCustomPlanFeaturePermission(organizationId, "aiSmartTools");
};
export const getIsAIDataAnalysisEnabled = async (organizationId: string): Promise<boolean> => {
return getCustomPlanFeaturePermission(organizationId, "aiDataAnalysis");
};
export const getIsAuditLogsEnabled = async (): Promise<boolean> => {
if (!AUDIT_LOG_ENABLED) return false;
return getSpecificFeatureFlag("auditLogs");
@@ -14,7 +14,8 @@ const ZEnterpriseLicenseFeatures = z.object({
sso: z.boolean(),
saml: z.boolean(),
spamProtection: z.boolean(),
ai: z.boolean(),
aiSmartTools: z.boolean(),
aiDataAnalysis: z.boolean(),
auditLogs: z.boolean(),
accessControl: z.boolean(),
quotas: z.boolean(),
@@ -37,7 +37,8 @@ describe("getFirstOrganization", () => {
projects: 3,
},
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
};
vi.mocked(prisma.organization.findFirst).mockResolvedValue(org);
const result = await getFirstOrganization();
+20 -5
View File
@@ -3,7 +3,7 @@ import type { Account } from "next-auth";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import type { TUser, TUserNotificationSettings } from "@formbricks/types/user";
import { createAccount } from "@/lib/account/service";
import { upsertAccount } from "@/lib/account/service";
import { DEFAULT_TEAM_ID, SKIP_INVITE_FOR_SSO } from "@/lib/constants";
import { getIsFreshInstance } from "@/lib/instance/service";
import { verifyInviteToken } from "@/lib/jwt";
@@ -23,6 +23,21 @@ import {
import { getFirstOrganization } from "@/modules/ee/sso/lib/organization";
import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team";
const syncSsoAccount = async (userId: string, account: Account) => {
await upsertAccount({
userId,
type: account.type,
provider: account.provider,
providerAccountId: account.providerAccountId,
...(account.access_token !== undefined ? { access_token: account.access_token } : {}),
...(account.refresh_token !== undefined ? { refresh_token: account.refresh_token } : {}),
...(account.expires_at !== undefined ? { expires_at: account.expires_at } : {}),
...(account.scope !== undefined ? { scope: account.scope } : {}),
...(account.token_type !== undefined ? { token_type: account.token_type } : {}),
...(account.id_token !== undefined ? { id_token: account.id_token } : {}),
});
};
export const handleSsoCallback = async ({
user,
account,
@@ -108,6 +123,7 @@ export const handleSsoCallback = async ({
// User with this provider found
// check if email still the same
if (existingUserWithAccount.email === user.email) {
await syncSsoAccount(existingUserWithAccount.id, account);
contextLogger.debug(
{ existingUserId: existingUserWithAccount.id },
"SSO callback successful: existing user, email matches"
@@ -133,6 +149,7 @@ export const handleSsoCallback = async ({
);
await updateUser(existingUserWithAccount.id, { email: user.email });
await syncSsoAccount(existingUserWithAccount.id, account);
return true;
}
@@ -154,6 +171,7 @@ export const handleSsoCallback = async ({
const existingUserWithEmail = await getUserByEmail(user.email);
if (existingUserWithEmail) {
await syncSsoAccount(existingUserWithEmail.id, account);
contextLogger.debug(
{ existingUserId: existingUserWithEmail.id, action: "existing_user_login" },
"SSO callback successful: existing user found by email"
@@ -342,6 +360,7 @@ export const handleSsoCallback = async ({
// send new user to brevo
createBrevoCustomer({ id: userProfile.id, email: userProfile.email });
await syncSsoAccount(userProfile.id, account);
if (isMultiOrgEnabled) {
contextLogger.debug(
@@ -358,10 +377,6 @@ export const handleSsoCallback = async ({
"Assigning user to organization"
);
await createMembership(organization.id, userProfile.id, { role: "member", accepted: true });
await createAccount({
...account,
userId: userProfile.id,
});
if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
contextLogger.debug(
@@ -45,7 +45,8 @@ export const mockSamlAccount: Account = {
export const mockOrganization: TOrganization = {
id: "org-123",
name: "Test Organization",
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: {
logoUrl: null,
faviconUrl: null,
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import type { TUser } from "@formbricks/types/user";
import { upsertAccount } from "@/lib/account/service";
import { createMembership } from "@/lib/membership/service";
import { createOrganization, getOrganization } from "@/lib/organization/service";
import { findMatchingLocale } from "@/lib/utils/locale";
@@ -62,7 +63,7 @@ vi.mock("@/modules/ee/sso/lib/team", () => ({
}));
vi.mock("@/lib/account/service", () => ({
createAccount: vi.fn(),
upsertAccount: vi.fn(),
}));
vi.mock("@/lib/membership/service", () => ({
@@ -203,6 +204,36 @@ describe("handleSsoCallback", () => {
});
});
test("should not overwrite stored tokens when the provider omits them", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
...mockUser,
email: mockUser.email,
accounts: [{ provider: mockAccount.provider }],
} as any);
const result = await handleSsoCallback({
user: mockUser,
account: {
...mockAccount,
access_token: undefined,
refresh_token: undefined,
expires_at: undefined,
scope: undefined,
token_type: undefined,
id_token: undefined,
},
callbackUrl: "http://localhost:3000",
});
expect(result).toBe(true);
expect(upsertAccount).toHaveBeenCalledWith({
userId: mockUser.id,
type: mockAccount.type,
provider: mockAccount.provider,
providerAccountId: mockAccount.providerAccountId,
});
});
test("should update user email if user with account exists but email changed", async () => {
const existingUser = {
...mockUser,
@@ -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");
});
});
+8 -6
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"),
@@ -125,6 +125,46 @@ describe("hasOrganizationEntitlementWithLicenseGuard", () => {
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "rbac")).toBe(false);
});
test("returns true when license active and ai-smart-tools mapped feature enabled", async () => {
mockGetContext.mockResolvedValue({
...baseContext,
features: ["ai-smart-tools"],
licenseStatus: "active",
licenseFeatures: { aiSmartTools: true } as TOrganizationEntitlementsContext["licenseFeatures"],
});
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "ai-smart-tools")).toBe(true);
});
test("returns false when license active but ai-smart-tools mapped feature disabled", async () => {
mockGetContext.mockResolvedValue({
...baseContext,
features: ["ai-smart-tools"],
licenseStatus: "active",
licenseFeatures: { aiSmartTools: false } as TOrganizationEntitlementsContext["licenseFeatures"],
});
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "ai-smart-tools")).toBe(false);
});
test("returns true when license active and ai-data-analysis mapped feature enabled", async () => {
mockGetContext.mockResolvedValue({
...baseContext,
features: ["ai-data-analysis"],
licenseStatus: "active",
licenseFeatures: { aiDataAnalysis: true } as TOrganizationEntitlementsContext["licenseFeatures"],
});
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "ai-data-analysis")).toBe(true);
});
test("returns false when license active but ai-data-analysis mapped feature disabled", async () => {
mockGetContext.mockResolvedValue({
...baseContext,
features: ["ai-data-analysis"],
licenseStatus: "active",
licenseFeatures: { aiDataAnalysis: false } as TOrganizationEntitlementsContext["licenseFeatures"],
});
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "ai-data-analysis")).toBe(false);
});
test("returns true when license active and feature has no license mapping", async () => {
mockGetContext.mockResolvedValue({
...baseContext,
@@ -10,6 +10,8 @@ const LICENSE_GUARDED_ENTITLEMENTS: Partial<Record<string, keyof TEnterpriseLice
rbac: "accessControl",
"spam-protection": "spamProtection",
contacts: "contacts",
"ai-smart-tools": "aiSmartTools",
"ai-data-analysis": "aiDataAnalysis",
};
const TRIAL_RESTRICTED_ENTITLEMENT_KEYS = [
@@ -99,4 +99,46 @@ describe("getSelfHostedOrganizationEntitlementsContext", () => {
expect(result.features).toContain("hide-branding");
});
test("maps aiSmartTools feature to ai-smart-tools entitlement", async () => {
mockGetOrg.mockResolvedValue({ id: "org1" } as any);
mockGetLicense.mockResolvedValue({
status: "active",
active: true,
features: { aiSmartTools: true },
} as any);
const result = await getSelfHostedOrganizationEntitlementsContext("org1");
expect(result.features).toContain("ai-smart-tools");
expect(result.features).not.toContain("ai-data-analysis");
});
test("maps aiDataAnalysis feature to ai-data-analysis entitlement", async () => {
mockGetOrg.mockResolvedValue({ id: "org1" } as any);
mockGetLicense.mockResolvedValue({
status: "active",
active: true,
features: { aiDataAnalysis: true },
} as any);
const result = await getSelfHostedOrganizationEntitlementsContext("org1");
expect(result.features).toContain("ai-data-analysis");
expect(result.features).not.toContain("ai-smart-tools");
});
test("maps both AI features when both are enabled", async () => {
mockGetOrg.mockResolvedValue({ id: "org1" } as any);
mockGetLicense.mockResolvedValue({
status: "active",
active: true,
features: { aiSmartTools: true, aiDataAnalysis: true },
} as any);
const result = await getSelfHostedOrganizationEntitlementsContext("org1");
expect(result.features).toContain("ai-smart-tools");
expect(result.features).toContain("ai-data-analysis");
});
});

Some files were not shown because too many files have changed in this diff Show More