Compare commits

...

9 Commits

Author SHA1 Message Date
Johannes f8baeb7860 strip current multi-lang editing out of survey editor 2026-04-01 16:06:53 +02:00
Dhruwang Jariwala 5bb8119ebf feat: split AI toggle into smart tools and data analysis settings (#7563) 2026-03-31 11:23:51 +00:00
Johannes 02411277d4 revert: remove fake-door workflows experiment (#7392) (#7631)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-03-31 10:47:33 +00:00
Dhruwang Jariwala 4cfb8c6d7b fix: resolve language code case mismatch in link survey rendering (#7624)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 11:34:20 +00:00
Anshuman Pandey e74a51a5ff fix: sync segment state after auto-save to prevent stale reference on publish (#7619) 2026-03-30 06:51:44 +00:00
Dhruwang Jariwala 29cc6a10fe fix: prevent auto-save from overwriting survey status during publish (#7618) 2026-03-30 06:34:20 +00:00
Bhagya Amarasinghe 01f765e969 fix: migrate auth sessions to database-backed storage (#7594) 2026-03-27 07:15:06 +00:00
Anshuman Pandey 9366960f18 feat: adds support for internal webhook urls (#7577) 2026-03-27 07:04:14 +00:00
IllimarR 697dc9cc99 feat: add Estonian language support for surveys (#7574)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-27 06:12:40 +00:00
112 changed files with 2136 additions and 2474 deletions
+5
View File
@@ -185,6 +185,11 @@ ENTERPRISE_LICENSE_KEY=
# Ignore Rate Limiting across the Formbricks app
# RATE_LIMITING_DISABLED=1
# Allow webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x)
# WARNING: Only enable this if you understand the SSRF risks. Useful for self-hosted instances
# that need to send webhooks to internal services.
# DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
@@ -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 = [
@@ -3,13 +3,41 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import type { TOrganizationRole } from "@formbricks/types/memberships";
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
async function updateOrganizationAction<T extends z.ZodRawShape>({
ctx,
organizationId,
schema,
data,
roles,
}: {
ctx: AuthenticatedActionClientCtx;
organizationId: string;
schema: z.ZodObject<T>;
data: z.infer<z.ZodObject<T>>;
roles: TOrganizationRole[];
}) {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [{ type: "organization", schema, data, roles }],
});
ctx.auditLoggingCtx.organizationId = organizationId;
const oldObject = await getOrganization(organizationId);
const result = await updateOrganization(organizationId, data);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
const ZUpdateOrganizationNameAction = z.object({
organizationId: ZId,
data: ZOrganizationUpdateInput.pick({ name: true }),
@@ -18,26 +46,55 @@ const ZUpdateOrganizationNameAction = z.object({
export const updateOrganizationNameAction = authenticatedActionClient
.inputSchema(ZUpdateOrganizationNameAction)
.action(
withAuditLogging("updated", "organization", async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
schema: ZOrganizationUpdateInput.pick({ name: true }),
data: parsedInput.data,
roles: ["owner"],
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
})
withAuditLogging(
"updated",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateOrganizationNameAction>;
}) =>
updateOrganizationAction({
ctx,
organizationId: parsedInput.organizationId,
schema: ZOrganizationUpdateInput.pick({ name: true }),
data: parsedInput.data,
roles: ["owner"],
})
)
);
const ZUpdateOrganizationAISettingsAction = z.object({
organizationId: ZId,
data: ZOrganizationUpdateInput.pick({ isAISmartToolsEnabled: true, isAIDataAnalysisEnabled: true }),
});
export const updateOrganizationAISettingsAction = authenticatedActionClient
.inputSchema(ZUpdateOrganizationAISettingsAction)
.action(
withAuditLogging(
"updated",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateOrganizationAISettingsAction>;
}) =>
updateOrganizationAction({
ctx,
organizationId: parsedInput.organizationId,
schema: ZOrganizationUpdateInput.pick({
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
}),
data: parsedInput.data,
roles: ["owner", "manager"],
})
)
);
const ZDeleteOrganizationAction = z.object({
@@ -0,0 +1,84 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { updateOrganizationAISettingsAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
interface AISettingsToggleProps {
organization: TOrganization;
membershipRole?: TOrganizationRole;
}
export const AISettingsToggle = ({ organization, membershipRole }: Readonly<AISettingsToggleProps>) => {
const [loadingField, setLoadingField] = useState<string | null>(null);
const { t } = useTranslation();
const router = useRouter();
const { isOwner, isManager } = getAccessFlags(membershipRole);
const canEdit = isOwner || isManager;
const handleToggle = async (
field: "isAISmartToolsEnabled" | "isAIDataAnalysisEnabled",
checked: boolean
) => {
setLoadingField(field);
try {
const response = await updateOrganizationAISettingsAction({
organizationId: organization.id,
data: { [field]: checked },
});
if (response?.data) {
toast.success(t("environments.settings.general.ai_settings_updated_successfully"));
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(response);
toast.error(errorMessage);
}
} catch {
toast.error(t("common.something_went_wrong"));
} finally {
setLoadingField(null);
}
};
return (
<div>
<AdvancedOptionToggle
isChecked={organization.isAISmartToolsEnabled}
onToggle={(checked) => handleToggle("isAISmartToolsEnabled", checked)}
htmlId="ai-smart-tools-toggle"
title={t("environments.settings.general.ai_smart_tools_enabled")}
description={t("environments.settings.general.ai_smart_tools_enabled_description")}
disabled={loadingField !== null || !canEdit}
customContainerClass="px-0"
/>
<AdvancedOptionToggle
isChecked={organization.isAIDataAnalysisEnabled}
onToggle={(checked) => handleToggle("isAIDataAnalysisEnabled", checked)}
htmlId="ai-data-analysis-toggle"
title={t("environments.settings.general.ai_data_analysis_enabled")}
description={t("environments.settings.general.ai_data_analysis_enabled_description")}
disabled={loadingField !== null || !canEdit}
customContainerClass="px-0"
/>
{!canEdit && (
<Alert variant="warning">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>
</Alert>
)}
</div>
);
};
@@ -11,6 +11,7 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
import { PageHeader } from "@/modules/ui/components/page-header";
import packageJson from "@/package.json";
import { SettingsCard } from "../../components/SettingsCard";
import { AISettingsToggle } from "./components/AISettingsToggle";
import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
import { SecurityListTip } from "./components/SecurityListTip";
@@ -60,6 +61,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
membershipRole={currentUserMembership?.role}
/>
</SettingsCard>
<SettingsCard
title={t("environments.settings.general.ai_enabled")}
description={t("environments.settings.general.ai_enabled_description")}>
<AISettingsToggle organization={organization} membershipRole={currentUserMembership?.role} />
</SettingsCard>
<EmailCustomizationSettings
organization={organization}
hasWhiteLabelPermission={hasWhiteLabelPermission}
@@ -1,208 +0,0 @@
"use client";
import { CheckCircle2, Sparkles } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
const FORMBRICKS_HOST = "https://app.formbricks.com";
const SURVEY_ID = "cr9r4b2r73x6hlmn5aa2ha44";
const ENVIRONMENT_ID = "cmk41i8bi92bdad01svi74dec";
interface WorkflowsPageProps {
userEmail: string;
organizationName: string;
billingPlan: string;
}
type Step = "prompt" | "followup" | "thankyou";
export const WorkflowsPage = ({ userEmail, organizationName, billingPlan }: WorkflowsPageProps) => {
const { t } = useTranslation();
const [step, setStep] = useState<Step>("prompt");
const [promptValue, setPromptValue] = useState("");
const [detailsValue, setDetailsValue] = useState("");
const [responseId, setResponseId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleGenerateWorkflow = async () => {
if (promptValue.trim().length < 100 || isSubmitting) return;
setIsSubmitting(true);
try {
const res = await fetch(`${FORMBRICKS_HOST}/api/v2/client/${ENVIRONMENT_ID}/responses`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
surveyId: SURVEY_ID,
finished: false,
data: {
workflow: promptValue.trim(),
useremail: userEmail,
orgname: organizationName,
billingplan: billingPlan,
},
}),
});
if (res.ok) {
const json = await res.json();
setResponseId(json.data?.id ?? null);
}
setStep("followup");
} catch {
setStep("followup");
} finally {
setIsSubmitting(false);
}
};
const handleSubmitFeedback = async () => {
if (isSubmitting) return;
setIsSubmitting(true);
if (responseId) {
try {
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
finished: true,
data: {
details: detailsValue.trim(),
},
}),
});
} catch {
// silently fail
}
}
setIsSubmitting(false);
setStep("thankyou");
};
const handleSkipFeedback = async () => {
if (!responseId) {
setStep("thankyou");
return;
}
try {
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
finished: true,
data: {},
}),
});
} catch {
// silently fail
}
setStep("thankyou");
};
if (step === "prompt") {
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-2xl space-y-8">
<div className="space-y-3 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-brand-light to-brand-dark shadow-md">
<Sparkles className="h-6 w-6 text-white" />
</div>
<h1 className="text-4xl font-bold tracking-tight text-slate-800">{t("workflows.heading")}</h1>
<p className="text-lg text-slate-500">{t("workflows.subheading")}</p>
</div>
<div className="relative">
<textarea
value={promptValue}
onChange={(e) => setPromptValue(e.target.value)}
placeholder={t("workflows.placeholder")}
rows={5}
className="w-full resize-none rounded-xl border border-slate-200 bg-white px-5 py-4 text-base text-slate-800 shadow-sm transition-all placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-brand-light/20"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
handleGenerateWorkflow();
}
}}
/>
<div className="mt-3 flex items-center justify-between">
<span
className={`text-xs ${promptValue.trim().length >= 100 ? "text-slate-400" : "text-amber-500"}`}>
{promptValue.trim().length} / 100
</span>
<Button
onClick={handleGenerateWorkflow}
disabled={promptValue.trim().length < 100 || isSubmitting}
loading={isSubmitting}
size="lg">
<Sparkles className="h-4 w-4" />
{t("workflows.generate_button")}
</Button>
</div>
</div>
</div>
</div>
);
}
if (step === "followup") {
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-2xl space-y-8">
<div className="space-y-3 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100">
<Sparkles className="h-6 w-6 text-brand-dark" />
</div>
<h1 className="text-3xl font-bold tracking-tight text-slate-800">
{t("workflows.coming_soon_title")}
</h1>
<p className="mx-auto max-w-md text-base text-slate-500">
{t("workflows.coming_soon_description")}
</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<label className="text-md mb-2 block font-medium text-slate-700">
{t("workflows.follow_up_label")}
</label>
<textarea
value={detailsValue}
onChange={(e) => setDetailsValue(e.target.value)}
placeholder={t("workflows.follow_up_placeholder")}
rows={4}
className="w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-800 transition-all placeholder:text-slate-400 focus:border-brand-dark focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand-light/20"
/>
<div className="mt-4 flex items-center justify-end gap-3">
<Button variant="ghost" onClick={handleSkipFeedback} className="text-slate-500">
{t("common.skip")}
</Button>
<Button
onClick={handleSubmitFeedback}
disabled={!detailsValue.trim() || isSubmitting}
loading={isSubmitting}>
{t("workflows.submit_button")}
</Button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-md space-y-6 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-50">
<CheckCircle2 className="h-8 w-8 text-green-500" />
</div>
<h1 className="text-2xl font-bold text-slate-800">{t("workflows.thank_you_title")}</h1>
<p className="text-base text-slate-500">{t("workflows.thank_you_description")}</p>
</div>
</div>
);
};
@@ -1,42 +0,0 @@
import { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getCloudBillingDisplayContext } from "@/modules/ee/billing/lib/cloud-billing-display";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { WorkflowsPage } from "./components/workflows-page";
export const metadata: Metadata = {
title: "Workflows",
};
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
if (!IS_FORMBRICKS_CLOUD) {
return notFound();
}
const { session, organization, isBilling } = await getEnvironmentAuth(params.environmentId);
if (isBilling) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
}
const user = await getUser(session.user.id);
if (!user) {
return redirect("/auth/login");
}
const cloudBillingDisplayContext = await getCloudBillingDisplayContext(organization.id);
return (
<WorkflowsPage
userEmail={user.email}
organizationName={organization.name}
billingPlan={cloudBillingDisplayContext.currentCloudPlan}
/>
);
};
export default Page;
@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { GET } from "./route";
const mocks = vi.hoisted(() => {
const nextAuthHandler = vi.fn(async () => new Response(null, { status: 200 }));
const nextAuth = vi.fn(() => nextAuthHandler);
return {
nextAuth,
nextAuthHandler,
baseSignIn: vi.fn(async () => true),
baseSession: vi.fn(async ({ session }: { session: unknown }) => session),
baseEventSignIn: vi.fn(),
queueAuditEventBackground: vi.fn(),
captureException: vi.fn(),
loggerError: vi.fn(),
};
});
vi.mock("next-auth", () => ({
default: mocks.nextAuth,
}));
vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: undefined,
}));
vi.mock("@sentry/nextjs", () => ({
captureException: mocks.captureException,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: mocks.loggerError,
})),
},
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {
callbacks: {
signIn: mocks.baseSignIn,
session: mocks.baseSession,
},
events: {
signIn: mocks.baseEventSignIn,
},
},
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventBackground: mocks.queueAuditEventBackground,
}));
const getWrappedAuthOptions = async (requestId: string = "req-123") => {
const request = new Request("http://localhost/api/auth/signin", {
headers: { "x-request-id": requestId },
});
await GET(request, {} as any);
expect(mocks.nextAuth).toHaveBeenCalledTimes(1);
return mocks.nextAuth.mock.calls[0][0];
};
describe("auth route audit logging", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
test("logs successful sign-in from the NextAuth signIn event after session creation", async () => {
const authOptions = await getWrappedAuthOptions();
const user = { id: "user_1", email: "user@example.com", name: "User Example" };
const account = { provider: "keycloak" };
await expect(authOptions.callbacks.signIn({ user, account })).resolves.toBe(true);
expect(mocks.queueAuditEventBackground).not.toHaveBeenCalled();
await authOptions.events.signIn({ user, account, isNewUser: false });
expect(mocks.baseEventSignIn).toHaveBeenCalledWith({ user, account, isNewUser: false });
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "signedIn",
targetType: "user",
userId: "user_1",
targetId: "user_1",
organizationId: "unknown",
status: "success",
userType: "user",
newObject: expect.objectContaining({
email: "user@example.com",
authMethod: "sso",
provider: "keycloak",
sessionStrategy: "database",
isNewUser: false,
}),
})
);
});
test("logs failed sign-in attempts from the callback stage with the request event id", async () => {
const error = new Error("Access denied");
mocks.baseSignIn.mockRejectedValueOnce(error);
const authOptions = await getWrappedAuthOptions("req-failure");
const user = { id: "user_2", email: "user2@example.com" };
const account = { provider: "credentials" };
await expect(authOptions.callbacks.signIn({ user, account })).rejects.toThrow("Access denied");
expect(mocks.baseEventSignIn).not.toHaveBeenCalled();
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith(
expect.objectContaining({
action: "signedIn",
targetType: "user",
userId: "user_2",
targetId: "user_2",
organizationId: "unknown",
status: "failure",
userType: "user",
eventId: "req-failure",
newObject: expect.objectContaining({
email: "user2@example.com",
authMethod: "password",
provider: "credentials",
errorMessage: "Access denied",
}),
})
);
});
});
+67 -68
View File
@@ -6,10 +6,26 @@ import { logger } from "@formbricks/logger";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { authOptions as baseAuthOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
export const fetchCache = "force-no-store";
const getAuthMethod = (account: Account | null) => {
if (account?.provider === "credentials") {
return "password";
}
if (account?.provider === "token") {
return "email_verification";
}
if (account?.provider) {
return "sso";
}
return "unknown";
};
const handler = async (req: Request, ctx: any) => {
const eventId = req.headers.get("x-request-id") ?? undefined;
@@ -17,44 +33,6 @@ const handler = async (req: Request, ctx: any) => {
...baseAuthOptions,
callbacks: {
...baseAuthOptions.callbacks,
async jwt(params: any) {
let result: any = params.token;
let error: any = undefined;
try {
if (baseAuthOptions.callbacks?.jwt) {
result = await baseAuthOptions.callbacks.jwt(params);
}
} catch (err) {
error = err;
logger.withContext({ eventId, err }).error("JWT callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
// Audit JWT operations (token refresh, updates)
if (params.trigger && params.token?.profile?.id) {
const status: TAuditStatus = error ? "failure" : "success";
const auditLog = {
action: "jwtTokenCreated" as const,
targetType: "user" as const,
userId: params.token.profile.id,
targetId: params.token.profile.id,
organizationId: UNKNOWN_DATA,
status,
userType: "user" as const,
newObject: { trigger: params.trigger, tokenType: "jwt" },
...(error ? { eventId } : {}),
};
queueAuditEventBackground(auditLog);
}
if (error) throw error;
return result;
},
async session(params: any) {
let result: any = params.session;
let error: any = undefined;
@@ -90,7 +68,7 @@ const handler = async (req: Request, ctx: any) => {
}) {
let result: boolean | string = true;
let error: any = undefined;
let authMethod = "unknown";
const authMethod = getAuthMethod(account);
try {
if (baseAuthOptions.callbacks?.signIn) {
@@ -102,15 +80,6 @@ const handler = async (req: Request, ctx: any) => {
credentials,
});
}
// Determine authentication method for more detailed logging
if (account?.provider === "credentials") {
authMethod = "password";
} else if (account?.provider === "token") {
authMethod = "email_verification";
} else if (account?.provider && account.provider !== "credentials") {
authMethod = "sso";
}
} catch (err) {
error = err;
result = false;
@@ -122,30 +91,60 @@ const handler = async (req: Request, ctx: any) => {
}
}
const status: TAuditStatus = result === false ? "failure" : "success";
const auditLog = {
action: "signedIn" as const,
targetType: "user" as const,
userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA,
status,
userType: "user" as const,
newObject: {
...user,
authMethod,
provider: account?.provider,
...(error ? { errorMessage: error.message } : {}),
},
...(status === "failure" ? { eventId } : {}),
};
queueAuditEventBackground(auditLog);
if (result === false) {
queueAuditEventBackground({
action: "signedIn",
targetType: "user",
userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA,
status: "failure",
userType: "user",
newObject: {
...user,
authMethod,
provider: account?.provider,
...(error instanceof Error ? { errorMessage: error.message } : {}),
},
eventId,
});
}
if (error) throw error;
return result;
},
},
events: {
...baseAuthOptions.events,
async signIn({ user, account, isNewUser }: any) {
try {
await baseAuthOptions.events?.signIn?.({ user, account, isNewUser });
} catch (err) {
logger.withContext({ eventId, err }).error("Sign-in event callback failed");
if (SENTRY_DSN && IS_PRODUCTION) {
Sentry.captureException(err);
}
}
queueAuditEventBackground({
action: "signedIn",
targetType: "user",
userId: user?.id ?? UNKNOWN_DATA,
targetId: user?.id ?? UNKNOWN_DATA,
organizationId: UNKNOWN_DATA,
status: "success",
userType: "user",
newObject: {
...user,
authMethod: getAuthMethod(account),
provider: account?.provider,
sessionStrategy: "database",
isNewUser: isNewUser ?? false,
},
});
},
},
};
return NextAuth(authOptions)(req, ctx);
@@ -76,7 +76,8 @@ const mockOrganization: TOrganization = {
},
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
};
const mockSurveys: TSurvey[] = [
@@ -49,7 +49,8 @@ const mockOrganization: TOrganization = {
},
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
};
const mockFollowUp: TSurveyCreateInputWithEnvironmentId["followUps"][number] = {
+7 -13
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
@@ -3182,14 +3187,3 @@ checksums:
templates/usability_question_9_headline: 5850229e97ae97698ce90b330ea49682
templates/usability_rating_description: 8c4f3818fe830ae544611f816265f1a1
templates/usability_score_name: 5cbf1172d24dfcb17d979dff6dfdf7e2
workflows/coming_soon_description: 1e0621d287924d84fb539afab7372b23
workflows/coming_soon_title: d79be80559c70c828cf20811d2ed5039
workflows/follow_up_label: ead918852c5840636a14baabfe94821e
workflows/follow_up_placeholder: f680918bec28192282e229c3d4b5e80a
workflows/generate_button: b194b6172a49af8374a19dd2cf39cfdc
workflows/heading: a98a6b14d3e955f38cc16386df9a4111
workflows/placeholder: f5d943582bf25e8734930844e598457b
workflows/subheading: ebf5e3b3aeb85e13e843358cc5476f42
workflows/submit_button: 7a062f2de02ce60b1d73e510ff1ca094
workflows/thank_you_description: 7623c1ba4f059c8d9e68aae3360b20b1
workflows/thank_you_title: 07edd8c50685a52c0969d711df26d768
+97
View File
@@ -0,0 +1,97 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { upsertAccount } from "./service";
const { mockUpsert } = vi.hoisted(() => ({
mockUpsert: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
account: {
upsert: mockUpsert,
},
},
}));
describe("account service", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("upsertAccount keeps user ownership immutable on update", async () => {
const accountData = {
userId: "user-1",
type: "oauth",
provider: "google",
providerAccountId: "provider-1",
access_token: "access-token",
refresh_token: "refresh-token",
expires_at: 123,
scope: "openid email",
token_type: "Bearer",
id_token: "id-token",
};
mockUpsert.mockResolvedValue({
id: "account-1",
createdAt: new Date(),
updatedAt: new Date(),
...accountData,
});
await upsertAccount(accountData);
expect(mockUpsert).toHaveBeenCalledWith({
where: {
provider_providerAccountId: {
provider: "google",
providerAccountId: "provider-1",
},
},
create: accountData,
update: {
access_token: "access-token",
refresh_token: "refresh-token",
expires_at: 123,
scope: "openid email",
token_type: "Bearer",
id_token: "id-token",
},
});
});
test("upsertAccount wraps Prisma known request errors", async () => {
const prismaError = Object.assign(Object.create(Prisma.PrismaClientKnownRequestError.prototype), {
message: "duplicate account",
});
mockUpsert.mockRejectedValue(prismaError);
await expect(
upsertAccount({
userId: "user-1",
type: "oauth",
provider: "google",
providerAccountId: "provider-1",
})
).rejects.toMatchObject({
name: "DatabaseError",
message: "duplicate account",
});
});
test("upsertAccount rethrows non-Prisma errors", async () => {
const error = new Error("unexpected failure");
mockUpsert.mockRejectedValue(error);
await expect(
upsertAccount({
userId: "user-1",
type: "oauth",
provider: "google",
providerAccountId: "provider-1",
})
).rejects.toThrow("unexpected failure");
});
});
+33
View File
@@ -20,3 +20,36 @@ export const createAccount = async (accountData: TAccountInput): Promise<TAccoun
throw error;
}
};
export const upsertAccount = async (accountData: TAccountInput): Promise<TAccount> => {
const [validatedAccountData] = validateInputs([accountData, ZAccountInput]);
const updateAccountData: Omit<TAccountInput, "userId" | "type" | "provider" | "providerAccountId"> = {
access_token: validatedAccountData.access_token,
refresh_token: validatedAccountData.refresh_token,
expires_at: validatedAccountData.expires_at,
scope: validatedAccountData.scope,
token_type: validatedAccountData.token_type,
id_token: validatedAccountData.id_token,
};
try {
const account = await prisma.account.upsert({
where: {
provider_providerAccountId: {
provider: validatedAccountData.provider,
providerAccountId: validatedAccountData.providerAccountId,
},
},
create: validatedAccountData,
update: updateAccountData,
});
return account;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
+1
View File
@@ -26,6 +26,7 @@ export const TERMS_URL = env.TERMS_URL;
export const IMPRINT_URL = env.IMPRINT_URL;
export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
export const DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS = env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS === "1";
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
+2
View File
@@ -15,6 +15,7 @@ export const env = createEnv({
BREVO_API_KEY: z.string().optional(),
BREVO_LIST_ID: z.string().optional(),
DATABASE_URL: z.url(),
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
DEBUG: z.enum(["1", "0"]).optional(),
AUTH_DEFAULT_TEAM_ID: z.string().optional(),
AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(),
@@ -141,6 +142,7 @@ export const env = createEnv({
BREVO_LIST_ID: process.env.BREVO_LIST_ID,
CRON_SECRET: process.env.CRON_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: process.env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS,
DEBUG: process.env.DEBUG,
AUTH_DEFAULT_TEAM_ID: process.env.AUTH_SSO_DEFAULT_TEAM_ID,
AUTH_SKIP_INVITE_FOR_SSO: process.env.AUTH_SKIP_INVITE_FOR_SSO,
+3 -1
View File
@@ -84,7 +84,9 @@ export const extractLanguageIds = (languages: TLanguage[]): string[] => {
export const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode: string | null) => {
if (!surveyLanguages?.length || !languageCode) return "default";
const language = surveyLanguages.find((surveyLanguage) => surveyLanguage.language.code === languageCode);
const language = surveyLanguages.find(
(surveyLanguage) => surveyLanguage.language.code.toLowerCase() === languageCode.toLowerCase()
);
return language?.default ? "default" : language?.language.code || "default";
};
+2 -1
View File
@@ -37,7 +37,8 @@ describe("auth", () => {
},
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
];
vi.mocked(getOrganizationsByUserId).mockResolvedValue(mockOrganizations);
+10 -5
View File
@@ -72,7 +72,8 @@ describe("Organization Service", () => {
stripeCustomerId: null,
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
};
@@ -124,7 +125,8 @@ describe("Organization Service", () => {
stripeCustomerId: null,
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
},
];
@@ -176,7 +178,8 @@ describe("Organization Service", () => {
createdAt: new Date(),
updatedAt: new Date(),
billing: expectedBilling,
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
};
@@ -235,7 +238,8 @@ describe("Organization Service", () => {
stripeCustomerId: null,
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
memberships: [{ userId: "user1" }, { userId: "user2" }],
projects: [
@@ -276,7 +280,8 @@ describe("Organization Service", () => {
stripeCustomerId: null,
usageCycleAnchor: expect.any(Date),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: false,
});
expect(prisma.organization.update).toHaveBeenCalledWith({
+4 -2
View File
@@ -34,7 +34,8 @@ export const select = {
stripe: true,
},
},
isAIEnabled: true,
isAISmartToolsEnabled: true,
isAIDataAnalysisEnabled: true,
whitelabel: true,
} satisfies Prisma.OrganizationSelect;
@@ -72,7 +73,8 @@ const mapOrganization = (organization: TOrganizationWithBilling): TOrganization
updatedAt: organization.updatedAt,
name: organization.name,
billing: mapOrganizationBilling(organization.billing),
isAIEnabled: organization.isAIEnabled,
isAISmartToolsEnabled: organization.isAISmartToolsEnabled,
isAIDataAnalysisEnabled: organization.isAIDataAnalysisEnabled,
whitelabel: organization.whitelabel as TOrganization["whitelabel"],
});
+2 -1
View File
@@ -232,7 +232,8 @@ export const mockOrganizationOutput: TOrganization = {
name: "mock Organization",
createdAt: currentDate,
updatedAt: currentDate,
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
billing: {
stripeCustomerId: null,
limits: {
+4 -2
View File
@@ -70,7 +70,8 @@ describe("User Service", () => {
},
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
{
id: "org2",
@@ -87,7 +88,8 @@ describe("User Service", () => {
},
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
},
];
@@ -9,6 +9,10 @@ vi.mock("node:dns", () => ({
},
}));
vi.mock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: false,
}));
const mockResolve = vi.mocked(dns.resolve);
const mockResolve6 = vi.mocked(dns.resolve6);
@@ -294,4 +298,78 @@ describe("validateWebhookUrl", () => {
});
});
});
describe("DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS", () => {
test("allows private IP URLs when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("http://127.0.0.1/")).resolves.toBeUndefined();
await expect(validateWithFlag("http://192.168.1.1/test")).resolves.toBeUndefined();
await expect(validateWithFlag("http://10.0.0.1/webhook")).resolves.toBeUndefined();
});
test("allows localhost when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("http://localhost/webhook")).resolves.toBeUndefined();
await expect(validateWithFlag("http://localhost:3333/webhook")).resolves.toBeUndefined();
});
test("allows localhost.localdomain when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("http://localhost.localdomain/path")).resolves.toBeUndefined();
});
test("allows hostname resolving to private IP when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
setupDnsResolution(["192.168.1.1"]);
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("https://internal.company.com/webhook")).resolves.toBeUndefined();
});
test("still rejects unresolvable hostnames when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
setupDnsResolution(null, null);
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("https://typo-gibberish.invalid/hook")).rejects.toThrow(
"Could not resolve webhook URL hostname"
);
});
test("still rejects invalid URL format when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("not-a-url")).rejects.toThrow("Invalid webhook URL format");
});
test("still rejects non-HTTP protocols when enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateWebhookUrl: validateWithFlag } = await import("./validate-webhook-url");
await expect(validateWithFlag("ftp://192.168.1.1/")).rejects.toThrow(
"Webhook URL must use HTTPS or HTTP protocol"
);
});
});
});
+16 -6
View File
@@ -1,6 +1,7 @@
import "server-only";
import dns from "node:dns";
import { InvalidInputError } from "@formbricks/types/errors";
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "../constants";
const BLOCKED_HOSTNAMES = new Set([
"localhost",
@@ -139,8 +140,10 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
const hostname = parsed.hostname;
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) {
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
}
}
// Direct IP literal — validate without DNS resolution
@@ -149,12 +152,17 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
if (isIPv4Literal || isIPv6Literal) {
const ip = isIPv6Literal ? stripIPv6Brackets(hostname) : hostname;
if (isPrivateIP(ip)) {
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
return;
}
// Skip DNS resolution for localhost-like hostnames when internal URLs are allowed since these are resolved via /etc/hosts and not DNS
if (DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
return;
}
// Domain name — resolve DNS and validate every resolved IP
let resolvedIPs: string[];
try {
@@ -168,9 +176,11 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
);
}
for (const ip of resolvedIPs) {
if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) {
for (const ip of resolvedIPs) {
if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
}
}
};
+7 -15
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",
@@ -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.",
@@ -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!"
}
}
+7 -15
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",
@@ -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.",
@@ -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!"
}
}
+7 -15
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",
@@ -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.",
@@ -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!"
}
}
+7 -15
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",
@@ -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.",
@@ -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!"
}
}
+7 -15
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",
@@ -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.",
@@ -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!"
}
}
+7 -15
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": "ワークスペースが正常に作成されました",
@@ -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": "これはあなたの唯一の組織であるため、離れることはできません。まず新しい組織を作成してください。",
@@ -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": "フィードバックありがとうございます!"
}
}
+7 -15
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",
@@ -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.",
@@ -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!"
}
}
+7 -15
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",
@@ -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.",
@@ -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!"
}
}
+7 -15
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",
@@ -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.",
@@ -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!"
}
}
+7 -15
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",
@@ -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.",
@@ -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!"
}
}
+7 -15
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": "Рабочий проект успешно создан",
@@ -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": "Вы не можете покинуть эту организацию, так как она у вас единственная. Сначала создайте новую организацию.",
@@ -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": "Спасибо за твой отзыв!"
}
}
+7 -15
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",
@@ -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.",
@@ -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!"
}
}
+7 -15
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": "工作区创建成功",
@@ -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": "您 不能 离开 此 组织,因为 这是 您 唯一的 组织。请 先 创建一个新的 组织。",
@@ -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": "感谢你的反馈!"
}
}
+7 -15
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": "工作區已成功建立",
@@ -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": "您無法離開此組織,因為它是您唯一的組織。請先建立新組織。",
@@ -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": "感謝你的回饋!"
}
}
+28 -40
View File
@@ -10,6 +10,25 @@ import { authOptions } from "./authOptions";
import { mockUser } from "./mock-data";
import { hashPassword } from "./utils";
vi.mock("@next-auth/prisma-adapter", () => ({
PrismaAdapter: vi.fn(() => ({
createUser: vi.fn(),
getUser: vi.fn(),
getUserByEmail: vi.fn(),
getUserByAccount: vi.fn(),
updateUser: vi.fn(),
deleteUser: vi.fn(),
linkAccount: vi.fn(),
unlinkAccount: vi.fn(),
createSession: vi.fn(),
getSessionAndUser: vi.fn(),
updateSession: vi.fn(),
deleteSession: vi.fn(),
createVerificationToken: vi.fn(),
useVerificationToken: vi.fn(),
})),
}));
// Mock encryption utilities
vi.mock("@/lib/encryption", () => ({
symmetricEncrypt: vi.fn((value: string) => `encrypted_${value}`),
@@ -300,51 +319,20 @@ describe("authOptions", () => {
});
describe("Callbacks", () => {
describe("jwt callback", () => {
test("should add profile information to token if user is found", async () => {
vi.spyOn(prisma.user, "findFirst").mockResolvedValue({
id: mockUser.id,
locale: mockUser.locale,
email: mockUser.email,
emailVerified: mockUser.emailVerified,
} as any);
const token = { email: mockUser.email };
if (!authOptions.callbacks?.jwt) {
throw new Error("jwt callback is not defined");
}
const result = await authOptions.callbacks.jwt({ token } as any);
expect(result).toEqual({
...token,
profile: { id: mockUser.id },
});
});
test("should return token unchanged if no existing user is found", async () => {
vi.spyOn(prisma.user, "findFirst").mockResolvedValue(null);
const token = { email: "nonexistent@example.com" };
if (!authOptions.callbacks?.jwt) {
throw new Error("jwt callback is not defined");
}
const result = await authOptions.callbacks.jwt({ token } as any);
expect(result).toEqual(token);
});
});
describe("session callback", () => {
test("should add user profile to session", async () => {
const token = {
id: "user6",
profile: { id: "user6", email: "user6@example.com" },
};
test("should add user id and isActive to session from database user", async () => {
const session = { user: { email: "user6@example.com" } };
const user = { id: "user6", isActive: false };
const session = { user: {} };
if (!authOptions.callbacks?.session) {
throw new Error("session callback is not defined");
}
const result = await authOptions.callbacks.session({ session, token } as any);
expect(result.user).toEqual(token.profile);
const result = await authOptions.callbacks.session({ session, user } as any);
expect(result.user).toEqual({
email: "user6@example.com",
id: "user6",
isActive: false,
});
});
});
+10 -21
View File
@@ -1,3 +1,4 @@
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import type { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { cookies } from "next/headers";
@@ -13,7 +14,7 @@ import {
} from "@/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { verifyToken } from "@/lib/jwt";
import { getUserByEmail, updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
import { updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
import {
logAuthAttempt,
logAuthEvent,
@@ -31,6 +32,7 @@ import { handleSsoCallback } from "@/modules/ee/sso/lib/sso-handlers";
import { createBrevoCustomer } from "./brevo";
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
providers: [
CredentialsProvider({
id: "credentials",
@@ -310,30 +312,17 @@ export const authOptions: NextAuthOptions = {
...(ENTERPRISE_LICENSE_KEY ? getSSOProviders() : []),
],
session: {
strategy: "database",
maxAge: SESSION_MAX_AGE,
},
callbacks: {
async jwt({ token }) {
const existingUser = await getUserByEmail(token?.email!);
if (!existingUser) {
return token;
async session({ session, user }) {
if (session.user) {
session.user.id = user.id;
if ("isActive" in user && typeof user.isActive === "boolean") {
session.user.isActive = user.isActive;
}
}
return {
...token,
profile: { id: existingUser.id },
isActive: existingUser.isActive,
};
},
async session({ session, token }) {
// @ts-expect-error
session.user.id = token?.id;
// @ts-expect-error
session.user = token.profile;
// @ts-expect-error
session.user.isActive = token.isActive;
return session;
},
async signIn({ user, account }) {
@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getProxySession, getSessionTokenFromRequest } from "./proxy-session";
const { mockFindUnique } = vi.hoisted(() => ({
mockFindUnique: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
session: {
findUnique: mockFindUnique,
},
},
}));
const createRequest = (cookies: Record<string, string> = {}) => ({
cookies: {
get: (name: string) => {
const value = cookies[name];
return value ? { value } : undefined;
},
},
});
describe("proxy-session", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("reads the secure session cookie when present", () => {
const request = createRequest({
"__Secure-next-auth.session-token": "secure-token",
});
expect(getSessionTokenFromRequest(request)).toBe("secure-token");
});
test("returns null when no session cookie is present", async () => {
const request = createRequest();
const session = await getProxySession(request);
expect(session).toBeNull();
expect(mockFindUnique).not.toHaveBeenCalled();
});
test("returns null when the session is expired", async () => {
mockFindUnique.mockResolvedValue({
userId: "user-1",
expires: new Date(Date.now() - 60_000),
user: {
isActive: true,
},
});
const request = createRequest({
"next-auth.session-token": "expired-token",
});
const session = await getProxySession(request);
expect(session).toBeNull();
expect(mockFindUnique).toHaveBeenCalledWith({
where: {
sessionToken: "expired-token",
},
select: {
userId: true,
expires: true,
user: {
select: {
isActive: true,
},
},
},
});
});
test("returns null when the session belongs to an inactive user", async () => {
mockFindUnique.mockResolvedValue({
userId: "user-1",
expires: new Date(Date.now() + 60_000),
user: {
isActive: false,
},
});
const request = createRequest({
"next-auth.session-token": "inactive-user-token",
});
const session = await getProxySession(request);
expect(session).toBeNull();
});
test("returns the session when the cookie maps to a valid session", async () => {
const validSession = {
userId: "user-1",
expires: new Date(Date.now() + 60_000),
user: {
isActive: true,
},
};
mockFindUnique.mockResolvedValue(validSession);
const request = createRequest({
"next-auth.session-token": "valid-token",
});
const session = await getProxySession(request);
expect(session).toEqual(validSession);
});
});
@@ -0,0 +1,54 @@
import { prisma } from "@formbricks/database";
const NEXT_AUTH_SESSION_COOKIE_NAMES = [
"__Secure-next-auth.session-token",
"next-auth.session-token",
] as const;
type TCookieStore = {
get: (name: string) => { value: string } | undefined;
};
type TRequestWithCookies = {
cookies: TCookieStore;
};
export const getSessionTokenFromRequest = (request: TRequestWithCookies): string | null => {
for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) {
const cookie = request.cookies.get(cookieName);
if (cookie?.value) {
return cookie.value;
}
}
return null;
};
export const getProxySession = async (request: TRequestWithCookies) => {
const sessionToken = getSessionTokenFromRequest(request);
if (!sessionToken) {
return null;
}
const session = await prisma.session.findUnique({
where: {
sessionToken,
},
select: {
userId: true,
expires: true,
user: {
select: {
isActive: true,
},
},
},
});
if (!session || session.expires <= new Date() || session.user.isActive === false) {
return null;
}
return session;
};
@@ -9,4 +9,6 @@ export const CLOUD_STRIPE_FEATURE_LOOKUP_KEYS = {
RBAC: "rbac",
SPAM_PROTECTION: "spam-protection",
CONTACTS: "contacts",
AI_SMART_TOOLS: "ai-smart-tools",
AI_DATA_ANALYSIS: "ai-data-analysis",
} as const;
@@ -146,7 +146,8 @@ describe("License Core Logic", () => {
sso: true,
saml: true,
spamProtection: true,
ai: false,
aiSmartTools: false,
aiDataAnalysis: false,
auditLogs: true,
accessControl: true,
quotas: true,
@@ -281,7 +282,8 @@ describe("License Core Logic", () => {
whitelabel: false,
removeBranding: false,
contacts: false,
ai: false,
aiSmartTools: false,
aiDataAnalysis: false,
saml: false,
spamProtection: false,
auditLogs: false,
@@ -302,7 +304,8 @@ describe("License Core Logic", () => {
whitelabel: false,
removeBranding: false,
contacts: false,
ai: false,
aiSmartTools: false,
aiDataAnalysis: false,
saml: false,
spamProtection: false,
auditLogs: false,
@@ -332,7 +335,8 @@ describe("License Core Logic", () => {
whitelabel: false,
removeBranding: false,
contacts: false,
ai: false,
aiSmartTools: false,
aiDataAnalysis: false,
saml: false,
spamProtection: false,
auditLogs: false,
@@ -521,7 +525,8 @@ describe("License Core Logic", () => {
sso: true,
saml: true,
spamProtection: true,
ai: false,
aiSmartTools: false,
aiDataAnalysis: false,
auditLogs: true,
accessControl: true,
quotas: true,
@@ -585,7 +590,8 @@ describe("License Core Logic", () => {
sso: true,
saml: true,
spamProtection: true,
ai: false,
aiSmartTools: false,
aiDataAnalysis: false,
auditLogs: true,
accessControl: true,
quotas: true,
@@ -640,7 +646,8 @@ describe("License Core Logic", () => {
sso: true,
saml: true,
spamProtection: true,
ai: false,
aiSmartTools: false,
aiDataAnalysis: false,
auditLogs: true,
accessControl: true,
quotas: true,
@@ -782,7 +789,8 @@ describe("License Core Logic", () => {
sso: true,
saml: true,
spamProtection: true,
ai: true,
aiSmartTools: true,
aiDataAnalysis: true,
auditLogs: true,
accessControl: true,
quotas: true,
@@ -810,7 +818,8 @@ describe("License Core Logic", () => {
sso: true,
saml: true,
spamProtection: true,
ai: true,
aiSmartTools: true,
aiDataAnalysis: true,
auditLogs: true,
accessControl: true,
quotas: true,
@@ -839,7 +848,8 @@ describe("License Core Logic", () => {
whitelabel: false,
removeBranding: false,
contacts: false,
ai: false,
aiSmartTools: false,
aiDataAnalysis: false,
saml: false,
spamProtection: false,
auditLogs: false,
@@ -910,7 +920,8 @@ describe("License Core Logic", () => {
whitelabel: true,
removeBranding: true,
contacts: true,
ai: true,
aiSmartTools: true,
aiDataAnalysis: true,
saml: true,
spamProtection: true,
auditLogs: true,
@@ -978,7 +989,8 @@ describe("License Core Logic", () => {
whitelabel: true,
removeBranding: true,
contacts: true,
ai: true,
aiSmartTools: true,
aiDataAnalysis: true,
saml: true,
spamProtection: true,
auditLogs: true,
@@ -1020,7 +1032,8 @@ describe("License Core Logic", () => {
sso: true,
saml: true,
spamProtection: true,
ai: false,
aiSmartTools: false,
aiDataAnalysis: false,
auditLogs: true,
accessControl: true,
quotas: true,
@@ -1146,7 +1159,8 @@ describe("License Core Logic", () => {
sso: true,
saml: true,
spamProtection: true,
ai: false,
aiSmartTools: false,
aiDataAnalysis: false,
auditLogs: true,
accessControl: true,
quotas: true,
@@ -1267,7 +1281,8 @@ describe("License Core Logic", () => {
whitelabel: true,
removeBranding: true,
contacts: true,
ai: true,
aiSmartTools: true,
aiDataAnalysis: true,
saml: true,
spamProtection: true,
auditLogs: true,
@@ -1322,7 +1337,8 @@ describe("License Core Logic", () => {
whitelabel: true,
removeBranding: true,
contacts: true,
ai: true,
aiSmartTools: true,
aiDataAnalysis: true,
saml: true,
spamProtection: true,
auditLogs: true,
@@ -1377,7 +1393,8 @@ describe("License Core Logic", () => {
whitelabel: true,
removeBranding: true,
contacts: true,
ai: true,
aiSmartTools: true,
aiDataAnalysis: true,
saml: true,
spamProtection: true,
auditLogs: true,
@@ -77,7 +77,8 @@ const LicenseFeaturesSchema = z.object({
whitelabel: z.boolean(),
removeBranding: z.boolean(),
contacts: z.boolean(),
ai: z.boolean(),
aiSmartTools: z.boolean(),
aiDataAnalysis: z.boolean(),
saml: z.boolean(),
spamProtection: z.boolean(),
auditLogs: z.boolean(),
@@ -144,7 +145,8 @@ const DEFAULT_FEATURES: TEnterpriseLicenseFeatures = {
whitelabel: false,
removeBranding: false,
contacts: false,
ai: false,
aiSmartTools: false,
aiDataAnalysis: false,
saml: false,
spamProtection: false,
auditLogs: false,
@@ -9,6 +9,8 @@ import { getEnterpriseLicense, getLicenseFeatures } from "./license";
import {
getAccessControlPermission,
getBiggerUploadFileSizePermission,
getIsAIDataAnalysisEnabled,
getIsAISmartToolsEnabled,
getIsAuditLogsEnabled,
getIsContactsEnabled,
getIsMultiOrgEnabled,
@@ -55,7 +57,8 @@ const defaultFeatures: TEnterpriseLicenseFeatures = {
sso: false,
saml: false,
spamProtection: false,
ai: false,
aiSmartTools: false,
aiDataAnalysis: false,
auditLogs: false,
accessControl: false,
quotas: false,
@@ -194,6 +197,72 @@ describe("License Utils", () => {
expect(access).toBe(true);
expect(quotas).toBe(true);
});
test("uses cloud AI smart tools entitlement", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(hasOrganizationEntitlementWithLicenseGuard).mockResolvedValueOnce(true);
const result = await getIsAISmartToolsEnabled("org_1");
expect(result).toBe(true);
expect(hasOrganizationEntitlementWithLicenseGuard).toHaveBeenCalledWith(
"org_1",
CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.AI_SMART_TOOLS
);
});
test("uses cloud AI data analysis entitlement", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(hasOrganizationEntitlementWithLicenseGuard).mockResolvedValueOnce(true);
const result = await getIsAIDataAnalysisEnabled("org_1");
expect(result).toBe(true);
expect(hasOrganizationEntitlementWithLicenseGuard).toHaveBeenCalledWith(
"org_1",
CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.AI_DATA_ANALYSIS
);
});
test("returns self-hosted AI features from license", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: {
...defaultFeatures,
aiSmartTools: true,
aiDataAnalysis: true,
},
});
const [smartTools, dataAnalysis] = await Promise.all([
getIsAISmartToolsEnabled("org_1"),
getIsAIDataAnalysisEnabled("org_1"),
]);
expect(smartTools).toBe(true);
expect(dataAnalysis).toBe(true);
});
test("returns false for self-hosted AI features when not enabled", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: {
...defaultFeatures,
aiSmartTools: false,
aiDataAnalysis: false,
},
});
const [smartTools, dataAnalysis] = await Promise.all([
getIsAISmartToolsEnabled("org_1"),
getIsAIDataAnalysisEnabled("org_1"),
]);
expect(smartTools).toBe(false);
expect(dataAnalysis).toBe(false);
});
});
describe("getBiggerUploadFileSizePermission", () => {
+14 -1
View File
@@ -29,13 +29,18 @@ const getFeaturePermission = async (
// On Self-hosted: requires active license AND feature enabled in license
const getCustomPlanFeaturePermission = async (
organizationId: string,
featureKey: keyof Pick<TEnterpriseLicenseFeatures, "accessControl" | "quotas" | "contacts">
featureKey: keyof Pick<
TEnterpriseLicenseFeatures,
"accessControl" | "quotas" | "contacts" | "aiSmartTools" | "aiDataAnalysis"
>
): Promise<boolean> => {
if (IS_FORMBRICKS_CLOUD) {
const featureLookupKeyMap: Record<string, string> = {
accessControl: CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.RBAC,
quotas: CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.QUOTA_MANAGEMENT,
contacts: CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.CONTACTS,
aiSmartTools: CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.AI_SMART_TOOLS,
aiDataAnalysis: CLOUD_STRIPE_FEATURE_LOOKUP_KEYS.AI_DATA_ANALYSIS,
};
const lookupKey = featureLookupKeyMap[featureKey];
if (lookupKey) {
@@ -109,6 +114,14 @@ export const getIsQuotasEnabled = async (organizationId: string): Promise<boolea
return getCustomPlanFeaturePermission(organizationId, "quotas");
};
export const getIsAISmartToolsEnabled = async (organizationId: string): Promise<boolean> => {
return getCustomPlanFeaturePermission(organizationId, "aiSmartTools");
};
export const getIsAIDataAnalysisEnabled = async (organizationId: string): Promise<boolean> => {
return getCustomPlanFeaturePermission(organizationId, "aiDataAnalysis");
};
export const getIsAuditLogsEnabled = async (): Promise<boolean> => {
if (!AUDIT_LOG_ENABLED) return false;
return getSpecificFeatureFlag("auditLogs");
@@ -14,7 +14,8 @@ const ZEnterpriseLicenseFeatures = z.object({
sso: z.boolean(),
saml: z.boolean(),
spamProtection: z.boolean(),
ai: z.boolean(),
aiSmartTools: z.boolean(),
aiDataAnalysis: z.boolean(),
auditLogs: z.boolean(),
accessControl: z.boolean(),
quotas: z.boolean(),
@@ -37,7 +37,8 @@ describe("getFirstOrganization", () => {
projects: 3,
},
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
};
vi.mocked(prisma.organization.findFirst).mockResolvedValue(org);
const result = await getFirstOrganization();
+20 -5
View File
@@ -3,7 +3,7 @@ import type { Account } from "next-auth";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import type { TUser, TUserNotificationSettings } from "@formbricks/types/user";
import { createAccount } from "@/lib/account/service";
import { upsertAccount } from "@/lib/account/service";
import { DEFAULT_TEAM_ID, SKIP_INVITE_FOR_SSO } from "@/lib/constants";
import { getIsFreshInstance } from "@/lib/instance/service";
import { verifyInviteToken } from "@/lib/jwt";
@@ -23,6 +23,21 @@ import {
import { getFirstOrganization } from "@/modules/ee/sso/lib/organization";
import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team";
const syncSsoAccount = async (userId: string, account: Account) => {
await upsertAccount({
userId,
type: account.type,
provider: account.provider,
providerAccountId: account.providerAccountId,
...(account.access_token !== undefined ? { access_token: account.access_token } : {}),
...(account.refresh_token !== undefined ? { refresh_token: account.refresh_token } : {}),
...(account.expires_at !== undefined ? { expires_at: account.expires_at } : {}),
...(account.scope !== undefined ? { scope: account.scope } : {}),
...(account.token_type !== undefined ? { token_type: account.token_type } : {}),
...(account.id_token !== undefined ? { id_token: account.id_token } : {}),
});
};
export const handleSsoCallback = async ({
user,
account,
@@ -108,6 +123,7 @@ export const handleSsoCallback = async ({
// User with this provider found
// check if email still the same
if (existingUserWithAccount.email === user.email) {
await syncSsoAccount(existingUserWithAccount.id, account);
contextLogger.debug(
{ existingUserId: existingUserWithAccount.id },
"SSO callback successful: existing user, email matches"
@@ -133,6 +149,7 @@ export const handleSsoCallback = async ({
);
await updateUser(existingUserWithAccount.id, { email: user.email });
await syncSsoAccount(existingUserWithAccount.id, account);
return true;
}
@@ -154,6 +171,7 @@ export const handleSsoCallback = async ({
const existingUserWithEmail = await getUserByEmail(user.email);
if (existingUserWithEmail) {
await syncSsoAccount(existingUserWithEmail.id, account);
contextLogger.debug(
{ existingUserId: existingUserWithEmail.id, action: "existing_user_login" },
"SSO callback successful: existing user found by email"
@@ -342,6 +360,7 @@ export const handleSsoCallback = async ({
// send new user to brevo
createBrevoCustomer({ id: userProfile.id, email: userProfile.email });
await syncSsoAccount(userProfile.id, account);
if (isMultiOrgEnabled) {
contextLogger.debug(
@@ -358,10 +377,6 @@ export const handleSsoCallback = async ({
"Assigning user to organization"
);
await createMembership(organization.id, userProfile.id, { role: "member", accepted: true });
await createAccount({
...account,
userId: userProfile.id,
});
if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) {
contextLogger.debug(
@@ -45,7 +45,8 @@ export const mockSamlAccount: Account = {
export const mockOrganization: TOrganization = {
id: "org-123",
name: "Test Organization",
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
whitelabel: {
logoUrl: null,
faviconUrl: null,
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import type { TUser } from "@formbricks/types/user";
import { upsertAccount } from "@/lib/account/service";
import { createMembership } from "@/lib/membership/service";
import { createOrganization, getOrganization } from "@/lib/organization/service";
import { findMatchingLocale } from "@/lib/utils/locale";
@@ -62,7 +63,7 @@ vi.mock("@/modules/ee/sso/lib/team", () => ({
}));
vi.mock("@/lib/account/service", () => ({
createAccount: vi.fn(),
upsertAccount: vi.fn(),
}));
vi.mock("@/lib/membership/service", () => ({
@@ -203,6 +204,36 @@ describe("handleSsoCallback", () => {
});
});
test("should not overwrite stored tokens when the provider omits them", async () => {
vi.mocked(prisma.user.findFirst).mockResolvedValue({
...mockUser,
email: mockUser.email,
accounts: [{ provider: mockAccount.provider }],
} as any);
const result = await handleSsoCallback({
user: mockUser,
account: {
...mockAccount,
access_token: undefined,
refresh_token: undefined,
expires_at: undefined,
scope: undefined,
token_type: undefined,
id_token: undefined,
},
callbackUrl: "http://localhost:3000",
});
expect(result).toBe(true);
expect(upsertAccount).toHaveBeenCalledWith({
userId: mockUser.id,
type: mockAccount.type,
provider: mockAccount.provider,
providerAccountId: mockAccount.providerAccountId,
});
});
test("should update user email if user with account exists but email changed", async () => {
const existingUser = {
...mockUser,
@@ -125,6 +125,46 @@ describe("hasOrganizationEntitlementWithLicenseGuard", () => {
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "rbac")).toBe(false);
});
test("returns true when license active and ai-smart-tools mapped feature enabled", async () => {
mockGetContext.mockResolvedValue({
...baseContext,
features: ["ai-smart-tools"],
licenseStatus: "active",
licenseFeatures: { aiSmartTools: true } as TOrganizationEntitlementsContext["licenseFeatures"],
});
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "ai-smart-tools")).toBe(true);
});
test("returns false when license active but ai-smart-tools mapped feature disabled", async () => {
mockGetContext.mockResolvedValue({
...baseContext,
features: ["ai-smart-tools"],
licenseStatus: "active",
licenseFeatures: { aiSmartTools: false } as TOrganizationEntitlementsContext["licenseFeatures"],
});
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "ai-smart-tools")).toBe(false);
});
test("returns true when license active and ai-data-analysis mapped feature enabled", async () => {
mockGetContext.mockResolvedValue({
...baseContext,
features: ["ai-data-analysis"],
licenseStatus: "active",
licenseFeatures: { aiDataAnalysis: true } as TOrganizationEntitlementsContext["licenseFeatures"],
});
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "ai-data-analysis")).toBe(true);
});
test("returns false when license active but ai-data-analysis mapped feature disabled", async () => {
mockGetContext.mockResolvedValue({
...baseContext,
features: ["ai-data-analysis"],
licenseStatus: "active",
licenseFeatures: { aiDataAnalysis: false } as TOrganizationEntitlementsContext["licenseFeatures"],
});
expect(await hasOrganizationEntitlementWithLicenseGuard("org1", "ai-data-analysis")).toBe(false);
});
test("returns true when license active and feature has no license mapping", async () => {
mockGetContext.mockResolvedValue({
...baseContext,
@@ -10,6 +10,8 @@ const LICENSE_GUARDED_ENTITLEMENTS: Partial<Record<string, keyof TEnterpriseLice
rbac: "accessControl",
"spam-protection": "spamProtection",
contacts: "contacts",
"ai-smart-tools": "aiSmartTools",
"ai-data-analysis": "aiDataAnalysis",
};
const TRIAL_RESTRICTED_ENTITLEMENT_KEYS = [
@@ -99,4 +99,46 @@ describe("getSelfHostedOrganizationEntitlementsContext", () => {
expect(result.features).toContain("hide-branding");
});
test("maps aiSmartTools feature to ai-smart-tools entitlement", async () => {
mockGetOrg.mockResolvedValue({ id: "org1" } as any);
mockGetLicense.mockResolvedValue({
status: "active",
active: true,
features: { aiSmartTools: true },
} as any);
const result = await getSelfHostedOrganizationEntitlementsContext("org1");
expect(result.features).toContain("ai-smart-tools");
expect(result.features).not.toContain("ai-data-analysis");
});
test("maps aiDataAnalysis feature to ai-data-analysis entitlement", async () => {
mockGetOrg.mockResolvedValue({ id: "org1" } as any);
mockGetLicense.mockResolvedValue({
status: "active",
active: true,
features: { aiDataAnalysis: true },
} as any);
const result = await getSelfHostedOrganizationEntitlementsContext("org1");
expect(result.features).toContain("ai-data-analysis");
expect(result.features).not.toContain("ai-smart-tools");
});
test("maps both AI features when both are enabled", async () => {
mockGetOrg.mockResolvedValue({ id: "org1" } as any);
mockGetLicense.mockResolvedValue({
status: "active",
active: true,
features: { aiSmartTools: true, aiDataAnalysis: true },
} as any);
const result = await getSelfHostedOrganizationEntitlementsContext("org1");
expect(result.features).toContain("ai-smart-tools");
expect(result.features).toContain("ai-data-analysis");
});
});
@@ -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;
};
@@ -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 }],
},
+4 -2
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,
@@ -11,9 +11,10 @@ import { AddWebhookModal } from "./add-webhook-modal";
interface AddWebhookButtonProps {
environment: TEnvironment;
surveys: TSurvey[];
allowInternalUrls: boolean;
}
export const AddWebhookButton = ({ environment, surveys }: AddWebhookButtonProps) => {
export const AddWebhookButton = ({ environment, surveys, allowInternalUrls }: AddWebhookButtonProps) => {
const { t } = useTranslation();
const [isAddWebhookModalOpen, setAddWebhookModalOpen] = useState(false);
return (
@@ -31,6 +32,7 @@ export const AddWebhookButton = ({ environment, surveys }: AddWebhookButtonProps
surveys={surveys}
open={isAddWebhookModalOpen}
setOpen={setAddWebhookModalOpen}
allowInternalUrls={allowInternalUrls}
/>
</>
);
@@ -34,9 +34,16 @@ interface AddWebhookModalProps {
open: boolean;
surveys: TSurvey[];
setOpen: (v: boolean) => void;
allowInternalUrls: boolean;
}
export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWebhookModalProps) => {
export const AddWebhookModal = ({
environmentId,
surveys,
open,
setOpen,
allowInternalUrls,
}: AddWebhookModalProps) => {
const router = useRouter();
const {
handleSubmit,
@@ -59,7 +66,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
sendSuccessToast: boolean
): Promise<{ success: boolean; secret?: string }> => {
try {
const { valid, error } = validWebHookURL(testEndpointInput);
const { valid, error } = validWebHookURL(testEndpointInput, allowInternalUrls);
if (!valid) {
toast.error(error ?? t("common.something_went_wrong_please_try_again"));
return { success: false };
@@ -23,9 +23,17 @@ interface WebhookModalProps {
webhook: Webhook;
surveys: TSurvey[];
isReadOnly: boolean;
allowInternalUrls: boolean;
}
export const WebhookModal = ({ open, setOpen, webhook, surveys, isReadOnly }: WebhookModalProps) => {
export const WebhookModal = ({
open,
setOpen,
webhook,
surveys,
isReadOnly,
allowInternalUrls,
}: WebhookModalProps) => {
const { t, i18n } = useTranslation();
const locale = (i18n.resolvedLanguage ?? i18n.language ?? "en-US") as TUserLocale;
const [activeTab, setActiveTab] = useState(0);
@@ -38,7 +46,13 @@ export const WebhookModal = ({ open, setOpen, webhook, surveys, isReadOnly }: We
{
title: t("common.settings"),
children: (
<WebhookSettingsTab webhook={webhook} surveys={surveys} setOpen={setOpen} isReadOnly={isReadOnly} />
<WebhookSettingsTab
webhook={webhook}
surveys={surveys}
setOpen={setOpen}
isReadOnly={isReadOnly}
allowInternalUrls={allowInternalUrls}
/>
),
},
];
@@ -26,9 +26,16 @@ interface WebhookSettingsTabProps {
surveys: TSurvey[];
setOpen: (v: boolean) => void;
isReadOnly: boolean;
allowInternalUrls: boolean;
}
export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: WebhookSettingsTabProps) => {
export const WebhookSettingsTab = ({
webhook,
surveys,
setOpen,
isReadOnly,
allowInternalUrls,
}: WebhookSettingsTabProps) => {
const { t } = useTranslation();
const router = useRouter();
const { register, handleSubmit } = useForm({
@@ -60,7 +67,7 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
const handleTestEndpoint = async (sendSuccessToast: boolean): Promise<boolean> => {
try {
const { valid, error } = validWebHookURL(testEndpointInput);
const { valid, error } = validWebHookURL(testEndpointInput, allowInternalUrls);
if (!valid) {
toast.error(error ?? t("common.something_went_wrong_please_try_again"));
return false;
@@ -14,6 +14,7 @@ interface WebhookTableProps {
surveys: TSurvey[];
children: [JSX.Element, JSX.Element[]];
isReadOnly: boolean;
allowInternalUrls: boolean;
}
export const WebhookTable = ({
@@ -22,6 +23,7 @@ export const WebhookTable = ({
surveys,
children: [TableHeading, webhookRows],
isReadOnly,
allowInternalUrls,
}: WebhookTableProps) => {
const [isWebhookDetailModalOpen, setWebhookDetailModalOpen] = useState(false);
const { t } = useTranslation();
@@ -71,6 +73,7 @@ export const WebhookTable = ({
webhook={activeWebhook}
surveys={surveys}
isReadOnly={isReadOnly}
allowInternalUrls={allowInternalUrls}
/>
</>
);
@@ -1,4 +1,4 @@
export const validWebHookURL = (urlInput: string) => {
export const validWebHookURL = (urlInput: string, allowInternalUrls = false) => {
const trimmedInput = urlInput.trim();
if (!trimmedInput) {
return { valid: false, error: "Please enter a URL" };
@@ -7,6 +7,13 @@ export const validWebHookURL = (urlInput: string) => {
try {
const url = new URL(trimmedInput);
if (allowInternalUrls) {
if (url.protocol !== "https:" && url.protocol !== "http:") {
return { valid: false, error: "URL must start with https:// or http://" };
}
return { valid: true };
}
if (url.protocol !== "https:") {
return { valid: false, error: "URL must start with https://" };
}
@@ -1,3 +1,4 @@
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "@/lib/constants";
import { getSurveys } from "@/lib/survey/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
@@ -21,13 +22,24 @@ export const WebhooksPage = async (props: { params: Promise<{ environmentId: str
getSurveys(params.environmentId, 200), // HOTFIX: not getting all surveys for now since it's maxing out the prisma accelerate limit
]);
const renderAddWebhookButton = () => <AddWebhookButton environment={environment} surveys={surveys} />;
const renderAddWebhookButton = () => (
<AddWebhookButton
environment={environment}
surveys={surveys}
allowInternalUrls={DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS}
/>
);
return (
<PageContentWrapper>
<GoBackButton />
<PageHeader pageTitle={t("common.webhooks")} cta={!isReadOnly ? renderAddWebhookButton() : <></>} />
<WebhookTable environment={environment} webhooks={webhooks} surveys={surveys} isReadOnly={isReadOnly}>
<WebhookTable
environment={environment}
webhooks={webhooks}
surveys={surveys}
isReadOnly={isReadOnly}
allowInternalUrls={DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS}>
<WebhookTableHeading />
{webhooks.map((webhook) => (
<WebhookRowData key={webhook.id} webhook={webhook} surveys={surveys} />
@@ -1,100 +0,0 @@
"use client";
import { ReactNode, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { TI18nString } from "@formbricks/types/i18n";
import { TSurvey, TSurveyRecallItem } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { getEnabledLanguages } from "@/lib/i18n/utils";
import { headlineToRecall, recallToHeadline } from "@/lib/utils/recall";
import { LanguageIndicator } from "@/modules/survey/multi-language-surveys/components/language-indicator";
interface MultiLangWrapperRenderProps {
value: TI18nString;
onChange: (value: string, recallItems?: TSurveyRecallItem[], fallbacks?: { [key: string]: string }) => void;
children?: ReactNode;
}
interface MultiLangWrapperProps {
isTranslationIncomplete: boolean;
value: TI18nString;
onChange: (value: TI18nString) => void;
localSurvey: TSurvey;
selectedLanguageCode: string;
setSelectedLanguageCode: (code: string) => void;
locale: TUserLocale;
render: (props: MultiLangWrapperRenderProps) => ReactNode;
}
export const MultiLangWrapper = ({
isTranslationIncomplete,
value,
localSurvey,
selectedLanguageCode,
setSelectedLanguageCode,
locale,
render,
onChange,
}: MultiLangWrapperProps) => {
const { t } = useTranslation();
const defaultLanguageCode =
localSurvey.languages.filter((lang) => lang.default)[0]?.language.code ?? "default";
const usedLanguageCode = selectedLanguageCode === defaultLanguageCode ? "default" : selectedLanguageCode;
const enabledLanguages = useMemo(
() => getEnabledLanguages(localSurvey.languages ?? []),
[localSurvey.languages]
);
const handleChange = (
newValue: string,
recallItems?: TSurveyRecallItem[],
fallbacks?: { [key: string]: string }
) => {
const updatedValue = {
...value,
[usedLanguageCode]:
recallItems && fallbacks ? headlineToRecall(newValue, recallItems, fallbacks) : newValue,
};
onChange(updatedValue);
};
return (
<div className="w-full">
<div>
{render({
value,
onChange: handleChange,
children:
enabledLanguages.length > 1 ? (
<LanguageIndicator
selectedLanguageCode={usedLanguageCode}
surveyLanguages={localSurvey.languages}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
/>
) : null,
})}
</div>
{enabledLanguages.length > 1 && (
<>
{usedLanguageCode !== "default" && value && typeof value["default"] !== "undefined" && (
<div className="mt-1 text-xs text-slate-500">
<strong>{t("environments.workspace.languages.translate")}:</strong>{" "}
{getTextContent(recallToHeadline(value, localSurvey, false, "default")["default"] ?? "")}
</div>
)}
{usedLanguageCode === "default" && localSurvey.languages?.length > 1 && isTranslationIncomplete && (
<div className="mt-1 text-xs text-red-400">
{t("environments.workspace.languages.incomplete_translations")}
</div>
)}
</>
)}
</div>
);
};
@@ -17,15 +17,16 @@ import {
TSurveyRedirectUrlCard,
TSurveyWelcomeCard,
} from "@formbricks/types/surveys/types";
import { isValidHTML } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { createI18nString, extractLanguageCodes } from "@/lib/i18n/utils";
import { md } from "@/lib/markdownIt";
import { useSyncScroll } from "@/lib/utils/hooks/useSyncScroll";
import { recallToHeadline } from "@/lib/utils/recall";
import { MultiLangWrapper } from "@/modules/survey/components/element-form-input/components/multi-lang-wrapper";
import { headlineToRecall, recallToHeadline } from "@/lib/utils/recall";
import { RecallWrapper } from "@/modules/survey/components/element-form-input/components/recall-wrapper";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { LocalizedEditor } from "@/modules/survey/multi-language-surveys/components/localized-editor";
import { Button } from "@/modules/ui/components/button";
import { Editor } from "@/modules/ui/components/editor";
import { FileInput } from "@/modules/ui/components/file-input";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
@@ -39,7 +40,6 @@ import {
getMatrixLabel,
getPlaceHolderById,
getWelcomeCardText,
isValueIncomplete,
} from "./utils";
interface ElementFormInputProps {
@@ -54,8 +54,8 @@ interface ElementFormInputProps {
updateChoice?: (choiceIdx: number, data: Partial<TSurveyElementChoice>) => void;
updateMatrixLabel?: (index: number, type: "row" | "column", matrixLabel: TI18nString) => void;
isInvalid: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
selectedLanguageCode?: string;
setSelectedLanguageCode?: (languageCode: string) => void;
label: string;
maxLength?: number;
placeholder?: string;
@@ -80,14 +80,14 @@ export const ElementFormInput = ({
updateChoice,
updateMatrixLabel,
isInvalid,
selectedLanguageCode: _selectedLanguageCode,
setSelectedLanguageCode: _setSelectedLanguageCode,
label,
selectedLanguageCode,
setSelectedLanguageCode,
maxLength,
placeholder,
onBlur,
className,
locale,
locale: _locale,
onKeyDown,
isStorageConfigured = true,
autoFocus,
@@ -96,9 +96,7 @@ export const ElementFormInput = ({
isExternalUrlsAllowed,
}: ElementFormInputProps) => {
const { t } = useTranslation();
const defaultLanguageCode =
localSurvey.languages.filter((lang) => lang.default)[0]?.language.code ?? "default";
const usedLanguageCode = selectedLanguageCode === defaultLanguageCode ? "default" : selectedLanguageCode;
const usedLanguageCode = "default";
const elements = useMemo(() => getElementsFromBlocks(localSurvey.blocks), [localSurvey.blocks]);
@@ -106,20 +104,21 @@ export const ElementFormInput = ({
const isChoice = id.includes("choice");
const isMatrixLabelRow = id.includes("row");
const isMatrixLabelColumn = id.includes("column");
const inputId = useMemo(() => {
return isChoice || isMatrixLabelColumn || isMatrixLabelRow ? id.split("-")[0] : id;
}, [id, isChoice, isMatrixLabelColumn, isMatrixLabelRow]);
const isEndingCard = elementIdx >= elements.length;
const isWelcomeCard = elementIdx === -1;
const index = getIndex(id, isChoice || isMatrixLabelColumn || isMatrixLabelRow);
const elementId = useMemo(() => {
return isWelcomeCard
? "start"
: isEndingCard
? localSurvey.endings[elementIdx - elements.length].id
: currentElement.id;
if (isWelcomeCard) {
return "start";
}
if (isEndingCard) {
return localSurvey.endings[elementIdx - elements.length].id;
}
return currentElement.id;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isWelcomeCard, isEndingCard, currentElement?.id]);
const endingCard = localSurvey.endings.find((ending) => ending.id === elementId);
@@ -128,11 +127,6 @@ export const ElementFormInput = ({
() => extractLanguageCodes(localSurvey.languages),
[localSurvey.languages]
);
const isTranslationIncomplete = useMemo(
() => isValueIncomplete(inputId, isInvalid, surveyLanguageCodes, value),
[value, inputId, isInvalid, surveyLanguageCodes]
);
const elementText = useMemo((): TI18nString => {
if (isChoice && typeof index === "number") {
return getChoiceLabel(currentElement, index, surveyLanguageCodes);
@@ -293,14 +287,14 @@ export const ElementFormInput = ({
const getFileUrl = (): string | undefined => {
if (isWelcomeCard) return localSurvey.welcomeCard.fileUrl;
if (isEndingCard) {
if (endingCard && endingCard.type === "endScreen") return endingCard.imageUrl;
if (endingCard?.type === "endScreen") return endingCard.imageUrl;
} else return currentElement.imageUrl;
};
const getVideoUrl = (): string | undefined => {
if (isWelcomeCard) return localSurvey.welcomeCard.videoUrl;
if (isEndingCard) {
if (endingCard && endingCard.type === "endScreen") return endingCard.videoUrl;
if (endingCard?.type === "endScreen") return endingCard.videoUrl;
} else return currentElement.videoUrl;
};
@@ -446,24 +440,43 @@ export const ElementFormInput = ({
<div className="flex w-full items-start gap-2">
<div className="flex-1">
<LocalizedEditor
key={`${elementId}-${id}-${selectedLanguageCode}`}
<Editor
id={id}
value={value}
localSurvey={localSurvey}
elementIdx={elementIdx}
isInvalid={isInvalid}
updateElement={(isWelcomeCard || isEndingCard ? updateSurvey : updateElement)!}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
disableLists
excludedToolbarItems={["blockType"]}
firstRender={firstRender}
setFirstRender={setFirstRender}
locale={locale}
elementId={elementId}
isCard={isWelcomeCard || isEndingCard}
autoFocus={autoFocus}
getText={() => {
const text = value ? (value.default ?? "") : "";
let html = md.render(text);
if (id === "headline" && text && !isValidHTML(text)) {
html = html.replaceAll(/<p>([\s\S]*?)<\/p>/g, "<p><strong>$1</strong></p>");
}
return html;
}}
key={`${elementId}-${id}-default`}
setFirstRender={setFirstRender}
setText={(editorValue: string) => {
if (suppressEditorUpdatesRef.current) return;
const sanitizedContent = isExternalUrlsAllowed
? editorValue
: editorValue.replaceAll(/<a[^>]*>(.*?)<\/a>/gi, "$1");
const translatedContent = {
...value,
default: sanitizedContent,
};
if (isWelcomeCard || isEndingCard) {
(updateSurvey as any)?.({ [id]: translatedContent });
return;
}
updateElement?.(elementIdx, { [id]: translatedContent });
}}
localSurvey={localSurvey}
elementId={elementId}
selectedLanguageCode="default"
isExternalUrlsAllowed={isExternalUrlsAllowed}
suppressUpdates={() => suppressEditorUpdatesRef.current}
/>
</div>
@@ -532,98 +545,68 @@ export const ElementFormInput = ({
<Label htmlFor={id}>{label}</Label>
</div>
)}
<MultiLangWrapper
isTranslationIncomplete={isTranslationIncomplete}
value={text}
<RecallWrapper
localSurvey={localSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
key={selectedLanguageCode}
onChange={(updatedText) => {
elementId={elementId}
value={text[usedLanguageCode]}
onChange={(updatedValue, recallItems, fallbacks) => {
const updatedText =
recallItems && fallbacks
? { ...text, [usedLanguageCode]: headlineToRecall(updatedValue, recallItems, fallbacks) }
: { ...text, [usedLanguageCode]: updatedValue };
setText(updatedText);
debouncedHandleUpdate(updatedText[usedLanguageCode]);
}}
render={({ value, onChange, children: languageIndicator }) => {
onAddFallback={() => {
inputRef.current?.focus();
}}
isRecallAllowed={false}
usedLanguageCode={usedLanguageCode}
render={({ value, onChange, highlightedJSX, children: recallComponents, isRecallSelectVisible }) => {
return (
<RecallWrapper
localSurvey={localSurvey}
elementId={elementId}
value={value[usedLanguageCode]}
onChange={(value, recallItems, fallbacks) => {
// Pass all values to MultiLangWrapper's onChange
onChange(value, recallItems, fallbacks);
}}
onAddFallback={() => {
inputRef.current?.focus();
}}
isRecallAllowed={false}
usedLanguageCode={usedLanguageCode}
render={({
value,
onChange,
highlightedJSX,
children: recallComponents,
isRecallSelectVisible,
}) => {
return (
<div className="flex flex-col gap-4 bg-white" ref={animationParent}>
<div className="flex w-full items-center space-x-2">
<div className="group relative w-full">
{languageIndicator}
{/* The highlight container is absolutely positioned behind the input */}
<div className="h-10 w-full"></div>
<div
ref={highlightContainerRef}
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent ${
localSurvey.languages?.length > 1 ? "pr-24" : ""
}`}
dir="auto"
key={highlightedJSX.toString()}>
{highlightedJSX}
</div>
<Input
key={`${elementId}-${id}-${usedLanguageCode}`}
value={
recallToHeadline(
{
[usedLanguageCode]: value,
},
localSurvey,
false,
usedLanguageCode
)[usedLanguageCode]
}
dir="auto"
onChange={(e) => onChange(e.target.value)}
id={id}
name={id}
placeholder={placeholder ?? getPlaceHolderById(id, t)}
aria-label={label}
maxLength={maxLength}
ref={inputRef}
onBlur={onBlur}
className={`absolute top-0 text-black caret-black ${
localSurvey.languages?.length > 1 ? "pr-24" : ""
} ${className}`}
isInvalid={
isInvalid &&
text[usedLanguageCode]?.trim() === "" &&
localSurvey.languages?.length > 1 &&
isTranslationIncomplete
}
autoComplete={isRecallSelectVisible ? "off" : "on"}
autoFocus={false}
onKeyDown={handleKeyDown}
/>
{recallComponents}
</div>
</div>
<div className="flex flex-col gap-4 bg-white" ref={animationParent}>
<div className="flex w-full items-center space-x-2">
<div className="group relative w-full">
<div className="h-10 w-full"></div>
<div
ref={highlightContainerRef}
className="no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent"
dir="auto"
key={`${elementId}-${id}-highlight`}>
{highlightedJSX}
</div>
);
}}
/>
<Input
key={`${elementId}-${id}-${usedLanguageCode}`}
value={
recallToHeadline(
{
[usedLanguageCode]: value,
},
localSurvey,
false,
usedLanguageCode
)[usedLanguageCode]
}
dir="auto"
onChange={(e) => onChange(e.target.value)}
id={id}
name={id}
placeholder={placeholder ?? getPlaceHolderById(id, t)}
aria-label={label}
maxLength={maxLength}
ref={inputRef}
onBlur={onBlur}
className={`absolute top-0 text-black caret-black ${className}`}
isInvalid={isInvalid && text[usedLanguageCode]?.trim() === ""}
autoComplete={isRecallSelectVisible ? "off" : "on"}
autoFocus={false}
onKeyDown={handleKeyDown}
/>
{recallComponents}
</div>
</div>
</div>
);
}}
/>
@@ -59,8 +59,6 @@ interface BlockCardProps {
setActiveElementId: (elementId: string | null) => void;
lastElement: boolean;
lastElementIndex: number;
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
invalidElements?: string[];
addElement: (element: any, index?: number) => void;
isFormbricksCloud: boolean;
@@ -95,8 +93,6 @@ export const BlockCard = ({
setActiveElementId,
lastElement,
lastElementIndex,
selectedLanguageCode,
setSelectedLanguageCode,
invalidElements,
addElement,
isFormbricksCloud,
@@ -136,11 +132,10 @@ export const BlockCard = ({
const [elementsParent] = useAutoAnimate();
const getElementHeadline = (
element: TSurveyElement,
languageCode: string
element: TSurveyElement
): (string | React.ReactElement)[] | string | undefined => {
const headlineData = recallToHeadline(element.headline, localSurvey, true, languageCode);
const headlineText = headlineData[languageCode];
const headlineData = recallToHeadline(element.headline, localSurvey, true, "default");
const headlineText = headlineData.default;
if (headlineText) {
return formatTextWithSlashes(getTextContent(headlineText ?? ""));
}
@@ -168,8 +163,8 @@ export const BlockCard = ({
element,
elementIdx,
updateElement,
selectedLanguageCode,
setSelectedLanguageCode,
selectedLanguageCode: "default",
setSelectedLanguageCode: () => {},
isInvalid: invalidElements ? invalidElements.includes(element.id) : false,
locale,
isStorageConfigured,
@@ -344,9 +339,7 @@ export const BlockCard = ({
})}
</p>
)}
<h3 className="text-sm font-semibold">
{getElementHeadline(element, selectedLanguageCode)}
</h3>
<h3 className="text-sm font-semibold">{getElementHeadline(element)}</h3>
{!isOpen && element.type !== TSurveyElementTypeEnum.CTA && (
<p className="mt-1 truncate text-xs text-slate-500">
{element?.required
@@ -427,7 +420,7 @@ export const BlockCard = ({
updateElement={updateElement}
updateBlockLogic={updateBlockLogic}
updateBlockLogicFallback={updateBlockLogicFallback}
selectedLanguageCode={selectedLanguageCode}
selectedLanguageCode="default"
/>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
@@ -460,8 +453,7 @@ export const BlockCard = ({
localSurvey={localSurvey}
block={block}
blockIndex={blockIdx}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
selectedLanguageCode="default"
updateBlockButtonLabel={updateBlockButtonLabel}
updateBlockLogic={updateBlockLogic}
updateBlockLogicFallback={updateBlockLogicFallback}
@@ -17,7 +17,6 @@ interface BlockSettingsProps {
block: TSurveyBlock;
blockIndex: number;
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
updateBlockButtonLabel: (
blockIndex: number,
labelKey: "buttonLabel" | "backButtonLabel",
@@ -35,7 +34,6 @@ export const BlockSettings = ({
block,
blockIndex,
selectedLanguageCode,
setSelectedLanguageCode,
updateBlockButtonLabel,
updateBlockLogic,
updateBlockLogicFallback,
@@ -98,7 +96,6 @@ export const BlockSettings = ({
}
}}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
placeholder={t("common.back")}
locale={locale}
isStorageConfigured={isStorageConfigured}
@@ -140,7 +137,6 @@ export const BlockSettings = ({
}
}}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
placeholder={t("common.next")}
locale={locale}
isStorageConfigured={isStorageConfigured}
@@ -25,8 +25,6 @@ interface BlocksDroppableProps {
duplicateElement: (elementIdx: number) => void;
activeElementId: string | null;
setActiveElementId: (elementId: string | null) => void;
selectedLanguageCode: string;
setSelectedLanguageCode: (language: string) => void;
invalidElements: string[] | null;
addElement: (element: any, index?: number) => void;
isFormbricksCloud: boolean;
@@ -52,9 +50,7 @@ export const BlocksDroppable = ({
setLocalSurvey,
moveElement,
project,
selectedLanguageCode,
setActiveElementId,
setSelectedLanguageCode,
updateElement,
updateBlockLogic,
updateBlockLogicFallback,
@@ -97,8 +93,6 @@ export const BlocksDroppable = ({
updateBlockLogicFallback={updateBlockLogicFallback}
updateBlockButtonLabel={updateBlockButtonLabel}
duplicateElement={duplicateElement}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
deleteElement={deleteElement}
activeElementId={activeElementId}
setActiveElementId={setActiveElementId}
@@ -33,8 +33,6 @@ interface EditEndingCardProps {
setActiveElementId: (id: string | null) => void;
activeElementId: string | null;
isInvalid: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
addEndingCard: (index: number) => void;
isFormbricksCloud: boolean;
locale: TUserLocale;
@@ -50,8 +48,6 @@ export const EditEndingCard = ({
setActiveElementId,
activeElementId,
isInvalid,
selectedLanguageCode,
setSelectedLanguageCode,
addEndingCard,
isFormbricksCloud,
locale,
@@ -232,14 +228,10 @@ export const EditEndingCard = ({
<p className="text-sm font-semibold">
{endingCard.type === "endScreen" &&
(endingCard.headline &&
recallToHeadline(endingCard.headline, localSurvey, true, selectedLanguageCode)[
selectedLanguageCode
]
recallToHeadline(endingCard.headline, localSurvey, true, "default").default
? formatTextWithSlashes(
getTextContent(
recallToHeadline(endingCard.headline, localSurvey, true, selectedLanguageCode)[
selectedLanguageCode
]
recallToHeadline(endingCard.headline, localSurvey, true, "default").default
)
)
: t("environments.surveys.edit.ending_card"))}
@@ -297,8 +289,6 @@ export const EditEndingCard = ({
localSurvey={localSurvey}
endingCardIndex={endingCardIndex}
isInvalid={isInvalid}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
updateSurvey={updateSurvey}
endingCard={endingCard}
locale={locale}
@@ -23,8 +23,6 @@ interface EditWelcomeCardProps {
setActiveElementId: (id: string | null) => void;
activeElementId: string | null;
isInvalid: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
locale: TUserLocale;
isStorageConfigured: boolean;
isExternalUrlsAllowed?: boolean;
@@ -36,8 +34,6 @@ export const EditWelcomeCard = ({
setActiveElementId,
activeElementId,
isInvalid,
selectedLanguageCode,
setSelectedLanguageCode,
locale,
isStorageConfigured = true,
isExternalUrlsAllowed,
@@ -155,8 +151,6 @@ export const EditWelcomeCard = ({
elementIdx={-1}
isInvalid={isInvalid}
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
@@ -171,8 +165,6 @@ export const EditWelcomeCard = ({
elementIdx={-1}
isInvalid={isInvalid}
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
@@ -191,8 +183,6 @@ export const EditWelcomeCard = ({
placeholder={t("common.next")}
isInvalid={isInvalid}
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
label={t("environments.surveys.edit.next_button_label")}
locale={locale}
isStorageConfigured={isStorageConfigured}
@@ -11,7 +11,7 @@ import {
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { createId } from "@paralleldrive/cuid2";
import { Language, Project } from "@prisma/client";
import { Project } from "@prisma/client";
import React, { SetStateAction, useEffect, useMemo } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -52,7 +52,6 @@ import {
isUsedInRecall,
} from "@/modules/survey/editor/lib/utils";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { MultiLanguageCard } from "@/modules/survey/multi-language-surveys/components/multi-language-card";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import { isEndingCardValid, isWelcomeCardValid, validateElement } from "../lib/validation";
@@ -62,11 +61,8 @@ interface ElementsViewProps {
activeElementId: string | null;
setActiveElementId: (elementId: string | null) => void;
project: Project;
projectLanguages: Language[];
invalidElements: string[] | null;
setInvalidElements: React.Dispatch<SetStateAction<string[] | null>>;
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
isFormbricksCloud: boolean;
isCxMode: boolean;
locale: TUserLocale;
@@ -83,11 +79,8 @@ export const ElementsView = ({
localSurvey,
setLocalSurvey,
project,
projectLanguages,
invalidElements,
setInvalidElements,
setSelectedLanguageCode,
selectedLanguageCode,
isFormbricksCloud,
isCxMode,
locale,
@@ -173,13 +166,10 @@ export const ElementsView = ({
const updatedElements = block.elements.map((element) => {
let updatedElement = { ...element };
if (element.headline[selectedLanguageCode]?.includes(`recall:${compareId}`)) {
if (element.headline.default?.includes(`recall:${compareId}`)) {
updatedElement.headline = {
...element.headline,
[selectedLanguageCode]: element.headline[selectedLanguageCode].replaceAll(
`recall:${compareId}`,
`recall:${updatedId}`
),
default: element.headline.default.replaceAll(`recall:${compareId}`, `recall:${updatedId}`),
};
}
@@ -785,7 +775,7 @@ export const ElementsView = ({
);
useEffect(() => {
const elementWithEmptyFallback = checkForEmptyFallBackValue(localSurvey, selectedLanguageCode);
const elementWithEmptyFallback = checkForEmptyFallBackValue(localSurvey, "default");
if (elementWithEmptyFallback) {
setActiveElementId(elementWithEmptyFallback.id);
if (activeElementId === elementWithEmptyFallback.id) {
@@ -793,7 +783,7 @@ export const ElementsView = ({
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeElementId, setActiveElementId, localSurvey, selectedLanguageCode]);
}, [activeElementId, setActiveElementId, localSurvey]);
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -850,8 +840,6 @@ export const ElementsView = ({
setActiveElementId={setActiveElementId}
activeElementId={activeElementId}
isInvalid={invalidElements ? invalidElements.includes("start") : false}
setSelectedLanguageCode={setSelectedLanguageCode}
selectedLanguageCode={selectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
@@ -874,8 +862,6 @@ export const ElementsView = ({
updateBlockLogicFallback={updateBlockLogicFallback}
updateBlockButtonLabel={updateBlockButtonLabel}
duplicateElement={duplicateElement}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
deleteElement={deleteElement}
activeElementId={activeElementId}
setActiveElementId={setActiveElementId}
@@ -915,8 +901,6 @@ export const ElementsView = ({
setActiveElementId={setActiveElementId}
activeElementId={activeElementId}
isInvalid={invalidElements ? invalidElements.includes(ending.id) : false}
setSelectedLanguageCode={setSelectedLanguageCode}
selectedLanguageCode={selectedLanguageCode}
addEndingCard={addEndingCard}
isFormbricksCloud={isFormbricksCloud}
locale={locale}
@@ -949,16 +933,6 @@ export const ElementsView = ({
setActiveElementId={setActiveElementId}
quotas={quotas}
/>
<MultiLanguageCard
localSurvey={localSurvey}
projectLanguages={projectLanguages}
setLocalSurvey={setLocalSurvey}
setActiveElementId={setActiveElementId}
activeElementId={activeElementId}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
/>
</>
)}
</div>
@@ -19,8 +19,6 @@ interface EndScreenFormProps {
localSurvey: TSurvey;
endingCardIndex: number;
isInvalid: boolean;
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
updateSurvey: (
input: Partial<TSurveyEndScreenCard & { _forceUpdate?: boolean }> | Partial<TSurveyRedirectUrlCard>
) => void;
@@ -34,8 +32,6 @@ export const EndScreenForm = ({
localSurvey,
endingCardIndex,
isInvalid,
selectedLanguageCode,
setSelectedLanguageCode,
updateSurvey,
endingCard,
locale,
@@ -44,12 +40,11 @@ export const EndScreenForm = ({
}: EndScreenFormProps) => {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const selectedLanguageCode = "default";
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
const questions = getElementsFromBlocks(localSurvey.blocks);
const defaultLanguageCode = localSurvey.languages.find((lang) => lang.default)?.language.code ?? "default";
const usedLanguageCode = selectedLanguageCode === defaultLanguageCode ? "default" : selectedLanguageCode;
const usedLanguageCode = "default";
const [showEndingCardCTA, setshowEndingCardCTA] = useState<boolean>(
endingCard.type === "endScreen" &&
@@ -66,8 +61,6 @@ export const EndScreenForm = ({
elementIdx={questions.length + endingCardIndex}
isInvalid={isInvalid}
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!endingCard.headline?.default || endingCard.headline.default.trim() === ""}
@@ -85,8 +78,6 @@ export const EndScreenForm = ({
elementIdx={questions.length + endingCardIndex}
isInvalid={isInvalid}
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
autoFocus={!endingCard.subheader?.default || endingCard.subheader.default.trim() === ""}
@@ -155,8 +146,6 @@ export const EndScreenForm = ({
elementIdx={questions.length + endingCardIndex}
isInvalid={isInvalid}
updateSurvey={updateSurvey}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
/>
@@ -7,7 +7,6 @@ import { TSurveyQuota } from "@formbricks/types/quota";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey, TSurveyEditorTabs, TSurveyStyling } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { extractLanguageCodes, getEnabledLanguages } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { useDocumentVisibility } from "@/lib/useDocumentVisibility";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
@@ -55,7 +54,6 @@ interface SurveyEditorProps {
export const SurveyEditor = ({
survey,
project,
projectLanguages,
environment,
actionClasses,
contactAttributeKeys,
@@ -85,7 +83,6 @@ export const SurveyEditor = ({
const [localSurvey, setLocalSurvey] = useState<TSurvey | null>(() => structuredClone(survey));
const [invalidElements, setInvalidElements] = useState<string[] | null>([]);
const [selectedLanguageCode, setSelectedLanguageCode] = useState<string>("default");
const surveyEditorRef = useRef(null);
const [localProject, setLocalProject] = useState<Project>(project);
@@ -147,14 +144,6 @@ export const SurveyEditor = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSurvey?.type]);
useEffect(() => {
if (!localSurvey?.languages) return;
const enabledLanguageCodes = extractLanguageCodes(getEnabledLanguages(localSurvey.languages ?? []));
if (!enabledLanguageCodes.includes(selectedLanguageCode)) {
setSelectedLanguageCode("default");
}
}, [localSurvey?.languages, selectedLanguageCode]);
if (!localSurvey) {
return <LoadingSkeleton />;
}
@@ -174,10 +163,7 @@ export const SurveyEditor = ({
setInvalidElements={setInvalidElements}
project={localProject}
responseCount={responseCount}
selectedLanguageCode={selectedLanguageCode}
setSelectedLanguageCode={setSelectedLanguageCode}
isCxMode={isCxMode}
locale={locale}
setIsCautionDialogOpen={setIsCautionDialogOpen}
isStorageConfigured={isStorageConfigured}
/>
@@ -199,11 +185,8 @@ export const SurveyEditor = ({
activeElementId={activeElementId}
setActiveElementId={setActiveElementId}
project={localProject}
projectLanguages={projectLanguages}
invalidElements={invalidElements}
setInvalidElements={setInvalidElements}
selectedLanguageCode={selectedLanguageCode || "default"}
setSelectedLanguageCode={setSelectedLanguageCode}
isFormbricksCloud={isFormbricksCloud}
isCxMode={isCxMode}
locale={locale}
@@ -255,7 +238,6 @@ export const SurveyEditor = ({
<FollowUpsView
localSurvey={localSurvey}
setLocalSurvey={setLocalSurveyNonNull}
selectedLanguageCode={selectedLanguageCode}
mailFrom={mailFrom}
isSurveyFollowUpsAllowed={isSurveyFollowUpsAllowed}
isFormbricksCloud={isFormbricksCloud}
@@ -273,7 +255,7 @@ export const SurveyEditor = ({
project={localProject}
environment={environment}
previewType={localSurvey.type === "app" ? "modal" : "fullwidth"}
languageCode={selectedLanguageCode}
languageCode="default"
isSpamProtectionAllowed={isSpamProtectionAllowed}
publicDomain={publicDomain}
/>
@@ -7,7 +7,6 @@ import { useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TSegment } from "@formbricks/types/segment";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import {
@@ -38,10 +37,7 @@ interface SurveyMenuBarProps {
setInvalidElements: React.Dispatch<React.SetStateAction<string[] | null>>;
project: Project;
responseCount: number;
selectedLanguageCode: string;
setSelectedLanguageCode: (selectedLanguage: string) => void;
isCxMode: boolean;
locale: string;
setIsCautionDialogOpen: (open: boolean) => void;
isStorageConfigured: boolean;
}
@@ -56,9 +52,7 @@ export const SurveyMenuBar = ({
setInvalidElements,
project,
responseCount,
selectedLanguageCode,
isCxMode,
locale,
setIsCautionDialogOpen,
isStorageConfigured = true,
}: SurveyMenuBarProps) => {
@@ -72,6 +66,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);
@@ -191,7 +186,17 @@ export const SurveyMenuBar = ({
const validateSurveyWithZod = (): boolean => {
const localSurveyValidation = ZSurvey.safeParse(localSurvey);
if (!localSurveyValidation.success) {
const issues = localSurveyValidation.error.issues;
const issues = localSurveyValidation.error.issues.filter((issue) => {
if (issue.code !== "custom") return true;
const params = issue.params as { invalidLanguageCodes?: string[] } | undefined;
if (params?.invalidLanguageCodes?.length) return false;
return !issue.message.includes("-fLang-");
});
if (issues.length === 0) {
return true;
}
const newInvalidIds: string[] = [];
for (const issue of issues) {
@@ -235,21 +240,9 @@ export const SurveyMenuBar = ({
const firstError = issues[0];
if (firstError.code === "custom") {
const params = firstError.params ?? ({} as { invalidLanguageCodes: string[] });
if (params.invalidLanguageCodes && params.invalidLanguageCodes.length) {
const invalidLanguageLabels = params.invalidLanguageCodes.map(
(invalidLanguage: string) => getLanguageLabel(invalidLanguage, locale) ?? invalidLanguage
);
const messageSplit = firstError.message.split("-fLang-")[0];
toast.error(`${messageSplit} ${invalidLanguageLabels.join(", ")}`);
} else {
toast.error(firstError.message, {
className: "w-fit !max-w-md",
});
}
toast.error(firstError.message, {
className: "w-fit !max-w-md",
});
return false;
}
@@ -269,8 +262,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 +282,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());
}
@@ -349,7 +351,7 @@ export const SurveyMenuBar = ({
}
try {
const isSurveyValidResult = isSurveyValid(localSurvey, selectedLanguageCode, t, responseCount);
const isSurveyValidResult = isSurveyValid(localSurvey, t, responseCount);
if (!isSurveyValidResult) {
setIsSurveySaving(false);
return false;
@@ -417,18 +419,21 @@ export const SurveyMenuBar = ({
};
const handleSurveyPublish = async () => {
isSurveyPublishingRef.current = true;
setIsSurveyPublishing(true);
const isSurveyValidatedWithZod = validateSurveyWithZod();
if (!isSurveyValidatedWithZod) {
isSurveyPublishingRef.current = false;
setIsSurveyPublishing(false);
return;
}
try {
const isSurveyValidResult = isSurveyValid(localSurvey, selectedLanguageCode, t, responseCount);
const isSurveyValidResult = isSurveyValid(localSurvey, t, responseCount);
if (!isSurveyValidResult) {
isSurveyPublishingRef.current = false;
setIsSurveyPublishing(false);
return;
}
@@ -445,10 +450,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 +463,7 @@ export const SurveyMenuBar = ({
} catch (error) {
console.error(error);
toast.error(t("environments.surveys.edit.error_publishing_survey"));
isSurveyPublishingRef.current = false;
setIsSurveyPublishing(false);
}
};
@@ -149,7 +149,8 @@ describe("Survey Editor Library Tests", () => {
features: {},
usageCycleAnchor: new Date(),
},
isAIEnabled: false,
isAISmartToolsEnabled: false,
isAIDataAnalysisEnabled: false,
};
beforeEach(() => {
@@ -949,7 +949,7 @@ describe("validation.isSurveyValid", () => {
});
test("should return true for a completely valid survey", () => {
expect(validation.isSurveyValid(baseSurvey, "en", mockT)).toBe(true);
expect(validation.isSurveyValid(baseSurvey, mockT)).toBe(true);
expect(toast.error).not.toHaveBeenCalled();
});
@@ -966,7 +966,7 @@ describe("validation.isSurveyValid", () => {
},
required: false,
});
expect(validation.isSurveyValid(baseSurvey, "de", mockT)).toBe(false);
expect(validation.isSurveyValid(baseSurvey, mockT)).toBe(false);
expect(toast.error).toHaveBeenCalledWith("environments.surveys.edit.fallback_missing");
});
@@ -975,7 +975,7 @@ describe("validation.isSurveyValid", () => {
...baseSurvey,
autoComplete: 0,
};
expect(validation.isSurveyValid(surveyWithZeroLimit, "en", mockT, 5)).toBe(false);
expect(validation.isSurveyValid(surveyWithZeroLimit, mockT, 5)).toBe(false);
expect(toast.error).toHaveBeenCalledWith("environments.surveys.edit.response_limit_can_t_be_set_to_0");
});
@@ -984,7 +984,7 @@ describe("validation.isSurveyValid", () => {
...baseSurvey,
autoComplete: 5,
};
expect(validation.isSurveyValid(surveyWithLowLimit, "en", mockT, 5)).toBe(false);
expect(validation.isSurveyValid(surveyWithLowLimit, mockT, 5)).toBe(false);
expect(toast.error).toHaveBeenCalledWith(
"environments.surveys.edit.response_limit_needs_to_exceed_number_of_received_responses",
{
@@ -998,7 +998,7 @@ describe("validation.isSurveyValid", () => {
...baseSurvey,
autoComplete: 3,
};
expect(validation.isSurveyValid(surveyWithLowLimit, "en", mockT, 5)).toBe(false);
expect(validation.isSurveyValid(surveyWithLowLimit, mockT, 5)).toBe(false);
expect(toast.error).toHaveBeenCalledWith(
"environments.surveys.edit.response_limit_needs_to_exceed_number_of_received_responses",
{
@@ -1012,7 +1012,7 @@ describe("validation.isSurveyValid", () => {
...baseSurvey,
autoComplete: 10,
};
expect(validation.isSurveyValid(surveyWithValidLimit, "en", mockT, 5)).toBe(true);
expect(validation.isSurveyValid(surveyWithValidLimit, mockT, 5)).toBe(true);
expect(toast.error).not.toHaveBeenCalled();
});
@@ -1021,7 +1021,7 @@ describe("validation.isSurveyValid", () => {
...baseSurvey,
autoComplete: null,
};
expect(validation.isSurveyValid(surveyWithNoLimit, "en", mockT, 5)).toBe(true);
expect(validation.isSurveyValid(surveyWithNoLimit, mockT, 5)).toBe(true);
expect(toast.error).not.toHaveBeenCalled();
});
@@ -1035,7 +1035,7 @@ describe("validation.isSurveyValid", () => {
},
} as unknown as TSurvey;
expect(validation.isSurveyValid(surveyWithEmptyClosedMessageHeading, "en", mockT)).toBe(false);
expect(validation.isSurveyValid(surveyWithEmptyClosedMessageHeading, mockT)).toBe(false);
expect(toast.error).toHaveBeenCalledWith(
"environments.surveys.edit.survey_closed_message_heading_required"
);
@@ -1051,7 +1051,7 @@ describe("validation.isSurveyValid", () => {
},
} as unknown as TSurvey;
expect(validation.isSurveyValid(surveyWithWhitespaceClosedMessageHeading, "en", mockT)).toBe(false);
expect(validation.isSurveyValid(surveyWithWhitespaceClosedMessageHeading, mockT)).toBe(false);
expect(toast.error).toHaveBeenCalledWith(
"environments.surveys.edit.survey_closed_message_heading_required"
);
@@ -1067,7 +1067,7 @@ describe("validation.isSurveyValid", () => {
},
} as unknown as TSurvey;
expect(validation.isSurveyValid(surveyWithHeadingOnlyClosedMessage, "en", mockT)).toBe(true);
expect(validation.isSurveyValid(surveyWithHeadingOnlyClosedMessage, mockT)).toBe(true);
expect(toast.error).not.toHaveBeenCalled();
});
@@ -1081,7 +1081,7 @@ describe("validation.isSurveyValid", () => {
},
} as unknown as TSurvey;
expect(validation.isSurveyValid(surveyWithClosedMessageContent, "en", mockT)).toBe(true);
expect(validation.isSurveyValid(surveyWithClosedMessageContent, mockT)).toBe(true);
expect(toast.error).not.toHaveBeenCalled();
});
@@ -1101,7 +1101,7 @@ describe("validation.isSurveyValid", () => {
},
} as unknown as TSurvey;
expect(validation.isSurveyValid(surveyWithInvalidSegment, "en", mockT)).toBe(false); // Zod parse will fail
expect(validation.isSurveyValid(surveyWithInvalidSegment, mockT)).toBe(false); // Zod parse will fail
expect(toast.error).toHaveBeenCalledWith("environments.surveys.edit.invalid_targeting");
});
@@ -1128,7 +1128,7 @@ describe("validation.isSurveyValid", () => {
const mockSafeParse = vi.spyOn(ZSegmentFilters, "safeParse");
mockSafeParse.mockReturnValue({ success: true, data: surveyWithValidSegment.segment!.filters } as any);
expect(validation.isSurveyValid(surveyWithValidSegment, "en", mockT)).toBe(true);
expect(validation.isSurveyValid(surveyWithValidSegment, mockT)).toBe(true);
expect(toast.error).not.toHaveBeenCalled();
mockSafeParse.mockRestore();
});
@@ -23,65 +23,56 @@ import {
TSurveyRedirectUrlCard,
TSurveyWelcomeCard,
} from "@formbricks/types/surveys/types";
import { findLanguageCodesForDuplicateLabels, getTextContent } from "@formbricks/types/surveys/validation";
import { extractLanguageCodes, getLocalizedValue } from "@/lib/i18n/utils";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { checkForEmptyFallBackValue } from "@/lib/utils/recall";
// Utility function to check if label is valid for all required languages
const getDefaultText = (label?: TI18nString): string => {
return getTextContent(label?.default ?? "");
};
export const isLabelValidForAllLanguages = (
label: TI18nString,
surveyLanguages: TSurveyLanguage[]
_surveyLanguages: TSurveyLanguage[]
): boolean => {
const filteredLanguages = surveyLanguages.filter((surveyLanguages) => {
return surveyLanguages.enabled;
});
const languageCodes = extractLanguageCodes(filteredLanguages);
const languages = languageCodes.length === 0 ? ["default"] : languageCodes;
return languages.every((language) => label?.[language] && getTextContent(label[language]).length > 0);
return getDefaultText(label).trim().length > 0;
};
// Validation logic for multiple choice elements
const handleI18nCheckForMultipleChoice = (
element: TSurveyMultipleChoiceElement,
languages: TSurveyLanguage[]
_languages: TSurveyLanguage[]
): boolean => {
const invalidLangCodes = findLanguageCodesForDuplicateLabels(
element.choices.map((choice) => choice.label),
languages
);
if (invalidLangCodes.length > 0) {
return false;
}
return element.choices.every((choice) => isLabelValidForAllLanguages(choice.label, languages));
const normalizedLabels = element.choices
.map((choice) => getDefaultText(choice.label).trim().toLowerCase())
.filter(Boolean);
const hasDuplicates = new Set(normalizedLabels).size !== normalizedLabels.length;
return !hasDuplicates && element.choices.every((choice) => getDefaultText(choice.label).trim().length > 0);
};
const handleI18nCheckForMatrixLabels = (
element: TSurveyMatrixElement,
languages: TSurveyLanguage[]
_languages: TSurveyLanguage[]
): boolean => {
const rowsAndColumns = [...element.rows, ...element.columns];
const rowLabels = element.rows.map((row) => getDefaultText(row.label).trim().toLowerCase()).filter(Boolean);
const colLabels = element.columns
.map((column) => getDefaultText(column.label).trim().toLowerCase())
.filter(Boolean);
const invalidRowsLangCodes = findLanguageCodesForDuplicateLabels(
element.rows.map((row) => row.label),
languages
);
const invalidColumnsLangCodes = findLanguageCodesForDuplicateLabels(
element.columns.map((column) => column.label),
languages
);
const hasDuplicateRows = new Set(rowLabels).size !== rowLabels.length;
const hasDuplicateColumns = new Set(colLabels).size !== colLabels.length;
if (invalidRowsLangCodes.length > 0 || invalidColumnsLangCodes.length > 0) {
if (hasDuplicateRows || hasDuplicateColumns) {
return false;
}
return rowsAndColumns.every((choice) => isLabelValidForAllLanguages(choice.label, languages));
return rowsAndColumns.every((choice) => getDefaultText(choice.label).trim().length > 0);
};
const handleI18nCheckForContactAndAddressFields = (
element: TSurveyContactInfoElement | TSurveyAddressElement,
languages: TSurveyLanguage[]
_languages: TSurveyLanguage[]
): boolean => {
let fields: TInputFieldConfig[] = [];
if (element.type === "contactInfo") {
@@ -93,7 +84,7 @@ const handleI18nCheckForContactAndAddressFields = (
}
return fields.every((field) => {
if (field.show) {
return isLabelValidForAllLanguages(field.placeholder, languages);
return getDefaultText(field.placeholder).trim().length > 0;
}
return true;
});
@@ -101,11 +92,11 @@ const handleI18nCheckForContactAndAddressFields = (
// Validation rules
export const validationRules = {
openText: (element: TSurveyOpenTextElement, languages: TSurveyLanguage[]) => {
openText: (element: TSurveyOpenTextElement, _languages: TSurveyLanguage[]) => {
return element.placeholder &&
getLocalizedValue(element.placeholder, "default").trim() !== "" &&
languages.length > 1
? isLabelValidForAllLanguages(element.placeholder, languages)
getLocalizedValue(element.placeholder, "default").trim() !== ""
? getDefaultText(element.placeholder).trim().length > 0
: true;
},
multipleChoiceMulti: (element: TSurveyMultipleChoiceElement, languages: TSurveyLanguage[]) => {
@@ -114,15 +105,15 @@ export const validationRules = {
multipleChoiceSingle: (element: TSurveyMultipleChoiceElement, languages: TSurveyLanguage[]) => {
return handleI18nCheckForMultipleChoice(element, languages);
},
consent: (element: TSurveyConsentElement, languages: TSurveyLanguage[]) => {
return isLabelValidForAllLanguages(element.label, languages);
consent: (element: TSurveyConsentElement, _languages: TSurveyLanguage[]) => {
return getDefaultText(element.label).trim().length > 0;
},
pictureSelection: (element: TSurveyPictureSelectionElement) => {
return element.choices.length >= 2;
},
cta: (element: TSurveyCTAElement, languages: TSurveyLanguage[]) => {
cta: (element: TSurveyCTAElement, _languages: TSurveyLanguage[]) => {
return element.buttonExternal && element.ctaButtonLabel
? isLabelValidForAllLanguages(element.ctaButtonLabel, languages)
? getDefaultText(element.ctaButtonLabel).trim().length > 0
: true;
},
matrix: (element: TSurveyMatrixElement, languages: TSurveyLanguage[]) => {
@@ -135,14 +126,14 @@ export const validationRules = {
return handleI18nCheckForContactAndAddressFields(element, languages);
},
// Assuming headline is of type TI18nString
defaultValidation: (element: TSurveyElement, languages: TSurveyLanguage[]) => {
defaultValidation: (element: TSurveyElement, _languages: TSurveyLanguage[]) => {
// headline and subheader are default for every element
const isHeadlineValid = isLabelValidForAllLanguages(element.headline, languages);
const isHeadlineValid = getDefaultText(element.headline).trim().length > 0;
const isSubheaderValid =
element.subheader &&
getLocalizedValue(element.subheader, "default").trim() !== "" &&
languages.length > 1
? isLabelValidForAllLanguages(element.subheader, languages)
getLocalizedValue(element.subheader, "default").trim() !== ""
? getDefaultText(element.subheader).trim().length > 0
: true;
let isValid = isHeadlineValid && isSubheaderValid;
const defaultLanguageCode = "default";
@@ -152,7 +143,7 @@ export const validationRules = {
for (const field of fieldsToValidate) {
const fieldValue = (element as unknown as Record<string, Record<string, string> | undefined>)[field];
if (fieldValue?.[defaultLanguageCode] !== undefined && fieldValue[defaultLanguageCode].trim() !== "") {
isValid = isValid && isLabelValidForAllLanguages(fieldValue, languages);
isValid = isValid && getDefaultText(fieldValue).trim().length > 0;
}
}
@@ -195,8 +186,8 @@ export const validateSurveyElementsInBatch = (
return invalidElements;
};
const isContentValid = (content: Record<string, string> | undefined, surveyLanguages: TSurveyLanguage[]) => {
return !content || isLabelValidForAllLanguages(content, surveyLanguages);
const isContentValid = (content: Record<string, string> | undefined, _surveyLanguages: TSurveyLanguage[]) => {
return !content || getDefaultText(content).trim().length > 0;
};
const hasValidSurveyClosedMessageHeading = (survey: TSurvey): boolean => {
@@ -247,13 +238,8 @@ export const isEndingCardValid = (
}
};
export const isSurveyValid = (
survey: TSurvey,
selectedLanguageCode: string,
t: TFunction,
responseCount?: number
) => {
const questionWithEmptyFallback = checkForEmptyFallBackValue(survey, selectedLanguageCode);
export const isSurveyValid = (survey: TSurvey, t: TFunction, responseCount?: number) => {
const questionWithEmptyFallback = checkForEmptyFallBackValue(survey, "default");
if (questionWithEmptyFallback) {
toast.error(t("environments.surveys.edit.fallback_missing"));
return false;
@@ -19,7 +19,6 @@ import { TooltipRenderer } from "@/modules/ui/components/tooltip";
interface FollowUpItemProps {
followUp: TSurveyFollowUp;
localSurvey: TSurvey;
selectedLanguageCode: string;
mailFrom: string;
userEmail: string;
teamMemberDetails: TFollowUpEmailToUser[];
@@ -31,7 +30,6 @@ export const FollowUpItem = ({
followUp,
localSurvey,
mailFrom,
selectedLanguageCode,
userEmail,
teamMemberDetails,
setLocalSurvey,
@@ -190,7 +188,6 @@ export const FollowUpItem = ({
open={editFollowUpModalOpen}
setOpen={setEditFollowUpModalOpen}
mailFrom={mailFrom}
selectedLanguageCode={selectedLanguageCode}
defaultValues={{
surveyFollowUpId: followUp.id,
followUpName: followUp.name,
@@ -68,7 +68,6 @@ interface AddFollowUpModalProps {
localSurvey: TSurvey;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
selectedLanguageCode: string;
mailFrom: string;
defaultValues?: Partial<TCreateSurveyFollowUpForm & { surveyFollowUpId: string }>;
mode?: "create" | "edit";
@@ -88,7 +87,6 @@ export const FollowUpModal = ({
localSurvey,
open,
setOpen,
selectedLanguageCode,
mailFrom,
defaultValues,
mode = "create",
@@ -98,6 +96,7 @@ export const FollowUpModal = ({
locale,
}: AddFollowUpModalProps) => {
const { t } = useTranslation();
const selectedLanguageCode = "default";
const ELEMENTS_ICON_MAP = getElementIconMap(t);
const containerRef = useRef<HTMLDivElement>(null);
const [firstRender, setFirstRender] = useState(true);
@@ -15,7 +15,6 @@ import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
interface FollowUpsViewProps {
localSurvey: TSurvey;
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
selectedLanguageCode: string;
mailFrom: string;
isSurveyFollowUpsAllowed: boolean;
isFormbricksCloud: boolean;
@@ -27,7 +26,6 @@ interface FollowUpsViewProps {
export const FollowUpsView = ({
localSurvey,
setLocalSurvey,
selectedLanguageCode,
mailFrom,
isSurveyFollowUpsAllowed,
isFormbricksCloud,
@@ -105,7 +103,6 @@ export const FollowUpsView = ({
followUp={followUp}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
selectedLanguageCode={selectedLanguageCode}
mailFrom={mailFrom}
userEmail={userEmail}
teamMemberDetails={teamMemberDetails}
@@ -120,7 +117,6 @@ export const FollowUpsView = ({
setLocalSurvey={setLocalSurvey}
open={addFollowUpModalOpen}
setOpen={setAddFollowUpModalOpen}
selectedLanguageCode={selectedLanguageCode}
mailFrom={mailFrom}
userEmail={userEmail}
teamMemberDetails={teamMemberDetails}
@@ -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,
+11 -3
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"],
@@ -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()
);
});
@@ -1,78 +0,0 @@
"use client";
import { Language } from "@prisma/client";
import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { DefaultTag } from "@/modules/ui/components/default-tag";
import { Label } from "@/modules/ui/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import type { ConfirmationModalProps } from "./multi-language-card";
interface DefaultLanguageSelectProps {
defaultLanguage?: Language;
handleDefaultLanguageChange: (languageCode: string) => void;
projectLanguages: Language[];
setConfirmationModalInfo: (confirmationModal: ConfirmationModalProps) => void;
locale: string;
}
export function DefaultLanguageSelect({
defaultLanguage,
handleDefaultLanguageChange,
projectLanguages,
setConfirmationModalInfo,
locale,
}: DefaultLanguageSelectProps) {
const { t } = useTranslation();
return (
<div className="space-y-2">
<Label>{t("environments.surveys.edit.1_choose_the_default_language_for_this_survey")}</Label>
<div className="flex items-center space-x-2">
<div className="w-48">
<Select
defaultValue={`${defaultLanguage?.code}`}
disabled={Boolean(defaultLanguage)}
onValueChange={(languageCode) => {
setConfirmationModalInfo({
open: true,
title:
t("environments.surveys.edit.confirm_default_language") +
": " +
getLanguageLabel(languageCode, locale),
body: t(
"environments.surveys.edit.once_set_the_default_language_for_this_survey_can_only_be_changed_by_disabling_the_multi_language_option_and_deleting_all_translations"
),
buttonText: t("common.confirm"),
onConfirm: () => {
handleDefaultLanguageChange(languageCode);
},
buttonVariant: "default",
});
}}
value={`${defaultLanguage?.code}`}>
<SelectTrigger className="w-full max-w-full truncate px-4 text-xs text-slate-800">
<SelectValue />
</SelectTrigger>
<SelectContent>
{projectLanguages.map((language) => (
<SelectItem
className="px-0.5 py-1 text-sm text-slate-800"
key={language.id}
value={language.code}>
{`${getLanguageLabel(language.code, locale)} (${language.code})`}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DefaultTag />
</div>
</div>
);
}
@@ -1,85 +0,0 @@
import { ChevronDown } from "lucide-react";
import { useRef, useState } from "react";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import type { TSurveyLanguage } from "@formbricks/types/surveys/types";
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
interface LanguageIndicatorProps {
selectedLanguageCode: string;
surveyLanguages: TSurveyLanguage[];
setSelectedLanguageCode: (languageCode: string) => void;
setFirstRender?: (firstRender: boolean) => void;
locale: string;
}
export function LanguageIndicator({
surveyLanguages,
selectedLanguageCode,
setSelectedLanguageCode,
setFirstRender,
locale,
}: LanguageIndicatorProps) {
const [showLanguageDropdown, setShowLanguageDropdown] = useState(false);
const toggleDropdown = () => {
setShowLanguageDropdown((prev) => !prev);
};
const languageDropdownRef = useRef(null);
const changeLanguage = (language: TSurveyLanguage) => {
setSelectedLanguageCode(language.default ? "default" : language.language.code);
if (setFirstRender) {
//for lexical editor
setFirstRender(true);
}
setShowLanguageDropdown(false);
};
const languageToBeDisplayed = surveyLanguages.find((language) => {
return selectedLanguageCode === "default"
? language.default
: language.language.code === selectedLanguageCode;
});
useClickOutside(languageDropdownRef, () => {
setShowLanguageDropdown(false);
});
return (
<div className="absolute right-2 top-2">
<button
aria-expanded={showLanguageDropdown}
aria-haspopup="true"
className="relative z-20 flex max-w-[120px] items-center justify-center rounded-md bg-slate-900 p-1 px-2 text-xs text-white hover:bg-slate-700"
onClick={toggleDropdown}
tabIndex={-1}
type="button">
<span className="max-w-full truncate">
{languageToBeDisplayed ? getLanguageLabel(languageToBeDisplayed.language.code, locale) : ""}
</span>
<ChevronDown className="ml-1 h-4 w-4 flex-shrink-0" />
</button>
{showLanguageDropdown ? (
<div
className="absolute right-0 z-30 mt-1 max-h-64 w-48 space-y-2 overflow-auto rounded-md bg-slate-900 p-1 text-xs text-white"
ref={languageDropdownRef}>
{surveyLanguages.map(
(language) =>
language.language.code !== languageToBeDisplayed?.language.code &&
language.enabled && (
<button
className="flex w-full rounded-sm p-1 text-left hover:bg-slate-700"
key={language.language.id}
onClick={() => {
changeLanguage(language);
}}
type="button">
<span className="min-w-0 flex-1 truncate">
{getLanguageLabel(language.language.code, locale)}
</span>
</button>
)
)}
</div>
) : null}
</div>
);
}
@@ -1,47 +0,0 @@
"use client";
import { Language } from "@prisma/client";
import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import type { TUserLocale } from "@formbricks/types/user";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
interface LanguageToggleProps {
language: Language;
isChecked: boolean;
onToggle: () => void;
onEdit: () => void;
locale: TUserLocale;
}
export function LanguageToggle({ language, isChecked, onToggle, onEdit, locale }: LanguageToggleProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col space-y-4">
<div className="flex max-w-96 items-center space-x-4">
<Switch
checked={isChecked}
id={`${language.code}-toggle`}
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
/>
<Label className="truncate font-medium text-slate-800" htmlFor={`${language.code}-toggle`}>
{getLanguageLabel(language.code, locale)}
</Label>
{isChecked ? (
<button
className="truncate text-xs text-slate-600 underline hover:text-slate-800"
onClick={onEdit}
type="button">
{t("environments.surveys.edit.edit_translations", {
lang: getLanguageLabel(language.code, locale),
})}
</button>
) : null}
</div>
</div>
);
}
@@ -1,191 +0,0 @@
"use client";
import { useMemo, useTransition } from "react";
import type { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import type { TI18nString } from "@formbricks/types/i18n";
import type { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types";
import { getTextContent, isValidHTML } from "@formbricks/types/surveys/validation";
import { TUserLocale } from "@formbricks/types/user";
import { md } from "@/lib/markdownIt";
import { recallToHeadline } from "@/lib/utils/recall";
import { isLabelValidForAllLanguages } from "@/modules/survey/editor/lib/validation";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { Editor } from "@/modules/ui/components/editor";
import { LanguageIndicator } from "./language-indicator";
interface LocalizedEditorProps {
id: string;
value: TI18nString | undefined;
localSurvey: TSurvey;
isInvalid: boolean;
updateElement: any;
selectedLanguageCode: string;
setSelectedLanguageCode: (languageCode: string) => void;
elementIdx: number;
firstRender: boolean;
setFirstRender?: Dispatch<SetStateAction<boolean>>;
locale: TUserLocale;
elementId: string;
isCard?: boolean; // Flag to indicate if this is a welcome/ending card
autoFocus?: boolean;
isExternalUrlsAllowed?: boolean;
suppressUpdates?: () => boolean; // Function to check if updates should be suppressed (e.g., during deletion)
}
const checkIfValueIsIncomplete = (
id: string,
isInvalid: boolean,
surveyLanguageCodes: TSurveyLanguage[],
value?: TI18nString
) => {
const labelIds = ["subheader", "headline", "html"];
if (value === undefined) return false;
const isDefaultIncomplete = labelIds.includes(id)
? getTextContent(value.default ?? "").trim() !== ""
: false;
return isInvalid && !isLabelValidForAllLanguages(value, surveyLanguageCodes) && isDefaultIncomplete;
};
export function LocalizedEditor({
id,
value,
localSurvey,
isInvalid,
updateElement,
selectedLanguageCode,
setSelectedLanguageCode,
elementIdx,
firstRender,
setFirstRender,
locale,
elementId,
isCard,
autoFocus,
isExternalUrlsAllowed,
suppressUpdates,
}: Readonly<LocalizedEditorProps>) {
// Derive elements from blocks for migrated surveys
const elements = useMemo(() => getElementsFromBlocks(localSurvey.blocks), [localSurvey.blocks]);
const { t } = useTranslation();
const isInComplete = useMemo(
() => checkIfValueIsIncomplete(id, isInvalid, localSurvey.languages, value),
[id, isInvalid, localSurvey.languages, value]
);
const [, startTransition] = useTransition();
return (
<div className="relative w-full">
<Editor
id={id}
disableLists
excludedToolbarItems={["blockType"]}
firstRender={firstRender}
autoFocus={autoFocus}
getText={() => {
const text = value ? (value[selectedLanguageCode] ?? "") : "";
let html = md.render(text);
// For backwards compatibility: wrap plain text headlines in <strong> tags
// This ensures old surveys maintain semibold styling when converted to HTML
if (id === "headline" && text && !isValidHTML(text)) {
// Use [\s\S]*? to match any character including newlines
html = html.replaceAll(/<p>([\s\S]*?)<\/p>/g, "<p><strong>$1</strong></p>");
}
return html;
}}
key={`${elementId}-${id}-${selectedLanguageCode}`}
setFirstRender={setFirstRender}
setText={(v: string) => {
// Early exit if updates are suppressed (e.g., during deletion)
// This prevents race conditions where setText fires with stale props before React updates state
if (suppressUpdates?.()) {
return;
}
let sanitizedContent = v;
if (!isExternalUrlsAllowed) {
sanitizedContent = v.replaceAll(/<a[^>]*>(.*?)<\/a>/gi, "$1");
}
const currentElement = elements[elementIdx];
startTransition(() => {
// if this is a card, we wanna check if the card exists in the localSurvey
if (isCard) {
const isWelcomeCard = elementIdx === -1;
const isEndingCard = elementIdx >= elements.length;
// For ending cards, check if the field exists before updating
if (isEndingCard) {
const ending = localSurvey.endings.find((ending) => ending.id === elementId);
// If the field doesn't exist on the ending card, don't create it
if ((ending as Record<string, unknown>)?.[id] === undefined) {
return;
}
}
// For welcome cards, check if it exists
if (isWelcomeCard && !localSurvey.welcomeCard) {
return;
}
const translatedContent = {
...value,
[selectedLanguageCode]: sanitizedContent,
};
updateElement({ [id]: translatedContent });
return;
}
// Check if the field exists on the element (not just if it's not undefined)
if (
currentElement &&
id in currentElement &&
(currentElement as Record<string, unknown>)[id] !== undefined
) {
const translatedContent = {
...value,
[selectedLanguageCode]: sanitizedContent,
};
updateElement(elementIdx, { [id]: translatedContent });
}
});
}}
localSurvey={localSurvey}
elementId={elementId}
selectedLanguageCode={selectedLanguageCode}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
{localSurvey.languages.length > 1 && (
<div>
<LanguageIndicator
selectedLanguageCode={selectedLanguageCode}
setFirstRender={setFirstRender}
setSelectedLanguageCode={setSelectedLanguageCode}
surveyLanguages={localSurvey.languages}
locale={locale}
/>
{value && selectedLanguageCode !== "default" && value.default ? (
<div className="mt-1 flex text-xs text-gray-500">
<strong>{t("environments.workspace.languages.translate")}:</strong>
<span className="ml-1">
{getTextContent(recallToHeadline(value, localSurvey, false, "default").default ?? "")}
</span>
</div>
) : null}
</div>
)}
{isInComplete ? (
<div className="mt-1 text-xs text-red-400">
{t("environments.workspace.languages.incomplete_translations")}
</div>
) : null}
</div>
);
}
@@ -1,300 +0,0 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { Language } from "@prisma/client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { ArrowUpRight, Languages } from "lucide-react";
import Link from "next/link";
import type { FC } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import { addMultiLanguageLabels, extractLanguageCodes, getEnabledLanguages } from "@/lib/i18n/utils";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
import { DefaultLanguageSelect } from "./default-language-select";
import { SecondaryLanguageSelect } from "./secondary-language-select";
interface MultiLanguageCardProps {
localSurvey: TSurvey;
projectLanguages: Language[];
setLocalSurvey: (survey: TSurvey) => void;
activeElementId: string | null;
setActiveElementId: (elementId: string | null) => void;
setSelectedLanguageCode: (language: string) => void;
locale: TUserLocale;
}
export interface ConfirmationModalProps {
body: string;
open: boolean;
title: string;
buttonText: string;
buttonVariant?: "default" | "destructive";
onConfirm: () => void;
}
export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
activeElementId,
localSurvey,
setActiveElementId,
setLocalSurvey,
projectLanguages,
setSelectedLanguageCode,
locale,
}) => {
const { t } = useTranslation();
const environmentId = localSurvey.environmentId;
const open = activeElementId === "multiLanguage";
const [isMultiLanguageActivated, setIsMultiLanguageActivated] = useState(localSurvey.languages.length > 0);
const [confirmationModalInfo, setConfirmationModalInfo] = useState<ConfirmationModalProps>({
title: "",
open: false,
body: "",
buttonText: "",
onConfirm: () => {},
});
const defaultLanguage = useMemo(
() => localSurvey.languages.find((language) => language.default)?.language,
[localSurvey.languages]
);
const setOpen = (open: boolean) => {
if (open) {
setActiveElementId("multiLanguage");
} else {
setActiveElementId(null);
}
};
useEffect(() => {
if (localSurvey.languages.length === 0) {
setIsMultiLanguageActivated(false);
}
}, [localSurvey.languages]);
const updateSurveyTranslations = (survey: TSurvey, updatedLanguages: TSurveyLanguage[]) => {
const translatedSurveyResult = addMultiLanguageLabels(survey, extractLanguageCodes(updatedLanguages));
const updatedSurvey = { ...translatedSurveyResult, languages: updatedLanguages };
setLocalSurvey(updatedSurvey as TSurvey);
};
const updateSurveyLanguages = (language: Language) => {
let updatedLanguages = localSurvey.languages;
const languageIndex = localSurvey.languages.findIndex(
(surveyLanguage) => surveyLanguage.language.code === language.code
);
if (languageIndex >= 0) {
// Toggle the 'enabled' property of the existing language
updatedLanguages = updatedLanguages.map((surveyLanguage, index) =>
index === languageIndex ? { ...surveyLanguage, enabled: !surveyLanguage.enabled } : surveyLanguage
);
} else {
// Add the new language
updatedLanguages = [
...updatedLanguages,
{
enabled: true,
default: false,
language,
},
];
}
updateSurveyTranslations(localSurvey, updatedLanguages);
};
const updateSurvey = (data: { languages: TSurveyLanguage[] }) => {
setLocalSurvey({ ...localSurvey, ...data });
};
const handleDefaultLanguageChange = (languageCode: string) => {
const language = projectLanguages.find((lang) => lang.code === languageCode);
if (language) {
let languageExists = false;
// Update all languages and check if the new default language already exists
const newLanguages =
localSurvey.languages.map((lang) => {
if (lang.language.code === language.code) {
languageExists = true;
return { ...lang, default: true };
}
return { ...lang, default: false };
}) ?? [];
if (!languageExists) {
// If the language doesn't exist, add it as the default
newLanguages.push({
enabled: true,
default: true,
language,
});
}
setConfirmationModalInfo({ ...confirmationModalInfo, open: false });
updateSurvey({ languages: newLanguages });
}
};
const handleActivationSwitchLogic = () => {
if (isMultiLanguageActivated) {
if (localSurvey.languages.length > 0) {
setConfirmationModalInfo({
open: true,
title: t("environments.surveys.edit.remove_translations"),
body: t("environments.surveys.edit.this_action_will_remove_all_the_translations_from_this_survey"),
buttonText: t("environments.surveys.edit.remove_translations"),
buttonVariant: "destructive",
onConfirm: () => {
updateSurveyTranslations(localSurvey, []);
setIsMultiLanguageActivated(false);
setConfirmationModalInfo({ ...confirmationModalInfo, open: false });
},
});
} else {
setIsMultiLanguageActivated(false);
}
} else {
setIsMultiLanguageActivated(true);
if (!open) {
setOpen(true);
}
}
};
const handleLanguageSwitchToggle = () => {
setLocalSurvey({ ...localSurvey, showLanguageSwitch: !localSurvey.showLanguageSwitch });
};
const [parent] = useAutoAnimate();
const enabledLanguages = getEnabledLanguages(localSurvey.languages);
return (
<div
className={cn(
open ? "shadow-lg" : "shadow-md",
"group z-10 flex flex-row rounded-lg bg-white text-slate-900"
)}>
<div
className={cn(
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none"
)}>
<p>
<Languages className="h-6 w-6 rounded-full bg-indigo-500 p-1 text-white" />
</p>
</div>
<Collapsible.Root
className="flex-1 rounded-r-lg border border-slate-200 transition-all duration-300 ease-in-out"
onOpenChange={setOpen}
open={open}>
<Collapsible.CollapsibleTrigger
asChild
className="flex cursor-pointer justify-between rounded-r-lg p-4 hover:bg-slate-50">
<div>
<div className="inline-flex">
<div>
<p className="text-sm font-semibold">{t("common.multiple_languages")}</p>
</div>
</div>
<div className="flex items-center space-x-2">
<Label htmlFor="multi-lang-toggle">
{isMultiLanguageActivated ? t("common.on") : t("common.off")}
</Label>
<Switch
checked={isMultiLanguageActivated}
disabled={projectLanguages.length === 0}
id="multi-lang-toggle"
onClick={(e) => {
e.stopPropagation();
handleActivationSwitchLogic();
}}
/>
</div>
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className={`flex flex-col px-4 ${open && "pb-6"}`} ref={parent}>
<div className="space-y-6 pt-3">
{projectLanguages.length === 0 && (
<div className="mb-4 text-sm italic text-slate-500">
{t("environments.surveys.edit.no_languages_found_add_first_one_to_get_started")}
</div>
)}
{projectLanguages.length > 0 && (
<div className="space-y-6">
{isMultiLanguageActivated ? (
<div className="space-y-6">
<DefaultLanguageSelect
defaultLanguage={defaultLanguage}
handleDefaultLanguageChange={handleDefaultLanguageChange}
projectLanguages={projectLanguages}
setConfirmationModalInfo={setConfirmationModalInfo}
locale={locale}
/>
{defaultLanguage && projectLanguages.length > 1 ? (
<SecondaryLanguageSelect
defaultLanguage={defaultLanguage}
localSurvey={localSurvey}
projectLanguages={projectLanguages}
setActiveElementId={setActiveElementId}
setSelectedLanguageCode={setSelectedLanguageCode}
updateSurveyLanguages={updateSurveyLanguages}
locale={locale}
/>
) : null}
</div>
) : (
<div className="text-sm italic text-slate-500">
{t("environments.surveys.edit.switch_multi_language_on_to_get_started")}
</div>
)}
</div>
)}
<Button asChild size="sm" variant="secondary">
<Link href={`/environments/${environmentId}/workspace/languages`} target="_blank">
{t("environments.surveys.edit.manage_languages")}
<ArrowUpRight />
</Link>
</Button>
{isMultiLanguageActivated && (
<AdvancedOptionToggle
customContainerClass="px-0 pt-0"
htmlId="languageSwitch"
disabled={enabledLanguages.length <= 1}
isChecked={!!localSurvey.showLanguageSwitch}
onToggle={handleLanguageSwitchToggle}
title={t("environments.surveys.edit.show_language_switch")}
description={t(
"environments.surveys.edit.enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey"
)}
childBorder={true}></AdvancedOptionToggle>
)}
<ConfirmationModal
buttonText={confirmationModalInfo.buttonText}
buttonVariant={confirmationModalInfo.buttonVariant}
onConfirm={confirmationModalInfo.onConfirm}
open={confirmationModalInfo.open}
setOpen={() => {
setConfirmationModalInfo((prev) => ({ ...prev, open: !prev.open }));
}}
body={confirmationModalInfo.body}
title={confirmationModalInfo.title}
/>
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
</div>
);
};
@@ -1,62 +0,0 @@
"use client";
import { Language } from "@prisma/client";
import { useTranslation } from "react-i18next";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
import { LanguageToggle } from "./language-toggle";
interface SecondaryLanguageSelectProps {
projectLanguages: Language[];
defaultLanguage: Language;
setSelectedLanguageCode: (languageCode: string) => void;
setActiveElementId: (elementId: string) => void;
localSurvey: TSurvey;
updateSurveyLanguages: (language: Language) => void;
locale: TUserLocale;
}
export function SecondaryLanguageSelect({
projectLanguages,
defaultLanguage,
setSelectedLanguageCode,
setActiveElementId,
localSurvey,
updateSurveyLanguages,
locale,
}: SecondaryLanguageSelectProps) {
const { t } = useTranslation();
const isLanguageToggled = (language: Language) => {
return localSurvey.languages.some(
(surveyLanguage) => surveyLanguage.language.code === language.code && surveyLanguage.enabled
);
};
const elements = getElementsFromBlocks(localSurvey.blocks);
return (
<div className="space-y-2">
<p className="text-sm font-medium text-slate-800">
{t("environments.surveys.edit.2_activate_translation_for_specific_languages")}
</p>{" "}
{projectLanguages
.filter((lang) => lang.id !== defaultLanguage.id)
.map((language) => (
<LanguageToggle
isChecked={isLanguageToggled(language)}
key={language.id}
language={language}
onEdit={() => {
setSelectedLanguageCode(language.code);
setActiveElementId(elements[0]?.id);
}}
onToggle={() => {
updateSurveyLanguages(language);
}}
locale={locale}
/>
))}
</div>
);
}
+1
View File
@@ -42,6 +42,7 @@
"@lexical/react": "0.41.0",
"@lexical/rich-text": "0.41.0",
"@lexical/table": "0.41.0",
"@next-auth/prisma-adapter": "1.0.7",
"@opentelemetry/auto-instrumentations-node": "0.71.0",
"@opentelemetry/exporter-metrics-otlp-http": "0.213.0",
"@opentelemetry/exporter-prometheus": "0.213.0",
@@ -485,5 +485,55 @@ test.describe("Authentication Security Tests - Vulnerability Prevention", () =>
logger.info(`✅ Malformed request handled gracefully: status ${response.status()}`);
});
test("should invalidate a copied session cookie after logout", async ({ page, browser, users }) => {
const user = await users.create();
await user.login();
const sessionCookie = (await page.context().cookies()).find((cookie) =>
cookie.name.includes("next-auth.session-token")
);
expect(sessionCookie).toBeDefined();
const preLogoutContext = await browser.newContext();
try {
await preLogoutContext.addCookies([sessionCookie!]);
const preLogoutPage = await preLogoutContext.newPage();
await preLogoutPage.goto("http://localhost:3000/environments");
await expect(preLogoutPage).not.toHaveURL(/\/auth\/login/);
} finally {
await preLogoutContext.close();
}
const signOutCsrfToken = await page
.context()
.request.get("/api/auth/csrf")
.then((response) => response.json())
.then((json) => json.csrfToken);
const signOutResponse = await page.context().request.post("/api/auth/signout", {
form: {
callbackUrl: "/auth/login",
csrfToken: signOutCsrfToken,
json: "true",
},
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
expect(signOutResponse.status()).not.toBe(500);
const replayContext = await browser.newContext();
try {
await replayContext.addCookies([sessionCookie!]);
const replayPage = await replayContext.newPage();
await replayPage.goto("http://localhost:3000/environments");
await expect(replayPage).toHaveURL(/\/auth\/login/);
} finally {
await replayContext.close();
}
});
});
});
+1 -494
View File
@@ -1,8 +1,7 @@
import { expect } from "@playwright/test";
import { surveys } from "@/playwright/utils/mock";
import { test } from "./lib/fixtures";
import * as helper from "./utils/helper";
import { createSurvey, createSurveyWithLogic, uploadFileForFileUploadQuestion } from "./utils/helper";
import { createSurvey, createSurveyWithLogic } from "./utils/helper";
test.use({
launchOptions: {
@@ -237,498 +236,6 @@ test.describe("Survey Create & Submit Response without logic", async () => {
});
});
test.describe("Multi Language Survey Create", async () => {
// 5 minutes
test.setTimeout(1000 * 60 * 5);
test("Create Survey", async ({ page, users }) => {
const user = await users.create();
await user.login();
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
//add a new language
await page.getByRole("link", { name: "Configuration" }).click();
await page.getByRole("link", { name: "Survey Languages" }).click();
await page.getByRole("button", { name: "Edit languages" }).click();
await page.getByRole("button", { name: "Add language" }).click();
await page.getByRole("button", { name: "Select" }).click();
await page.getByPlaceholder("Search items").click();
await page.getByPlaceholder("Search items").fill("Eng");
await page.getByText("English", { exact: true }).click();
await page.getByRole("button", { name: "Save changes" }).click();
await page.getByRole("button", { name: "Edit languages" }).click();
await page.getByRole("button", { name: "Add language" }).click();
await page.getByRole("button", { name: "Select" }).click();
await page.getByRole("textbox", { name: "Search items" }).click();
await page.getByRole("textbox", { name: "Search items" }).fill("German");
await page.getByText("German", { exact: true }).nth(1).click();
await page.getByRole("button", { name: "Save changes" }).click();
await page.waitForTimeout(2000);
await page.getByRole("link", { name: "Surveys" }).click();
await page.getByText("Start from scratch").click();
await page.getByRole("button", { name: "Create survey", exact: true }).click();
await page.locator("#multi-lang-toggle").click();
await page.getByRole("combobox").click();
await page.getByLabel("English (en)").click();
await page.getByRole("button", { name: "Confirm" }).click();
await page.getByLabel("German").click();
await page.locator("#welcome-toggle").click();
// Add questions in default language
await page.getByText("Add Block").click();
await page.getByRole("button", { name: "Single-Select" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.singleSelectQuestion.question);
await page.getByPlaceholder("Option 1").fill(surveys.createAndSubmit.singleSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").fill(surveys.createAndSubmit.singleSelectQuestion.options[1]);
await page
.locator("div")
.filter({ hasText: /^Add BlockChoose the first question on your Block$/ })
.nth(1)
.click();
await page.getByRole("button", { name: "Multi-Select Ask respondents" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.multiSelectQuestion.question);
await page.getByPlaceholder("Option 1").fill(surveys.createAndSubmit.multiSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").fill(surveys.createAndSubmit.multiSelectQuestion.options[1]);
await page.getByPlaceholder("Option 3").fill(surveys.createAndSubmit.multiSelectQuestion.options[2]);
await page
.locator("div")
.filter({ hasText: /^Add BlockChoose the first question on your Block$/ })
.nth(1)
.click();
await page.getByRole("button", { name: "Picture Selection" }).click();
await helper.fillRichTextEditor(
page,
"Question*",
surveys.createAndSubmit.pictureSelectQuestion.question
);
// Handle file uploads
await uploadFileForFileUploadQuestion(page);
await page
.locator("div")
.filter({ hasText: /^Add BlockChoose the first question on your Block$/ })
.nth(1)
.click();
await page.getByRole("button", { name: "Rating" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.ratingQuestion.question);
await page.getByPlaceholder("Not good").fill(surveys.createAndSubmit.ratingQuestion.lowLabel);
await page.getByPlaceholder("Very satisfied").fill(surveys.createAndSubmit.ratingQuestion.highLabel);
await page
.locator("div")
.filter({ hasText: /^Add BlockChoose the first question on your Block$/ })
.nth(1)
.click();
await page.getByRole("button", { name: "Net Promoter Score (NPS)" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.npsQuestion.question);
await page.getByLabel("Lower label").fill(surveys.createAndSubmit.npsQuestion.lowLabel);
await page.getByLabel("Upper label").fill(surveys.createAndSubmit.npsQuestion.highLabel);
await page
.locator("div")
.filter({ hasText: /^Add BlockChoose the first question on your Block$/ })
.nth(1)
.click();
await page.getByRole("button", { name: "Date" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.dateQuestion.question);
await page
.locator("div")
.filter({ hasText: /^Add BlockChoose the first question on your Block$/ })
.nth(1)
.click();
await page.getByRole("button", { name: "File Upload" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.fileUploadQuestion.question);
await page
.locator("div")
.filter({ hasText: /^Add BlockChoose the first question on your Block$/ })
.nth(1)
.click();
await page.getByRole("button", { name: "Matrix" }).scrollIntoViewIfNeeded();
await page.getByRole("button", { name: "Matrix" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.matrix.question);
await page.locator("#row-0").click();
await page.locator("#row-0").fill(surveys.createAndSubmit.matrix.rows[0]);
await page.locator("#row-1").click();
await page.locator("#row-1").fill(surveys.createAndSubmit.matrix.rows[1]);
await page.getByRole("button", { name: "Add row" }).click();
await page.locator("#row-2").click();
await page.locator("#row-2").fill(surveys.createAndSubmit.matrix.rows[2]);
await page.locator("#column-0").click();
await page.locator("#column-0").fill(surveys.createAndSubmit.matrix.columns[0]);
await page.locator("#column-1").click();
await page.locator("#column-1").fill(surveys.createAndSubmit.matrix.columns[1]);
await page.getByRole("button", { name: "Add column" }).click();
await page.locator("#column-2").click();
await page.locator("#column-2").fill(surveys.createAndSubmit.matrix.columns[2]);
await page.getByRole("button", { name: "Add column" }).click();
await page.locator("#column-3").click();
await page.locator("#column-3").fill(surveys.createAndSubmit.matrix.columns[3]);
await page
.locator("div")
.filter({ hasText: /^Add BlockChoose the first question on your Block$/ })
.nth(1)
.click();
await page.getByRole("button", { name: "Address" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.address.question);
await page.getByRole("row", { name: "Address Line 2" }).getByRole("switch").nth(1).click();
await page.getByRole("row", { name: "City" }).getByRole("cell").nth(2).click();
await page.getByRole("row", { name: "State" }).getByRole("switch").nth(1).click();
await page.getByRole("row", { name: "Zip" }).getByRole("cell").nth(2).click();
await page.getByRole("row", { name: "Country" }).getByRole("switch").nth(1).click();
await page
.locator("div")
.filter({ hasText: /^Add BlockChoose the first question on your Block$/ })
.nth(1)
.click();
await page.getByRole("button", { name: "Ranking" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.createAndSubmit.ranking.question);
await page.getByPlaceholder("Option 1").click();
await page.getByPlaceholder("Option 1").fill(surveys.createAndSubmit.ranking.choices[0]);
await page.getByPlaceholder("Option 2").click();
await page.getByPlaceholder("Option 2").fill(surveys.createAndSubmit.ranking.choices[1]);
await page.getByRole("button", { name: "Add option" }).click();
await page.getByPlaceholder("Option 3").click();
await page.getByPlaceholder("Option 3").fill(surveys.createAndSubmit.ranking.choices[2]);
await page.getByRole("button", { name: "Add option" }).click();
await page.getByPlaceholder("Option 4").click();
await page.getByPlaceholder("Option 4").fill(surveys.createAndSubmit.ranking.choices[3]);
await page.getByRole("button", { name: "Add option" }).click();
await page.getByPlaceholder("Option 5").click();
await page.getByPlaceholder("Option 5").fill(surveys.createAndSubmit.ranking.choices[4]);
// Enable translation in german
await page.getByText("Welcome CardShownOn").click();
await page.getByRole("button", { name: "English" }).nth(1).click();
await page.getByRole("button", { name: "German" }).click();
// Fill welcome card in german using rich text editor helper
await helper.fillRichTextEditor(page, "Note*", surveys.germanCreate.welcomeCard.headline);
await helper.fillRichTextEditor(page, "Welcome message", surveys.germanCreate.welcomeCard.description);
await page.getByPlaceholder("Next").click();
await page.getByPlaceholder("Next").fill(surveys.germanCreate.welcomeCard.buttonLabel);
// Fill Open text question in german
await page.getByRole("main").getByText("Free text").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.openTextQuestion.question);
await page.getByLabel("Placeholder").click();
await page.getByLabel("Placeholder").fill(surveys.germanCreate.openTextQuestion.placeholder);
await page.getByText("Show Block settings").first().click();
await page.getByRole("textbox", { name: "Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page
.locator("div")
.filter({ hasText: /^Block 11 question$/ })
.first()
.click();
// Fill Single select question in german
await page.getByRole("main").getByText("Single-Select").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.singleSelectQuestion.question);
await page.getByPlaceholder("Option 1").click();
await page.getByPlaceholder("Option 1").fill(surveys.germanCreate.singleSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").click();
await page.getByPlaceholder("Option 2").fill(surveys.germanCreate.singleSelectQuestion.options[1]);
await page.getByText("Show Block settings").first().click();
await page.getByRole("textbox", { name: "Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
.locator("div")
.filter({ hasText: /^Block 21 question$/ })
.first()
.click();
// Fill Multi select question in german
await page.getByRole("main").getByRole("heading", { name: "Multi-Select" }).click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.multiSelectQuestion.question);
await page.getByPlaceholder("Option 1").click();
await page.getByPlaceholder("Option 1").fill(surveys.germanCreate.multiSelectQuestion.options[0]);
await page.getByPlaceholder("Option 2").click();
await page.getByPlaceholder("Option 2").fill(surveys.germanCreate.multiSelectQuestion.options[1]);
await page.getByPlaceholder("Option 3").click();
await page.getByPlaceholder("Option 3").fill(surveys.germanCreate.multiSelectQuestion.options[2]);
await page.getByText("Show Block settings").first().click();
await page.getByRole("textbox", { name: "Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
.locator("div")
.filter({ hasText: /^Block 31 question$/ })
.first()
.click();
// Fill Picture select question in german
await page.getByRole("main").getByText("Picture Selection").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.pictureSelectQuestion.question);
await page.getByText("Show Block settings").first().click();
await page.getByRole("textbox", { name: "Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
.locator("div")
.filter({ hasText: /^Block 41 question$/ })
.first()
.click();
// Fill Rating question in german
await page.getByRole("main").getByText("Rating").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.ratingQuestion.question);
await page.getByPlaceholder("Not good").click();
await page.getByPlaceholder("Not good").fill(surveys.germanCreate.ratingQuestion.lowLabel);
await page.getByPlaceholder("Very satisfied").click();
await page.getByPlaceholder("Very satisfied").fill(surveys.germanCreate.ratingQuestion.highLabel);
await page.getByText("Show Block settings").first().click();
await page.getByRole("textbox", { name: "Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
.locator("div")
.filter({ hasText: /^Block 51 question$/ })
.first()
.click();
// Fill NPS question in german
await page.getByRole("main").getByText("Net Promoter Score (NPS)").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.npsQuestion.question);
await page.getByLabel("Lower Label").click();
await page.getByLabel("Lower Label").fill(surveys.germanCreate.npsQuestion.lowLabel);
await page.getByLabel("Upper Label").click();
await page.getByLabel("Upper Label").fill(surveys.germanCreate.npsQuestion.highLabel);
await page.getByText("Show Block settings").first().click();
await page.getByRole("textbox", { name: "Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
.locator("div")
.filter({ hasText: /^Block 61 question$/ })
.first()
.click();
// Fill Date question in german
await page.getByRole("main").getByText("Date").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.dateQuestion.question);
await page.getByText("Show Block settings").first().click();
await page.getByRole("textbox", { name: "Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
.locator("div")
.filter({ hasText: /^Block 71 question$/ })
.first()
.click();
// Fill File upload question in german
await page.getByRole("main").getByText("File Upload").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.fileUploadQuestion.question);
await page.getByText("Show Block settings").first().click();
await page.getByRole("textbox", { name: "Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
.locator("div")
.filter({ hasText: /^Block 81 question$/ })
.first()
.click();
// Fill Matrix question in german
await page.getByRole("main").getByText("Matrix").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.matrix.question);
await page.locator("#row-0").click();
await page.locator("#row-0").fill(surveys.germanCreate.matrix.rows[0]);
await page.locator("#row-1").click();
await page.locator("#row-1").fill(surveys.germanCreate.matrix.rows[1]);
await page.locator("#row-2").click();
await page.locator("#row-2").fill(surveys.germanCreate.matrix.rows[2]);
await page.locator("#column-0").click();
await page.locator("#column-0").fill(surveys.germanCreate.matrix.columns[0]);
await page.locator("#column-1").click();
await page.locator("#column-1").fill(surveys.germanCreate.matrix.columns[1]);
await page.locator("#column-2").click();
await page.locator("#column-2").fill(surveys.germanCreate.matrix.columns[2]);
await page.locator("#column-3").click();
await page.locator("#column-3").fill(surveys.germanCreate.matrix.columns[3]);
await page.getByText("Show Block settings").first().click();
await page.getByRole("textbox", { name: "Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
.locator("div")
.filter({ hasText: /^Block 91 question$/ })
.first()
.click();
// Fill Address question in german
await page.getByRole("main").getByText("Address").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.addressQuestion.question);
await page.locator('[id="addressLine1\\.placeholder"]').click();
await page
.locator('[id="addressLine1\\.placeholder"]')
.fill(surveys.germanCreate.addressQuestion.placeholder.addressLine1);
await page.locator('[id="addressLine2\\.placeholder"]').click();
await page
.locator('[id="addressLine2\\.placeholder"]')
.fill(surveys.germanCreate.addressQuestion.placeholder.addressLine2);
await page.locator('[id="city\\.placeholder"]').click();
await page
.locator('[id="city\\.placeholder"]')
.fill(surveys.germanCreate.addressQuestion.placeholder.city);
await page.locator('[id="state\\.placeholder"]').click();
await page
.locator('[id="state\\.placeholder"]')
.fill(surveys.germanCreate.addressQuestion.placeholder.state);
await page.locator('[id="zip\\.placeholder"]').click();
await page.locator('[id="zip\\.placeholder"]').fill(surveys.germanCreate.addressQuestion.placeholder.zip);
await page.locator('[id="country\\.placeholder"]').click();
await page
.locator('[id="country\\.placeholder"]')
.fill(surveys.germanCreate.addressQuestion.placeholder.country);
await page.getByText("Show Block settings").first().click();
await page.getByRole("textbox", { name: "Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
.locator("div")
.filter({ hasText: /^Block 101 question$/ })
.first()
.click();
// Fill Ranking question in german
await page.getByRole("main").getByText("Ranking").click();
await helper.fillRichTextEditor(page, "Question*", surveys.germanCreate.ranking.question);
await page.getByPlaceholder("Option 1").click();
await page.getByPlaceholder("Option 1").fill(surveys.germanCreate.ranking.choices[0]);
await page.getByPlaceholder("Option 2").click();
await page.getByPlaceholder("Option 2").fill(surveys.germanCreate.ranking.choices[1]);
await page.getByPlaceholder("Option 3").click();
await page.getByPlaceholder("Option 3").fill(surveys.germanCreate.ranking.choices[2]);
await page.getByPlaceholder("Option 4").click();
await page.getByPlaceholder("Option 4").fill(surveys.germanCreate.ranking.choices[3]);
await page.getByPlaceholder("Option 5").click();
await page.getByPlaceholder("Option 5").fill(surveys.germanCreate.ranking.choices[4]);
await page.getByText("Show Block settings").first().click();
await page.getByRole("textbox", { name: "Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "Button Label", exact: true })
.first()
.fill(surveys.germanCreate.next);
await page.getByRole("textbox", { name: "“Back” Button Label", exact: true }).first().click();
await page
.getByRole("textbox", { name: "“Back” Button Label", exact: true })
.first()
.fill(surveys.germanCreate.back);
await page
.locator("div")
.filter({ hasText: /^Block 111 question$/ })
.first()
.click();
// Fill Thank you card in german
await page.getByText("Ending card").first().click();
await helper.fillRichTextEditor(page, "Note*", surveys.germanCreate.endingCard.headline);
await helper.fillRichTextEditor(page, "Description", surveys.germanCreate.endingCard.description);
await page.locator("#showButton").check();
await page.getByPlaceholder("Create your own Survey").click();
await page.getByPlaceholder("Create your own Survey").fill(surveys.germanCreate.endingCard.buttonLabel);
// TODO: @pandeymangg - figure out if this is required
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.locator("#howToSendCardTrigger").click();
await expect(page.locator("#howToSendCardOption-link")).toBeVisible();
await page.locator("#howToSendCardOption-link").click();
// Wait for any auto-save to complete before publishing
await page.waitForTimeout(2000);
await page.getByRole("button", { name: "Publish" }).click();
await page.waitForTimeout(2000);
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/summary(\?.*)?$/, { timeout: 60000 });
await page.getByLabel("Select Language").click();
await page.getByText("German").click();
await page.getByLabel("Copy survey link to clipboard").click();
const germanSurveyUrl = await page.evaluate("navigator.clipboard.readText()");
expect(germanSurveyUrl).toContain("lang=de");
});
});
test.describe("Testing Survey with advanced logic", async () => {
// 8 minutes
test.setTimeout(1000 * 60 * 8);
+85
View File
@@ -0,0 +1,85 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { proxy } from "./proxy";
const { mockGetProxySession, mockIsPublicDomainConfigured, mockIsRequestFromPublicDomain } = vi.hoisted(
() => ({
mockGetProxySession: vi.fn(),
mockIsPublicDomainConfigured: vi.fn(),
mockIsRequestFromPublicDomain: vi.fn(),
})
);
vi.mock("@/modules/auth/lib/proxy-session", () => ({
getProxySession: mockGetProxySession,
}));
vi.mock("@/app/middleware/domain-utils", () => ({
isPublicDomainConfigured: mockIsPublicDomainConfigured,
isRequestFromPublicDomain: mockIsRequestFromPublicDomain,
}));
vi.mock("@/app/middleware/endpoint-validator", () => ({
isAuthProtectedRoute: (url: string) => url.startsWith("/environments"),
isRouteAllowedForDomain: vi.fn(() => true),
}));
vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "http://localhost:3000",
}));
vi.mock("@/lib/utils/url", () => ({
isValidCallbackUrl: (url: string) => url.startsWith("http://localhost:3000"),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
describe("proxy", () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsPublicDomainConfigured.mockReturnValue(false);
mockIsRequestFromPublicDomain.mockReturnValue(false);
});
test("redirects unauthenticated protected routes to login with callbackUrl", async () => {
mockGetProxySession.mockResolvedValue(null);
const response = await proxy(new NextRequest("http://localhost:3000/environments/test"));
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(
"http://localhost:3000/auth/login?callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Fenvironments%2Ftest"
);
});
test("rejects invalid callback URLs", async () => {
mockGetProxySession.mockResolvedValue(null);
const response = await proxy(
new NextRequest("http://localhost:3000/auth/login?callbackUrl=https%3A%2F%2Fevil.example")
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({ error: "Invalid callback URL" });
});
test("redirects authenticated callback requests to the callback URL", async () => {
mockGetProxySession.mockResolvedValue({
userId: "user-1",
expires: new Date(Date.now() + 60_000),
});
const response = await proxy(
new NextRequest(
"http://localhost:3000/auth/login?callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Fenvironments%2Ftest"
)
);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe("http://localhost:3000/environments/test");
});
});
+4 -4
View File
@@ -1,4 +1,3 @@
import { getToken } from "next-auth/jwt";
import { NextRequest, NextResponse } from "next/server";
import { v4 as uuidv4 } from "uuid";
import { logger } from "@formbricks/logger";
@@ -6,11 +5,12 @@ import { isPublicDomainConfigured, isRequestFromPublicDomain } from "@/app/middl
import { isAuthProtectedRoute, isRouteAllowedForDomain } from "@/app/middleware/endpoint-validator";
import { WEBAPP_URL } from "@/lib/constants";
import { isValidCallbackUrl } from "@/lib/utils/url";
import { getProxySession } from "@/modules/auth/lib/proxy-session";
const handleAuth = async (request: NextRequest): Promise<Response | null> => {
const token = await getToken({ req: request as any });
const session = await getProxySession(request);
if (isAuthProtectedRoute(request.nextUrl.pathname) && !token) {
if (isAuthProtectedRoute(request.nextUrl.pathname) && !session) {
const loginUrl = `${WEBAPP_URL}/auth/login?callbackUrl=${encodeURIComponent(WEBAPP_URL + request.nextUrl.pathname + request.nextUrl.search)}`;
return NextResponse.redirect(loginUrl);
}
@@ -21,7 +21,7 @@ const handleAuth = async (request: NextRequest): Promise<Response | null> => {
return NextResponse.json({ error: "Invalid callback URL" }, { status: 400 });
}
if (token && callbackUrl) {
if (session && callbackUrl) {
return NextResponse.redirect(callbackUrl);
}
+1 -1
View File
@@ -15,7 +15,7 @@ export default defineConfig({
provider: "v8", // Use V8 as the coverage provider
reporter: ["text", "html", "lcov"], // Generate text summary and HTML reports
reportsDirectory: "./coverage", // Output coverage reports to the coverage/ directory
include: ["app/**/*.ts", "modules/**/*.ts", "lib/**/*.ts", "lingodotdev/**/*.ts"],
include: ["app/**/*.ts", "modules/**/*.ts", "lib/**/*.ts", "lingodotdev/**/*.ts", "proxy.ts"],
exclude: [
// Build and configuration files
"**/.next/**", // Next.js build output
@@ -32,6 +32,7 @@ These variables are present inside your machine's docker-compose file. Restart t
| PASSWORD_RESET_DISABLED | Disables password reset functionality if set to 1. | optional | |
| 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 | |
| INVITE_DISABLED | Disables the ability for invited users to create an account if set to 1. | optional | |
| MAIL_FROM | Email address to send emails from. | optional (required if email services are to be enabled) | |
| MAIL_FROM_NAME | Email name/title to send emails from. | optional (required if email services are to be enabled) | |
@@ -70,6 +70,18 @@ endpoint with [ngrok](https://ngrok.com/docs/universal-gateway/http).
workflow while validating the webhook setup.
</Note>
### Allowing Internal URLs (Self-Hosted Only)
By default, Formbricks blocks webhook URLs that point to private or internal IP addresses (e.g. `localhost`, `192.168.x.x`, `10.x.x.x`) to prevent [SSRF attacks](https://owasp.org/www-community/attacks/Server-Side_Request_Forgery). If you are self-hosting Formbricks and need to send webhooks to internal services, you can set the following environment variable:
```sh
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
```
<Warning>
Only enable this on trusted, self-hosted environments. Enabling this on a publicly accessible instance exposes your server to SSRF risks.
</Warning>
If you encounter any issues or need help setting up webhooks, feel free to reach out to us on [GitHub Discussions](https://github.com/formbricks/formbricks/discussions). 😃
---
@@ -45,28 +45,13 @@ How to deliver a specific language depends on the survey type (app or link surve
![Survey Overview](/images/xm-and-surveys/surveys/general-features/multi-language-surveys/surveys-home.webp)
</Step>
<Step title="Enable Multi-language Support">
In the survey editor, scroll down to the **Multiple Languages** section at the bottom and enable the toggle next to it:
![Enable Multi-language for a survey](/images/xm-and-surveys/surveys/general-features/multi-language-surveys/enable-multi-lang.webp)
Choose a **Default Language** for your survey.
<Note>Changing the default language will reset all the translations you have made for the survey.</Note>
</Step>
<Step title="Add Supported Languages">
Add the languages from the dropdown that you want to support in your survey:
![Enable Multi-language for a survey](/images/xm-and-surveys/surveys/general-features/multi-language-surveys/add-language-in-survey.webp)
</Step>
<Step title="Preview and Translate Content">
You can now see the survey in the selected language by clicking on the language dropdown in any of the questions.
Now you can translate all survey content, including questions, options, and button placeholders, into the selected language.
<Step title="Current editor behavior">
Survey language definitions still come from **Configuration → Survey Languages**.
<Note>
The old translation controls inside the Questions tab have been removed temporarily. In-editor translation
editing will return with the upcoming dedicated **Language** tab.
</Note>
</Step>
<Step title="Publish Your Survey">

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