Compare commits

...

8 Commits

Author SHA1 Message Date
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
81 changed files with 2148 additions and 721 deletions

View File

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

2
.gitignore vendored
View File

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

View File

@@ -247,4 +247,15 @@ 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.
<a id="readme-de"></a>
## Deutsch
Formbricks ist eine freie, quelloffene und datenschutzorientierte Plattform für Surveys und Experience Management. Mit In-App-, Website-, Link- und E-Mail-Umfragen sammelt ihr Feedback entlang der gesamten User Journey.
- Website & Cloud: [formbricks.com](https://formbricks.com/) und [Cloud starten](https://app.formbricks.com/auth/signup)
- Self-Hosting: [Deployment-Dokumentation](https://formbricks.com/docs/self-hosting/deployment)
- Beitrag & Community: [Beitragen](https://formbricks.com/docs/developer-docs/contributing/get-started), [GitHub Discussions](https://github.com/formbricks/formbricks/discussions) und [Issues](https://github.com/formbricks/formbricks/issues)
- Sicherheit & Lizenz: [`SECURITY.md`](./SECURITY.md) und [AGPLv3](https://github.com/formbricks/formbricks/blob/main/LICENSE)
<p align="right"><a href="#top">🔼 Back to top</a></p>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -76,7 +76,8 @@ const mockOrganization: TOrganization = {
},
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
};
const mockSurveys: TSurvey[] = [

View File

@@ -49,7 +49,8 @@ const mockOrganization: TOrganization = {
},
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
};
const mockFollowUp: TSurveyCreateInputWithEnvironmentId["followUps"][number] = {

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

View File

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

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

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

View File

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

View File

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

View File

@@ -37,7 +37,8 @@ describe("auth", () => {
},
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
];
vi.mocked(getOrganizationsByUserId).mockResolvedValue(mockOrganizations);

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({

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"],
});

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: {

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,
},
];

View File

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

View File

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

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

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

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

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

View File

@@ -400,7 +400,6 @@
"show_response_count": "回答数を表示",
"shown": "表示済み",
"size": "サイズ",
"skip": "スキップ",
"skipped": "スキップ済み",
"skips": "スキップ数",
"some_files_failed_to_upload": "一部のファイルのアップロードに失敗しました",
@@ -471,7 +470,6 @@
"website_survey": "ウェブサイトフォーム",
"weeks": "週間",
"welcome_card": "ウェルカムカード",
"workflows": "ワークフロー",
"workspace": "ワークスペース",
"workspace_configuration": "ワークスペース設定",
"workspace_created_successfully": "ワークスペースが正常に作成されました",
@@ -507,7 +505,7 @@
"forgot_password_email_change_password": "パスワードを変更",
"forgot_password_email_did_not_request": "このリクエストに心当たりのない場合は、このメールを無視してください。",
"forgot_password_email_heading": "パスワードを変更",
"forgot_password_email_link_valid_for_24_hours": "このリンクは24時間有効です。",
"forgot_password_email_link_valid_for_24_hours": "このリンクは{minutes}分間有効です。",
"forgot_password_email_subject": "Formbricksのパスワードをリセットしてください",
"forgot_password_email_text": "パスワード変更のリンクがリクエストされました。以下のリンクをクリックして変更できます。",
"hidden_field": "非表示フィールド",
@@ -1131,6 +1129,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 +2619,8 @@
"csat_question_1_headline": "この$[projectName]を友人や同僚に勧める可能性はどのくらいありますか?",
"csat_question_1_lower_label": "可能性が低い",
"csat_question_1_upper_label": "可能性が非常に高い",
"csat_question_2_choice_1": "やや満足",
"csat_question_2_choice_2": "非常に満足",
"csat_question_2_choice_1": "非常に満足",
"csat_question_2_choice_2": "やや満足",
"csat_question_2_choice_3": "満足も不満もない",
"csat_question_2_choice_4": "やや不満",
"csat_question_2_choice_5": "非常に不満",
@@ -3337,18 +3342,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": "フィードバックありがとうございます!"
}
}

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

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

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

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

View File

@@ -400,7 +400,6 @@
"show_response_count": "Показать количество ответов",
"shown": "Показано",
"size": "Размер",
"skip": "Пропустить",
"skipped": "Пропущено",
"skips": "Пропуски",
"some_files_failed_to_upload": "Не удалось загрузить некоторые файлы",
@@ -471,7 +470,6 @@
"website_survey": "Опрос сайта",
"weeks": "недели",
"welcome_card": "Приветственная карточка",
"workflows": "Воркфлоу",
"workspace": "Рабочее пространство",
"workspace_configuration": "Настройка рабочего пространства",
"workspace_created_successfully": "Рабочий проект успешно создан",
@@ -507,7 +505,7 @@
"forgot_password_email_change_password": "Сменить пароль",
"forgot_password_email_did_not_request": "Если вы не запрашивали это, просто проигнорируйте это письмо.",
"forgot_password_email_heading": "Сменить пароль",
"forgot_password_email_link_valid_for_24_hours": "Ссылка действительна в течение 24 часов.",
"forgot_password_email_link_valid_for_24_hours": "Ссылка действительна в течение {minutes} минут.",
"forgot_password_email_subject": "Сбросьте свой пароль Formbricks",
"forgot_password_email_text": "Вы запросили ссылку для смены пароля. Вы можете сделать это, перейдя по ссылке ниже:",
"hidden_field": "Скрытое поле",
@@ -1131,6 +1129,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 +2619,8 @@
"csat_question_1_headline": "Насколько вероятно, что вы порекомендуете $[projectName] другу или коллеге?",
"csat_question_1_lower_label": "Маловероятно",
"csat_question_1_upper_label": "Очень вероятно",
"csat_question_2_choice_1": "В целом доволен",
"csat_question_2_choice_2": "Очень доволен",
"csat_question_2_choice_1": "Очень доволен",
"csat_question_2_choice_2": "В целом доволен",
"csat_question_2_choice_3": "Ни доволен, ни недоволен",
"csat_question_2_choice_4": "В целом недоволен",
"csat_question_2_choice_5": "Очень недоволен",
@@ -3337,18 +3342,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": "Спасибо за твой отзыв!"
}
}

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

View File

@@ -400,7 +400,6 @@
"show_response_count": "显示 响应 计数",
"shown": "显示",
"size": "尺寸",
"skip": "跳过",
"skipped": "跳过",
"skips": "跳过",
"some_files_failed_to_upload": "某些文件上传失败",
@@ -471,7 +470,6 @@
"website_survey": "网站 调查",
"weeks": "周",
"welcome_card": "欢迎 卡片",
"workflows": "工作流",
"workspace": "工作区",
"workspace_configuration": "工作区配置",
"workspace_created_successfully": "工作区创建成功",
@@ -507,7 +505,7 @@
"forgot_password_email_change_password": "更改 密码",
"forgot_password_email_did_not_request": "如果您 未 请求此 项 ,请 忽略 此邮件 。",
"forgot_password_email_heading": "更改 密码",
"forgot_password_email_link_valid_for_24_hours": "链接在 24 小时 内有效。",
"forgot_password_email_link_valid_for_24_hours": "链接在{minutes}分钟内有效。",
"forgot_password_email_subject": "重置您的 Formbricks 密码",
"forgot_password_email_text": "您 已 请求 一个 链接 来 更改 您的 密码。 您 可以 点击 下方 链接 完成 这个 操作:",
"hidden_field": "隐藏字段",
@@ -1131,6 +1129,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 +2619,8 @@
"csat_question_1_headline": "您有多大可能向朋友或同事推荐这款 $[projectName] ",
"csat_question_1_lower_label": "不可能",
"csat_question_1_upper_label": "非常 可能",
"csat_question_2_choice_1": "有点 满意",
"csat_question_2_choice_2": "非常 满意",
"csat_question_2_choice_1": "非常 满意",
"csat_question_2_choice_2": "有点 满意",
"csat_question_2_choice_3": "既不 满意 也 不 不满意",
"csat_question_2_choice_4": "有点 不满意",
"csat_question_2_choice_5": "非常 不 满意",
@@ -3337,18 +3342,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": "感谢你的反馈!"
}
}

View File

@@ -400,7 +400,6 @@
"show_response_count": "顯示回應數",
"shown": "已顯示",
"size": "大小",
"skip": "略過",
"skipped": "已跳過",
"skips": "跳過次數",
"some_files_failed_to_upload": "部分檔案上傳失敗",
@@ -471,7 +470,6 @@
"website_survey": "網站問卷",
"weeks": "週",
"welcome_card": "歡迎卡片",
"workflows": "工作流程",
"workspace": "工作區",
"workspace_configuration": "工作區設定",
"workspace_created_successfully": "工作區已成功建立",
@@ -507,7 +505,7 @@
"forgot_password_email_change_password": "變更密碼",
"forgot_password_email_did_not_request": "如果您沒有要求此操作,請忽略此電子郵件。",
"forgot_password_email_heading": "變更密碼",
"forgot_password_email_link_valid_for_24_hours": "此連結有效期為 24 小時。",
"forgot_password_email_link_valid_for_24_hours": "此連結有效期為 {minutes} 分鐘。",
"forgot_password_email_subject": "重設您的 Formbricks 密碼",
"forgot_password_email_text": "您已請求變更密碼的連結。您可以點擊以下連結來執行此操作:",
"hidden_field": "隱藏欄位",
@@ -1131,6 +1129,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 +2619,8 @@
"csat_question_1_headline": "您向朋友或同事推薦此 {projectName} 的可能性有多高?",
"csat_question_1_lower_label": "不太可能",
"csat_question_1_upper_label": "非常可能",
"csat_question_2_choice_1": "有點滿意",
"csat_question_2_choice_2": "非常滿意",
"csat_question_2_choice_1": "非常滿意",
"csat_question_2_choice_2": "有點滿意",
"csat_question_2_choice_3": "既不滿意也不不滿意",
"csat_question_2_choice_4": "有點不滿意",
"csat_question_2_choice_5": "非常不滿意",
@@ -3337,18 +3342,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": "感謝你的回饋!"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,12 @@ const mapLicenseFeaturesToEntitlements = (
if (features.contacts) {
entitlementKeys.push(CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.CONTACTS);
}
if (features.aiSmartTools) {
entitlementKeys.push(CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.AI_SMART_TOOLS);
}
if (features.aiDataAnalysis) {
entitlementKeys.push(CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.AI_DATA_ANALYSIS);
}
return entitlementKeys;
};

View File

@@ -277,7 +277,8 @@ describe("utils.ts", () => {
updatedAt: new Date("2024-01-02"),
name: "Test Organization",
billing: { stripeCustomerId: null, limits: {}, usageCycleAnchor: new Date() },
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
memberships: [
{
@@ -417,7 +418,8 @@ describe("utils.ts", () => {
updatedAt: new Date("2024-01-02"),
name: "Test Organization",
billing: { stripeCustomerId: null, limits: {}, usageCycleAnchor: new Date() },
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
memberships: [
{
@@ -527,7 +529,8 @@ describe("utils.ts", () => {
updatedAt: new Date("2024-01-02"),
name: "Test Organization",
billing: null,
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
memberships: [
{
@@ -577,7 +580,8 @@ describe("utils.ts", () => {
createdAt: new Date(),
updatedAt: new Date(),
billing: { stripeCustomerId: null, limits: {}, usageCycleAnchor: new Date() },
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
memberships: [], // No membership
},
@@ -660,7 +664,8 @@ describe("utils.ts", () => {
createdAt: new Date(),
updatedAt: new Date(),
billing: { stripeCustomerId: null, limits: {}, usageCycleAnchor: new Date() },
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
memberships: [{ userId: "user123", organizationId: "org123", role: "owner", accepted: true }],
},
@@ -699,7 +704,8 @@ describe("utils.ts", () => {
createdAt: new Date(),
updatedAt: new Date(),
billing: { stripeCustomerId: null, limits: {}, usageCycleAnchor: new Date() },
isAIEnabled: true,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
whitelabel: true,
memberships: [{ userId: "user123", organizationId: "org456", role: "member", accepted: true }],
},

View File

@@ -184,7 +184,8 @@ export const getEnvironmentWithRelations = reactCache(async (environmentId: stri
stripe: true,
},
},
isAIEnabled: true,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
whitelabel: true,
// Current user's membership only (filtered at DB level)
memberships: {
@@ -247,7 +248,8 @@ export const getEnvironmentWithRelations = reactCache(async (environmentId: stri
updatedAt: data.project.organization.updatedAt,
name: data.project.organization.name,
billing: data.project.organization.billing,
isAIEnabled: data.project.organization.isAIEnabled,
isAISmartToolsEnabled: data.project.organization.isAISmartToolsEnabled,
isAIDataAnalysisEnabled: data.project.organization.isAIDataAnalysisEnabled,
whitelabel: data.project.organization.whitelabel,
},
environments: data.project.environments,

View File

@@ -72,6 +72,7 @@ export const SurveyMenuBar = ({
const [lastAutoSaved, setLastAutoSaved] = useState<Date | null>(null);
const isSuccessfullySavedRef = useRef(false);
const isAutoSavingRef = useRef(false);
const isSurveyPublishingRef = useRef(false);
// Refs for interval-based auto-save (to access current values without re-creating interval)
const localSurveyRef = useRef(localSurvey);
@@ -269,8 +270,8 @@ export const SurveyMenuBar = ({
// Skip if tab is not visible (no computation, no API calls for background tabs)
if (document.hidden) return;
// Skip if already saving (manual or auto)
if (isAutoSavingRef.current || isSurveySavingRef.current) return;
// Skip if already saving, publishing, or auto-saving
if (isAutoSavingRef.current || isSurveySavingRef.current || isSurveyPublishingRef.current) return;
// Check for changes using refs (avoids re-creating interval on every change)
const { updatedAt: localUpdatedAt, ...localSurveyRest } = localSurveyRef.current;
@@ -289,10 +290,19 @@ export const SurveyMenuBar = ({
} as unknown as TSurveyDraft);
if (updatedSurveyResponse?.data) {
const savedData = updatedSurveyResponse.data;
// If the segment changed on the server (e.g., private segment was deleted when
// switching from app to link type), update localSurvey to prevent stale segment
// references when publishing
if (!isEqual(localSurveyRef.current.segment, savedData.segment)) {
setLocalSurvey({ ...localSurveyRef.current, segment: savedData.segment });
}
// Update surveyRef (not localSurvey state) to prevent re-renders during auto-save.
// This keeps the UI stable while still tracking that changes have been saved.
// The comparison uses refs, so this prevents unnecessary re-saves.
surveyRef.current = { ...updatedSurveyResponse.data };
surveyRef.current = { ...savedData };
isSuccessfullySavedRef.current = true;
setLastAutoSaved(new Date());
}
@@ -417,11 +427,13 @@ export const SurveyMenuBar = ({
};
const handleSurveyPublish = async () => {
isSurveyPublishingRef.current = true;
setIsSurveyPublishing(true);
const isSurveyValidatedWithZod = validateSurveyWithZod();
if (!isSurveyValidatedWithZod) {
isSurveyPublishingRef.current = false;
setIsSurveyPublishing(false);
return;
}
@@ -429,6 +441,7 @@ export const SurveyMenuBar = ({
try {
const isSurveyValidResult = isSurveyValid(localSurvey, selectedLanguageCode, t, responseCount);
if (!isSurveyValidResult) {
isSurveyPublishingRef.current = false;
setIsSurveyPublishing(false);
return;
}
@@ -445,10 +458,12 @@ export const SurveyMenuBar = ({
if (!publishResult?.data) {
const errorMessage = getFormattedErrorMessage(publishResult);
toast.error(errorMessage);
isSurveyPublishingRef.current = false;
setIsSurveyPublishing(false);
return;
}
isSurveyPublishingRef.current = false;
setIsSurveyPublishing(false);
// Set flag to prevent beforeunload warning during navigation
isSuccessfullySavedRef.current = true;
@@ -456,6 +471,7 @@ export const SurveyMenuBar = ({
} catch (error) {
console.error(error);
toast.error(t("environments.surveys.edit.error_publishing_survey"));
isSurveyPublishingRef.current = false;
setIsSurveyPublishing(false);
}
};

View File

@@ -149,7 +149,8 @@ describe("Survey Editor Library Tests", () => {
features: {},
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
};
beforeEach(() => {

View File

@@ -83,8 +83,13 @@ describe("getOrganizationAIKeys", () => {
});
const mockOrgId = "org_test789";
const mockOrganizationData: { isAIEnabled: boolean; billing: TOrganizationBilling } = {
isAIEnabled: true,
const mockOrganizationData: {
isAISmartToolsEnabled: boolean;
isAIDataAnalysisEnabled: boolean;
billing: TOrganizationBilling;
} = {
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
billing: {
stripeCustomerId: null,
usageCycleAnchor: new Date(),
@@ -106,7 +111,8 @@ describe("getOrganizationAIKeys", () => {
id: mockOrgId,
},
select: {
isAIEnabled: true,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
billing: {
select: {
stripeCustomerId: true,

View File

@@ -30,14 +30,21 @@ export const getOrganizationIdFromEnvironmentId = reactCache(
);
export const getOrganizationAIKeys = reactCache(
async (organizationId: string): Promise<{ isAIEnabled: boolean; billing: TOrganizationBilling } | null> => {
async (
organizationId: string
): Promise<{
isAISmartToolsEnabled: boolean;
isAIDataAnalysisEnabled: boolean;
billing: TOrganizationBilling;
} | null> => {
try {
const organization = await prisma.organization.findUnique({
where: {
id: organizationId,
},
select: {
isAIEnabled: true,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
billing: {
select: {
stripeCustomerId: true,
@@ -54,7 +61,8 @@ export const getOrganizationAIKeys = reactCache(
}
return {
isAIEnabled: organization.isAIEnabled,
isAISmartToolsEnabled: organization.isAISmartToolsEnabled,
isAIDataAnalysisEnabled: organization.isAIDataAnalysisEnabled,
billing: {
stripeCustomerId: organization.billing.stripeCustomerId,
limits: organization.billing.limits as TOrganizationBilling["limits"],

View File

@@ -202,7 +202,7 @@ function getLanguageCode(langParam: string | undefined, survey: TSurvey): string
const selectedLanguage = survey.languages.find((surveyLanguage) => {
return (
surveyLanguage.language.code === langParam.toLowerCase() ||
surveyLanguage.language.code.toLowerCase() === langParam.toLowerCase() ||
surveyLanguage.language.alias?.toLowerCase() === langParam.toLowerCase()
);
});

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
-- Step 1: Add new columns
ALTER TABLE "Organization" ADD COLUMN "isAISmartToolsEnabled" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Organization" ADD COLUMN "isAIDataAnalysisEnabled" BOOLEAN NOT NULL DEFAULT false;
-- Step 2: Migrate existing data -- organizations that had AI enabled get both new flags set to true
UPDATE "Organization" SET "isAISmartToolsEnabled" = "isAIEnabled", "isAIDataAnalysisEnabled" = "isAIEnabled";
-- Step 3: Drop old column
ALTER TABLE "Organization" DROP COLUMN "isAIEnabled";

View File

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

View File

@@ -661,21 +661,23 @@ model Project {
/// @property projects - Collection of projects owned by the organization
/// @property billing - JSON field containing billing information
/// @property whitelabel - Whitelabel configuration for the organization
/// @property isAIEnabled - Controls access to AI-powered features
/// @property isAISmartToolsEnabled - Controls access to AI smart tools (e.g. translations) that never touch collected data
/// @property isAIDataAnalysisEnabled - Controls access to AI data analysis features that touch experience data
model Organization {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
name String
memberships Membership[]
projects Project[]
billing OrganizationBilling?
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
name String
memberships Membership[]
projects Project[]
billing OrganizationBilling?
/// [OrganizationWhitelabel]
whitelabel Json @default("{}")
invites Invite[]
isAIEnabled Boolean @default(false)
teams Team[]
apiKeys ApiKey[]
whitelabel Json @default("{}")
invites Invite[]
isAISmartToolsEnabled Boolean @default(false)
isAIDataAnalysisEnabled Boolean @default(false)
teams Team[]
apiKeys ApiKey[]
}
/// Stores billing and Stripe synchronization data for an organization.
@@ -879,6 +881,20 @@ model VerificationToken {
@@unique([identifier, token])
}
/// Stores the active password reset token for a user.
/// Tokens are opaque, hashed at rest, revocable, and single-use.
model PasswordResetToken {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
tokenHash String @unique @map(name: "token_hash")
expiresAt DateTime @map(name: "expires_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String @unique
@@index([expiresAt])
}
/// Represents a user in the Formbricks system.
/// Central model for user authentication and profile management.
///
@@ -897,25 +913,26 @@ model User {
emailVerified DateTime? @map(name: "email_verified")
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
twoFactorEnabled Boolean @default(false)
backupCodes String?
password String?
identityProvider IdentityProvider @default(email)
identityProvider IdentityProvider @default(email)
identityProviderAccountId String?
memberships Membership[]
accounts Account[]
sessions Session[]
passwordResetToken PasswordResetToken?
groupId String?
invitesCreated Invite[] @relation("inviteCreatedBy")
invitesAccepted Invite[] @relation("inviteAcceptedBy")
invitesCreated Invite[] @relation("inviteCreatedBy")
invitesAccepted Invite[] @relation("inviteAcceptedBy")
/// [UserNotificationSettings]
notificationSettings Json @default("{}")
notificationSettings Json @default("{}")
/// [Locale]
locale String @default("en-US")
locale String @default("en-US")
surveys Survey[]
teamUsers TeamUser[]
lastLoginAt DateTime?
isActive Boolean @default(true)
isActive Boolean @default(true)
}
/// Defines a segment of contacts based on attributes.

View File

@@ -71,5 +71,6 @@ export const ZOrganization = z.object({
updatedAt: z.coerce.date(),
name: z.string(),
whitelabel: ZOrganizationWhiteLabel,
isAIEnabled: z.boolean().default(false) as z.ZodType<Organization["isAIEnabled"]>,
isAISmartToolsEnabled: z.boolean().default(false) as z.ZodType<Organization["isAISmartToolsEnabled"]>,
isAIDataAnalysisEnabled: z.boolean().default(false) as z.ZodType<Organization["isAIDataAnalysisEnabled"]>,
}) satisfies z.ZodType<Organization>;

View File

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

View File

@@ -12,6 +12,7 @@ export const exampleData = {
forgotPasswordEmail: {
verifyLink: "https://app.formbricks.com/auth/forgot-password/reset?token=example-reset-token",
linkValidityInMinutes: 30,
},
newEmailVerification: {
@@ -132,7 +133,8 @@ export const exampleData = {
},
},
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
} as unknown as TOrganization,
},

View File

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

View File

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

View File

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

View File

@@ -85,7 +85,8 @@ export const ZOrganization = z.object({
}),
whitelabel: ZOrganizationWhitelabel.optional(),
billing: ZOrganizationBilling,
isAIEnabled: z.boolean().prefault(false),
isAISmartToolsEnabled: z.boolean().prefault(false),
isAIDataAnalysisEnabled: z.boolean().prefault(false),
});
export const ZOrganizationCreateInput = z.object({
@@ -99,7 +100,8 @@ export const ZOrganizationUpdateInput = z.object({
name: z.string(),
whitelabel: ZOrganizationWhitelabel.optional(),
billing: ZOrganizationBilling.optional(),
isAIEnabled: z.boolean().optional(),
isAISmartToolsEnabled: z.boolean().optional(),
isAIDataAnalysisEnabled: z.boolean().optional(),
});
export type TOrganizationUpdateInput = z.infer<typeof ZOrganizationUpdateInput>;

View File

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