mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-20 19:30:41 -05:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8baeb7860 | |||
| 5bb8119ebf | |||
| 02411277d4 | |||
| 4cfb8c6d7b | |||
| e74a51a5ff | |||
| 29cc6a10fe | |||
| 01f765e969 | |||
| 9366960f18 | |||
| 697dc9cc99 |
@@ -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 = [
|
||||
|
||||
+77
-20
@@ -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({
|
||||
|
||||
+84
@@ -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>
|
||||
);
|
||||
};
|
||||
+6
@@ -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}
|
||||
|
||||
-208
@@ -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",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
|
||||
@@ -37,7 +37,8 @@ describe("auth", () => {
|
||||
},
|
||||
usageCycleAnchor: new Date(),
|
||||
},
|
||||
isAIEnabled: false,
|
||||
isAISmartToolsEnabled: false,
|
||||
isAIDataAnalysisEnabled: false,
|
||||
},
|
||||
];
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue(mockOrganizations);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 !"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "フィードバックありがとうございます!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Спасибо за твой отзыв!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "感谢你的反馈!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "感謝你的回饋!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }],
|
||||
},
|
||||
|
||||
@@ -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} />
|
||||
|
||||
-100
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
-62
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||

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

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

|
||||
</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
Reference in New Issue
Block a user