Compare commits

..

29 Commits

Author SHA1 Message Date
Cursor Agent 50156d474c refactor: improve test coverage for DOM readiness checks
- Extracted ensureBodyExists to separate dom-utils module for better testability
- Added comprehensive tests for dom-utils (100% coverage)
- Added tests for null document.head scenarios in styles module
- Added tests for null document.getElementById in setStyleNonce
- Improved overall test coverage from 42.9% to 90%+ on new code
- All 535 tests pass
2026-03-22 16:52:26 +00:00
Cursor Agent 87b859d02a test: add DOM readiness tests to verify fix
Added comprehensive tests to verify the ensureBodyExists and safe DOM access patterns work correctly in various scenarios.
2026-03-22 16:44:02 +00:00
Cursor Agent df7e768216 fix: prevent null access to document.body during survey rendering
Fixes FORMBRICKS-VD

Added checks to ensure document.body and document.head exist before attempting DOM manipulation:
- renderSurvey now waits for document.body to be available before appending modal container
- addStylesToDom checks for document.head existence before adding styles
- addCustomThemeToDom checks for document.head existence before adding custom theme
- setStyleNonce safely checks for document.getElementById before updating existing elements

This prevents TypeError: can't access property 'removeChild' of null that occurred when surveys loaded before the DOM was fully ready, particularly in Firefox with Turbopack.
2026-03-22 16:41:15 +00:00
Tiago a96ba8b1e7 docs: clarify v2 contact API request body shapes (#1089) (#7552) 2026-03-20 16:23:06 +00:00
Johannes e830871361 docs: update docs re multi-lang (#7547) 2026-03-20 15:56:03 +00:00
Matti Nannt 998e5c0819 fix: resolve high severity dependabot alerts (#7551) 2026-03-20 15:55:15 +00:00
Balázs Úr 13a56b0237 fix: mark language selector tooltip as translatable (#7520) 2026-03-20 12:17:26 +00:00
Dhruwang Jariwala 0b5418a03a feat: searchable dropdown (#7530)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-03-20 12:15:48 +00:00
Anshuman Pandey 0d8a338965 fix: fixes welcome card logo removal bug (#7544) 2026-03-20 10:06:01 +00:00
Tiago d3250736a9 feat: add V3 surveys API (#7499) 2026-03-20 09:55:33 +00:00
Dhruwang Jariwala e6ee6a6b0d feat: choice rotation (#7512)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-20 06:54:05 +00:00
Dhruwang Jariwala c0b097f929 refactor: update CTA component styles and utility class groups (#7532) 2026-03-20 06:43:35 +00:00
Tiago 78d336f8c7 chore: Improve the webhook "Test Endpoint" feature (#7527) 2026-03-19 16:13:48 +01:00
Dhruwang Jariwala 95a7a265b9 feat: enhance survey display in webhook row with limited visibility (#7535) 2026-03-19 12:56:53 +00:00
Dhruwang Jariwala 136e59da68 fix: allow survey updation without followup access (#7528) 2026-03-19 11:42:14 +00:00
Anshuman Pandey eb0a87cf80 fix: fixes the loading skeleton on workspaces/tags page and some sentry improvements (#7533) 2026-03-19 11:09:52 +00:00
Anshuman Pandey 0dcb98ac29 fix: sdk init issues (#7516)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-19 11:04:12 +00:00
Balázs Úr 540f7aaae7 chore: change LINGO_API_KEY environment variable name (#7521) 2026-03-19 07:30:44 +00:00
Dhruwang Jariwala 2d4614a0bd chore: forward customer state to chatwoot (#7518) 2026-03-19 07:13:23 +00:00
Dhruwang Jariwala 633bf18204 fix: auto-expand multi-language card when toggle is enabled (#7504)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 12:18:35 +00:00
Balázs Úr 9a6cbd05b6 fix: mark various strings as translatable (#7338)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-18 11:30:38 +00:00
Johannes 94b0248075 fix: only allow URL in exact match URL (#7505)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-18 07:20:14 +00:00
Johannes 082de1042d feat: add validation for custom survey closed message heading (#7502) 2026-03-18 06:40:57 +00:00
Johannes 8c19587baa fix: ensure at least one filter is required for segments (#7503)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-18 06:39:58 +00:00
Anshuman Pandey 433750d3fe fix: removes pino pretty from edge runtime (#7510) 2026-03-18 06:32:55 +00:00
Johannes 61befd5ffd feat: add enterprise license features table (#7492)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-18 06:14:40 +00:00
Dhruwang Jariwala 1e7817fb69 fix: pre-strip style attributes before DOMPurify to prevent CSP violations (#7489)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-17 15:33:44 +00:00
Anshuman Pandey f250bc7e88 fix: fixes race between setUserId and trigger (#7498)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-17 08:57:07 +00:00
Santosh c7faa29437 fix: derive organizationId from resources in server actions to prevent cross-org IDOR (#7409)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-17 05:36:58 +00:00
151 changed files with 6664 additions and 1652 deletions
+1 -1
View File
@@ -231,4 +231,4 @@ REDIS_URL=redis://localhost:6379
# Lingo.dev API key for translation generation # Lingo.dev API key for translation generation
LINGODOTDEV_API_KEY=your_api_key_here LINGO_API_KEY=your_api_key_here
@@ -0,0 +1,146 @@
"use client";
import type { TFunction } from "i18next";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import type { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
import { Badge } from "@/modules/ui/components/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
type TPublicLicenseFeatureKey = Exclude<keyof TEnterpriseLicenseFeatures, "isMultiOrgEnabled" | "ai">;
type TFeatureDefinition = {
key: TPublicLicenseFeatureKey;
labelKey: string;
docsUrl: string;
};
const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
return [
{
key: "contacts",
labelKey: t("environments.settings.enterprise.license_feature_contacts"),
docsUrl:
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/contact-management-segments",
},
{
key: "projects",
labelKey: t("environments.settings.enterprise.license_feature_projects"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/license",
},
{
key: "whitelabel",
labelKey: t("environments.settings.enterprise.license_feature_whitelabel"),
docsUrl:
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/whitelabel-email-follow-ups",
},
{
key: "removeBranding",
labelKey: t("environments.settings.enterprise.license_feature_remove_branding"),
docsUrl:
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/hide-powered-by-formbricks",
},
{
key: "twoFactorAuth",
labelKey: t("environments.settings.enterprise.license_feature_two_factor_auth"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/core-features/user-management/two-factor-auth",
},
{
key: "sso",
labelKey: t("environments.settings.enterprise.license_feature_sso"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/oidc-sso",
},
{
key: "saml",
labelKey: t("environments.settings.enterprise.license_feature_saml"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/saml-sso",
},
{
key: "spamProtection",
labelKey: t("environments.settings.enterprise.license_feature_spam_protection"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/spam-protection",
},
{
key: "auditLogs",
labelKey: t("environments.settings.enterprise.license_feature_audit_logs"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/audit-logging",
},
{
key: "accessControl",
labelKey: t("environments.settings.enterprise.license_feature_access_control"),
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/team-access",
},
{
key: "quotas",
labelKey: t("environments.settings.enterprise.license_feature_quotas"),
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/quota-management",
},
];
};
interface EnterpriseLicenseFeaturesTableProps {
features: TEnterpriseLicenseFeatures;
}
export const EnterpriseLicenseFeaturesTable = ({ features }: EnterpriseLicenseFeaturesTableProps) => {
const { t } = useTranslation();
return (
<SettingsCard
title={t("environments.settings.enterprise.license_features_table_title")}
description={t("environments.settings.enterprise.license_features_table_description")}
noPadding>
<Table>
<TableHeader>
<TableRow className="hover:bg-white">
<TableHead>{t("environments.settings.enterprise.license_features_table_feature")}</TableHead>
<TableHead>{t("environments.settings.enterprise.license_features_table_access")}</TableHead>
<TableHead>{t("environments.settings.enterprise.license_features_table_value")}</TableHead>
<TableHead>{t("common.documentation")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{getFeatureDefinitions(t).map((feature) => {
const value = features[feature.key];
const isEnabled = typeof value === "boolean" ? value : value === null || value > 0;
let displayValue: number | string = "—";
if (typeof value === "number") {
displayValue = value;
} else if (value === null) {
displayValue = t("environments.settings.enterprise.license_features_table_unlimited");
}
return (
<TableRow key={feature.key} className="hover:bg-white">
<TableCell className="font-medium text-slate-900">{t(feature.labelKey)}</TableCell>
<TableCell>
<Badge
type={isEnabled ? "success" : "gray"}
size="normal"
text={
isEnabled
? t("environments.settings.enterprise.license_features_table_enabled")
: t("environments.settings.enterprise.license_features_table_disabled")
}
/>
</TableCell>
<TableCell className="text-slate-600">{displayValue}</TableCell>
<TableCell>
<Link
href={feature.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-slate-700 underline underline-offset-2 hover:text-slate-900">
{t("common.read_docs")}
</Link>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</SettingsCard>
);
};
@@ -15,6 +15,7 @@ import { SettingsCard } from "../../../components/SettingsCard";
interface EnterpriseLicenseStatusProps { interface EnterpriseLicenseStatusProps {
status: TLicenseStatus; status: TLicenseStatus;
lastChecked: Date;
gracePeriodEnd?: Date; gracePeriodEnd?: Date;
environmentId: string; environmentId: string;
} }
@@ -44,6 +45,7 @@ const getBadgeConfig = (
export const EnterpriseLicenseStatus = ({ export const EnterpriseLicenseStatus = ({
status, status,
lastChecked,
gracePeriodEnd, gracePeriodEnd,
environmentId, environmentId,
}: EnterpriseLicenseStatusProps) => { }: EnterpriseLicenseStatusProps) => {
@@ -92,7 +94,19 @@ export const EnterpriseLicenseStatus = ({
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" /> <div className="flex flex-wrap items-center gap-3">
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
<span className="text-sm text-slate-500">
{t("common.updated_at")}{" "}
{new Date(lastChecked).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})}
</span>
</div>
</div> </div>
<Button <Button
type="button" type="button"
@@ -10,6 +10,7 @@ import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { EnterpriseLicenseFeaturesTable } from "./components/EnterpriseLicenseFeaturesTable";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => { const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params; const params = await props.params;
@@ -93,15 +94,19 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
/> />
</PageHeader> </PageHeader>
{hasLicense ? ( {hasLicense ? (
<EnterpriseLicenseStatus <>
status={licenseState.status} <EnterpriseLicenseStatus
gracePeriodEnd={ status={licenseState.status}
licenseState.status === "unreachable" lastChecked={licenseState.lastChecked}
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS) gracePeriodEnd={
: undefined licenseState.status === "unreachable"
} ? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
environmentId={params.environmentId} : undefined
/> }
environmentId={params.environmentId}
/>
{licenseState.features && <EnterpriseLicenseFeaturesTable features={licenseState.features} />}
</>
) : ( ) : (
<div> <div>
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0"> <div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
@@ -64,15 +64,17 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
const ZResetSurveyAction = z.object({ const ZResetSurveyAction = z.object({
surveyId: ZId, surveyId: ZId,
organizationId: ZId,
projectId: ZId, projectId: ZId,
}); });
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action( export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => { withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
organizationId: parsedInput.organizationId, organizationId,
access: [ access: [
{ {
type: "organization", type: "organization",
@@ -81,12 +83,12 @@ export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSur
{ {
type: "projectTeam", type: "projectTeam",
minPermission: "readWrite", minPermission: "readWrite",
projectId: parsedInput.projectId, projectId,
}, },
], ],
}); });
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId; ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId; ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null; ctx.auditLoggingCtx.oldObject = null;
@@ -64,7 +64,7 @@ export const SurveyAnalysisCTA = ({
const [isResetModalOpen, setIsResetModalOpen] = useState(false); const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false); const [isResetting, setIsResetting] = useState(false);
const { organizationId, project } = useEnvironment(); const { project } = useEnvironment();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly); const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted; const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -128,7 +128,6 @@ export const SurveyAnalysisCTA = ({
setIsResetting(true); setIsResetting(true);
const result = await resetSurveyAction({ const result = await resetSurveyAction({
surveyId: survey.id, surveyId: survey.id,
organizationId: organizationId,
projectId: project.id, projectId: project.id,
}); });
if (result?.data) { if (result?.data) {
@@ -2,21 +2,16 @@
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZResponseFilterCriteria } from "@formbricks/types/responses"; import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { ZSurvey } from "@formbricks/types/surveys/types";
import { getOrganization } from "@/lib/organization/service";
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service"; import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service"; import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service"; import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper"; import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { getQuotas } from "@/modules/ee/quotas/lib/quotas"; import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
import { getOrganizationBilling } from "@/modules/survey/lib/survey"; import { getOrganizationBilling } from "@/modules/survey/lib/survey";
const ZGetResponsesDownloadUrlAction = z.object({ const ZGetResponsesDownloadUrlAction = z.object({
@@ -97,68 +92,3 @@ export const getSurveyFilterDataAction = authenticatedActionClient
return { environmentTags: tags, attributes, meta, hiddenFields, quotas }; return { environmentTags: tags, attributes, meta, hiddenFields, quotas };
}); });
/**
* Checks if survey follow-ups are enabled for the given organization.
*
* @param {string} organizationId The ID of the organization to check.
* @returns {Promise<void>} A promise that resolves if the permission is granted.
* @throws {ResourceNotFoundError} If the organization is not found.
* @throws {OperationNotAllowedError} If survey follow-ups are not enabled for the organization.
*/
const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<void> => {
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization not found", organizationId);
}
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationId);
if (!isSurveyFollowUpsEnabled) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
}
};
export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey).action(
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
await checkAuthorizationUpdated({
userId: ctx.user?.id ?? "",
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.id),
minPermission: "readWrite",
},
],
});
const { followUps } = parsedInput;
const oldSurvey = await getSurvey(parsedInput.id);
if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission(organizationId);
}
if (followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
// Context for audit log
ctx.auditLoggingCtx.surveyId = parsedInput.id;
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.oldObject = oldSurvey;
const newSurvey = await updateSurvey(parsedInput);
ctx.auditLoggingCtx.newObject = newSurvey;
return newSurvey;
})
);
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateSurveyAction } from "@/modules/survey/editor/actions";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -14,7 +15,6 @@ import {
SelectValue, SelectValue,
} from "@/modules/ui/components/select"; } from "@/modules/ui/components/select";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator"; import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
import { updateSurveyAction } from "../actions";
interface SurveyStatusDropdownProps { interface SurveyStatusDropdownProps {
environment: TEnvironment; environment: TEnvironment;
+324
View File
@@ -0,0 +1,324 @@
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "./api-wrapper";
const { mockAuthenticateRequest, mockGetServerSession } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
mockGetServerSession: vi.fn(),
}));
vi.mock("next-auth", () => ({
getServerSession: mockGetServerSession,
}));
vi.mock("@/app/api/v1/auth", () => ({
authenticateRequest: mockAuthenticateRequest,
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: vi.fn(),
warn: vi.fn(),
})),
},
}));
describe("withV3ApiWrapper", () => {
beforeEach(() => {
vi.resetAllMocks();
mockGetServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
});
afterEach(() => {
vi.clearAllMocks();
});
test("uses session auth first in both mode and injects request id into plain responses", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockGetServerSession.mockResolvedValue({
user: { id: "user_1", name: "Test", email: "t@example.com" },
expires: "2026-01-01",
});
const handler = vi.fn(async ({ authentication, requestId, instance }) => {
expect(authentication).toMatchObject({ user: { id: "user_1" } });
expect(requestId).toBe("req-1");
expect(instance).toBe("/api/v3/surveys");
return Response.json({ ok: true });
});
const wrapped = withV3ApiWrapper({
auth: "both",
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys?limit=10", {
headers: { "x-request-id": "req-1" },
}),
{} as never
);
expect(response.status).toBe(200);
expect(response.headers.get("X-Request-Id")).toBe("req-1");
expect(handler).toHaveBeenCalledOnce();
expect(vi.mocked(applyRateLimit)).toHaveBeenCalledWith(
expect.objectContaining({ namespace: "api:v3" }),
"user_1"
);
expect(mockAuthenticateRequest).not.toHaveBeenCalled();
});
test("falls back to api key auth in both mode", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockAuthenticateRequest.mockResolvedValue({
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: false } },
environmentPermissions: [],
});
const handler = vi.fn(async ({ authentication }) => {
expect(authentication).toMatchObject({ apiKeyId: "key_1" });
return Response.json({ ok: true });
});
const wrapped = withV3ApiWrapper({
auth: "both",
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys", {
headers: { "x-api-key": "fbk_test" },
}),
{} as never
);
expect(response.status).toBe(200);
expect(vi.mocked(applyRateLimit)).toHaveBeenCalledWith(
expect.objectContaining({ namespace: "api:v3" }),
"key_1"
);
expect(mockGetServerSession).not.toHaveBeenCalled();
});
test("returns 401 problem response when authentication is required but missing", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "both",
handler,
});
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {} as never);
expect(response.status).toBe(401);
expect(handler).not.toHaveBeenCalled();
expect(response.headers.get("Content-Type")).toBe("application/problem+json");
});
test("returns 400 problem response for invalid query input", async () => {
mockGetServerSession.mockResolvedValue({
user: { id: "user_1" },
expires: "2026-01-01",
});
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "both",
schemas: {
query: z.object({
limit: z.coerce.number().int().positive(),
}),
},
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys?limit=oops", {
headers: { "x-request-id": "req-invalid" },
}),
{} as never
);
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
const body = await response.json();
expect(body.invalid_params).toEqual(expect.arrayContaining([expect.objectContaining({ name: "limit" })]));
expect(body.requestId).toBe("req-invalid");
});
test("parses body, repeated query params, and async route params", async () => {
const handler = vi.fn(async ({ parsedInput }) => {
expect(parsedInput).toEqual({
body: { name: "Survey API" },
query: { tag: ["a", "b"] },
params: { workspaceId: "ws_123" },
});
return Response.json(
{ ok: true },
{
headers: {
"X-Request-Id": "handler-request-id",
},
}
);
});
const wrapped = withV3ApiWrapper({
auth: "none",
schemas: {
body: z.object({
name: z.string(),
}),
query: z.object({
tag: z.array(z.string()),
}),
params: z.object({
workspaceId: z.string(),
}),
},
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys?tag=a&tag=b", {
method: "POST",
body: JSON.stringify({ name: "Survey API" }),
headers: {
"Content-Type": "application/json",
},
}),
{
params: Promise.resolve({
workspaceId: "ws_123",
}),
} as never
);
expect(response.status).toBe(200);
expect(response.headers.get("X-Request-Id")).toBe("handler-request-id");
expect(handler).toHaveBeenCalledOnce();
});
test("returns 400 problem response for malformed JSON input", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "none",
schemas: {
body: z.object({
name: z.string(),
}),
},
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys", {
method: "POST",
body: "{",
headers: {
"Content-Type": "application/json",
},
}),
{} as never
);
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
const body = await response.json();
expect(body.invalid_params).toEqual([
{
name: "body",
reason: "Malformed JSON input, please check your request body",
},
]);
});
test("returns 400 problem response for invalid route params", async () => {
const handler = vi.fn(async () => Response.json({ ok: true }));
const wrapped = withV3ApiWrapper({
auth: "none",
schemas: {
params: z.object({
workspaceId: z.string().min(3),
}),
},
handler,
});
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {
params: Promise.resolve({
workspaceId: "x",
}),
} as never);
expect(response.status).toBe(400);
expect(handler).not.toHaveBeenCalled();
const body = await response.json();
expect(body.invalid_params).toEqual(
expect.arrayContaining([expect.objectContaining({ name: "workspaceId" })])
);
});
test("returns 429 problem response when rate limited", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockGetServerSession.mockResolvedValue({
user: { id: "user_1" },
expires: "2026-01-01",
});
vi.mocked(applyRateLimit).mockRejectedValueOnce(new TooManyRequestsError("Too many requests", 60));
const wrapped = withV3ApiWrapper({
auth: "both",
handler: async () => Response.json({ ok: true }),
});
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {} as never);
expect(response.status).toBe(429);
expect(response.headers.get("Retry-After")).toBe("60");
const body = await response.json();
expect(body.code).toBe("too_many_requests");
});
test("returns 500 problem response when the handler throws unexpectedly", async () => {
mockGetServerSession.mockResolvedValue({
user: { id: "user_1" },
expires: "2026-01-01",
});
const wrapped = withV3ApiWrapper({
auth: "both",
handler: async () => {
throw new Error("boom");
},
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys", {
headers: { "x-request-id": "req-boom" },
}),
{} as never
);
expect(response.status).toBe(500);
const body = await response.json();
expect(body.code).toBe("internal_server_error");
expect(body.requestId).toBe("req-boom");
});
});
+349
View File
@@ -0,0 +1,349 @@
import { getServerSession } from "next-auth";
import { type NextRequest } from "next/server";
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { authenticateRequest } from "@/app/api/v1/auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import type { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
import {
type InvalidParam,
problemBadRequest,
problemInternalError,
problemTooManyRequests,
problemUnauthorized,
} from "./response";
import type { TV3Authentication } from "./types";
type TV3Schema = z.ZodTypeAny;
type MaybePromise<T> = T | Promise<T>;
export type TV3AuthMode = "none" | "session" | "apiKey" | "both";
export type TV3Schemas = {
body?: TV3Schema;
query?: TV3Schema;
params?: TV3Schema;
};
export type TV3ParsedInput<S extends TV3Schemas | undefined> = S extends object
? {
[K in keyof S as NonNullable<S[K]> extends TV3Schema ? K : never]: z.infer<NonNullable<S[K]>>;
}
: Record<string, never>;
export type TV3HandlerParams<TParsedInput = Record<string, never>, TProps = unknown> = {
req: NextRequest;
props: TProps;
authentication: TV3Authentication;
parsedInput: TParsedInput;
requestId: string;
instance: string;
};
export type TWithV3ApiWrapperParams<S extends TV3Schemas | undefined, TProps = unknown> = {
auth?: TV3AuthMode;
schemas?: S;
rateLimit?: boolean;
customRateLimitConfig?: TRateLimitConfig;
handler: (params: TV3HandlerParams<TV3ParsedInput<S>, TProps>) => MaybePromise<Response>;
};
function getUnauthenticatedDetail(authMode: TV3AuthMode): string {
if (authMode === "session") {
return "Session required";
}
if (authMode === "apiKey") {
return "API key required";
}
return "Not authenticated";
}
function formatZodIssues(error: z.ZodError, fallbackName: "body" | "query" | "params"): InvalidParam[] {
return error.issues.map((issue) => ({
name: issue.path.length > 0 ? issue.path.join(".") : fallbackName,
reason: issue.message,
}));
}
function searchParamsToObject(searchParams: URLSearchParams): Record<string, string | string[]> {
const query: Record<string, string | string[]> = {};
for (const key of new Set(searchParams.keys())) {
const values = searchParams.getAll(key);
query[key] = values.length > 1 ? values : (values[0] ?? "");
}
return query;
}
function getRateLimitIdentifier(authentication: TV3Authentication): string | null {
if (!authentication) {
return null;
}
if ("user" in authentication && authentication.user?.id) {
return authentication.user.id;
}
if ("apiKeyId" in authentication) {
return authentication.apiKeyId;
}
return null;
}
function isPromiseLike<T>(value: unknown): value is Promise<T> {
return typeof value === "object" && value !== null && "then" in value;
}
async function getRouteParams<TProps>(props: TProps): Promise<Record<string, unknown>> {
if (!props || typeof props !== "object" || !("params" in props)) {
return {};
}
const params = (props as { params?: unknown }).params;
if (!params) {
return {};
}
const resolvedParams = isPromiseLike<Record<string, unknown>>(params) ? await params : params;
return typeof resolvedParams === "object" && resolvedParams !== null
? (resolvedParams as Record<string, unknown>)
: {};
}
async function authenticateV3Request(req: NextRequest, authMode: TV3AuthMode): Promise<TV3Authentication> {
if (authMode === "none") {
return null;
}
if (authMode === "both" && req.headers.has("x-api-key")) {
const apiKeyAuth = await authenticateRequest(req);
if (apiKeyAuth) {
return apiKeyAuth;
}
}
if (authMode === "session" || authMode === "both") {
const session = await getServerSession(authOptions);
if (session?.user?.id) {
return session;
}
if (authMode === "session") {
return null;
}
}
if (authMode === "apiKey" || authMode === "both") {
return await authenticateRequest(req);
}
return null;
}
async function parseV3Input<S extends TV3Schemas | undefined, TProps>(
req: NextRequest,
props: TProps,
schemas: S | undefined,
requestId: string,
instance: string
): Promise<
| { ok: true; parsedInput: TV3ParsedInput<S> }
| {
ok: false;
response: Response;
}
> {
const parsedInput = {} as TV3ParsedInput<S>;
if (schemas?.body) {
let bodyData: unknown;
try {
bodyData = await req.json();
} catch {
return {
ok: false,
response: problemBadRequest(requestId, "Invalid request body", {
instance,
invalid_params: [{ name: "body", reason: "Malformed JSON input, please check your request body" }],
}),
};
}
const bodyResult = schemas.body.safeParse(bodyData);
if (!bodyResult.success) {
return {
ok: false,
response: problemBadRequest(requestId, "Invalid request body", {
instance,
invalid_params: formatZodIssues(bodyResult.error, "body"),
}),
};
}
parsedInput.body = bodyResult.data as TV3ParsedInput<S>["body"];
}
if (schemas?.query) {
const queryResult = schemas.query.safeParse(searchParamsToObject(req.nextUrl.searchParams));
if (!queryResult.success) {
return {
ok: false,
response: problemBadRequest(requestId, "Invalid query parameters", {
instance,
invalid_params: formatZodIssues(queryResult.error, "query"),
}),
};
}
parsedInput.query = queryResult.data as TV3ParsedInput<S>["query"];
}
if (schemas?.params) {
const paramsResult = schemas.params.safeParse(await getRouteParams(props));
if (!paramsResult.success) {
return {
ok: false,
response: problemBadRequest(requestId, "Invalid route parameters", {
instance,
invalid_params: formatZodIssues(paramsResult.error, "params"),
}),
};
}
parsedInput.params = paramsResult.data as TV3ParsedInput<S>["params"];
}
return { ok: true, parsedInput };
}
function ensureRequestIdHeader(response: Response, requestId: string): Response {
if (response.headers.get("X-Request-Id")) {
return response;
}
const headers = new Headers(response.headers);
headers.set("X-Request-Id", requestId);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
async function authenticateV3RequestOrRespond(
req: NextRequest,
authMode: TV3AuthMode,
requestId: string,
instance: string
): Promise<
{ authentication: TV3Authentication; response: null } | { authentication: null; response: Response }
> {
const authentication = await authenticateV3Request(req, authMode);
if (!authentication && authMode !== "none") {
return {
authentication: null,
response: problemUnauthorized(requestId, getUnauthenticatedDetail(authMode), instance),
};
}
return {
authentication,
response: null,
};
}
async function applyV3RateLimitOrRespond(params: {
authentication: TV3Authentication;
enabled: boolean;
config: TRateLimitConfig;
requestId: string;
log: ReturnType<typeof logger.withContext>;
}): Promise<Response | null> {
const { authentication, enabled, config, requestId, log } = params;
if (!enabled) {
return null;
}
const identifier = getRateLimitIdentifier(authentication);
if (!identifier) {
return null;
}
try {
await applyRateLimit(config, identifier);
} catch (error) {
log.warn({ error, statusCode: 429 }, "V3 API rate limit exceeded");
return problemTooManyRequests(
requestId,
error instanceof Error ? error.message : "Rate limit exceeded",
error instanceof TooManyRequestsError ? error.retryAfter : undefined
);
}
return null;
}
export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unknown>(
params: TWithV3ApiWrapperParams<S, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const { auth = "both", schemas, rateLimit = true, customRateLimitConfig, handler } = params;
return async (req: NextRequest, props: TProps): Promise<Response> => {
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
const instance = req.nextUrl.pathname;
const log = logger.withContext({
requestId,
method: req.method,
path: instance,
});
try {
const authResult = await authenticateV3RequestOrRespond(req, auth, requestId, instance);
if (authResult.response) {
log.warn({ statusCode: authResult.response.status }, "V3 API authentication failed");
return authResult.response;
}
const parsedInputResult = await parseV3Input(req, props, schemas, requestId, instance);
if (!parsedInputResult.ok) {
log.warn({ statusCode: parsedInputResult.response.status }, "V3 API request validation failed");
return parsedInputResult.response;
}
const rateLimitResponse = await applyV3RateLimitOrRespond({
authentication: authResult.authentication,
enabled: rateLimit,
config: customRateLimitConfig ?? rateLimitConfigs.api.v3,
requestId,
log,
});
if (rateLimitResponse) {
return rateLimitResponse;
}
const response = await handler({
req,
props,
authentication: authResult.authentication,
parsedInput: parsedInputResult.parsedInput,
requestId,
instance,
});
return ensureRequestIdHeader(response, requestId);
} catch (error) {
log.error({ error, statusCode: 500 }, "V3 API unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
};
};
+274
View File
@@ -0,0 +1,274 @@
import { ApiKeyPermission, EnvironmentType } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { getEnvironment } from "@/lib/utils/services";
import { requireSessionWorkspaceAccess, requireV3WorkspaceAccess } from "./auth";
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromProjectId: vi.fn(),
}));
vi.mock("@/lib/utils/services", () => ({
getEnvironment: vi.fn(),
}));
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
checkAuthorizationUpdated: vi.fn(),
}));
const requestId = "req-123";
describe("requireSessionWorkspaceAccess", () => {
test("returns 401 when authentication is null", async () => {
const result = await requireSessionWorkspaceAccess(null, "proj_abc", "read", requestId);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(401);
expect((result as Response).headers.get("Content-Type")).toBe("application/problem+json");
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.status).toBe(401);
expect(body.code).toBe("not_authenticated");
expect(getEnvironment).not.toHaveBeenCalled();
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
});
test("returns 401 when authentication is API key (no user)", async () => {
const result = await requireSessionWorkspaceAccess(
{ apiKeyId: "key_1", organizationId: "org_1", environmentPermissions: [] } as any,
"proj_abc",
"read",
requestId
);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(401);
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("not_authenticated");
expect(getEnvironment).not.toHaveBeenCalled();
});
test("returns 403 when workspace (environment) is not found (avoid leaking existence)", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_nonexistent",
"read",
requestId
);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(403);
expect((result as Response).headers.get("Content-Type")).toBe("application/problem+json");
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("forbidden");
expect(getEnvironment).toHaveBeenCalledWith("env_nonexistent");
expect(checkAuthorizationUpdated).not.toHaveBeenCalled();
});
test("returns 403 when user has no access to workspace", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_abc",
projectId: "proj_abc",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_1");
vi.mocked(checkAuthorizationUpdated).mockRejectedValueOnce(new AuthorizationError("Not authorized"));
const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_abc",
"read",
requestId
);
expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(403);
const body = await (result as Response).json();
expect(body.requestId).toBe(requestId);
expect(body.code).toBe("forbidden");
expect(checkAuthorizationUpdated).toHaveBeenCalledWith({
userId: "user_1",
organizationId: "org_1",
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", projectId: "proj_abc", minPermission: "read" },
],
});
});
test("returns workspace context when session is valid and user has access", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_abc",
projectId: "proj_abc",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_1");
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
const result = await requireSessionWorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_abc",
"readWrite",
requestId
);
expect(result).not.toBeInstanceOf(Response);
expect(result).toEqual({
environmentId: "env_abc",
projectId: "proj_abc",
organizationId: "org_1",
});
expect(checkAuthorizationUpdated).toHaveBeenCalledWith({
userId: "user_1",
organizationId: "org_1",
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", projectId: "proj_abc", minPermission: "readWrite" },
],
});
});
});
const keyBase = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_k",
organizationAccess: { accessControl: { read: true, write: false } },
};
function envPerm(environmentId: string, permission: ApiKeyPermission = ApiKeyPermission.read) {
return {
environmentId,
environmentType: EnvironmentType.development,
projectId: "proj_k",
projectName: "K",
permission,
};
}
describe("requireV3WorkspaceAccess", () => {
beforeEach(() => {
vi.mocked(getEnvironment).mockResolvedValue({
id: "env_k",
projectId: "proj_k",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValue("org_k");
});
test("401 when authentication is null", async () => {
const r = await requireV3WorkspaceAccess(null, "env_x", "read", requestId);
expect((r as Response).status).toBe(401);
});
test("delegates to session flow when user is present", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_s",
projectId: "proj_s",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_s");
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
const r = await requireV3WorkspaceAccess(
{ user: { id: "user_1" }, expires: "" } as any,
"env_s",
"read",
requestId
);
expect(r).toEqual({
environmentId: "env_s",
projectId: "proj_s",
organizationId: "org_s",
});
});
test("returns context for API key with read on workspace", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_a", ApiKeyPermission.read)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_a", "read", requestId);
expect(r).toEqual({
environmentId: "ws_a",
projectId: "proj_k",
organizationId: "org_k",
});
expect(getEnvironment).toHaveBeenCalledWith("ws_a");
});
test("returns context for API key with write on workspace", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_b", ApiKeyPermission.write)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_b", "read", requestId);
expect(r).toEqual({
environmentId: "ws_b",
projectId: "proj_k",
organizationId: "org_k",
});
});
test("returns 403 when API key permission is lower than the required permission", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_write", ApiKeyPermission.read)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_write", "readWrite", requestId);
expect((r as Response).status).toBe(403);
});
test("403 when API key has no matching environment", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("other_env")],
};
const r = await requireV3WorkspaceAccess(auth as any, "wanted", "read", requestId);
expect((r as Response).status).toBe(403);
});
test("403 when API key permission is not list-eligible (runtime value)", async () => {
const auth = {
...keyBase,
environmentPermissions: [
{
...envPerm("ws_c"),
permission: "invalid" as unknown as ApiKeyPermission,
},
],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_c", "read", requestId);
expect((r as Response).status).toBe(403);
});
test("returns context for API key with manage on workspace", async () => {
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_m", ApiKeyPermission.manage)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_m", "manage", requestId);
expect(r).toEqual({
environmentId: "ws_m",
projectId: "proj_k",
organizationId: "org_k",
});
});
test("returns 403 when the workspace cannot be resolved for an API key", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
const auth = {
...keyBase,
environmentPermissions: [envPerm("ws_missing", ApiKeyPermission.manage)],
};
const r = await requireV3WorkspaceAccess(auth as any, "ws_missing", "read", requestId);
expect((r as Response).status).toBe(403);
});
test("401 when auth is neither session nor valid API key payload", async () => {
const r = await requireV3WorkspaceAccess({ user: {} } as any, "env", "read", requestId);
expect((r as Response).status).toBe(401);
});
});
+122
View File
@@ -0,0 +1,122 @@
/**
* V3 API auth — session (browser) or API key with environment-scoped access.
*/
import { ApiKeyPermission } from "@prisma/client";
import { logger } from "@formbricks/logger";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import type { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { problemForbidden, problemUnauthorized } from "./response";
import type { TV3Authentication } from "./types";
import { type V3WorkspaceContext, resolveV3WorkspaceContext } from "./workspace-context";
function apiKeyPermissionAllows(permission: ApiKeyPermission, minPermission: TTeamPermission): boolean {
const grantedRank = {
[ApiKeyPermission.read]: 1,
[ApiKeyPermission.write]: 2,
[ApiKeyPermission.manage]: 3,
}[permission];
const requiredRank = {
read: 1,
readWrite: 2,
manage: 3,
}[minPermission];
return grantedRank >= requiredRank;
}
/**
* Require session and workspace access. workspaceId is resolved via the V3 workspace-context layer.
* Returns a Response (401 or 403) on failure, or the resolved workspace context on success so callers
* use internal IDs (environmentId, projectId, organizationId) without resolving again.
* We use 403 (not 404) when the workspace is not found to avoid leaking resource existence.
*/
export async function requireSessionWorkspaceAccess(
authentication: TV3Authentication,
workspaceId: string,
minPermission: TTeamPermission,
requestId: string,
instance?: string
): Promise<Response | V3WorkspaceContext> {
// --- Session checks ---
if (!authentication) {
return problemUnauthorized(requestId, "Not authenticated", instance);
}
if (!("user" in authentication) || !authentication.user?.id) {
return problemUnauthorized(requestId, "Session required", instance);
}
const userId = authentication.user.id;
const log = logger.withContext({ requestId, workspaceId });
try {
// Resolve workspaceId → environmentId, projectId, organizationId (single place to change when Workspace exists).
const context = await resolveV3WorkspaceContext(workspaceId);
// Org + project-team access; we use internal IDs from context.
await checkAuthorizationUpdated({
userId,
organizationId: context.organizationId,
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", projectId: context.projectId, minPermission },
],
});
return context;
} catch (err) {
if (err instanceof ResourceNotFoundError || err instanceof AuthorizationError) {
const message = err instanceof ResourceNotFoundError ? "Workspace not found" : "Forbidden";
log.warn({ statusCode: 403, errorCode: err.name }, message);
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
throw err;
}
}
/** Session or API key: authorize `workspaceId` against the resolved V3 workspace context. */
export async function requireV3WorkspaceAccess(
authentication: TV3Authentication,
workspaceId: string,
minPermission: TTeamPermission,
requestId: string,
instance?: string
): Promise<Response | V3WorkspaceContext> {
if (!authentication) {
return problemUnauthorized(requestId, "Not authenticated", instance);
}
if ("user" in authentication && authentication.user?.id) {
return requireSessionWorkspaceAccess(authentication, workspaceId, minPermission, requestId, instance);
}
const keyAuth = authentication as TAuthenticationApiKey;
if (keyAuth.apiKeyId && Array.isArray(keyAuth.environmentPermissions)) {
const log = logger.withContext({ requestId, workspaceId, apiKeyId: keyAuth.apiKeyId });
try {
const context = await resolveV3WorkspaceContext(workspaceId);
const permission = keyAuth.environmentPermissions.find(
(environmentPermission) => environmentPermission.environmentId === context.environmentId
);
if (!permission || !apiKeyPermissionAllows(permission.permission, minPermission)) {
log.warn({ statusCode: 403 }, "API key not allowed for workspace");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
return context;
} catch (error) {
if (error instanceof ResourceNotFoundError) {
log.warn({ statusCode: 403, errorCode: error.name }, "Workspace not found");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
throw error;
}
}
return problemUnauthorized(requestId, "Not authenticated", instance);
}
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, test } from "vitest";
import {
problemBadRequest,
problemForbidden,
problemInternalError,
problemNotFound,
problemTooManyRequests,
problemUnauthorized,
successListResponse,
} from "./response";
describe("v3 problem responses", () => {
test("problemBadRequest includes invalid_params", async () => {
const res = problemBadRequest("rid", "bad", {
invalid_params: [{ name: "x", reason: "y" }],
instance: "/p",
});
expect(res.status).toBe(400);
expect(res.headers.get("X-Request-Id")).toBe("rid");
const body = await res.json();
expect(body.code).toBe("bad_request");
expect(body.requestId).toBe("rid");
expect(body.invalid_params).toEqual([{ name: "x", reason: "y" }]);
expect(body.instance).toBe("/p");
});
test("problemUnauthorized default detail", async () => {
const res = problemUnauthorized("r1");
expect(res.status).toBe(401);
const body = await res.json();
expect(body.detail).toBe("Not authenticated");
expect(body.code).toBe("not_authenticated");
});
test("problemForbidden", async () => {
const res = problemForbidden("r2", undefined, "/api/x");
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
expect(body.instance).toBe("/api/x");
});
test("problemInternalError", async () => {
const res = problemInternalError("r3", "oops", "/i");
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
expect(body.detail).toBe("oops");
});
test("problemNotFound includes details", async () => {
const res = problemNotFound("r4", "Survey", "s1", "/s");
expect(res.status).toBe(404);
const body = await res.json();
expect(body.code).toBe("not_found");
expect(body.details).toEqual({ resource_type: "Survey", resource_id: "s1" });
});
test("problemTooManyRequests with Retry-After", async () => {
const res = problemTooManyRequests("r5", "slow down", 60);
expect(res.status).toBe(429);
expect(res.headers.get("Retry-After")).toBe("60");
const body = await res.json();
expect(body.code).toBe("too_many_requests");
});
test("problemTooManyRequests without Retry-After", async () => {
const res = problemTooManyRequests("r6", "nope");
expect(res.headers.get("Retry-After")).toBeNull();
});
});
describe("successListResponse", () => {
test("sets X-Request-Id and default cache", async () => {
const res = successListResponse(
[{ a: 1 }],
{ limit: 10, nextCursor: "cursor-1" },
{
requestId: "req-x",
}
);
expect(res.status).toBe(200);
expect(res.headers.get("X-Request-Id")).toBe("req-x");
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.json()).toEqual({
data: [{ a: 1 }],
meta: { limit: 10, nextCursor: "cursor-1" },
});
});
test("custom Cache-Control", async () => {
const res = successListResponse([], { limit: 5, nextCursor: null }, { cache: "private, max-age=0" });
expect(res.headers.get("Cache-Control")).toBe("private, max-age=0");
});
});
+149
View File
@@ -0,0 +1,149 @@
/**
* V3 API response helpers — RFC 9457 Problem Details (application/problem+json)
* and list envelope for success responses.
*/
const PROBLEM_JSON = "application/problem+json" as const;
const CACHE_NO_STORE = "private, no-store" as const;
export type InvalidParam = { name: string; reason: string };
export type ProblemExtension = {
code?: string;
requestId: string;
details?: Record<string, unknown>;
invalid_params?: InvalidParam[];
};
export type ProblemBody = {
type?: string;
title: string;
status: number;
detail: string;
instance?: string;
} & ProblemExtension;
function problemResponse(
status: number,
title: string,
detail: string,
requestId: string,
options?: {
type?: string;
instance?: string;
code?: string;
details?: Record<string, unknown>;
invalid_params?: InvalidParam[];
headers?: Record<string, string>;
}
): Response {
const body: ProblemBody = {
title,
status,
detail,
requestId,
...(options?.type && { type: options.type }),
...(options?.instance && { instance: options.instance }),
...(options?.code && { code: options.code }),
...(options?.details && { details: options.details }),
...(options?.invalid_params && { invalid_params: options.invalid_params }),
};
const headers: Record<string, string> = {
"Content-Type": PROBLEM_JSON,
"Cache-Control": CACHE_NO_STORE,
"X-Request-Id": requestId,
...options?.headers,
};
return Response.json(body, { status, headers });
}
export function problemBadRequest(
requestId: string,
detail: string,
options?: { invalid_params?: InvalidParam[]; instance?: string }
): Response {
return problemResponse(400, "Bad Request", detail, requestId, {
code: "bad_request",
instance: options?.instance,
invalid_params: options?.invalid_params,
});
}
export function problemUnauthorized(
requestId: string,
detail: string = "Not authenticated",
instance?: string
): Response {
return problemResponse(401, "Unauthorized", detail, requestId, {
code: "not_authenticated",
instance,
});
}
export function problemForbidden(
requestId: string,
detail: string = "You are not authorized to access this resource",
instance?: string
): Response {
return problemResponse(403, "Forbidden", detail, requestId, {
code: "forbidden",
instance,
});
}
/**
* 404 with resource details. Do not use for auth-sensitive or existence-sensitive resources:
* the body includes resource_type and resource_id, which can leak existence to unauthenticated or unauthorized callers.
* Prefer problemForbidden with a generic message for those cases.
*/
export function problemNotFound(
requestId: string,
resourceType: string,
resourceId: string | null,
instance?: string
): Response {
return problemResponse(404, "Not Found", `${resourceType} not found`, requestId, {
code: "not_found",
details: { resource_type: resourceType, resource_id: resourceId },
instance,
});
}
export function problemInternalError(
requestId: string,
detail: string = "An unexpected error occurred.",
instance?: string
): Response {
return problemResponse(500, "Internal Server Error", detail, requestId, {
code: "internal_server_error",
instance,
});
}
export function problemTooManyRequests(requestId: string, detail: string, retryAfter?: number): Response {
const headers: Record<string, string> = {};
if (retryAfter !== undefined) {
headers["Retry-After"] = String(retryAfter);
}
return problemResponse(429, "Too Many Requests", detail, requestId, {
code: "too_many_requests",
headers,
});
}
export function successListResponse<T, TMeta extends Record<string, unknown>>(
data: T[],
meta: TMeta,
options?: { requestId?: string; cache?: string }
): Response {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
};
if (options?.requestId) {
headers["X-Request-Id"] = options.requestId;
}
return Response.json({ data, meta }, { status: 200, headers });
}
+4
View File
@@ -0,0 +1,4 @@
import type { Session } from "next-auth";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
export type TV3Authentication = TAuthenticationApiKey | Session | null;
@@ -0,0 +1,38 @@
import { describe, expect, test, vi } from "vitest";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { getEnvironment } from "@/lib/utils/services";
import { resolveV3WorkspaceContext } from "./workspace-context";
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromProjectId: vi.fn(),
}));
vi.mock("@/lib/utils/services", () => ({
getEnvironment: vi.fn(),
}));
describe("resolveV3WorkspaceContext", () => {
test("returns environmentId, projectId and organizationId when workspace exists (today: workspaceId === environmentId)", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env_abc",
projectId: "proj_xyz",
} as any);
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValueOnce("org_123");
const result = await resolveV3WorkspaceContext("env_abc");
expect(result).toEqual({
environmentId: "env_abc",
projectId: "proj_xyz",
organizationId: "org_123",
});
expect(getEnvironment).toHaveBeenCalledWith("env_abc");
expect(getOrganizationIdFromProjectId).toHaveBeenCalledWith("proj_xyz");
});
test("throws when workspace (environment) does not exist", async () => {
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
await expect(resolveV3WorkspaceContext("env_nonexistent")).rejects.toThrow(ResourceNotFoundError);
expect(getEnvironment).toHaveBeenCalledWith("env_nonexistent");
expect(getOrganizationIdFromProjectId).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,50 @@
/**
* V3 API workspace → internal IDs translation layer (retro-compatibility / future-proofing).
*
* Workspace is the default container for surveys. We are deprecating Environment and making
* Workspace that container. In the API, workspaceId refers to that container.
*
* Today: workspaceId is mapped to environmentId (Environment is the current container for surveys).
* When Environment is deprecated and Workspace exists: resolve workspaceId to the Workspace entity
* (and derive environmentId or equivalent from it). Change only this file.
*/
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { getEnvironment } from "@/lib/utils/services";
/**
* Internal IDs derived from a V3 workspace identifier.
* Today: environmentId is the workspace (Environment = container for surveys until Workspace exists).
*/
export type V3WorkspaceContext = {
/** Environment ID — the container for surveys today. Replaced by workspace when Environment is deprecated. */
environmentId: string;
/** Project ID used for projectTeam auth. */
projectId: string;
/** Organization ID used for org-level auth. */
organizationId: string;
};
/**
* Resolves a V3 API workspaceId to internal environmentId, projectId, and organizationId.
* Today: workspaceId is treated as environmentId (workspace = container for surveys = Environment).
*
* @throws ResourceNotFoundError if the workspace (environment) does not exist.
*/
export async function resolveV3WorkspaceContext(workspaceId: string): Promise<V3WorkspaceContext> {
// Today: workspaceId is the environment id (survey container). Look it up.
const environment = await getEnvironment(workspaceId);
if (!environment) {
throw new ResourceNotFoundError("environment", workspaceId);
}
// Derive org for auth; project comes from the environment.
const organizationId = await getOrganizationIdFromProjectId(environment.projectId);
// We looked up by workspaceId (as environment id), so the resolved environment id is workspaceId.
return {
environmentId: workspaceId,
projectId: environment.projectId,
organizationId,
};
}
@@ -0,0 +1,122 @@
import { describe, expect, test } from "vitest";
import { collectMultiValueQueryParam, parseV3SurveysListQuery } from "./parse-v3-surveys-list-query";
const wid = "clxx1234567890123456789012";
function params(qs: string): URLSearchParams {
return new URLSearchParams(qs);
}
describe("collectMultiValueQueryParam", () => {
test("merges repeated keys and comma-separated values", () => {
const sp = params("status=draft&status=inProgress&type=link,app");
expect(collectMultiValueQueryParam(sp, "status")).toEqual(["draft", "inProgress"]);
expect(collectMultiValueQueryParam(sp, "type")).toEqual(["link", "app"]);
});
test("dedupes", () => {
const sp = params("status=draft&status=draft");
expect(collectMultiValueQueryParam(sp, "status")).toEqual(["draft"]);
});
});
describe("parseV3SurveysListQuery", () => {
test("rejects unsupported query parameters like filterCriteria", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&filterCriteria={}`));
expect(r.ok).toBe(false);
if (!r.ok) expect(r.invalid_params[0].name).toBe("filterCriteria");
});
test("rejects unknown query parameters", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&foo=bar`));
expect(r.ok).toBe(false);
if (!r.ok)
expect(r.invalid_params[0]).toEqual({
name: "foo",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
});
test("rejects the legacy after query parameter", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&after=legacy-cursor`));
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.invalid_params[0]).toEqual({
name: "after",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
}
});
test("rejects the legacy flat name query parameter", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&name=Foo`));
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.invalid_params[0]).toEqual({
name: "name",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
}
});
test("parses minimal query", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}`));
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.limit).toBe(20);
expect(r.cursor).toBeNull();
expect(r.sortBy).toBe("updatedAt");
expect(r.filterCriteria).toBeUndefined();
}
});
test("builds filter from explicit operator params", () => {
const r = parseV3SurveysListQuery(
params(
`workspaceId=${wid}&filter[name][contains]=Foo&filter[status][in]=inProgress&filter[status][in]=draft&filter[type][in]=link&sortBy=updatedAt`
)
);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.filterCriteria).toEqual({
name: "Foo",
status: ["inProgress", "draft"],
type: ["link"],
});
expect(r.sortBy).toBe("updatedAt");
}
});
test("invalid status", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&filter[status][in]=notastatus`));
expect(r.ok).toBe(false);
});
test("rejects the createdBy filter", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&filter[createdBy][in]=you`));
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.invalid_params[0]).toEqual({
name: "filter[createdBy][in]",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
}
});
test("rejects an invalid cursor", () => {
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&cursor=not-a-real-cursor`));
expect(r.ok).toBe(false);
if (!r.ok) {
expect(r.invalid_params).toEqual([
{
name: "cursor",
reason: "The cursor is invalid.",
},
]);
}
});
});
@@ -0,0 +1,159 @@
/**
* Validates GET /api/v3/surveys query string and builds {@link TSurveyFilterCriteria} for list/count.
* Keeps HTTP parsing separate from the route handler and shared survey list service.
*/
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import {
type TSurveyFilterCriteria,
ZSurveyFilters,
ZSurveyStatus,
ZSurveyType,
} from "@formbricks/types/surveys/types";
import {
type TSurveyListPageCursor,
type TSurveyListSort,
decodeSurveyListPageCursor,
normalizeSurveyListSort,
} from "@/modules/survey/list/lib/survey-page";
const V3_SURVEYS_DEFAULT_LIMIT = 20;
const V3_SURVEYS_MAX_LIMIT = 100;
const FILTER_NAME_CONTAINS_QUERY_PARAM = "filter[name][contains]" as const;
const FILTER_STATUS_IN_QUERY_PARAM = "filter[status][in]" as const;
const FILTER_TYPE_IN_QUERY_PARAM = "filter[type][in]" as const;
const SUPPORTED_QUERY_PARAMS = [
"workspaceId",
"limit",
"cursor",
FILTER_NAME_CONTAINS_QUERY_PARAM,
FILTER_STATUS_IN_QUERY_PARAM,
FILTER_TYPE_IN_QUERY_PARAM,
"sortBy",
] as const;
const SUPPORTED_QUERY_PARAM_SET = new Set<string>(SUPPORTED_QUERY_PARAMS);
type InvalidParam = { name: string; reason: string };
/** Collect repeated query keys and comma-separated values for operator-style filters. */
export function collectMultiValueQueryParam(searchParams: URLSearchParams, key: string): string[] {
const acc: string[] = [];
for (const raw of searchParams.getAll(key)) {
for (const part of raw.split(",")) {
const t = part.trim();
if (t) acc.push(t);
}
}
return [...new Set(acc)];
}
const ZV3SurveysListQuery = z.object({
workspaceId: ZId,
limit: z.coerce.number().int().min(1).max(V3_SURVEYS_MAX_LIMIT).default(V3_SURVEYS_DEFAULT_LIMIT),
cursor: z.string().min(1).optional(),
[FILTER_NAME_CONTAINS_QUERY_PARAM]: z
.string()
.max(512)
.optional()
.transform((s) => (s === undefined || s.trim() === "" ? undefined : s.trim())),
[FILTER_STATUS_IN_QUERY_PARAM]: z.array(ZSurveyStatus).optional(),
[FILTER_TYPE_IN_QUERY_PARAM]: z.array(ZSurveyType).optional(),
sortBy: ZSurveyFilters.shape.sortBy.optional(),
});
export type TV3SurveysListQuery = z.infer<typeof ZV3SurveysListQuery>;
export type TV3SurveysListQueryParseResult =
| {
ok: true;
workspaceId: string;
limit: number;
cursor: TSurveyListPageCursor | null;
sortBy: TSurveyListSort;
filterCriteria: TSurveyFilterCriteria | undefined;
}
| { ok: false; invalid_params: InvalidParam[] };
function getUnsupportedQueryParams(searchParams: URLSearchParams): InvalidParam[] {
const unsupportedParams = [
...new Set(Array.from(searchParams.keys()).filter((key) => !SUPPORTED_QUERY_PARAM_SET.has(key))),
];
return unsupportedParams.map((name) => ({
name,
reason: `Unsupported query parameter. Use only ${SUPPORTED_QUERY_PARAMS.join(", ")}.`,
}));
}
function buildFilterCriteria(q: TV3SurveysListQuery): TSurveyFilterCriteria | undefined {
const f: TSurveyFilterCriteria = {};
if (q[FILTER_NAME_CONTAINS_QUERY_PARAM]) f.name = q[FILTER_NAME_CONTAINS_QUERY_PARAM];
if (q[FILTER_STATUS_IN_QUERY_PARAM]?.length) f.status = q[FILTER_STATUS_IN_QUERY_PARAM];
if (q[FILTER_TYPE_IN_QUERY_PARAM]?.length) f.type = q[FILTER_TYPE_IN_QUERY_PARAM];
return Object.keys(f).length > 0 ? f : undefined;
}
export function parseV3SurveysListQuery(searchParams: URLSearchParams): TV3SurveysListQueryParseResult {
const unsupportedQueryParams = getUnsupportedQueryParams(searchParams);
if (unsupportedQueryParams.length > 0) {
return {
ok: false,
invalid_params: unsupportedQueryParams,
};
}
const statusVals = collectMultiValueQueryParam(searchParams, FILTER_STATUS_IN_QUERY_PARAM);
const typeVals = collectMultiValueQueryParam(searchParams, FILTER_TYPE_IN_QUERY_PARAM);
const raw = {
workspaceId: searchParams.get("workspaceId"),
limit: searchParams.get("limit") ?? undefined,
cursor: searchParams.get("cursor")?.trim() || undefined,
[FILTER_NAME_CONTAINS_QUERY_PARAM]: searchParams.get(FILTER_NAME_CONTAINS_QUERY_PARAM) ?? undefined,
[FILTER_STATUS_IN_QUERY_PARAM]: statusVals.length > 0 ? statusVals : undefined,
[FILTER_TYPE_IN_QUERY_PARAM]: typeVals.length > 0 ? typeVals : undefined,
sortBy: searchParams.get("sortBy")?.trim() || undefined,
};
const result = ZV3SurveysListQuery.safeParse(raw);
if (!result.success) {
return {
ok: false,
invalid_params: result.error.issues.map((issue) => ({
name: issue.path.join(".") || "query",
reason: issue.message,
})),
};
}
const q = result.data;
const sortBy = normalizeSurveyListSort(q.sortBy);
let cursor: TSurveyListPageCursor | null = null;
if (q.cursor) {
try {
cursor = decodeSurveyListPageCursor(q.cursor, sortBy);
} catch (error) {
return {
ok: false,
invalid_params: [
{
name: "cursor",
reason: error instanceof Error ? error.message : "The cursor is invalid.",
},
],
};
}
}
return {
ok: true,
workspaceId: q.workspaceId,
limit: q.limit,
cursor,
sortBy,
filterCriteria: buildFilterCriteria(q),
};
}
+357
View File
@@ -0,0 +1,357 @@
import { ApiKeyPermission, EnvironmentType } from "@prisma/client";
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
import { GET } from "./route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/app/api/v1/auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/app/api/v1/auth")>();
return { ...actual, authenticateRequest: mockAuthenticateRequest };
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return { ...actual, AUDIT_LOG_ENABLED: false };
});
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/modules/survey/list/lib/survey-page", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/modules/survey/list/lib/survey-page")>();
return {
...actual,
getSurveyListPage: vi.fn(),
};
});
vi.mock("@/modules/survey/list/lib/survey", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/modules/survey/list/lib/survey")>();
return {
...actual,
getSurveyCount: vi.fn(),
};
});
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
const validWorkspaceId = "clxx1234567890123456789012";
const resolvedEnvironmentId = "clzz9876543210987654321098";
function createRequest(url: string, requestId?: string, extraHeaders?: Record<string, string>): NextRequest {
const headers: Record<string, string> = { ...extraHeaders };
if (requestId) headers["x-request-id"] = requestId;
return new NextRequest(url, { headers });
}
const apiKeyAuth = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: {
accessControl: { read: true, write: false },
},
environmentPermissions: [
{
environmentId: validWorkspaceId,
environmentType: EnvironmentType.development,
projectId: "proj_1",
projectName: "P",
permission: ApiKeyPermission.read,
},
],
};
describe("GET /api/v3/surveys", () => {
beforeEach(() => {
vi.resetAllMocks();
getServerSession.mockResolvedValue({
user: { id: "user_1", name: "User", email: "u@example.com" },
expires: "2026-01-01",
} as any);
mockAuthenticateRequest.mockResolvedValue(null);
vi.mocked(requireV3WorkspaceAccess).mockImplementation(async (auth, workspaceId) => {
if (auth && "apiKeyId" in auth) {
const p = auth.environmentPermissions.find((e) => e.environmentId === workspaceId);
if (!p) {
return new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
);
}
return {
environmentId: workspaceId,
projectId: p.projectId,
organizationId: auth.organizationId,
};
}
return {
environmentId: resolvedEnvironmentId,
projectId: "proj_1",
organizationId: "org_1",
};
});
vi.mocked(getSurveyListPage).mockResolvedValue({ surveys: [], nextCursor: null });
vi.mocked(getSurveyCount).mockResolvedValue(0);
});
afterEach(() => {
vi.clearAllMocks();
});
test("returns 401 when no session and no API key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
const res = await GET(req, {} as any);
expect(res.status).toBe(401);
expect(res.headers.get("Content-Type")).toBe("application/problem+json");
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 200 with session and valid workspaceId", async () => {
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-456");
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("application/json");
expect(res.headers.get("X-Request-Id")).toBe("req-456");
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
validWorkspaceId,
"read",
"req-456",
"/api/v3/surveys"
);
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
limit: 20,
cursor: null,
sortBy: "updatedAt",
filterCriteria: undefined,
});
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, undefined);
});
test("returns 200 with x-api-key when workspace is on the key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-k", {
"x-api-key": "fbk_test",
});
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ apiKeyId: "key_1" }),
validWorkspaceId,
"read",
"req-k",
"/api/v3/surveys"
);
expect(getSurveyListPage).toHaveBeenCalledWith(validWorkspaceId, {
limit: 20,
cursor: null,
sortBy: "updatedAt",
filterCriteria: undefined,
});
expect(getSurveyCount).toHaveBeenCalledWith(validWorkspaceId, undefined);
});
test("returns 403 when API key does not include workspace", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue({
...apiKeyAuth,
environmentPermissions: [
{
environmentId: "claa1111111111111111111111",
environmentType: EnvironmentType.development,
projectId: "proj_x",
projectName: "X",
permission: ApiKeyPermission.read,
},
],
} as any);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, undefined, {
"x-api-key": "fbk_test",
});
const res = await GET(req, {} as any);
expect(res.status).toBe(403);
});
test("returns 400 when the createdBy filter is used", async () => {
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filter[createdBy][in]=you`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.invalid_params?.some((p: { name: string }) => p.name === "filter[createdBy][in]")).toBe(true);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 400 when workspaceId is missing", async () => {
const req = createRequest("http://localhost/api/v3/surveys");
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 400 when workspaceId is not cuid2", async () => {
const req = createRequest("http://localhost/api/v3/surveys?workspaceId=not-a-cuid");
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
});
test("returns 400 when limit exceeds max", async () => {
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&limit=101`);
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
});
test("reflects limit, nextCursor, and totalCount in meta", async () => {
vi.mocked(getSurveyListPage).mockResolvedValue({
surveys: [],
nextCursor: "cursor-123",
});
vi.mocked(getSurveyCount).mockResolvedValue(42);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&limit=10`);
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.meta).toEqual({ limit: 10, nextCursor: "cursor-123", totalCount: 42 });
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
limit: 10,
cursor: null,
sortBy: "updatedAt",
filterCriteria: undefined,
});
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, undefined);
});
test("passes filter query to getSurveyListPage", async () => {
const filterCriteria = { status: ["inProgress"] };
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filter[status][in]=inProgress&sortBy=updatedAt`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
limit: 20,
cursor: null,
sortBy: "updatedAt",
filterCriteria,
});
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, filterCriteria);
});
test("returns 400 when filterCriteria is used", async () => {
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filterCriteria=${encodeURIComponent("{}")}`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 403 when auth returns 403", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce(
new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req-789",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
)
);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
const res = await GET(req, {} as any);
expect(res.status).toBe(403);
});
test("list items expose workspaceId instead of environmentId and omit internal fields", async () => {
vi.mocked(getSurveyListPage).mockResolvedValue({
surveys: [
{
id: "s1",
name: "Survey 1",
environmentId: "env_1",
type: "link",
status: "draft",
createdAt: new Date(),
updatedAt: new Date(),
responseCount: 0,
creator: { name: "Test" },
singleUse: null,
} as any,
],
nextCursor: null,
});
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
const res = await GET(req, {} as any);
const body = await res.json();
expect(body.data[0]).not.toHaveProperty("blocks");
expect(body.data[0]).not.toHaveProperty("singleUse");
expect(body.data[0]).not.toHaveProperty("_count");
expect(body.data[0]).not.toHaveProperty("environmentId");
expect(body.data[0].id).toBe("s1");
expect(body.data[0].workspaceId).toBe("env_1");
});
test("returns 403 when getSurveyListPage throws ResourceNotFoundError", async () => {
vi.mocked(getSurveyListPage).mockRejectedValueOnce(new ResourceNotFoundError("survey", "s1"));
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-nf");
const res = await GET(req, {} as any);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
});
test("returns 500 when getSurveyListPage throws DatabaseError", async () => {
vi.mocked(getSurveyListPage).mockRejectedValueOnce(new DatabaseError("db down"));
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-db");
const res = await GET(req, {} as any);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
test("returns 500 on unexpected error from getSurveyListPage", async () => {
vi.mocked(getSurveyListPage).mockRejectedValueOnce(new Error("boom"));
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-err");
const res = await GET(req, {} as any);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
});
+81
View File
@@ -0,0 +1,81 @@
/**
* GET /api/v3/surveys — list surveys for a workspace.
* Session cookie or x-api-key; scope by workspaceId only.
*/
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import {
problemBadRequest,
problemForbidden,
problemInternalError,
successListResponse,
} from "@/app/api/v3/lib/response";
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
import { parseV3SurveysListQuery } from "./parse-v3-surveys-list-query";
import { serializeV3SurveyListItem } from "./serializers";
export const GET = withV3ApiWrapper({
auth: "both",
handler: async ({ req, authentication, requestId, instance }) => {
const log = logger.withContext({ requestId });
try {
const searchParams = new URL(req.url).searchParams;
const parsed = parseV3SurveysListQuery(searchParams);
if (!parsed.ok) {
log.warn({ statusCode: 400, invalidParams: parsed.invalid_params }, "Validation failed");
return problemBadRequest(requestId, "Invalid query parameters", {
invalid_params: parsed.invalid_params,
instance,
});
}
const authResult = await requireV3WorkspaceAccess(
authentication,
parsed.workspaceId,
"read",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
const { environmentId } = authResult;
const [{ surveys, nextCursor }, totalCount] = await Promise.all([
getSurveyListPage(environmentId, {
limit: parsed.limit,
cursor: parsed.cursor,
sortBy: parsed.sortBy,
filterCriteria: parsed.filterCriteria,
}),
getSurveyCount(environmentId, parsed.filterCriteria),
]);
return successListResponse(
surveys.map(serializeV3SurveyListItem),
{
limit: parsed.limit,
nextCursor,
totalCount,
},
{ requestId, cache: "private, no-store" }
);
} catch (err) {
if (err instanceof ResourceNotFoundError) {
log.warn({ statusCode: 403, errorCode: err.name }, "Resource not found");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
if (err instanceof DatabaseError) {
log.error({ error: err, statusCode: 500 }, "Database error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error: err, statusCode: 500 }, "V3 surveys list unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
@@ -0,0 +1,18 @@
import type { TSurvey } from "@/modules/survey/list/types/surveys";
export type TV3SurveyListItem = Omit<TSurvey, "environmentId" | "singleUse"> & {
workspaceId: string;
};
/**
* Keep the v3 API contract isolated from internal persistence naming.
* Internally surveys are still scoped by environmentId; externally v3 exposes workspaceId.
*/
export function serializeV3SurveyListItem(survey: TSurvey): TV3SurveyListItem {
const { environmentId, singleUse: _omitSingleUse, ...rest } = survey;
return {
...rest,
workspaceId: environmentId,
};
}
+49 -19
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { getIsActiveCustomerAction } from "./actions";
interface ChatwootWidgetProps { interface ChatwootWidgetProps {
chatwootBaseUrl: string; chatwootBaseUrl: string;
@@ -12,6 +13,18 @@ interface ChatwootWidgetProps {
const CHATWOOT_SCRIPT_ID = "chatwoot-script"; const CHATWOOT_SCRIPT_ID = "chatwoot-script";
interface ChatwootInstance {
setUser: (
userId: string,
userInfo: {
email?: string | null;
name?: string | null;
}
) => void;
setCustomAttributes: (attributes: Record<string, unknown>) => void;
reset: () => void;
}
export const ChatwootWidget = ({ export const ChatwootWidget = ({
userEmail, userEmail,
userName, userName,
@@ -20,15 +33,14 @@ export const ChatwootWidget = ({
chatwootBaseUrl, chatwootBaseUrl,
}: ChatwootWidgetProps) => { }: ChatwootWidgetProps) => {
const userSetRef = useRef(false); const userSetRef = useRef(false);
const customerStatusSetRef = useRef(false);
const getChatwoot = useCallback((): ChatwootInstance | null => {
return (globalThis as unknown as { $chatwoot: ChatwootInstance }).$chatwoot ?? null;
}, []);
const setUserInfo = useCallback(() => { const setUserInfo = useCallback(() => {
const $chatwoot = ( const $chatwoot = getChatwoot();
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot;
if (userId && $chatwoot && !userSetRef.current) { if (userId && $chatwoot && !userSetRef.current) {
$chatwoot.setUser(userId, { $chatwoot.setUser(userId, {
email: userEmail, email: userEmail,
@@ -36,7 +48,19 @@ export const ChatwootWidget = ({
}); });
userSetRef.current = true; userSetRef.current = true;
} }
}, [userId, userEmail, userName]); }, [userId, userEmail, userName, getChatwoot]);
const setCustomerStatus = useCallback(async () => {
if (customerStatusSetRef.current) return;
const $chatwoot = getChatwoot();
if (!$chatwoot) return;
const response = await getIsActiveCustomerAction();
if (response?.data !== undefined) {
$chatwoot.setCustomAttributes({ isActiveCustomer: response.data });
}
customerStatusSetRef.current = true;
}, [getChatwoot]);
useEffect(() => { useEffect(() => {
if (!chatwootWebsiteToken) return; if (!chatwootWebsiteToken) return;
@@ -65,23 +89,19 @@ export const ChatwootWidget = ({
const handleChatwootReady = () => setUserInfo(); const handleChatwootReady = () => setUserInfo();
globalThis.addEventListener("chatwoot:ready", handleChatwootReady); globalThis.addEventListener("chatwoot:ready", handleChatwootReady);
const handleChatwootOpen = () => setCustomerStatus();
globalThis.addEventListener("chatwoot:open", handleChatwootOpen);
// Check if Chatwoot is already ready // Check if Chatwoot is already ready
if ( if (getChatwoot()) {
(
globalThis as unknown as {
$chatwoot: {
setUser: (userId: string, userInfo: { email?: string | null; name?: string | null }) => void;
};
}
).$chatwoot
) {
setUserInfo(); setUserInfo();
} }
return () => { return () => {
globalThis.removeEventListener("chatwoot:ready", handleChatwootReady); globalThis.removeEventListener("chatwoot:ready", handleChatwootReady);
globalThis.removeEventListener("chatwoot:open", handleChatwootOpen);
const $chatwoot = (globalThis as unknown as { $chatwoot: { reset: () => void } }).$chatwoot; const $chatwoot = getChatwoot();
if ($chatwoot) { if ($chatwoot) {
$chatwoot.reset(); $chatwoot.reset();
} }
@@ -90,8 +110,18 @@ export const ChatwootWidget = ({
scriptElement?.remove(); scriptElement?.remove();
userSetRef.current = false; userSetRef.current = false;
customerStatusSetRef.current = false;
}; };
}, [chatwootBaseUrl, chatwootWebsiteToken, userId, userEmail, userName, setUserInfo]); }, [
chatwootBaseUrl,
chatwootWebsiteToken,
userId,
userEmail,
userName,
setUserInfo,
setCustomerStatus,
getChatwoot,
]);
return null; return null;
}; };
+18
View File
@@ -0,0 +1,18 @@
"use server";
import { TCloudBillingPlan } from "@formbricks/types/organizations";
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
export const getIsActiveCustomerAction = authenticatedActionClient.action(async ({ ctx }) => {
const paidBillingPlans = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
const organizations = await getOrganizationsByUserId(ctx.user.id);
return organizations.some((organization) => {
const stripe = organization.billing.stripe;
const isPaidPlan = stripe?.plan ? paidBillingPlans.has(stripe.plan) : false;
const isActiveSubscription =
stripe?.subscriptionStatus === "active" || stripe?.subscriptionStatus === "trialing";
return isPaidPlan && isActiveSubscription;
});
});
@@ -421,6 +421,38 @@ describe("withV1ApiWrapper", () => {
expect(handler).not.toHaveBeenCalled(); expect(handler).not.toHaveBeenCalled();
}); });
test("uses unauthenticatedResponse when provided instead of default 401", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { getServerSession } = await import("next-auth");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Session,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(getServerSession).mockResolvedValue(null);
const custom401 = new Response(JSON.stringify({ title: "Custom", status: 401 }), {
status: 401,
headers: { "Content-Type": "application/problem+json" },
});
const handler = vi.fn();
const req = createMockRequest({ url: "https://api.test/api/v3/surveys" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({
handler,
unauthenticatedResponse: () => custom401,
});
const res = await wrapped(req, undefined);
expect(res).toBe(custom401);
expect(handler).not.toHaveBeenCalled();
expect(mockContextualLoggerError).toHaveBeenCalled();
});
test("handles rate limiting errors", async () => { test("handles rate limiting errors", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers"); const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
+11 -1
View File
@@ -38,6 +38,11 @@ export interface TWithV1ApiWrapperParams<TResult extends { response: Response },
action?: TAuditAction; action?: TAuditAction;
targetType?: TAuditTarget; targetType?: TAuditTarget;
customRateLimitConfig?: TRateLimitConfig; customRateLimitConfig?: TRateLimitConfig;
/**
* When the route requires auth but the client is unauthenticated, the wrapper normally returns
* the legacy JSON 401. Use this to return a custom response (e.g. RFC 9457 problem+json for V3).
*/
unauthenticatedResponse?: (req: NextRequest) => Response;
} }
enum ApiV1RouteTypeEnum { enum ApiV1RouteTypeEnum {
@@ -265,7 +270,7 @@ const getRouteType = (
export const withV1ApiWrapper = <TResult extends { response: Response }, TProps = unknown>( export const withV1ApiWrapper = <TResult extends { response: Response }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps> params: TWithV1ApiWrapperParams<TResult, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => { ): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const { handler, action, targetType, customRateLimitConfig } = params; const { handler, action, targetType, customRateLimitConfig, unauthenticatedResponse } = params;
return async (req: NextRequest, props: TProps): Promise<Response> => { return async (req: NextRequest, props: TProps): Promise<Response> => {
// === Audit Log Setup === // === Audit Log Setup ===
const saveAuditLog = action && targetType; const saveAuditLog = action && targetType;
@@ -287,6 +292,11 @@ export const withV1ApiWrapper = <TResult extends { response: Response }, TProps
const authentication = await handleAuthentication(authenticationMethod, req); const authentication = await handleAuthentication(authenticationMethod, req);
if (!authentication && routeType !== ApiV1RouteTypeEnum.Client) { if (!authentication && routeType !== ApiV1RouteTypeEnum.Client) {
if (unauthenticatedResponse) {
const res = unauthenticatedResponse(req);
await processResponse(res, req, auditLog);
return res;
}
return responses.notAuthenticatedResponse(); return responses.notAuthenticatedResponse();
} }
@@ -90,6 +90,17 @@ describe("endpoint-validator", () => {
}); });
describe("isManagementApiRoute", () => { describe("isManagementApiRoute", () => {
test("should return Both for v3 surveys routes", () => {
expect(isManagementApiRoute("/api/v3/surveys")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Both,
});
expect(isManagementApiRoute("/api/v3/surveys/clxxxxxxxxxxxxxxxxxxxxxxxx")).toEqual({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.Both,
});
});
test("should return correct object for management API routes with API key authentication", () => { test("should return correct object for management API routes with API key authentication", () => {
expect(isManagementApiRoute("/api/v1/management/something")).toEqual({ expect(isManagementApiRoute("/api/v1/management/something")).toEqual({
isManagementApi: true, isManagementApi: true,
@@ -22,6 +22,9 @@ export const isClientSideApiRoute = (url: string): { isClientSideApi: boolean; i
export const isManagementApiRoute = ( export const isManagementApiRoute = (
url: string url: string
): { isManagementApi: boolean; authenticationMethod: AuthenticationMethod } => { ): { isManagementApi: boolean; authenticationMethod: AuthenticationMethod } => {
// V3 surveys: session cookie or x-api-key (same pattern as management storage)
if (/^\/api\/v3\/surveys(?:\/|$)/.test(url))
return { isManagementApi: true, authenticationMethod: AuthenticationMethod.Both };
if (url.includes("/api/v1/management/storage")) if (url.includes("/api/v1/management/storage"))
return { isManagementApi: true, authenticationMethod: AuthenticationMethod.Both }; return { isManagementApi: true, authenticationMethod: AuthenticationMethod.Both };
if (url.includes("/api/v1/webhooks")) if (url.includes("/api/v1/webhooks"))
+29 -2
View File
@@ -267,6 +267,7 @@ checksums:
common/new: 126d036fae5fb6b629728ecb97e6195b common/new: 126d036fae5fb6b629728ecb97e6195b
common/new_version_available: 399ddfc4232712e18ddab2587356b3dc common/new_version_available: 399ddfc4232712e18ddab2587356b3dc
common/next: 89ddbcf710eba274963494f312bdc8a9 common/next: 89ddbcf710eba274963494f312bdc8a9
common/no_actions_found: 4d92b789eb121fc76cd6868136dcbcd4
common/no_background_image_found: 4108a781a9022c65671a826d4e299d5b common/no_background_image_found: 4108a781a9022c65671a826d4e299d5b
common/no_code: f602144ab7d28a5b19a446bf74b4dcc4 common/no_code: f602144ab7d28a5b19a446bf74b4dcc4
common/no_files_uploaded: c97be829e195a41b2f6b6717b87a232b common/no_files_uploaded: c97be829e195a41b2f6b6717b87a232b
@@ -312,6 +313,7 @@ checksums:
common/please_select_at_least_one_survey: fb1cbeb670480115305e23444c347e50 common/please_select_at_least_one_survey: fb1cbeb670480115305e23444c347e50
common/please_select_at_least_one_trigger: e88e64a1010a039745e80ed2e30951fe common/please_select_at_least_one_trigger: e88e64a1010a039745e80ed2e30951fe
common/please_upgrade_your_plan: 03d54a21ecd27723c72a13644837e5ed common/please_upgrade_your_plan: 03d54a21ecd27723c72a13644837e5ed
common/powered_by_formbricks: 1c3e19894583292bfaf686cac84a4960
common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d
common/preview_survey: 7409e9c118e3e5d5f2a86201c2b354f2 common/preview_survey: 7409e9c118e3e5d5f2a86201c2b354f2
common/privacy: 7459744a63ef8af4e517a09024bd7c08 common/privacy: 7459744a63ef8af4e517a09024bd7c08
@@ -353,6 +355,7 @@ checksums:
common/select: 5ac04c47a98deb85906bc02e0de91ab0 common/select: 5ac04c47a98deb85906bc02e0de91ab0
common/select_all: eedc7cdb02de467c15dc418a066a77f2 common/select_all: eedc7cdb02de467c15dc418a066a77f2
common/select_filter: c50082c3981f1161022f9787a19aed71 common/select_filter: c50082c3981f1161022f9787a19aed71
common/select_language: d75cf5fbce8a4c7a9055e2210af74480
common/select_survey: bac52e59c7847417bef6fe7b7096b475 common/select_survey: bac52e59c7847417bef6fe7b7096b475
common/select_teams: ae5d451929846ae6367562bc671a1af9 common/select_teams: ae5d451929846ae6367562bc671a1af9
common/selected: 9f09e059ba20c88ed34e2b4e8e032d56 common/selected: 9f09e059ba20c88ed34e2b4e8e032d56
@@ -806,6 +809,7 @@ checksums:
environments/integrations/webhooks/endpoint_pinged: 3b1fce00e61d4b9d2bdca390649c58b6 environments/integrations/webhooks/endpoint_pinged: 3b1fce00e61d4b9d2bdca390649c58b6
environments/integrations/webhooks/endpoint_pinged_error: 96c312fe8214757c4a934cdfbe177027 environments/integrations/webhooks/endpoint_pinged_error: 96c312fe8214757c4a934cdfbe177027
environments/integrations/webhooks/learn_to_verify: 25b2a035e2109170b28f4e16db76ad39 environments/integrations/webhooks/learn_to_verify: 25b2a035e2109170b28f4e16db76ad39
environments/integrations/webhooks/no_triggers: 6b68cddfc45b3f7e20644a24a1bbea69
environments/integrations/webhooks/please_check_console: 7b1787e82a0d762df02c011ebb1650ea environments/integrations/webhooks/please_check_console: 7b1787e82a0d762df02c011ebb1650ea
environments/integrations/webhooks/please_enter_a_url: c24c74d0ce7ed3a6b858aadbc82108fe environments/integrations/webhooks/please_enter_a_url: c24c74d0ce7ed3a6b858aadbc82108fe
environments/integrations/webhooks/response_created: 8c43b1b6d748f6096f6f8d9232a3c469 environments/integrations/webhooks/response_created: 8c43b1b6d748f6096f6f8d9232a3c469
@@ -1012,6 +1016,25 @@ checksums:
environments/settings/enterprise/enterprise_features: 3271476140733924b2a2477c4fdf3d12 environments/settings/enterprise/enterprise_features: 3271476140733924b2a2477c4fdf3d12
environments/settings/enterprise/get_an_enterprise_license_to_get_access_to_all_features: afd3c00f19097e88ed051800979eea44 environments/settings/enterprise/get_an_enterprise_license_to_get_access_to_all_features: afd3c00f19097e88ed051800979eea44
environments/settings/enterprise/keep_full_control_over_your_data_privacy_and_security: 43aa041cc3e2b2fdd35d2d34659a6b7a environments/settings/enterprise/keep_full_control_over_your_data_privacy_and_security: 43aa041cc3e2b2fdd35d2d34659a6b7a
environments/settings/enterprise/license_feature_access_control: bdc5ce7e88ad724d4abd3e8a07a9de5d
environments/settings/enterprise/license_feature_audit_logs: e93f59c176cfc8460d2bd56551ed78b8
environments/settings/enterprise/license_feature_contacts: fd76522bc82324ac914e124cdf9935b0
environments/settings/enterprise/license_feature_projects: 8ba082a84aa35cf851af1cf874b853e2
environments/settings/enterprise/license_feature_quotas: e6afead11b5b8ae627885ce2b84a548f
environments/settings/enterprise/license_feature_remove_branding: a5c71d43cd3ed25e6e48bca64e8ffc9f
environments/settings/enterprise/license_feature_saml: 86b76024524fc585b2c3950126ef6f62
environments/settings/enterprise/license_feature_spam_protection: e1fb0dd0723044bf040b92d8fc58015d
environments/settings/enterprise/license_feature_sso: 8c029b7dd2cb3aa1393d2814aba6cd7b
environments/settings/enterprise/license_feature_two_factor_auth: bc68ddd9c3c82225ef641f097e0940db
environments/settings/enterprise/license_feature_whitelabel: 81e9ec1d4230419f4230e6f5a318497c
environments/settings/enterprise/license_features_table_access: 550606d4a12bdf108c1b12b925ca1b3a
environments/settings/enterprise/license_features_table_description: d6260830d0703f5a2c9ed59c9da462e3
environments/settings/enterprise/license_features_table_disabled: 0889a3dfd914a7ef638611796b17bf72
environments/settings/enterprise/license_features_table_enabled: 20236664b7e62df0e767921b4450205f
environments/settings/enterprise/license_features_table_feature: 58f5f3f37862b6312a2f20ec1a1fd0e8
environments/settings/enterprise/license_features_table_title: 82d1d7b30d876cf4312f78140a90e394
environments/settings/enterprise/license_features_table_unlimited: e1a92523172cd1bdde5550689840e42d
environments/settings/enterprise/license_features_table_value: 34b0eaa85808b15cbc4be94c64d0146b
environments/settings/enterprise/license_instance_mismatch_description: 00f47e33ff54fca52ce9b125cd77fda5 environments/settings/enterprise/license_instance_mismatch_description: 00f47e33ff54fca52ce9b125cd77fda5
environments/settings/enterprise/license_invalid_description: b500c22ab17893fdf9532d2bd94aa526 environments/settings/enterprise/license_invalid_description: b500c22ab17893fdf9532d2bd94aa526
environments/settings/enterprise/license_status: f6f85c59074ca2455321bd5288d94be8 environments/settings/enterprise/license_status: f6f85c59074ca2455321bd5288d94be8
@@ -1359,6 +1382,7 @@ checksums:
environments/surveys/edit/error_saving_changes: b75aa9e4e42e1d43c8f9c33c2b7dc9a7 environments/surveys/edit/error_saving_changes: b75aa9e4e42e1d43c8f9c33c2b7dc9a7
environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: 7b99f30397dcde76f65e1ab64bdbd113 environments/surveys/edit/even_after_they_submitted_a_response_e_g_feedback_box: 7b99f30397dcde76f65e1ab64bdbd113
environments/surveys/edit/everyone: 2112aa71b568773e8e8a792c63f4d413 environments/surveys/edit/everyone: 2112aa71b568773e8e8a792c63f4d413
environments/surveys/edit/expand_preview: 6b694829e05432b9b54e7da53bc5be2f
environments/surveys/edit/external_urls_paywall_tooltip: 427f29bbbec18ebf8b3ea8d0253ddd66 environments/surveys/edit/external_urls_paywall_tooltip: 427f29bbbec18ebf8b3ea8d0253ddd66
environments/surveys/edit/fallback_missing: 43dbedbe1a178d455e5f80783a7b6722 environments/surveys/edit/fallback_missing: 43dbedbe1a178d455e5f80783a7b6722
environments/surveys/edit/fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first: ad4afe2980e1dfeffb20aa78eb892350 environments/surveys/edit/fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first: ad4afe2980e1dfeffb20aa78eb892350
@@ -1610,6 +1634,7 @@ checksums:
environments/surveys/edit/show_survey_maximum_of: 721ed61b01a9fc8ce4becb72823bb72e environments/surveys/edit/show_survey_maximum_of: 721ed61b01a9fc8ce4becb72823bb72e
environments/surveys/edit/show_survey_to_users: d5e90fd17babfea978fce826e9df89b0 environments/surveys/edit/show_survey_to_users: d5e90fd17babfea978fce826e9df89b0
environments/surveys/edit/show_to_x_percentage_of_targeted_users: b745169011fa7e8ca475baa5500c5197 environments/surveys/edit/show_to_x_percentage_of_targeted_users: b745169011fa7e8ca475baa5500c5197
environments/surveys/edit/shrink_preview: 42567389520b226f211f94f052197ad8
environments/surveys/edit/simple: 65575bd903091299bc4a94b7517a6288 environments/surveys/edit/simple: 65575bd903091299bc4a94b7517a6288
environments/surveys/edit/six_points: c6c09b3f07171dc388cb5a610ea79af7 environments/surveys/edit/six_points: c6c09b3f07171dc388cb5a610ea79af7
environments/surveys/edit/smiley: e68e3b28fc3c04255e236c6a0feb662b environments/surveys/edit/smiley: e68e3b28fc3c04255e236c6a0feb662b
@@ -1625,10 +1650,12 @@ checksums:
environments/surveys/edit/styling_set_to_theme_styles: f2c108bf422372b00cf7c87f1b042f69 environments/surveys/edit/styling_set_to_theme_styles: f2c108bf422372b00cf7c87f1b042f69
environments/surveys/edit/subheading: c0f6f57155692fd8006381518ce4fef0 environments/surveys/edit/subheading: c0f6f57155692fd8006381518ce4fef0
environments/surveys/edit/subtract: 2d83b8b9ef35110f2583ddc155b6c486 environments/surveys/edit/subtract: 2d83b8b9ef35110f2583ddc155b6c486
environments/surveys/edit/survey_closed_message_heading_required: f7c48e324c4a5c335ec68eaa27b2d67e
environments/surveys/edit/survey_completed_heading: dae5ac4a02a886dc9d9fc40927091919 environments/surveys/edit/survey_completed_heading: dae5ac4a02a886dc9d9fc40927091919
environments/surveys/edit/survey_completed_subheading: db537c356c3ab6564d24de0d11a0fee2 environments/surveys/edit/survey_completed_subheading: db537c356c3ab6564d24de0d11a0fee2
environments/surveys/edit/survey_display_settings: 8ed19e6a8e1376f7a1ba037d82c4ae11 environments/surveys/edit/survey_display_settings: 8ed19e6a8e1376f7a1ba037d82c4ae11
environments/surveys/edit/survey_placement: 083c10f257337f9648bf9d435b18ec2c environments/surveys/edit/survey_placement: 083c10f257337f9648bf9d435b18ec2c
environments/surveys/edit/survey_preview: 33644451073149383d3ace08be930739
environments/surveys/edit/survey_styling: 7f96d6563e934e65687b74374a33b1dc environments/surveys/edit/survey_styling: 7f96d6563e934e65687b74374a33b1dc
environments/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579 environments/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579
environments/surveys/edit/switch_multi_language_on_to_get_started: cca0ef91ee49095da30cd1e3f26c406f environments/surveys/edit/switch_multi_language_on_to_get_started: cca0ef91ee49095da30cd1e3f26c406f
@@ -2897,7 +2924,7 @@ checksums:
templates/preview_survey_question_2_choice_2_label: 1af148222f327f28cf0db6513de5989e templates/preview_survey_question_2_choice_2_label: 1af148222f327f28cf0db6513de5989e
templates/preview_survey_question_2_headline: 5cfb173d156555227fbc2c97ad921e72 templates/preview_survey_question_2_headline: 5cfb173d156555227fbc2c97ad921e72
templates/preview_survey_question_2_subheader: 2e652d8acd68d072e5a0ae686c4011c0 templates/preview_survey_question_2_subheader: 2e652d8acd68d072e5a0ae686c4011c0
templates/preview_survey_question_open_text_headline: a9509a47e0456ae98ec3ddac3d6fad2c templates/preview_survey_question_open_text_headline: 573f1b04b79f672ad42ba5e54320a940
templates/preview_survey_question_open_text_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee templates/preview_survey_question_open_text_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
templates/preview_survey_question_open_text_subheader: 3c7bf09f3f17b02bc2fbbbdb347a5830 templates/preview_survey_question_open_text_subheader: 3c7bf09f3f17b02bc2fbbbdb347a5830
templates/preview_survey_welcome_card_headline: 8778dc41547a2778d0f9482da989fc00 templates/preview_survey_welcome_card_headline: 8778dc41547a2778d0f9482da989fc00
@@ -3150,7 +3177,7 @@ checksums:
templates/usability_score_name: 5cbf1172d24dfcb17d979dff6dfdf7e2 templates/usability_score_name: 5cbf1172d24dfcb17d979dff6dfdf7e2
workflows/coming_soon_description: 1e0621d287924d84fb539afab7372b23 workflows/coming_soon_description: 1e0621d287924d84fb539afab7372b23
workflows/coming_soon_title: d79be80559c70c828cf20811d2ed5039 workflows/coming_soon_title: d79be80559c70c828cf20811d2ed5039
workflows/follow_up_label: 8cafe669370271035aeac8e8cab0f123 workflows/follow_up_label: ead918852c5840636a14baabfe94821e
workflows/follow_up_placeholder: f680918bec28192282e229c3d4b5e80a workflows/follow_up_placeholder: f680918bec28192282e229c3d4b5e80a
workflows/generate_button: b194b6172a49af8374a19dd2cf39cfdc workflows/generate_button: b194b6172a49af8374a19dd2cf39cfdc
workflows/heading: a98a6b14d3e955f38cc16386df9a4111 workflows/heading: a98a6b14d3e955f38cc16386df9a4111
+37 -2
View File
@@ -294,6 +294,7 @@
"new": "Neu", "new": "Neu",
"new_version_available": "Formbricks {version} ist da. Jetzt aktualisieren!", "new_version_available": "Formbricks {version} ist da. Jetzt aktualisieren!",
"next": "Weiter", "next": "Weiter",
"no_actions_found": "Keine Aktionen gefunden",
"no_background_image_found": "Kein Hintergrundbild gefunden.", "no_background_image_found": "Kein Hintergrundbild gefunden.",
"no_code": "No Code", "no_code": "No Code",
"no_files_uploaded": "Keine Dateien hochgeladen", "no_files_uploaded": "Keine Dateien hochgeladen",
@@ -339,6 +340,7 @@
"please_select_at_least_one_survey": "Bitte wähle mindestens eine Umfrage aus", "please_select_at_least_one_survey": "Bitte wähle mindestens eine Umfrage aus",
"please_select_at_least_one_trigger": "Bitte wähle mindestens einen Auslöser aus", "please_select_at_least_one_trigger": "Bitte wähle mindestens einen Auslöser aus",
"please_upgrade_your_plan": "Bitte aktualisieren Sie Ihren Plan", "please_upgrade_your_plan": "Bitte aktualisieren Sie Ihren Plan",
"powered_by_formbricks": "Bereitgestellt von Formbricks",
"preview": "Vorschau", "preview": "Vorschau",
"preview_survey": "Umfragevorschau", "preview_survey": "Umfragevorschau",
"privacy": "Datenschutz", "privacy": "Datenschutz",
@@ -380,6 +382,7 @@
"select": "Auswählen", "select": "Auswählen",
"select_all": "Alles auswählen", "select_all": "Alles auswählen",
"select_filter": "Filter auswählen", "select_filter": "Filter auswählen",
"select_language": "Sprache auswählen",
"select_survey": "Umfrage auswählen", "select_survey": "Umfrage auswählen",
"select_teams": "Teams auswählen", "select_teams": "Teams auswählen",
"selected": "Ausgewählt", "selected": "Ausgewählt",
@@ -850,9 +853,16 @@
"created_by_third_party": "Erstellt von einer dritten Partei", "created_by_third_party": "Erstellt von einer dritten Partei",
"discord_webhook_not_supported": "Discord-Webhooks werden derzeit nicht unterstützt.", "discord_webhook_not_supported": "Discord-Webhooks werden derzeit nicht unterstützt.",
"empty_webhook_message": "Deine Webhooks werden hier angezeigt, sobald Du sie hinzufügst ⏲️", "empty_webhook_message": "Deine Webhooks werden hier angezeigt, sobald Du sie hinzufügst ⏲️",
"endpoint_bad_gateway_error": "Ungültiges Gateway (502): Proxy-/Gateway-Fehler, Dienst nicht erreichbar",
"endpoint_gateway_timeout_error": "Gateway-Zeitüberschreitung (504): Gateway-Zeitüberschreitung, Dienst nicht erreichbar",
"endpoint_internal_server_error": "Interner Serverfehler (500): Der Dienst ist auf einen unerwarteten Fehler gestoßen",
"endpoint_method_not_allowed_error": "Methode nicht erlaubt (405): Der Endpoint existiert, akzeptiert aber keine POST-Anfragen",
"endpoint_not_found_error": "Nicht gefunden (404): Der Endpoint existiert nicht",
"endpoint_pinged": "Juhu! Wir können den Webhook anpingen!", "endpoint_pinged": "Juhu! Wir können den Webhook anpingen!",
"endpoint_pinged_error": "Kann den Webhook nicht anpingen!", "endpoint_pinged_error": "Kann den Webhook nicht anpingen!",
"endpoint_service_unavailable_error": "Dienst nicht verfügbar (503): Dienst ist vorübergehend nicht verfügbar",
"learn_to_verify": "Erfahren Sie, wie Sie Webhook-Signaturen verifizieren", "learn_to_verify": "Erfahren Sie, wie Sie Webhook-Signaturen verifizieren",
"no_triggers": "Keine Trigger",
"please_check_console": "Bitte überprüfe die Konsole für weitere Details", "please_check_console": "Bitte überprüfe die Konsole für weitere Details",
"please_enter_a_url": "Bitte gib eine URL ein", "please_enter_a_url": "Bitte gib eine URL ein",
"response_created": "Antwort erstellt", "response_created": "Antwort erstellt",
@@ -1071,6 +1081,25 @@
"enterprise_features": "Unternehmensfunktionen", "enterprise_features": "Unternehmensfunktionen",
"get_an_enterprise_license_to_get_access_to_all_features": "Hol dir eine Enterprise-Lizenz, um Zugriff auf alle Funktionen zu erhalten.", "get_an_enterprise_license_to_get_access_to_all_features": "Hol dir eine Enterprise-Lizenz, um Zugriff auf alle Funktionen zu erhalten.",
"keep_full_control_over_your_data_privacy_and_security": "Behalte die volle Kontrolle über deine Daten, Privatsphäre und Sicherheit.", "keep_full_control_over_your_data_privacy_and_security": "Behalte die volle Kontrolle über deine Daten, Privatsphäre und Sicherheit.",
"license_feature_access_control": "Zugriffskontrolle (RBAC)",
"license_feature_audit_logs": "Audit-Protokolle",
"license_feature_contacts": "Kontakte & Segmente",
"license_feature_projects": "Arbeitsbereiche",
"license_feature_quotas": "Kontingente",
"license_feature_remove_branding": "Branding entfernen",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Spam-Schutz",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Zwei-Faktor-Authentifizierung",
"license_feature_whitelabel": "White-Label-E-Mails",
"license_features_table_access": "Zugriff",
"license_features_table_description": "Enterprise-Funktionen und Limits, die für diese Instanz aktuell verfügbar sind.",
"license_features_table_disabled": "Deaktiviert",
"license_features_table_enabled": "Aktiviert",
"license_features_table_feature": "Funktion",
"license_features_table_title": "Lizenzierte Funktionen",
"license_features_table_unlimited": "Unbegrenzt",
"license_features_table_value": "Wert",
"license_instance_mismatch_description": "Diese Lizenz ist derzeit an eine andere Formbricks-Instanz gebunden. Falls diese Installation neu aufgebaut oder verschoben wurde, bitte den Formbricks-Support, die vorherige Instanzbindung zu entfernen.", "license_instance_mismatch_description": "Diese Lizenz ist derzeit an eine andere Formbricks-Instanz gebunden. Falls diese Installation neu aufgebaut oder verschoben wurde, bitte den Formbricks-Support, die vorherige Instanzbindung zu entfernen.",
"license_invalid_description": "Der Lizenzschlüssel in deiner ENTERPRISE_LICENSE_KEY-Umgebungsvariable ist nicht gültig. Bitte überprüfe auf Tippfehler oder fordere einen neuen Schlüssel an.", "license_invalid_description": "Der Lizenzschlüssel in deiner ENTERPRISE_LICENSE_KEY-Umgebungsvariable ist nicht gültig. Bitte überprüfe auf Tippfehler oder fordere einen neuen Schlüssel an.",
"license_status": "Lizenzstatus", "license_status": "Lizenzstatus",
@@ -1430,6 +1459,7 @@
"error_saving_changes": "Fehler beim Speichern der Änderungen", "error_saving_changes": "Fehler beim Speichern der Änderungen",
"even_after_they_submitted_a_response_e_g_feedback_box": "Mehrfachantworten erlauben; weiterhin anzeigen, auch nach einer Antwort (z.B. Feedback-Box).", "even_after_they_submitted_a_response_e_g_feedback_box": "Mehrfachantworten erlauben; weiterhin anzeigen, auch nach einer Antwort (z.B. Feedback-Box).",
"everyone": "Jeder", "everyone": "Jeder",
"expand_preview": "Vorschau erweitern",
"external_urls_paywall_tooltip": "Bitte upgrade auf einen kostenpflichtigen Tarif, um externe URLs anzupassen. So helfen wir, Phishing zu verhindern.", "external_urls_paywall_tooltip": "Bitte upgrade auf einen kostenpflichtigen Tarif, um externe URLs anzupassen. So helfen wir, Phishing zu verhindern.",
"fallback_missing": "Fehlender Fallback", "fallback_missing": "Fehlender Fallback",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
@@ -1655,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Antwortlimit muss die Anzahl der erhaltenen Antworten ({responseCount}) überschreiten.", "response_limit_needs_to_exceed_number_of_received_responses": "Antwortlimit muss die Anzahl der erhaltenen Antworten ({responseCount}) überschreiten.",
"response_limits_redirections_and_more": "Antwort Limits, Weiterleitungen und mehr.", "response_limits_redirections_and_more": "Antwort Limits, Weiterleitungen und mehr.",
"response_options": "Antwortoptionen", "response_options": "Antwortoptionen",
"reverse_order_occasionally": "Reihenfolge gelegentlich umkehren",
"reverse_order_occasionally_except_last": "Reihenfolge gelegentlich umkehren, außer letzter",
"roundness": "Rundheit", "roundness": "Rundheit",
"roundness_description": "Steuert, wie abgerundet die Ecken sind.", "roundness_description": "Steuert, wie abgerundet die Ecken sind.",
"row_used_in_logic_error": "Diese Zeile wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.", "row_used_in_logic_error": "Diese Zeile wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.",
@@ -1683,6 +1715,7 @@
"show_survey_maximum_of": "Umfrage maximal anzeigen von", "show_survey_maximum_of": "Umfrage maximal anzeigen von",
"show_survey_to_users": "Umfrage % der Nutzer anzeigen", "show_survey_to_users": "Umfrage % der Nutzer anzeigen",
"show_to_x_percentage_of_targeted_users": "Zeige {percentage}% der Zielbenutzer", "show_to_x_percentage_of_targeted_users": "Zeige {percentage}% der Zielbenutzer",
"shrink_preview": "Vorschau verkleinern",
"simple": "Einfach", "simple": "Einfach",
"six_points": "6 Punkte", "six_points": "6 Punkte",
"smiley": "Smiley", "smiley": "Smiley",
@@ -1698,10 +1731,12 @@
"styling_set_to_theme_styles": "Styling auf Themenstile eingestellt", "styling_set_to_theme_styles": "Styling auf Themenstile eingestellt",
"subheading": "Zwischenüberschrift", "subheading": "Zwischenüberschrift",
"subtract": "Subtrahieren -", "subtract": "Subtrahieren -",
"survey_closed_message_heading_required": "Füge der benutzerdefinierten Nachricht für geschlossene Umfragen eine Überschrift hinzu.",
"survey_completed_heading": "Umfrage abgeschlossen", "survey_completed_heading": "Umfrage abgeschlossen",
"survey_completed_subheading": "Diese kostenlose und quelloffene Umfrage wurde geschlossen", "survey_completed_subheading": "Diese kostenlose und quelloffene Umfrage wurde geschlossen",
"survey_display_settings": "Einstellungen zur Anzeige der Umfrage", "survey_display_settings": "Einstellungen zur Anzeige der Umfrage",
"survey_placement": "Platzierung der Umfrage", "survey_placement": "Platzierung der Umfrage",
"survey_preview": "Umfragevorschau 👀",
"survey_styling": "Umfrage Styling", "survey_styling": "Umfrage Styling",
"survey_trigger": "Auslöser der Umfrage", "survey_trigger": "Auslöser der Umfrage",
"switch_multi_language_on_to_get_started": "Aktiviere Mehrsprachigkeit, um loszulegen 👉", "switch_multi_language_on_to_get_started": "Aktiviere Mehrsprachigkeit, um loszulegen 👉",
@@ -3052,7 +3087,7 @@
"preview_survey_question_2_choice_2_label": "Nein, danke!", "preview_survey_question_2_choice_2_label": "Nein, danke!",
"preview_survey_question_2_headline": "Möchtest Du auf dem Laufenden bleiben?", "preview_survey_question_2_headline": "Möchtest Du auf dem Laufenden bleiben?",
"preview_survey_question_2_subheader": "Dies ist eine Beispielbeschreibung.", "preview_survey_question_2_subheader": "Dies ist eine Beispielbeschreibung.",
"preview_survey_question_open_text_headline": "Möchtest Du noch etwas teilen?", "preview_survey_question_open_text_headline": "Möchten Sie noch etwas mitteilen?",
"preview_survey_question_open_text_placeholder": "Tippe deine Antwort hier...", "preview_survey_question_open_text_placeholder": "Tippe deine Antwort hier...",
"preview_survey_question_open_text_subheader": "Dein Feedback hilft uns, besser zu werden.", "preview_survey_question_open_text_subheader": "Dein Feedback hilft uns, besser zu werden.",
"preview_survey_welcome_card_headline": "Willkommen!", "preview_survey_welcome_card_headline": "Willkommen!",
@@ -3307,7 +3342,7 @@
"workflows": { "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_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!", "coming_soon_title": "Wir sind fast da!",
"follow_up_label": "Gibt es noch etwas, das du hinzufügen möchtest?", "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?", "follow_up_placeholder": "Welche konkreten Aufgaben möchten Sie automatisieren? Gibt es Tools oder Integrationen, die Sie einbinden möchten?",
"generate_button": "Workflow generieren", "generate_button": "Workflow generieren",
"heading": "Welchen Workflow möchtest du erstellen?", "heading": "Welchen Workflow möchtest du erstellen?",
+37 -2
View File
@@ -294,6 +294,7 @@
"new": "New", "new": "New",
"new_version_available": "Formbricks {version} is here. Upgrade now!", "new_version_available": "Formbricks {version} is here. Upgrade now!",
"next": "Next", "next": "Next",
"no_actions_found": "No actions found",
"no_background_image_found": "No background image found.", "no_background_image_found": "No background image found.",
"no_code": "No code", "no_code": "No code",
"no_files_uploaded": "No files were uploaded", "no_files_uploaded": "No files were uploaded",
@@ -339,6 +340,7 @@
"please_select_at_least_one_survey": "Please select at least one survey", "please_select_at_least_one_survey": "Please select at least one survey",
"please_select_at_least_one_trigger": "Please select at least one trigger", "please_select_at_least_one_trigger": "Please select at least one trigger",
"please_upgrade_your_plan": "Please upgrade your plan", "please_upgrade_your_plan": "Please upgrade your plan",
"powered_by_formbricks": "Powered by Formbricks",
"preview": "Preview", "preview": "Preview",
"preview_survey": "Preview Survey", "preview_survey": "Preview Survey",
"privacy": "Privacy Policy", "privacy": "Privacy Policy",
@@ -380,6 +382,7 @@
"select": "Select", "select": "Select",
"select_all": "Select all", "select_all": "Select all",
"select_filter": "Select filter", "select_filter": "Select filter",
"select_language": "Select Language",
"select_survey": "Select Survey", "select_survey": "Select Survey",
"select_teams": "Select teams", "select_teams": "Select teams",
"selected": "Selected", "selected": "Selected",
@@ -850,9 +853,16 @@
"created_by_third_party": "Created by a Third Party", "created_by_third_party": "Created by a Third Party",
"discord_webhook_not_supported": "Discord webhooks are currently not supported.", "discord_webhook_not_supported": "Discord webhooks are currently not supported.",
"empty_webhook_message": "Your webhooks will appear here as soon as you add them. ⏲️", "empty_webhook_message": "Your webhooks will appear here as soon as you add them. ⏲️",
"endpoint_bad_gateway_error": "Bad Gateway (502): Proxy/gateway error, service not reachable",
"endpoint_gateway_timeout_error": "Gateway Timeout (504): Gateway timeout, service not reachable",
"endpoint_internal_server_error": "Internal Server Error (500): The service encountered an unexpected error",
"endpoint_method_not_allowed_error": "Method Not Allowed (405): The endpoint exists, but doesn't accept POST requests",
"endpoint_not_found_error": "Not Found (404): The endpoint doesn't exist",
"endpoint_pinged": "Yay! We are able to ping the webhook!", "endpoint_pinged": "Yay! We are able to ping the webhook!",
"endpoint_pinged_error": "Unable to ping the webhook!", "endpoint_pinged_error": "Unable to ping the webhook!",
"endpoint_service_unavailable_error": "Service Unavailable (503): Service is temporarily down",
"learn_to_verify": "Learn how to verify webhook signatures", "learn_to_verify": "Learn how to verify webhook signatures",
"no_triggers": "No Triggers",
"please_check_console": "Please check the console for more details", "please_check_console": "Please check the console for more details",
"please_enter_a_url": "Please enter a URL", "please_enter_a_url": "Please enter a URL",
"response_created": "Response Created", "response_created": "Response Created",
@@ -1071,6 +1081,25 @@
"enterprise_features": "Enterprise Features", "enterprise_features": "Enterprise Features",
"get_an_enterprise_license_to_get_access_to_all_features": "Get an Enterprise license to get access to all features.", "get_an_enterprise_license_to_get_access_to_all_features": "Get an Enterprise license to get access to all features.",
"keep_full_control_over_your_data_privacy_and_security": "Keep full control over your data privacy and security.", "keep_full_control_over_your_data_privacy_and_security": "Keep full control over your data privacy and security.",
"license_feature_access_control": "Access control (RBAC)",
"license_feature_audit_logs": "Audit logs",
"license_feature_contacts": "Contacts & Segments",
"license_feature_projects": "Workspaces",
"license_feature_quotas": "Quotas",
"license_feature_remove_branding": "Remove branding",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Spam protection",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Two-factor authentication",
"license_feature_whitelabel": "White-label emails",
"license_features_table_access": "Access",
"license_features_table_description": "Enterprise features and limits currently available to this instance.",
"license_features_table_disabled": "Disabled",
"license_features_table_enabled": "Enabled",
"license_features_table_feature": "Feature",
"license_features_table_title": "Licensed Features",
"license_features_table_unlimited": "Unlimited",
"license_features_table_value": "Value",
"license_instance_mismatch_description": "This license is currently bound to a different Formbricks instance. If this installation was rebuilt or moved, ask Formbricks support to disconnect the previous instance binding.", "license_instance_mismatch_description": "This license is currently bound to a different Formbricks instance. If this installation was rebuilt or moved, ask Formbricks support to disconnect the previous instance binding.",
"license_invalid_description": "The license key in your ENTERPRISE_LICENSE_KEY environment variable is not valid. Please check for typos or request a new key.", "license_invalid_description": "The license key in your ENTERPRISE_LICENSE_KEY environment variable is not valid. Please check for typos or request a new key.",
"license_status": "License Status", "license_status": "License Status",
@@ -1430,6 +1459,7 @@
"error_saving_changes": "Error saving changes", "error_saving_changes": "Error saving changes",
"even_after_they_submitted_a_response_e_g_feedback_box": "Allow multiple responses; continue showing even after a response (e.g., Feedback Box).", "even_after_they_submitted_a_response_e_g_feedback_box": "Allow multiple responses; continue showing even after a response (e.g., Feedback Box).",
"everyone": "Everyone", "everyone": "Everyone",
"expand_preview": "Expand Preview",
"external_urls_paywall_tooltip": "Please upgrade to a paid plan to customize external URLs. This helps us prevent phishing.", "external_urls_paywall_tooltip": "Please upgrade to a paid plan to customize external URLs. This helps us prevent phishing.",
"fallback_missing": "Fallback missing", "fallback_missing": "Fallback missing",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.",
@@ -1655,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Response limit needs to exceed number of received responses ({responseCount}).", "response_limit_needs_to_exceed_number_of_received_responses": "Response limit needs to exceed number of received responses ({responseCount}).",
"response_limits_redirections_and_more": "Response limits, redirections and more.", "response_limits_redirections_and_more": "Response limits, redirections and more.",
"response_options": "Response Options", "response_options": "Response Options",
"reverse_order_occasionally": "Reverse order occasionally",
"reverse_order_occasionally_except_last": "Reverse order occasionally except last",
"roundness": "Roundness", "roundness": "Roundness",
"roundness_description": "Controls how rounded corners are.", "roundness_description": "Controls how rounded corners are.",
"row_used_in_logic_error": "This row is used in logic of question {questionIndex}. Please remove it from logic first.", "row_used_in_logic_error": "This row is used in logic of question {questionIndex}. Please remove it from logic first.",
@@ -1683,6 +1715,7 @@
"show_survey_maximum_of": "Show survey maximum of", "show_survey_maximum_of": "Show survey maximum of",
"show_survey_to_users": "Show survey to % of users", "show_survey_to_users": "Show survey to % of users",
"show_to_x_percentage_of_targeted_users": "Show to {percentage}% of targeted users", "show_to_x_percentage_of_targeted_users": "Show to {percentage}% of targeted users",
"shrink_preview": "Shrink Preview",
"simple": "Simple", "simple": "Simple",
"six_points": "6 points", "six_points": "6 points",
"smiley": "Smiley", "smiley": "Smiley",
@@ -1698,10 +1731,12 @@
"styling_set_to_theme_styles": "Styling set to theme styles", "styling_set_to_theme_styles": "Styling set to theme styles",
"subheading": "Subheading", "subheading": "Subheading",
"subtract": "Subtract -", "subtract": "Subtract -",
"survey_closed_message_heading_required": "Add a heading to the custom survey closed message.",
"survey_completed_heading": "Survey Completed", "survey_completed_heading": "Survey Completed",
"survey_completed_subheading": "This free & open-source survey has been closed", "survey_completed_subheading": "This free & open-source survey has been closed",
"survey_display_settings": "Survey Display Settings", "survey_display_settings": "Survey Display Settings",
"survey_placement": "Survey Placement", "survey_placement": "Survey Placement",
"survey_preview": "Survey Preview 👀",
"survey_styling": "Survey styling", "survey_styling": "Survey styling",
"survey_trigger": "Survey Trigger", "survey_trigger": "Survey Trigger",
"switch_multi_language_on_to_get_started": "Switch multi-language on to get started 👉", "switch_multi_language_on_to_get_started": "Switch multi-language on to get started 👉",
@@ -3052,7 +3087,7 @@
"preview_survey_question_2_choice_2_label": "No, thank you!", "preview_survey_question_2_choice_2_label": "No, thank you!",
"preview_survey_question_2_headline": "Want to stay in the loop?", "preview_survey_question_2_headline": "Want to stay in the loop?",
"preview_survey_question_2_subheader": "This is an example description.", "preview_survey_question_2_subheader": "This is an example description.",
"preview_survey_question_open_text_headline": "Anything else you'd like to share?", "preview_survey_question_open_text_headline": "Anything else you would like to share?",
"preview_survey_question_open_text_placeholder": "Type your answer here…", "preview_survey_question_open_text_placeholder": "Type your answer here…",
"preview_survey_question_open_text_subheader": "Your feedback helps us improve.", "preview_survey_question_open_text_subheader": "Your feedback helps us improve.",
"preview_survey_welcome_card_headline": "Welcome!", "preview_survey_welcome_card_headline": "Welcome!",
@@ -3307,7 +3342,7 @@
"workflows": { "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_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!", "coming_soon_title": "We are almost there!",
"follow_up_label": "Is there anything else you'd like to add?", "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?", "follow_up_placeholder": "What specific tasks would you like to automate? Any tools or integrations you would want included?",
"generate_button": "Generate workflow", "generate_button": "Generate workflow",
"heading": "What workflow do you want to create?", "heading": "What workflow do you want to create?",
+35
View File
@@ -294,6 +294,7 @@
"new": "Nuevo", "new": "Nuevo",
"new_version_available": "Formbricks {version} está aquí. ¡Actualiza ahora!", "new_version_available": "Formbricks {version} está aquí. ¡Actualiza ahora!",
"next": "Siguiente", "next": "Siguiente",
"no_actions_found": "No se encontraron acciones",
"no_background_image_found": "No se encontró imagen de fondo.", "no_background_image_found": "No se encontró imagen de fondo.",
"no_code": "Sin código", "no_code": "Sin código",
"no_files_uploaded": "No se subieron archivos", "no_files_uploaded": "No se subieron archivos",
@@ -339,6 +340,7 @@
"please_select_at_least_one_survey": "Por favor, selecciona al menos una encuesta", "please_select_at_least_one_survey": "Por favor, selecciona al menos una encuesta",
"please_select_at_least_one_trigger": "Por favor, selecciona al menos un disparador", "please_select_at_least_one_trigger": "Por favor, selecciona al menos un disparador",
"please_upgrade_your_plan": "Por favor, actualiza tu plan", "please_upgrade_your_plan": "Por favor, actualiza tu plan",
"powered_by_formbricks": "Desarrollado por Formbricks",
"preview": "Vista previa", "preview": "Vista previa",
"preview_survey": "Vista previa de la encuesta", "preview_survey": "Vista previa de la encuesta",
"privacy": "Política de privacidad", "privacy": "Política de privacidad",
@@ -380,6 +382,7 @@
"select": "Seleccionar", "select": "Seleccionar",
"select_all": "Seleccionar todo", "select_all": "Seleccionar todo",
"select_filter": "Seleccionar filtro", "select_filter": "Seleccionar filtro",
"select_language": "Seleccionar idioma",
"select_survey": "Seleccionar encuesta", "select_survey": "Seleccionar encuesta",
"select_teams": "Seleccionar equipos", "select_teams": "Seleccionar equipos",
"selected": "Seleccionado", "selected": "Seleccionado",
@@ -850,9 +853,16 @@
"created_by_third_party": "Creado por un tercero", "created_by_third_party": "Creado por un tercero",
"discord_webhook_not_supported": "Los webhooks de Discord no son compatibles actualmente.", "discord_webhook_not_supported": "Los webhooks de Discord no son compatibles actualmente.",
"empty_webhook_message": "Tus webhooks aparecerán aquí tan pronto como los añadas. ⏲️", "empty_webhook_message": "Tus webhooks aparecerán aquí tan pronto como los añadas. ⏲️",
"endpoint_bad_gateway_error": "Puerta de enlace incorrecta (502): Error de proxy o puerta de enlace, servicio no accesible",
"endpoint_gateway_timeout_error": "Tiempo de espera de la puerta de enlace agotado (504): Tiempo de espera de la puerta de enlace agotado, servicio no accesible",
"endpoint_internal_server_error": "Error interno del servidor (500): El servicio encontró un error inesperado",
"endpoint_method_not_allowed_error": "Método no permitido (405): El endpoint existe, pero no acepta solicitudes POST",
"endpoint_not_found_error": "No encontrado (404): El endpoint no existe",
"endpoint_pinged": "¡Genial! ¡Podemos hacer ping al webhook!", "endpoint_pinged": "¡Genial! ¡Podemos hacer ping al webhook!",
"endpoint_pinged_error": "¡No se puede hacer ping al webhook!", "endpoint_pinged_error": "¡No se puede hacer ping al webhook!",
"endpoint_service_unavailable_error": "Servicio no disponible (503): El servicio está temporalmente caído",
"learn_to_verify": "Aprende a verificar las firmas de webhook", "learn_to_verify": "Aprende a verificar las firmas de webhook",
"no_triggers": "Sin activadores",
"please_check_console": "Por favor, consulta la consola para más detalles", "please_check_console": "Por favor, consulta la consola para más detalles",
"please_enter_a_url": "Por favor, introduce una URL", "please_enter_a_url": "Por favor, introduce una URL",
"response_created": "Respuesta creada", "response_created": "Respuesta creada",
@@ -1071,6 +1081,25 @@
"enterprise_features": "Características empresariales", "enterprise_features": "Características empresariales",
"get_an_enterprise_license_to_get_access_to_all_features": "Obtén una licencia empresarial para acceder a todas las características.", "get_an_enterprise_license_to_get_access_to_all_features": "Obtén una licencia empresarial para acceder a todas las características.",
"keep_full_control_over_your_data_privacy_and_security": "Mantén el control total sobre la privacidad y seguridad de tus datos.", "keep_full_control_over_your_data_privacy_and_security": "Mantén el control total sobre la privacidad y seguridad de tus datos.",
"license_feature_access_control": "Control de acceso (RBAC)",
"license_feature_audit_logs": "Registros de auditoría",
"license_feature_contacts": "Contactos y segmentos",
"license_feature_projects": "Espacios de trabajo",
"license_feature_quotas": "Cuotas",
"license_feature_remove_branding": "Eliminar marca",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Protección contra spam",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Autenticación de dos factores",
"license_feature_whitelabel": "Correos sin marca",
"license_features_table_access": "Acceso",
"license_features_table_description": "Funciones y límites empresariales disponibles actualmente para esta instancia.",
"license_features_table_disabled": "Desactivado",
"license_features_table_enabled": "Activado",
"license_features_table_feature": "Función",
"license_features_table_title": "Funciones con licencia",
"license_features_table_unlimited": "Ilimitado",
"license_features_table_value": "Valor",
"license_instance_mismatch_description": "Esta licencia está actualmente vinculada a una instancia diferente de Formbricks. Si esta instalación fue reconstruida o migrada, solicita al soporte de Formbricks que desconecte la vinculación de la instancia anterior.", "license_instance_mismatch_description": "Esta licencia está actualmente vinculada a una instancia diferente de Formbricks. Si esta instalación fue reconstruida o migrada, solicita al soporte de Formbricks que desconecte la vinculación de la instancia anterior.",
"license_invalid_description": "La clave de licencia en tu variable de entorno ENTERPRISE_LICENSE_KEY no es válida. Por favor, comprueba si hay errores tipográficos o solicita una clave nueva.", "license_invalid_description": "La clave de licencia en tu variable de entorno ENTERPRISE_LICENSE_KEY no es válida. Por favor, comprueba si hay errores tipográficos o solicita una clave nueva.",
"license_status": "Estado de la licencia", "license_status": "Estado de la licencia",
@@ -1430,6 +1459,7 @@
"error_saving_changes": "Error al guardar los cambios", "error_saving_changes": "Error al guardar los cambios",
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir respuestas múltiples; seguir mostrando incluso después de una respuesta (p. ej., cuadro de comentarios).", "even_after_they_submitted_a_response_e_g_feedback_box": "Permitir respuestas múltiples; seguir mostrando incluso después de una respuesta (p. ej., cuadro de comentarios).",
"everyone": "Todos", "everyone": "Todos",
"expand_preview": "Expandir vista previa",
"external_urls_paywall_tooltip": "Por favor, actualiza a un plan de pago para personalizar URLs externas. Esto nos ayuda a prevenir el phishing.", "external_urls_paywall_tooltip": "Por favor, actualiza a un plan de pago para personalizar URLs externas. Esto nos ayuda a prevenir el phishing.",
"fallback_missing": "Falta respaldo", "fallback_missing": "Falta respaldo",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} se usa en la lógica de la pregunta {questionIndex}. Por favor, elimínalo primero de la lógica.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} se usa en la lógica de la pregunta {questionIndex}. Por favor, elimínalo primero de la lógica.",
@@ -1655,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "El límite de respuestas debe superar el número de respuestas recibidas ({responseCount}).", "response_limit_needs_to_exceed_number_of_received_responses": "El límite de respuestas debe superar el número de respuestas recibidas ({responseCount}).",
"response_limits_redirections_and_more": "Límites de respuestas, redirecciones y más.", "response_limits_redirections_and_more": "Límites de respuestas, redirecciones y más.",
"response_options": "Opciones de respuesta", "response_options": "Opciones de respuesta",
"reverse_order_occasionally": "Invertir orden ocasionalmente",
"reverse_order_occasionally_except_last": "Invertir orden ocasionalmente excepto el último",
"roundness": "Redondez", "roundness": "Redondez",
"roundness_description": "Controla qué tan redondeadas están las esquinas.", "roundness_description": "Controla qué tan redondeadas están las esquinas.",
"row_used_in_logic_error": "Esta fila se utiliza en la lógica de la pregunta {questionIndex}. Por favor, elimínala de la lógica primero.", "row_used_in_logic_error": "Esta fila se utiliza en la lógica de la pregunta {questionIndex}. Por favor, elimínala de la lógica primero.",
@@ -1683,6 +1715,7 @@
"show_survey_maximum_of": "Mostrar encuesta un máximo de", "show_survey_maximum_of": "Mostrar encuesta un máximo de",
"show_survey_to_users": "Mostrar encuesta al % de usuarios", "show_survey_to_users": "Mostrar encuesta al % de usuarios",
"show_to_x_percentage_of_targeted_users": "Mostrar al {percentage} % de usuarios objetivo", "show_to_x_percentage_of_targeted_users": "Mostrar al {percentage} % de usuarios objetivo",
"shrink_preview": "Contraer vista previa",
"simple": "Simple", "simple": "Simple",
"six_points": "6 puntos", "six_points": "6 puntos",
"smiley": "Emoticono", "smiley": "Emoticono",
@@ -1698,10 +1731,12 @@
"styling_set_to_theme_styles": "Estilo configurado según los estilos del tema", "styling_set_to_theme_styles": "Estilo configurado según los estilos del tema",
"subheading": "Subtítulo", "subheading": "Subtítulo",
"subtract": "Restar -", "subtract": "Restar -",
"survey_closed_message_heading_required": "Añade un encabezado al mensaje personalizado de encuesta cerrada.",
"survey_completed_heading": "Encuesta completada", "survey_completed_heading": "Encuesta completada",
"survey_completed_subheading": "Esta encuesta gratuita y de código abierto ha sido cerrada", "survey_completed_subheading": "Esta encuesta gratuita y de código abierto ha sido cerrada",
"survey_display_settings": "Ajustes de visualización de la encuesta", "survey_display_settings": "Ajustes de visualización de la encuesta",
"survey_placement": "Ubicación de la encuesta", "survey_placement": "Ubicación de la encuesta",
"survey_preview": "Vista previa de la encuesta 👀",
"survey_styling": "Estilo del formulario", "survey_styling": "Estilo del formulario",
"survey_trigger": "Activador de la encuesta", "survey_trigger": "Activador de la encuesta",
"switch_multi_language_on_to_get_started": "Activa el modo multiidioma para comenzar 👉", "switch_multi_language_on_to_get_started": "Activa el modo multiidioma para comenzar 👉",
+37 -2
View File
@@ -294,6 +294,7 @@
"new": "Nouveau", "new": "Nouveau",
"new_version_available": "Formbricks {version} est là. Mettez à jour maintenant !", "new_version_available": "Formbricks {version} est là. Mettez à jour maintenant !",
"next": "Suivant", "next": "Suivant",
"no_actions_found": "Aucune action trouvée",
"no_background_image_found": "Aucune image de fond trouvée.", "no_background_image_found": "Aucune image de fond trouvée.",
"no_code": "Sans code", "no_code": "Sans code",
"no_files_uploaded": "Aucun fichier n'a été téléchargé.", "no_files_uploaded": "Aucun fichier n'a été téléchargé.",
@@ -339,6 +340,7 @@
"please_select_at_least_one_survey": "Veuillez sélectionner au moins une enquête.", "please_select_at_least_one_survey": "Veuillez sélectionner au moins une enquête.",
"please_select_at_least_one_trigger": "Veuillez sélectionner au moins un déclencheur.", "please_select_at_least_one_trigger": "Veuillez sélectionner au moins un déclencheur.",
"please_upgrade_your_plan": "Veuillez mettre à niveau votre plan", "please_upgrade_your_plan": "Veuillez mettre à niveau votre plan",
"powered_by_formbricks": "Propulsé par Formbricks",
"preview": "Aperçu", "preview": "Aperçu",
"preview_survey": "Aperçu de l'enquête", "preview_survey": "Aperçu de l'enquête",
"privacy": "Politique de confidentialité", "privacy": "Politique de confidentialité",
@@ -380,6 +382,7 @@
"select": "Sélectionner", "select": "Sélectionner",
"select_all": "Sélectionner tout", "select_all": "Sélectionner tout",
"select_filter": "Sélectionner un filtre", "select_filter": "Sélectionner un filtre",
"select_language": "Sélectionner la langue",
"select_survey": "Sélectionner l'enquête", "select_survey": "Sélectionner l'enquête",
"select_teams": "Sélectionner les équipes", "select_teams": "Sélectionner les équipes",
"selected": "Sélectionné", "selected": "Sélectionné",
@@ -850,9 +853,16 @@
"created_by_third_party": "Créé par un tiers", "created_by_third_party": "Créé par un tiers",
"discord_webhook_not_supported": "Les webhooks Discord ne sont actuellement pas pris en charge.", "discord_webhook_not_supported": "Les webhooks Discord ne sont actuellement pas pris en charge.",
"empty_webhook_message": "Vos webhooks apparaîtront ici dès que vous les ajouterez. ⏲️", "empty_webhook_message": "Vos webhooks apparaîtront ici dès que vous les ajouterez. ⏲️",
"endpoint_bad_gateway_error": "Mauvaise passerelle (502) : Erreur de proxy/passerelle, service inaccessible",
"endpoint_gateway_timeout_error": "Délai d'attente de la passerelle dépassé (504) : Le délai d'attente de la passerelle a expiré, service inaccessible",
"endpoint_internal_server_error": "Erreur interne du serveur (500) : Le service a rencontré une erreur inattendue",
"endpoint_method_not_allowed_error": "Méthode non autorisée (405) : Le point de terminaison existe, mais n'accepte pas les requêtes POST",
"endpoint_not_found_error": "Introuvable (404) : Le point de terminaison n'existe pas",
"endpoint_pinged": "Yay ! Nous pouvons pinger le webhook !", "endpoint_pinged": "Yay ! Nous pouvons pinger le webhook !",
"endpoint_pinged_error": "Impossible de pinger le webhook !", "endpoint_pinged_error": "Impossible de pinger le webhook !",
"endpoint_service_unavailable_error": "Service indisponible (503) : Le service est temporairement indisponible",
"learn_to_verify": "Découvrez comment vérifier les signatures de webhook", "learn_to_verify": "Découvrez comment vérifier les signatures de webhook",
"no_triggers": "Aucun déclencheur",
"please_check_console": "Veuillez vérifier la console pour plus de détails.", "please_check_console": "Veuillez vérifier la console pour plus de détails.",
"please_enter_a_url": "Veuillez entrer une URL.", "please_enter_a_url": "Veuillez entrer une URL.",
"response_created": "Réponse créée", "response_created": "Réponse créée",
@@ -1071,6 +1081,25 @@
"enterprise_features": "Fonctionnalités d'entreprise", "enterprise_features": "Fonctionnalités d'entreprise",
"get_an_enterprise_license_to_get_access_to_all_features": "Obtenez une licence Entreprise pour accéder à toutes les fonctionnalités.", "get_an_enterprise_license_to_get_access_to_all_features": "Obtenez une licence Entreprise pour accéder à toutes les fonctionnalités.",
"keep_full_control_over_your_data_privacy_and_security": "Gardez un contrôle total sur la confidentialité et la sécurité de vos données.", "keep_full_control_over_your_data_privacy_and_security": "Gardez un contrôle total sur la confidentialité et la sécurité de vos données.",
"license_feature_access_control": "Contrôle d'accès (RBAC)",
"license_feature_audit_logs": "Journaux d'audit",
"license_feature_contacts": "Contacts et segments",
"license_feature_projects": "Espaces de travail",
"license_feature_quotas": "Quotas",
"license_feature_remove_branding": "Retirer l'image de marque",
"license_feature_saml": "SSO SAML",
"license_feature_spam_protection": "Protection anti-spam",
"license_feature_sso": "SSO OIDC",
"license_feature_two_factor_auth": "Authentification à deux facteurs",
"license_feature_whitelabel": "E-mails en marque blanche",
"license_features_table_access": "Accès",
"license_features_table_description": "Fonctionnalités Enterprise et limites actuellement disponibles pour cette instance.",
"license_features_table_disabled": "Désactivé",
"license_features_table_enabled": "Activé",
"license_features_table_feature": "Fonctionnalité",
"license_features_table_title": "Fonctionnalités sous licence",
"license_features_table_unlimited": "Illimité",
"license_features_table_value": "Valeur",
"license_instance_mismatch_description": "Cette licence est actuellement liée à une autre instance Formbricks. Si cette installation a été reconstruite ou déplacée, demande au support Formbricks de déconnecter la liaison de l'instance précédente.", "license_instance_mismatch_description": "Cette licence est actuellement liée à une autre instance Formbricks. Si cette installation a été reconstruite ou déplacée, demande au support Formbricks de déconnecter la liaison de l'instance précédente.",
"license_invalid_description": "La clé de licence dans votre variable d'environnement ENTERPRISE_LICENSE_KEY n'est pas valide. Veuillez vérifier les fautes de frappe ou demander une nouvelle clé.", "license_invalid_description": "La clé de licence dans votre variable d'environnement ENTERPRISE_LICENSE_KEY n'est pas valide. Veuillez vérifier les fautes de frappe ou demander une nouvelle clé.",
"license_status": "Statut de la licence", "license_status": "Statut de la licence",
@@ -1430,6 +1459,7 @@
"error_saving_changes": "Erreur lors de l'enregistrement des modifications", "error_saving_changes": "Erreur lors de l'enregistrement des modifications",
"even_after_they_submitted_a_response_e_g_feedback_box": "Autoriser plusieurs réponses; continuer à afficher même après une réponse (par exemple, boîte de commentaires).", "even_after_they_submitted_a_response_e_g_feedback_box": "Autoriser plusieurs réponses; continuer à afficher même après une réponse (par exemple, boîte de commentaires).",
"everyone": "Tout le monde", "everyone": "Tout le monde",
"expand_preview": "Agrandir l'aperçu",
"external_urls_paywall_tooltip": "Merci de passer à une offre payante pour personnaliser les URLs externes. Cela nous aide à empêcher lhameçonnage.", "external_urls_paywall_tooltip": "Merci de passer à une offre payante pour personnaliser les URLs externes. Cela nous aide à empêcher lhameçonnage.",
"fallback_missing": "Fallback manquant", "fallback_missing": "Fallback manquant",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
@@ -1655,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "La limite de réponses doit dépasser le nombre de réponses reçues ({responseCount}).", "response_limit_needs_to_exceed_number_of_received_responses": "La limite de réponses doit dépasser le nombre de réponses reçues ({responseCount}).",
"response_limits_redirections_and_more": "Limites de réponse, redirections et plus.", "response_limits_redirections_and_more": "Limites de réponse, redirections et plus.",
"response_options": "Options de réponse", "response_options": "Options de réponse",
"reverse_order_occasionally": "Inverser l'ordre occasionnellement",
"reverse_order_occasionally_except_last": "Inverser l'ordre occasionnellement sauf le dernier",
"roundness": "Rondeur", "roundness": "Rondeur",
"roundness_description": "Contrôle l'arrondi des coins.", "roundness_description": "Contrôle l'arrondi des coins.",
"row_used_in_logic_error": "Cette ligne est utilisée dans la logique de la question {questionIndex}. Veuillez d'abord la supprimer de la logique.", "row_used_in_logic_error": "Cette ligne est utilisée dans la logique de la question {questionIndex}. Veuillez d'abord la supprimer de la logique.",
@@ -1683,6 +1715,7 @@
"show_survey_maximum_of": "Afficher le maximum du sondage de", "show_survey_maximum_of": "Afficher le maximum du sondage de",
"show_survey_to_users": "Afficher l'enquête à % des utilisateurs", "show_survey_to_users": "Afficher l'enquête à % des utilisateurs",
"show_to_x_percentage_of_targeted_users": "Afficher à {percentage}% des utilisateurs ciblés", "show_to_x_percentage_of_targeted_users": "Afficher à {percentage}% des utilisateurs ciblés",
"shrink_preview": "Réduire l'aperçu",
"simple": "Simple", "simple": "Simple",
"six_points": "6 points", "six_points": "6 points",
"smiley": "Sourire", "smiley": "Sourire",
@@ -1698,10 +1731,12 @@
"styling_set_to_theme_styles": "Style défini sur les styles du thème", "styling_set_to_theme_styles": "Style défini sur les styles du thème",
"subheading": "Sous-titre", "subheading": "Sous-titre",
"subtract": "Soustraire -", "subtract": "Soustraire -",
"survey_closed_message_heading_required": "Ajoute un titre au message personnalisé de sondage fermé.",
"survey_completed_heading": "Enquête terminée", "survey_completed_heading": "Enquête terminée",
"survey_completed_subheading": "Cette enquête gratuite et open-source a été fermée", "survey_completed_subheading": "Cette enquête gratuite et open-source a été fermée",
"survey_display_settings": "Paramètres d'affichage de l'enquête", "survey_display_settings": "Paramètres d'affichage de l'enquête",
"survey_placement": "Placement de l'enquête", "survey_placement": "Placement de l'enquête",
"survey_preview": "Aperçu du sondage 👀",
"survey_styling": "Style de formulaire", "survey_styling": "Style de formulaire",
"survey_trigger": "Déclencheur d'enquête", "survey_trigger": "Déclencheur d'enquête",
"switch_multi_language_on_to_get_started": "Activez le mode multilingue pour commencer 👉", "switch_multi_language_on_to_get_started": "Activez le mode multilingue pour commencer 👉",
@@ -3052,7 +3087,7 @@
"preview_survey_question_2_choice_2_label": "Non, merci !", "preview_survey_question_2_choice_2_label": "Non, merci !",
"preview_survey_question_2_headline": "Souhaitez-vous être informé ?", "preview_survey_question_2_headline": "Souhaitez-vous être informé ?",
"preview_survey_question_2_subheader": "Ceci est un exemple de description.", "preview_survey_question_2_subheader": "Ceci est un exemple de description.",
"preview_survey_question_open_text_headline": "Autre chose que vous aimeriez partager?", "preview_survey_question_open_text_headline": "Souhaitez-vous partager autre chose ?",
"preview_survey_question_open_text_placeholder": "Entrez votre réponse ici...", "preview_survey_question_open_text_placeholder": "Entrez votre réponse ici...",
"preview_survey_question_open_text_subheader": "Vos commentaires nous aident à nous améliorer.", "preview_survey_question_open_text_subheader": "Vos commentaires nous aident à nous améliorer.",
"preview_survey_welcome_card_headline": "Bienvenue !", "preview_survey_welcome_card_headline": "Bienvenue !",
@@ -3307,7 +3342,7 @@
"workflows": { "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_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!", "coming_soon_title": "Nous y sommes presque!",
"follow_up_label": "Y a-t-il autre chose que vous aimeriez ajouter?", "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 ?", "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", "generate_button": "Générer le workflow",
"heading": "Quel workflow souhaitez-vous créer?", "heading": "Quel workflow souhaitez-vous créer?",
+117 -82
View File
@@ -175,7 +175,7 @@
"copy_code": "Kód másolása", "copy_code": "Kód másolása",
"copy_link": "Hivatkozás másolása", "copy_link": "Hivatkozás másolása",
"count_attributes": "{count, plural, one {{count} attribútum} other {{count} attribútum}}", "count_attributes": "{count, plural, one {{count} attribútum} other {{count} attribútum}}",
"count_contacts": "{count, plural, one {{count} kontakt}} other {{count} kontakt}}", "count_contacts": "{count, plural, one {{count} partner} other {{count} partner}}",
"count_members": "{count, plural, one {{count} tag} other {{count} tag}}", "count_members": "{count, plural, one {{count} tag} other {{count} tag}}",
"count_questions": "{count, plural, one {{count} kérdés} other {{count} kérdés}}", "count_questions": "{count, plural, one {{count} kérdés} other {{count} kérdés}}",
"count_responses": "{count, plural, one {{count} válasz} other {{count} válasz}}", "count_responses": "{count, plural, one {{count} válasz} other {{count} válasz}}",
@@ -294,6 +294,7 @@
"new": "Új", "new": "Új",
"new_version_available": "A Formbricks {version} megérkezett. Frissítsen most!", "new_version_available": "A Formbricks {version} megérkezett. Frissítsen most!",
"next": "Következő", "next": "Következő",
"no_actions_found": "Nem találhatók műveletek",
"no_background_image_found": "Nem található háttérkép.", "no_background_image_found": "Nem található háttérkép.",
"no_code": "Kód nélkül", "no_code": "Kód nélkül",
"no_files_uploaded": "Nem lettek fájlok feltöltve", "no_files_uploaded": "Nem lettek fájlok feltöltve",
@@ -339,6 +340,7 @@
"please_select_at_least_one_survey": "Válasszon legalább egy kérdőívet", "please_select_at_least_one_survey": "Válasszon legalább egy kérdőívet",
"please_select_at_least_one_trigger": "Válasszon legalább egy aktiválót", "please_select_at_least_one_trigger": "Válasszon legalább egy aktiválót",
"please_upgrade_your_plan": "Váltson magasabb csomagra", "please_upgrade_your_plan": "Váltson magasabb csomagra",
"powered_by_formbricks": "A gépházban: Formbricks",
"preview": "Előnézet", "preview": "Előnézet",
"preview_survey": "Kérdőív előnézete", "preview_survey": "Kérdőív előnézete",
"privacy": "Adatvédelmi irányelvek", "privacy": "Adatvédelmi irányelvek",
@@ -360,7 +362,7 @@
"reorder_and_hide_columns": "Oszlopok átrendezése és elrejtése", "reorder_and_hide_columns": "Oszlopok átrendezése és elrejtése",
"replace": "Csere", "replace": "Csere",
"report_survey": "Kérdőív jelentése", "report_survey": "Kérdőív jelentése",
"request_trial_license": "Próbalicenc kérése", "request_trial_license": "Próbaidőszaki licenc kérése",
"reset_to_default": "Visszaállítás az alapértelmezettre", "reset_to_default": "Visszaállítás az alapértelmezettre",
"response": "Válasz", "response": "Válasz",
"response_id": "Válaszazonosító", "response_id": "Válaszazonosító",
@@ -380,6 +382,7 @@
"select": "Kiválasztás", "select": "Kiválasztás",
"select_all": "Összes kiválasztása", "select_all": "Összes kiválasztása",
"select_filter": "Szűrő kiválasztása", "select_filter": "Szűrő kiválasztása",
"select_language": "Nyelv kiválasztása",
"select_survey": "Kérdőív kiválasztása", "select_survey": "Kérdőív kiválasztása",
"select_teams": "Csapatok kiválasztása", "select_teams": "Csapatok kiválasztása",
"selected": "Kiválasztva", "selected": "Kiválasztva",
@@ -399,7 +402,7 @@
"something_went_wrong": "Valami probléma történt", "something_went_wrong": "Valami probléma történt",
"something_went_wrong_please_try_again": "Valami probléma történt. Próbálja meg újra.", "something_went_wrong_please_try_again": "Valami probléma történt. Próbálja meg újra.",
"sort_by": "Rendezési sorrend", "sort_by": "Rendezési sorrend",
"start_free_trial": "Ingyenes próbaverzió indítása", "start_free_trial": "Ingyenes próbaidőszak indítása",
"status": "Állapot", "status": "Állapot",
"step_by_step_manual": "Lépésenkénti kézikönyv", "step_by_step_manual": "Lépésenkénti kézikönyv",
"storage_not_configured": "A fájltároló nincs beállítva, a feltöltések valószínűleg sikertelenek lesznek", "storage_not_configured": "A fájltároló nincs beállítva, a feltöltések valószínűleg sikertelenek lesznek",
@@ -434,9 +437,9 @@
"title": "Cím", "title": "Cím",
"top_left": "Balra fent", "top_left": "Balra fent",
"top_right": "Jobbra fent", "top_right": "Jobbra fent",
"trial_days_remaining": "{count} nap van hátra a próbaidőszakból", "trial_days_remaining": "{count} nap van hátra a próbaidőszakából",
"trial_expired": "A próbaidőszak lejárt", "trial_expired": "A próbaidőszaka lejárt",
"trial_one_day_remaining": "1 nap van hátra a próbaidőszakból", "trial_one_day_remaining": "1 nap van hátra a próbaidőszakából",
"try_again": "Próbálja újra", "try_again": "Próbálja újra",
"type": "Típus", "type": "Típus",
"unknown_survey": "Ismeretlen kérdőív", "unknown_survey": "Ismeretlen kérdőív",
@@ -444,7 +447,7 @@
"update": "Frissítés", "update": "Frissítés",
"updated": "Frissítve", "updated": "Frissítve",
"updated_at": "Frissítve", "updated_at": "Frissítve",
"upgrade_plan": "Csomag frissítése", "upgrade_plan": "Magasabb csomagra váltás",
"upload": "Feltöltés", "upload": "Feltöltés",
"upload_failed": "A feltöltés nem sikerült. Próbálja meg újra.", "upload_failed": "A feltöltés nem sikerült. Próbálja meg újra.",
"upload_input_description": "Kattintson vagy húzza ide a fájlok feltöltéséhez.", "upload_input_description": "Kattintson vagy húzza ide a fájlok feltöltéséhez.",
@@ -537,7 +540,7 @@
"survey_response_finished_email_view_survey_summary": "Kérdőív összegzésének megtekintése", "survey_response_finished_email_view_survey_summary": "Kérdőív összegzésének megtekintése",
"text_variable": "Szöveg változó", "text_variable": "Szöveg változó",
"verification_email_click_on_this_link": "Erre a hivatkozásra is kattinthat:", "verification_email_click_on_this_link": "Erre a hivatkozásra is kattinthat:",
"verification_email_heading": "Már majdnem megvagyunk!", "verification_email_heading": "Már majdnem kész vagyunk!",
"verification_email_hey": "Helló 👋", "verification_email_hey": "Helló 👋",
"verification_email_if_expired_request_new_token": "Ha lejárt, kérjen új tokent itt:", "verification_email_if_expired_request_new_token": "Ha lejárt, kérjen új tokent itt:",
"verification_email_link_valid_for_24_hours": "A hivatkozás 24 órán keresztül érvényes.", "verification_email_link_valid_for_24_hours": "A hivatkozás 24 órán keresztül érvényes.",
@@ -605,15 +608,15 @@
"test_match": "Illeszkedés tesztelése", "test_match": "Illeszkedés tesztelése",
"test_your_url": "URL tesztelése", "test_your_url": "URL tesztelése",
"this_action_was_created_automatically_you_cannot_make_changes_to_it": "Ez a művelet automatikusan lett létrehozva. Nem végezhet változtatásokat rajta.", "this_action_was_created_automatically_you_cannot_make_changes_to_it": "Ez a művelet automatikusan lett létrehozva. Nem végezhet változtatásokat rajta.",
"this_action_will_be_triggered_after_user_stays_on_page": "Ez a művelet akkor fog aktiválódni, miután a felhasználó a megadott ideig az oldalon tartózkodik.", "this_action_will_be_triggered_after_user_stays_on_page": "Ez a művelet azután lesz aktiválva, hogy a felhasználó az oldalon marad a megadott időtartamig.",
"this_action_will_be_triggered_when_the_page_is_loaded": "Ez a művelet akkor lesz aktiválva, ha az oldal betöltődik.", "this_action_will_be_triggered_when_the_page_is_loaded": "Ez a művelet akkor lesz aktiválva, ha az oldal betöltődik.",
"this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Ez a művelet akkor lesz aktiválva, ha a felhasználó az oldal 50%-áig görget.", "this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page": "Ez a művelet akkor lesz aktiválva, ha a felhasználó az oldal 50%-áig görget.",
"this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Ez a művelet akkor lesz aktiválva, ha a felhasználó megpróbálja elhagyni az oldalt.", "this_action_will_be_triggered_when_the_user_tries_to_leave_the_page": "Ez a művelet akkor lesz aktiválva, ha a felhasználó megpróbálja elhagyni az oldalt.",
"this_is_a_code_action_please_make_changes_in_your_code_base": "Ez egy kódművelet. A változtatásokat a kódbázisban hajtsa végre.", "this_is_a_code_action_please_make_changes_in_your_code_base": "Ez egy kódművelet. A változtatásokat a kódbázisban hajtsa végre.",
"time_in_seconds": "Idő másodpercben", "time_in_seconds": "Idő másodpercben",
"time_in_seconds_placeholder": "pl. 10", "time_in_seconds_placeholder": "például 10",
"time_in_seconds_with_unit": "{seconds} mp", "time_in_seconds_with_unit": "{seconds} mp",
"time_on_page": "Oldalon töltött idő", "time_on_page": "Idő az oldalon",
"track_new_user_action": "Új felhasználói művelet követése", "track_new_user_action": "Új felhasználói művelet követése",
"track_user_action_to_display_surveys_or_create_user_segment": "Felhasználói művelet követése a kérdőívek megjelenítéséhez vagy felhasználói szakasz létrehozásához.", "track_user_action_to_display_surveys_or_create_user_segment": "Felhasználói művelet követése a kérdőívek megjelenítéséhez vagy felhasználói szakasz létrehozásához.",
"url": "URL", "url": "URL",
@@ -850,9 +853,16 @@
"created_by_third_party": "Harmadik fél által létrehozva", "created_by_third_party": "Harmadik fél által létrehozva",
"discord_webhook_not_supported": "A Discord webhorgok jelenleg nem támogatottak.", "discord_webhook_not_supported": "A Discord webhorgok jelenleg nem támogatottak.",
"empty_webhook_message": "A webhorgai itt fognak megjelenni, amint hozzáadja azokat. ⏲️", "empty_webhook_message": "A webhorgai itt fognak megjelenni, amint hozzáadja azokat. ⏲️",
"endpoint_bad_gateway_error": "Hibás átjáró (502): Proxy-/átjáróhiba, a szolgáltatás nem érhető el",
"endpoint_gateway_timeout_error": "Átjáró időtúllépés (504): Átjáró időtúllépés, a szolgáltatás nem érhető el",
"endpoint_internal_server_error": "Belső szerverhiba (500): A szolgáltatás váratlan hibába ütközött",
"endpoint_method_not_allowed_error": "A metódus nem engedélyezett (405): A végpont létezik, de nem fogad POST kéréseket",
"endpoint_not_found_error": "Nem található (404): A végpont nem létezik",
"endpoint_pinged": "Hurrá! Képesek vagyunk pingelni a webhorgot!", "endpoint_pinged": "Hurrá! Képesek vagyunk pingelni a webhorgot!",
"endpoint_pinged_error": "Nem lehet pingelni a webhorgot!", "endpoint_pinged_error": "Nem lehet pingelni a webhorgot!",
"endpoint_service_unavailable_error": "A szolgáltatás nem érhető el (503): A szolgáltatás átmenetileg nem elérhető",
"learn_to_verify": "Tudja meg, hogy kell ellenőrizni a webhorog aláírásait", "learn_to_verify": "Tudja meg, hogy kell ellenőrizni a webhorog aláírásait",
"no_triggers": "Nincsenek Triggerek",
"please_check_console": "További részletekért nézze meg a konzolt", "please_check_console": "További részletekért nézze meg a konzolt",
"please_enter_a_url": "Adjon meg egy URL-t", "please_enter_a_url": "Adjon meg egy URL-t",
"response_created": "Válasz létrehozva", "response_created": "Válasz létrehozva",
@@ -973,79 +983,79 @@
}, },
"billing": { "billing": {
"add_payment_method": "Fizetési mód hozzáadása", "add_payment_method": "Fizetési mód hozzáadása",
"add_payment_method_to_upgrade_tooltip": "Kérjük, adjon hozzá egy fizetési módot fent a fizetős csomagra való frissítéshez", "add_payment_method_to_upgrade_tooltip": "Adjon hozzá fizetési módot fent, hogy fizetős csomagra váltson",
"billing_interval_toggle": "Számlázási időszak", "billing_interval_toggle": "Számlázási időköz",
"current_plan_badge": "Jelenlegi", "current_plan_badge": "Jelenlegi",
"current_plan_cta": "Jelenlegi csomag", "current_plan_cta": "Jelenlegi csomag",
"custom_plan_description": "A szervezete egyedi számlázási beállítással rendelkezik. Továbbra is válthat az alábbi standard csomagok egyikére.", "custom_plan_description": "A szervezete egyéni számlázási beállítással rendelkezik. Ugyanakkor áttérhet az alábbi szabványos csomagok egyikére.",
"custom_plan_title": "Egyedi csomag", "custom_plan_title": "Egyéni csomag",
"failed_to_start_trial": "A próbaidőszak indítása sikertelen. Kérjük, próbálja meg újra.", "failed_to_start_trial": "Nem sikerült a próbaidőszak indítása. Próbálja meg újra.",
"keep_current_plan": "Jelenlegi csomag megtartása", "keep_current_plan": "Jelenlegi csomag megtartása",
"manage_billing_details": "Kártyaadatok és számlák kezelése", "manage_billing_details": "Kártyarészletek és számlák kezelése",
"monthly": "Havi", "monthly": "Havi",
"most_popular": "Legnépszerűbb", "most_popular": "Legnépszerűbb",
"pending_change_removed": "Az ütemezett csomagváltás eltávolítva.", "pending_change_removed": "Az ütemezett csomagváltoztatás eltávolítva.",
"pending_plan_badge": "Ütemezett", "pending_plan_badge": "Ütemezett",
"pending_plan_change_description": "A csomagja {{date}}-án átvált erre: {{plan}}.", "pending_plan_change_description": "A csomagja {{plan}} csomagra fog váltani ekkor: {{date}}.",
"pending_plan_change_title": "Ütemezett csomagváltás", "pending_plan_change_title": "Ütemezett csomagváltoztatás",
"pending_plan_cta": "Ütemezett", "pending_plan_cta": "Ütemezett",
"per_month": "havonta", "per_month": "havonta",
"per_year": "évente", "per_year": "évente",
"plan_change_applied": "A csomag sikeresen frissítve.", "plan_change_applied": "A csomag sikeresen frissítve.",
"plan_change_scheduled": "A csomagváltás sikeresen ütemezve.", "plan_change_scheduled": "A csomagváltoztatás sikeresen ütemezve.",
"plan_custom": "Custom", "plan_custom": "Egyéni",
"plan_feature_everything_in_hobby": "Minden, ami a Hobby csomagban", "plan_feature_everything_in_hobby": "Minden a Hobbi csomagban",
"plan_feature_everything_in_pro": "Minden, ami a Pro csomagban", "plan_feature_everything_in_pro": "Minden a Pro csomagban",
"plan_hobby": "Hobby", "plan_hobby": "Hobbi",
"plan_hobby_description": "Magánszemélyek és kisebb csapatok számára, akik most kezdik a Formbricks Cloud használatát.", "plan_hobby_description": "Magánszemélyeknek és kis csapatoknak, akik most teszik meg a kezdeti lépéseket a Formbricks Cloud szolgáltatással.",
"plan_hobby_feature_responses": "250 válasz / hó", "plan_hobby_feature_responses": "250 válasz/hónap",
"plan_hobby_feature_workspaces": "1 munkaterület", "plan_hobby_feature_workspaces": "1 munkaterület",
"plan_pro": "Pro", "plan_pro": "Pro",
"plan_pro_description": "Növekvő csapatok számára, amelyeknek magasabb korlátokra, automatizálásokra és dinamikus túlhasználatra van szükségük.", "plan_pro_description": "Növekvő csapatoknak, akiknek magasabb korlátokra, automatizálásra és dinamikus túllépési lehetőségekre van szükségük.",
"plan_pro_feature_responses": "2 000 válasz / hó (dinamikus túlhasználat)", "plan_pro_feature_responses": "2000 válasz/hónap (dinamikus túllépés)",
"plan_pro_feature_workspaces": "3 munkaterület", "plan_pro_feature_workspaces": "3 munkaterület",
"plan_scale": "Scale", "plan_scale": "Méretezés",
"plan_scale_description": "Nagyobb csapatok számára, amelyeknek nagyobb kapacitásra, erősebb irányításra és magasabb válaszmennyiségre van szükségük.", "plan_scale_description": "Nagyobb csapatoknak, amelyeknek bb kapacitásra, erősebb irányításra és nagyobb válaszmennyiségre van szükségük.",
"plan_scale_feature_responses": "5000 válasz / hónap (dinamikus túllépés)", "plan_scale_feature_responses": "5000 válasz/hónap (dinamikus túllépés)",
"plan_scale_feature_workspaces": "5 munkaterület", "plan_scale_feature_workspaces": "5 munkaterület",
"plan_selection_description": "Hasonlítsa össze a Hobby, Pro és Scale csomagokat, majd váltson csomagot közvetlenül a Formbricks alkalmazásból.", "plan_selection_description": "Hobbi, Pro és Méretezés csomagok összehasonlítása, majd csomagok közötti váltás közvetlenül a Formbricksben.",
"plan_selection_title": "Válassza ki az Ön csomagját", "plan_selection_title": "Csomag kiválasztása",
"plan_unknown": "Ismeretlen", "plan_unknown": "Ismeretlen",
"remove_branding": "Márkajel eltávolítása", "remove_branding": "Márkajel eltávolítása",
"retry_setup": "Újrapróbálkozás a beállítással", "retry_setup": "Beállítás újrapróbálása",
"select_plan_header_subtitle": "Nincs szükség bankkártyára, nincsenek rejtett feltételek.", "select_plan_header_subtitle": "Nincs szükség hitelkártyára, nincs kötöttség.",
"select_plan_header_title": "Zökkenőmentesen integrált felmérések, 100%-ban az Ön márkája.", "select_plan_header_title": "Zökkenőmentesen integrált kérdőívek, 100%-ban az Ön márkájához igazítva.",
"status_trialing": "Próbaverzió", "status_trialing": "Próbaidőszak",
"stay_on_hobby_plan": "A Hobby csomagnál szeretnék maradni", "stay_on_hobby_plan": "A Hobbi csomagnál szeretnék maradni",
"stripe_setup_incomplete": "Számlázás beállítása nem teljes", "stripe_setup_incomplete": "A számlázási beállítás befejezetlen",
"stripe_setup_incomplete_description": "A számlázás beállítása nem sikerült teljesen. Aktiválja előfizetését az újrapróbálkozással.", "stripe_setup_incomplete_description": "A számlázási beállítás nem fejeződött be sikeresen. Próbálja meg újra aktiválni az előfizetését.",
"subscription": "Előfizetés", "subscription": "Előfizetés",
"subscription_description": "Kezelje előfizetését és kövesse nyomon a használatot", "subscription_description": "Az előfizetési csomag kezelése és a használat felügyelete",
"switch_at_period_end": "Váltás az időszak végén", "switch_at_period_end": "Váltás az időszak végén",
"switch_plan_now": "Csomag váltása most", "switch_plan_now": "Csomag váltása most",
"this_includes": "Ez tartalmazza", "this_includes": "Ezeket tartalmazza",
"trial_alert_description": "Adjon hozzá fizetési módot, hogy megtarthassa a hozzáférést az összes funkcióhoz.", "trial_alert_description": "Fizetési mód hozzáadása az összes funkcióhoz való hozzáférés megtartásához.",
"trial_already_used": "Ehhez az e-mail címhez már igénybe vettek ingyenes próbaidőszakot. Kérjük, válasszon helyette fizetős csomagot.", "trial_already_used": "Ehhez az e-mail-címhez már használatban van egy ingyenes próbaidőszak. Váltson inkább fizetős csomagra.",
"trial_feature_api_access": "API-hozzáférés", "trial_feature_api_access": "API-hozzáférés",
"trial_feature_attribute_segmentation": "Attribútumalapú szegmentálás", "trial_feature_attribute_segmentation": "Attribútumalapú szakaszolás",
"trial_feature_contact_segment_management": "Kapcsolat- és szegmenskezelés", "trial_feature_contact_segment_management": "Partner- és szakaszkezelés",
"trial_feature_email_followups": "E-mail követések", "trial_feature_email_followups": "E-mailes utókövetések",
"trial_feature_hide_branding": "Formbricks márkajelzés elrejtése", "trial_feature_hide_branding": "Formbricks márkajel elrejtése",
"trial_feature_mobile_sdks": "iOS és Android SDK-k", "trial_feature_mobile_sdks": "iOS és Android SDK-k",
"trial_feature_respondent_identification": "Válaszadó-azonosítás", "trial_feature_respondent_identification": "Válaszadó-azonosítás",
"trial_feature_unlimited_seats": "Korlátlan számú felhasználói hely", "trial_feature_unlimited_seats": "Korlátlan számú hely",
"trial_feature_webhooks": "Egyéni webhookok", "trial_feature_webhooks": "Egyéni webhorgok",
"trial_no_credit_card": "14 napos próbaidőszak, bankkártya nélkül", "trial_no_credit_card": "14 napos próbaidőszak, nincs szükség hitelkártyára",
"trial_payment_method_added_description": "Minden rendben! A Pro csomag automatikusan folytatódik a próbaidőszak lejárta után.", "trial_payment_method_added_description": "Mindent beállított! A Pro csomagja a próbaidőszak vége után automatikusan folytatódik.",
"trial_title": "Szerezze meg a Formbricks Pro-t ingyen!", "trial_title": "Szerezze meg a Formbricks Pro csomagot ingyen!",
"unlimited_responses": "Korlátlan válaszok", "unlimited_responses": "Korlátlan válaszok",
"unlimited_workspaces": "Korlátlan munkaterület", "unlimited_workspaces": "Korlátlan munkaterület",
"upgrade": "Frissítés", "upgrade": "Frissítés",
"upgrade_now": "Frissítés most", "upgrade_now": "Frissítés most",
"usage_cycle": "Usage cycle", "usage_cycle": "Használati ciklus",
"used": "felhasználva", "used": "használva",
"yearly": "Éves", "yearly": "Évente",
"yearly_checkout_unavailable": "Az éves fizetés még nem érhető el. Kérjük, adjon hozzá fizetési módot egy havi előfizetéshez, vagy vegye fel a kapcsolatot az ügyfélszolgálattal.", "yearly_checkout_unavailable": "Az éves fizetési lehetőség még nem érhető el. Először adjon hozzá fizetési módot egy havi csomaghoz, vagy vegye fel a kapcsolatot az ügyfélszolgálattal.",
"your_plan": "Az Ön csomagja" "your_plan": "Az Ön csomagja"
}, },
"domain": { "domain": {
@@ -1071,29 +1081,48 @@
"enterprise_features": "Vállalati funkciók", "enterprise_features": "Vállalati funkciók",
"get_an_enterprise_license_to_get_access_to_all_features": "Vállalati licenc megszerzése az összes funkcióhoz való hozzáféréshez.", "get_an_enterprise_license_to_get_access_to_all_features": "Vállalati licenc megszerzése az összes funkcióhoz való hozzáféréshez.",
"keep_full_control_over_your_data_privacy_and_security": "Az adatvédelem és biztonság fölötti rendelkezés teljes kézben tartása.", "keep_full_control_over_your_data_privacy_and_security": "Az adatvédelem és biztonság fölötti rendelkezés teljes kézben tartása.",
"license_instance_mismatch_description": "Ez a licenc jelenleg egy másik Formbricks példányhoz van kötve. Amennyiben ez a telepítés újra lett építve vagy áthelyezésre került, kérje a Formbricks ügyfélszolgálatát, hogy bontsa fel az előző példány kötését.", "license_feature_access_control": "Hozzáférés-vezérlés (RBAC)",
"license_feature_audit_logs": "Auditálási naplók",
"license_feature_contacts": "Partnerek és szakaszok",
"license_feature_projects": "Munkaterületek",
"license_feature_quotas": "Kvóták",
"license_feature_remove_branding": "Márkajel eltávolítása",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Szemét elleni védekezés",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Kétfaktoros hitelesítés",
"license_feature_whitelabel": "Fehér címkés e-mailek",
"license_features_table_access": "Hozzáférés",
"license_features_table_description": "Az példányhoz jelenleg elérhető vállalati funkciók és korlátok.",
"license_features_table_disabled": "Letiltva",
"license_features_table_enabled": "Engedélyezve",
"license_features_table_feature": "Funkció",
"license_features_table_title": "Licencelt funkciók",
"license_features_table_unlimited": "Korlátlan",
"license_features_table_value": "Érték",
"license_instance_mismatch_description": "Ez a licenc jelenleg egy másik Formbricks-példányhoz van kötve. Ha ezt a telepítést újraépítették vagy áthelyezték, akkor kérje meg a Formbricks ügyfélszolgálatát, hogy szüntessék meg a korábbi példányhoz való kötést.",
"license_invalid_description": "Az ENTERPRISE_LICENSE_KEY környezeti változóban lévő licenckulcs nem érvényes. Ellenőrizze, hogy nem gépelte-e el, vagy kérjen új kulcsot.", "license_invalid_description": "Az ENTERPRISE_LICENSE_KEY környezeti változóban lévő licenckulcs nem érvényes. Ellenőrizze, hogy nem gépelte-e el, vagy kérjen új kulcsot.",
"license_status": "Licencállapot", "license_status": "Licencállapot",
"license_status_active": "Aktív", "license_status_active": "Aktív",
"license_status_description": "A vállalati licenc állapota.", "license_status_description": "A vállalati licenc állapota.",
"license_status_expired": "Lejárt", "license_status_expired": "Lejárt",
"license_status_instance_mismatch": "Másik Példányhoz Kötve", "license_status_instance_mismatch": "Másik példányhoz kötve",
"license_status_invalid": "Érvénytelen licenc", "license_status_invalid": "Érvénytelen licenc",
"license_status_unreachable": "Nem érhető el", "license_status_unreachable": "Nem érhető el",
"license_unreachable_grace_period": "A licenckiszolgálót nem lehet elérni. A vállalati funkciók egy 3 napos türelmi időszak alatt aktívak maradnak, egészen eddig: {gracePeriodEnd}.", "license_unreachable_grace_period": "A licenckiszolgálót nem lehet elérni. A vállalati funkciók egy 3 napos türelmi időszak alatt aktívak maradnak, egészen eddig: {gracePeriodEnd}.",
"no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Nincs szükség telefonálásra, nincs feltételekhez kötöttség: kérjen 30 napos ingyenes próbalicencet az összes funkció kipróbálásához az alábbi űrlap kitöltésével:", "no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "Nincs szükség telefonálásra, nincs feltételekhez kötöttség: kérjen 30 napos próbaidőszaki licencet az összes funkció kipróbálásához az alábbi űrlap kitöltésével:",
"no_credit_card_no_sales_call_just_test_it": "Nem kell hitelkártya. Nincsenek értékesítési hívások. Egyszerűen csak próbálja ki :)", "no_credit_card_no_sales_call_just_test_it": "Nem kell hitelkártya. Nincsenek értékesítési hívások. Egyszerűen csak próbálja ki :)",
"on_request": "Kérésre", "on_request": "Kérésre",
"organization_roles": "Szervezeti szerepek (adminisztrátor, szerkesztő, fejlesztő stb.)", "organization_roles": "Szervezeti szerepek (adminisztrátor, szerkesztő, fejlesztő stb.)",
"questions_please_reach_out_to": "Kérdése van? Írjon nekünk erre az e-mail-címre:", "questions_please_reach_out_to": "Kérdése van? Írjon nekünk erre az e-mail-címre:",
"recheck_license": "Licenc újraellenőrzése", "recheck_license": "Licenc újraellenőrzése",
"recheck_license_failed": "A licencellenőrzés nem sikerült. Lehet, hogy a licenckiszolgáló nem érhető el.", "recheck_license_failed": "A licencellenőrzés nem sikerült. Lehet, hogy a licenckiszolgáló nem érhető el.",
"recheck_license_instance_mismatch": "Ez a licenc egy másik Formbricks példányhoz van kötve. Kérje a Formbricks ügyfélszolgálatát, hogy bontsa fel az előző kötést.", "recheck_license_instance_mismatch": "Ez a licenc egy másik Formbricks-példányhoz van kötve. Kérje meg a Formbricks ügyfélszolgálatát, hogy szüntessék meg a korábbi kötést.",
"recheck_license_invalid": "A licenckulcs érvénytelen. Ellenőrizze az ENTERPRISE_LICENSE_KEY értékét.", "recheck_license_invalid": "A licenckulcs érvénytelen. Ellenőrizze az ENTERPRISE_LICENSE_KEY értékét.",
"recheck_license_success": "A licencellenőrzés sikeres", "recheck_license_success": "A licencellenőrzés sikeres",
"recheck_license_unreachable": "A licenckiszolgáló nem érhető el. Próbálja meg később újra.", "recheck_license_unreachable": "A licenckiszolgáló nem érhető el. Próbálja meg később újra.",
"rechecking": "Újraellenőrzés…", "rechecking": "Újraellenőrzés…",
"request_30_day_trial_license": "30 napos ingyenes licenc kérése", "request_30_day_trial_license": "30 napos próbaidőszaki licenc kérése",
"saml_sso": "SAML SSO", "saml_sso": "SAML SSO",
"service_level_agreement": "Szolgáltatási megállapodás", "service_level_agreement": "Szolgáltatási megállapodás",
"soc2_hipaa_iso_27001_compliance_check": "SOC2, HIPAA, ISO 27001 megfelelőségi ellenőrzés", "soc2_hipaa_iso_27001_compliance_check": "SOC2, HIPAA, ISO 27001 megfelelőségi ellenőrzés",
@@ -1430,21 +1459,22 @@
"error_saving_changes": "Hiba a változtatások mentésekor", "error_saving_changes": "Hiba a változtatások mentésekor",
"even_after_they_submitted_a_response_e_g_feedback_box": "Több válasz lehetővé tétele. Még válasz után is látható marad (például visszajelző doboz).", "even_after_they_submitted_a_response_e_g_feedback_box": "Több válasz lehetővé tétele. Még válasz után is látható marad (például visszajelző doboz).",
"everyone": "Mindenki", "everyone": "Mindenki",
"external_urls_paywall_tooltip": "Kérjük, váltson fizetős csomagra, hogy testre szabhassa a külső URL-eket. Ez segít megelőzni az adathalászatot.", "expand_preview": "Előnézet kinyitása",
"external_urls_paywall_tooltip": "Váltson a magasabb fizetős csomagra a külső URL-ek személyre szabásához. Ez segít nekünk megelőzni az adathalászatot.",
"fallback_missing": "Tartalék hiányzik", "fallback_missing": "Tartalék hiányzik",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "A(z) {fieldId} használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "A(z) {fieldId} használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",
"fieldId_is_used_in_quota_please_remove_it_from_quota_first": "A(z) „{fieldId}” rejtett mező használatban van a(z) „{quotaName}” kvótában", "fieldId_is_used_in_quota_please_remove_it_from_quota_first": "A(z) „{fieldId}” rejtett mező használatban van a(z) „{quotaName}” kvótában",
"field_name_eg_score_price": "Mező neve, például pontszám, ár", "field_name_eg_score_price": "Mező neve, például pontszám, ár",
"first_name": "Keresztnév", "first_name": "Keresztnév",
"five_points_recommended": "5 pont (ajánlott)", "five_points_recommended": "5 pont (ajánlott)",
"follow_ups": "Követések", "follow_ups": "Utókövetések",
"follow_ups_delete_modal_text": "Biztosan törölni szeretné ezt a követést?", "follow_ups_delete_modal_text": "Biztosan törölni szeretné ezt az utókövetést?",
"follow_ups_delete_modal_title": "Törli a követést?", "follow_ups_delete_modal_title": "Törli az utókövetést?",
"follow_ups_empty_description": "Üzenetek küldése a válaszadóknak, önmagának vagy csapattársaknak.", "follow_ups_empty_description": "Üzenetek küldése a válaszadóknak, önmagának vagy csapattársaknak.",
"follow_ups_empty_heading": "Automatikus követések küldése", "follow_ups_empty_heading": "Automatikus utókövetések küldése",
"follow_ups_ending_card_delete_modal_text": "Ez a befejező kártya használatban van a követésekben. A törlése eltávolítja az összes követésből. Biztosan törölni szeretné?", "follow_ups_ending_card_delete_modal_text": "Ez a befejező kártya használatban van az utókövetésekben. A törlése eltávolítja az összes utókövetésből. Biztosan törölni szeretné?",
"follow_ups_ending_card_delete_modal_title": "Törli a befejező kártyát?", "follow_ups_ending_card_delete_modal_title": "Törli a befejező kártyát?",
"follow_ups_hidden_field_error": "A rejtett mező használatban van egy követésben. Először távolítsa el a követésből.", "follow_ups_hidden_field_error": "A rejtett mező használatban van egy utókövetésben. Először távolítsa el az utókövetésből.",
"follow_ups_include_hidden_fields": "Rejtett mezők értékeinek felvétele", "follow_ups_include_hidden_fields": "Rejtett mezők értékeinek felvétele",
"follow_ups_include_variables": "Változó értékeinek felvétele", "follow_ups_include_variables": "Változó értékeinek felvétele",
"follow_ups_item_ending_tag": "Befejezések", "follow_ups_item_ending_tag": "Befejezések",
@@ -1468,21 +1498,21 @@
"follow_ups_modal_action_to_description": "Az az e-mail-cím, ahova az e-mail elküldésre kerül", "follow_ups_modal_action_to_description": "Az az e-mail-cím, ahova az e-mail elküldésre kerül",
"follow_ups_modal_action_to_label": "Címzett", "follow_ups_modal_action_to_label": "Címzett",
"follow_ups_modal_action_to_warning": "Nem találhatók érvényes beállítások az e-mailek küldéséhez, adjon hozzá néhány szabad szöveges vagy kapcsolatfelvételi információkat tartalmazó kérdést vagy rejtett mezőt", "follow_ups_modal_action_to_warning": "Nem találhatók érvényes beállítások az e-mailek küldéséhez, adjon hozzá néhány szabad szöveges vagy kapcsolatfelvételi információkat tartalmazó kérdést vagy rejtett mezőt",
"follow_ups_modal_create_heading": "Új követés létrehozása", "follow_ups_modal_create_heading": "Új utókövetés létrehozása",
"follow_ups_modal_created_successfull_toast": "A követés létrehozva, és akkor lesz elmentve, ha elmenti a kérdőívet.", "follow_ups_modal_created_successfull_toast": "Az utókövetés létrehozva, és akkor lesz elmentve, ha elmenti a kérdőívet.",
"follow_ups_modal_edit_heading": "A követés szerkesztése", "follow_ups_modal_edit_heading": "Az utókövetés szerkesztése",
"follow_ups_modal_edit_no_id": "Nincs kérdőívkövetési azonosító megadva, nem lehet frissíteni a kérdőívkövetést", "follow_ups_modal_edit_no_id": "Nincs kérdőív-utókövetési azonosító megadva, nem lehet frissíteni a kérdőív utókövetését",
"follow_ups_modal_name_label": "Követés neve", "follow_ups_modal_name_label": "Utókövetés neve",
"follow_ups_modal_name_placeholder": "A követés elnevezése", "follow_ups_modal_name_placeholder": "Az utókövetés elnevezése",
"follow_ups_modal_subheading": "Üzenetek küldése a válaszadóknak, önmagának vagy csapattársaknak", "follow_ups_modal_subheading": "Üzenetek küldése a válaszadóknak, önmagának vagy csapattársaknak",
"follow_ups_modal_trigger_description": "Mikor kell ezt a követést aktiválni?", "follow_ups_modal_trigger_description": "Mikor kell ezt az utókövetést aktiválni?",
"follow_ups_modal_trigger_label": "Aktiváló", "follow_ups_modal_trigger_label": "Aktiváló",
"follow_ups_modal_trigger_type_ending": "A válaszadó egy adott befejezést lát", "follow_ups_modal_trigger_type_ending": "A válaszadó egy adott befejezést lát",
"follow_ups_modal_trigger_type_ending_select": "Befejezések kiválasztása: ", "follow_ups_modal_trigger_type_ending_select": "Befejezések kiválasztása: ",
"follow_ups_modal_trigger_type_ending_warning": "Válasszon legalább egy befejezést, vagy változtassa meg az aktiváló típusát", "follow_ups_modal_trigger_type_ending_warning": "Válasszon legalább egy befejezést, vagy változtassa meg az aktiváló típusát",
"follow_ups_modal_trigger_type_response": "A válaszadó kitölti a kérdőívet", "follow_ups_modal_trigger_type_response": "A válaszadó kitölti a kérdőívet",
"follow_ups_modal_updated_successfull_toast": "A követés frissítve, és akkor lesz elmentve, ha elmenti a kérdőívet.", "follow_ups_modal_updated_successfull_toast": "Az utókövetés frissítve, és akkor lesz elmentve, ha elmenti a kérdőívet.",
"follow_ups_new": "Új követés", "follow_ups_new": "Új utókövetés",
"formbricks_sdk_is_not_connected": "A Formbricks SDK nincs csatlakoztatva", "formbricks_sdk_is_not_connected": "A Formbricks SDK nincs csatlakoztatva",
"four_points": "4 pont", "four_points": "4 pont",
"heading": "Címsor", "heading": "Címsor",
@@ -1655,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "A válaszkorlátnak meg kell haladnia a kapott válaszok számát ({responseCount}).", "response_limit_needs_to_exceed_number_of_received_responses": "A válaszkorlátnak meg kell haladnia a kapott válaszok számát ({responseCount}).",
"response_limits_redirections_and_more": "Válaszkorlátok, átirányítások és egyebek.", "response_limits_redirections_and_more": "Válaszkorlátok, átirányítások és egyebek.",
"response_options": "Válasz beállításai", "response_options": "Válasz beállításai",
"reverse_order_occasionally": "Sorrend alkalmi megfordítása",
"reverse_order_occasionally_except_last": "Sorrend alkalmi megfordítása az utolsó kivételével",
"roundness": "Kerekesség", "roundness": "Kerekesség",
"roundness_description": "Annak vezérlése, hogy a sarkok mennyire legyenek lekerekítve.", "roundness_description": "Annak vezérlése, hogy a sarkok mennyire legyenek lekerekítve.",
"row_used_in_logic_error": "Ez a sor használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.", "row_used_in_logic_error": "Ez a sor használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",
@@ -1683,6 +1715,7 @@
"show_survey_maximum_of": "Kérdőív megjelenítése legfeljebb:", "show_survey_maximum_of": "Kérdőív megjelenítése legfeljebb:",
"show_survey_to_users": "Kérdőív megjelenítése a felhasználók ennyi százalékának", "show_survey_to_users": "Kérdőív megjelenítése a felhasználók ennyi százalékának",
"show_to_x_percentage_of_targeted_users": "Megjelenítés a célzott felhasználók {percentage}%-ának", "show_to_x_percentage_of_targeted_users": "Megjelenítés a célzott felhasználók {percentage}%-ának",
"shrink_preview": "Előnézet összecsukása",
"simple": "Egyszerű", "simple": "Egyszerű",
"six_points": "6 pont", "six_points": "6 pont",
"smiley": "Hangulatjel", "smiley": "Hangulatjel",
@@ -1698,10 +1731,12 @@
"styling_set_to_theme_styles": "A stílus a téma stílusaira állítva", "styling_set_to_theme_styles": "A stílus a téma stílusaira állítva",
"subheading": "Alcím", "subheading": "Alcím",
"subtract": "Kivonás -", "subtract": "Kivonás -",
"survey_closed_message_heading_required": "Címsor hozzáadása az egyéni kérdőív záró üzenetéhez.",
"survey_completed_heading": "A kérdőív kitöltve", "survey_completed_heading": "A kérdőív kitöltve",
"survey_completed_subheading": "Ez a szabad és nyílt forráskódú kérdőív le lett zárva", "survey_completed_subheading": "Ez a szabad és nyílt forráskódú kérdőív le lett zárva",
"survey_display_settings": "Kérdőív megjelenítésének beállításai", "survey_display_settings": "Kérdőív megjelenítésének beállításai",
"survey_placement": "Kérdőív elhelyezése", "survey_placement": "Kérdőív elhelyezése",
"survey_preview": "Kérdőív előnézete 👀",
"survey_styling": "Kérdőív stílusának beállítása", "survey_styling": "Kérdőív stílusának beállítása",
"survey_trigger": "Kérdőív aktiválója", "survey_trigger": "Kérdőív aktiválója",
"switch_multi_language_on_to_get_started": "Kapcsolja be a többnyelvűséget a kezdéshez 👉", "switch_multi_language_on_to_get_started": "Kapcsolja be a többnyelvűséget a kezdéshez 👉",
@@ -2764,8 +2799,8 @@
"evaluate_content_quality_question_2_placeholder": "Írja be ide a válaszát…", "evaluate_content_quality_question_2_placeholder": "Írja be ide a válaszát…",
"evaluate_content_quality_question_3_headline": "Csodálatos! Van még valami, amit szeretne, hogy kitárgyaljunk?", "evaluate_content_quality_question_3_headline": "Csodálatos! Van még valami, amit szeretne, hogy kitárgyaljunk?",
"evaluate_content_quality_question_3_placeholder": "Témák, trendek, oktatóanyagok…", "evaluate_content_quality_question_3_placeholder": "Témák, trendek, oktatóanyagok…",
"fake_door_follow_up_description": "Követés olyan felhasználókkal, akik belefutottak az egyik „fake door” kísérletébe.", "fake_door_follow_up_description": "Utókövetés olyan felhasználókkal, akik belefutottak az egyik „fake door” kísérletébe.",
"fake_door_follow_up_name": "„Fake door” követés", "fake_door_follow_up_name": "„Fake door” utókövetés",
"fake_door_follow_up_question_1_headline": "Mennyire fontos ez a funkció az Ön számára?", "fake_door_follow_up_question_1_headline": "Mennyire fontos ez a funkció az Ön számára?",
"fake_door_follow_up_question_1_lower_label": "Nem fontos", "fake_door_follow_up_question_1_lower_label": "Nem fontos",
"fake_door_follow_up_question_1_upper_label": "Nagyon fontos", "fake_door_follow_up_question_1_upper_label": "Nagyon fontos",
@@ -2774,7 +2809,7 @@
"fake_door_follow_up_question_2_choice_3": "3. szempont", "fake_door_follow_up_question_2_choice_3": "3. szempont",
"fake_door_follow_up_question_2_choice_4": "4. szempont", "fake_door_follow_up_question_2_choice_4": "4. szempont",
"fake_door_follow_up_question_2_headline": "Mit kell feltétlenül tartalmaznia ennek összeállításakor?", "fake_door_follow_up_question_2_headline": "Mit kell feltétlenül tartalmaznia ennek összeállításakor?",
"feature_chaser_description": "Követés olyan felhasználókkal, akik épp most használtak egy bizonyos funkciót.", "feature_chaser_description": "Utókövetés olyan felhasználókkal, akik épp most használtak egy bizonyos funkciót.",
"feature_chaser_name": "Funkcióvadász", "feature_chaser_name": "Funkcióvadász",
"feature_chaser_question_1_headline": "Mennyire fontos a [FUNKCIÓ HOZZÁADÁSA] az Ön számára?", "feature_chaser_question_1_headline": "Mennyire fontos a [FUNKCIÓ HOZZÁADÁSA] az Ön számára?",
"feature_chaser_question_1_lower_label": "Nem fontos", "feature_chaser_question_1_lower_label": "Nem fontos",
+35
View File
@@ -294,6 +294,7 @@
"new": "新規", "new": "新規",
"new_version_available": "Formbricks {version} が利用可能です。今すぐアップグレード!", "new_version_available": "Formbricks {version} が利用可能です。今すぐアップグレード!",
"next": "次へ", "next": "次へ",
"no_actions_found": "アクションが見つかりません",
"no_background_image_found": "背景画像が見つかりません。", "no_background_image_found": "背景画像が見つかりません。",
"no_code": "ノーコード", "no_code": "ノーコード",
"no_files_uploaded": "ファイルがアップロードされていません", "no_files_uploaded": "ファイルがアップロードされていません",
@@ -339,6 +340,7 @@
"please_select_at_least_one_survey": "少なくとも1つのフォームを選択してください", "please_select_at_least_one_survey": "少なくとも1つのフォームを選択してください",
"please_select_at_least_one_trigger": "少なくとも1つのトリガーを選択してください", "please_select_at_least_one_trigger": "少なくとも1つのトリガーを選択してください",
"please_upgrade_your_plan": "プランをアップグレードしてください", "please_upgrade_your_plan": "プランをアップグレードしてください",
"powered_by_formbricks": "Powered by Formbricks",
"preview": "プレビュー", "preview": "プレビュー",
"preview_survey": "フォームをプレビュー", "preview_survey": "フォームをプレビュー",
"privacy": "プライバシーポリシー", "privacy": "プライバシーポリシー",
@@ -380,6 +382,7 @@
"select": "選択", "select": "選択",
"select_all": "すべて選択", "select_all": "すべて選択",
"select_filter": "フィルターを選択", "select_filter": "フィルターを選択",
"select_language": "言語を選択",
"select_survey": "フォームを選択", "select_survey": "フォームを選択",
"select_teams": "チームを選択", "select_teams": "チームを選択",
"selected": "選択済み", "selected": "選択済み",
@@ -850,9 +853,16 @@
"created_by_third_party": "サードパーティによって作成", "created_by_third_party": "サードパーティによって作成",
"discord_webhook_not_supported": "現在、Discord Webhook はサポートしていません。", "discord_webhook_not_supported": "現在、Discord Webhook はサポートしていません。",
"empty_webhook_message": "Webhook は追加するとここに表示されます。⏲️", "empty_webhook_message": "Webhook は追加するとここに表示されます。⏲️",
"endpoint_bad_gateway_error": "不正なゲートウェイ (502): プロキシまたはゲートウェイのエラーにより、サービスに到達できません",
"endpoint_gateway_timeout_error": "ゲートウェイタイムアウト (504): ゲートウェイのタイムアウトにより、サービスに到達できません",
"endpoint_internal_server_error": "内部サーバーエラー (500): サービスで予期しないエラーが発生しました",
"endpoint_method_not_allowed_error": "許可されていないメソッド (405): エンドポイントは存在しますが、POST リクエストを受け付けません",
"endpoint_not_found_error": "見つかりません (404): エンドポイントが存在しません",
"endpoint_pinged": "成功!Webhook に ping できました。", "endpoint_pinged": "成功!Webhook に ping できました。",
"endpoint_pinged_error": "Webhook への ping に失敗しました。", "endpoint_pinged_error": "Webhook への ping に失敗しました。",
"endpoint_service_unavailable_error": "サービス利用不可 (503): サービスは一時的に停止しています",
"learn_to_verify": "Webhook署名の検証方法を学ぶ", "learn_to_verify": "Webhook署名の検証方法を学ぶ",
"no_triggers": "トリガーなし",
"please_check_console": "詳細はコンソールを確認してください", "please_check_console": "詳細はコンソールを確認してください",
"please_enter_a_url": "URL を入力してください", "please_enter_a_url": "URL を入力してください",
"response_created": "回答作成", "response_created": "回答作成",
@@ -1071,6 +1081,25 @@
"enterprise_features": "エンタープライズ機能", "enterprise_features": "エンタープライズ機能",
"get_an_enterprise_license_to_get_access_to_all_features": "すべての機能にアクセスするには、エンタープライズライセンスを取得してください。", "get_an_enterprise_license_to_get_access_to_all_features": "すべての機能にアクセスするには、エンタープライズライセンスを取得してください。",
"keep_full_control_over_your_data_privacy_and_security": "データのプライバシーとセキュリティを完全に制御できます。", "keep_full_control_over_your_data_privacy_and_security": "データのプライバシーとセキュリティを完全に制御できます。",
"license_feature_access_control": "アクセス制御(RBAC",
"license_feature_audit_logs": "監査ログ",
"license_feature_contacts": "連絡先とセグメント",
"license_feature_projects": "ワークスペース",
"license_feature_quotas": "クォータ",
"license_feature_remove_branding": "ブランディングの削除",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "スパム保護",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "二要素認証",
"license_feature_whitelabel": "ホワイトラベルメール",
"license_features_table_access": "アクセス",
"license_features_table_description": "このインスタンスで現在利用可能なエンタープライズ機能と制限。",
"license_features_table_disabled": "無効",
"license_features_table_enabled": "有効",
"license_features_table_feature": "機能",
"license_features_table_title": "ライセンス機能",
"license_features_table_unlimited": "無制限",
"license_features_table_value": "値",
"license_instance_mismatch_description": "このライセンスは現在、別のFormbricksインスタンスに紐付けられています。このインストールが再構築または移動された場合は、Formbricksサポートに連絡して、以前のインスタンスの紐付けを解除してもらってください。", "license_instance_mismatch_description": "このライセンスは現在、別のFormbricksインスタンスに紐付けられています。このインストールが再構築または移動された場合は、Formbricksサポートに連絡して、以前のインスタンスの紐付けを解除してもらってください。",
"license_invalid_description": "ENTERPRISE_LICENSE_KEY環境変数のライセンスキーが無効です。入力ミスがないか確認するか、新しいキーをリクエストしてください。", "license_invalid_description": "ENTERPRISE_LICENSE_KEY環境変数のライセンスキーが無効です。入力ミスがないか確認するか、新しいキーをリクエストしてください。",
"license_status": "ライセンスステータス", "license_status": "ライセンスステータス",
@@ -1430,6 +1459,7 @@
"error_saving_changes": "変更の保存中にエラーが発生しました", "error_saving_changes": "変更の保存中にエラーが発生しました",
"even_after_they_submitted_a_response_e_g_feedback_box": "複数の回答を許可;回答後も表示を継続(例:フィードボックス)。", "even_after_they_submitted_a_response_e_g_feedback_box": "複数の回答を許可;回答後も表示を継続(例:フィードボックス)。",
"everyone": "全員", "everyone": "全員",
"expand_preview": "プレビューを展開",
"external_urls_paywall_tooltip": "外部URLをカスタマイズするには有料プランへのアップグレードが必要です。フィッシング防止のためご協力をお願いいたします。", "external_urls_paywall_tooltip": "外部URLをカスタマイズするには有料プランへのアップグレードが必要です。フィッシング防止のためご協力をお願いいたします。",
"fallback_missing": "フォールバックがありません", "fallback_missing": "フォールバックがありません",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
@@ -1655,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "回答数の上限は、受信済みの回答数 ({responseCount}) を超える必要があります。", "response_limit_needs_to_exceed_number_of_received_responses": "回答数の上限は、受信済みの回答数 ({responseCount}) を超える必要があります。",
"response_limits_redirections_and_more": "回答数の上限、リダイレクトなど。", "response_limits_redirections_and_more": "回答数の上限、リダイレクトなど。",
"response_options": "回答オプション", "response_options": "回答オプション",
"reverse_order_occasionally": "順序をランダムに逆転",
"reverse_order_occasionally_except_last": "最後以外の順序をランダムに逆転",
"roundness": "丸み", "roundness": "丸み",
"roundness_description": "角の丸みを調整します。", "roundness_description": "角の丸みを調整します。",
"row_used_in_logic_error": "この行は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。", "row_used_in_logic_error": "この行は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
@@ -1683,6 +1715,7 @@
"show_survey_maximum_of": "フォームの最大表示回数", "show_survey_maximum_of": "フォームの最大表示回数",
"show_survey_to_users": "ユーザーの {percentage}% にフォームを表示", "show_survey_to_users": "ユーザーの {percentage}% にフォームを表示",
"show_to_x_percentage_of_targeted_users": "ターゲットユーザーの {percentage}% に表示", "show_to_x_percentage_of_targeted_users": "ターゲットユーザーの {percentage}% に表示",
"shrink_preview": "プレビューを縮小",
"simple": "シンプル", "simple": "シンプル",
"six_points": "6点", "six_points": "6点",
"smiley": "スマイリー", "smiley": "スマイリー",
@@ -1698,10 +1731,12 @@
"styling_set_to_theme_styles": "スタイルをテーマのスタイルに設定しました", "styling_set_to_theme_styles": "スタイルをテーマのスタイルに設定しました",
"subheading": "サブ見出し", "subheading": "サブ見出し",
"subtract": "減算 -", "subtract": "減算 -",
"survey_closed_message_heading_required": "カスタムアンケート終了メッセージに見出しを追加してください。",
"survey_completed_heading": "フォームが完了しました", "survey_completed_heading": "フォームが完了しました",
"survey_completed_subheading": "この無料のオープンソースフォームは閉鎖されました", "survey_completed_subheading": "この無料のオープンソースフォームは閉鎖されました",
"survey_display_settings": "フォーム表示設定", "survey_display_settings": "フォーム表示設定",
"survey_placement": "フォームの配置", "survey_placement": "フォームの配置",
"survey_preview": "アンケートプレビュー 👀",
"survey_styling": "フォームのスタイル", "survey_styling": "フォームのスタイル",
"survey_trigger": "フォームのトリガー", "survey_trigger": "フォームのトリガー",
"switch_multi_language_on_to_get_started": "多言語機能をオンにして開始 👉", "switch_multi_language_on_to_get_started": "多言語機能をオンにして開始 👉",
+37 -2
View File
@@ -294,6 +294,7 @@
"new": "Nieuw", "new": "Nieuw",
"new_version_available": "Formbricks {version} is hier. Upgrade nu!", "new_version_available": "Formbricks {version} is hier. Upgrade nu!",
"next": "Volgende", "next": "Volgende",
"no_actions_found": "Geen acties gevonden",
"no_background_image_found": "Geen achtergrondafbeelding gevonden.", "no_background_image_found": "Geen achtergrondafbeelding gevonden.",
"no_code": "Geen code", "no_code": "Geen code",
"no_files_uploaded": "Er zijn geen bestanden geüpload", "no_files_uploaded": "Er zijn geen bestanden geüpload",
@@ -339,6 +340,7 @@
"please_select_at_least_one_survey": "Selecteer ten minste één enquête", "please_select_at_least_one_survey": "Selecteer ten minste één enquête",
"please_select_at_least_one_trigger": "Selecteer ten minste één trigger", "please_select_at_least_one_trigger": "Selecteer ten minste één trigger",
"please_upgrade_your_plan": "Upgrade je abonnement", "please_upgrade_your_plan": "Upgrade je abonnement",
"powered_by_formbricks": "Mogelijk gemaakt door Formbricks",
"preview": "Voorbeeld", "preview": "Voorbeeld",
"preview_survey": "Voorbeeld van enquête", "preview_survey": "Voorbeeld van enquête",
"privacy": "Privacybeleid", "privacy": "Privacybeleid",
@@ -380,6 +382,7 @@
"select": "Selecteer", "select": "Selecteer",
"select_all": "Selecteer alles", "select_all": "Selecteer alles",
"select_filter": "Filter selecteren", "select_filter": "Filter selecteren",
"select_language": "Selecteer taal",
"select_survey": "Selecteer Enquête", "select_survey": "Selecteer Enquête",
"select_teams": "Selecteer teams", "select_teams": "Selecteer teams",
"selected": "Gekozen", "selected": "Gekozen",
@@ -850,9 +853,16 @@
"created_by_third_party": "Gemaakt door een derde partij", "created_by_third_party": "Gemaakt door een derde partij",
"discord_webhook_not_supported": "Discord-webhooks worden momenteel niet ondersteund.", "discord_webhook_not_supported": "Discord-webhooks worden momenteel niet ondersteund.",
"empty_webhook_message": "Uw webhooks verschijnen hier zodra u ze toevoegt. ⏲️", "empty_webhook_message": "Uw webhooks verschijnen hier zodra u ze toevoegt. ⏲️",
"endpoint_bad_gateway_error": "Ongeldige gateway (502): Proxy-/gatewayfout, service niet bereikbaar",
"endpoint_gateway_timeout_error": "Gateway-time-out (504): Gateway-time-out, service niet bereikbaar",
"endpoint_internal_server_error": "Interne serverfout (500): De service is een onverwachte fout tegengekomen",
"endpoint_method_not_allowed_error": "Methode niet toegestaan (405): Het endpoint bestaat, maar accepteert geen POST-verzoeken",
"endpoint_not_found_error": "Niet gevonden (404): Het endpoint bestaat niet",
"endpoint_pinged": "Jawel! We kunnen de webhook pingen!", "endpoint_pinged": "Jawel! We kunnen de webhook pingen!",
"endpoint_pinged_error": "Kan de webhook niet pingen!", "endpoint_pinged_error": "Kan de webhook niet pingen!",
"endpoint_service_unavailable_error": "Service niet beschikbaar (503): De service is tijdelijk niet beschikbaar",
"learn_to_verify": "Leer hoe je webhook-handtekeningen kunt verifiëren", "learn_to_verify": "Leer hoe je webhook-handtekeningen kunt verifiëren",
"no_triggers": "Geen triggers",
"please_check_console": "Controleer de console voor meer details", "please_check_console": "Controleer de console voor meer details",
"please_enter_a_url": "Voer een URL in", "please_enter_a_url": "Voer een URL in",
"response_created": "Reactie gemaakt", "response_created": "Reactie gemaakt",
@@ -1071,6 +1081,25 @@
"enterprise_features": "Enterprise-functies", "enterprise_features": "Enterprise-functies",
"get_an_enterprise_license_to_get_access_to_all_features": "Ontvang een Enterprise-licentie om toegang te krijgen tot alle functies.", "get_an_enterprise_license_to_get_access_to_all_features": "Ontvang een Enterprise-licentie om toegang te krijgen tot alle functies.",
"keep_full_control_over_your_data_privacy_and_security": "Houd de volledige controle over de privacy en beveiliging van uw gegevens.", "keep_full_control_over_your_data_privacy_and_security": "Houd de volledige controle over de privacy en beveiliging van uw gegevens.",
"license_feature_access_control": "Toegangscontrole (RBAC)",
"license_feature_audit_logs": "Auditlogboeken",
"license_feature_contacts": "Contacten & Segmenten",
"license_feature_projects": "Werkruimtes",
"license_feature_quotas": "Quota's",
"license_feature_remove_branding": "Branding verwijderen",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Spambescherming",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Tweefactorauthenticatie",
"license_feature_whitelabel": "Whitelabel-e-mails",
"license_features_table_access": "Toegang",
"license_features_table_description": "Enterprise-functies en limieten die momenteel beschikbaar zijn voor deze instantie.",
"license_features_table_disabled": "Uitgeschakeld",
"license_features_table_enabled": "Ingeschakeld",
"license_features_table_feature": "Functie",
"license_features_table_title": "Gelicentieerde Functies",
"license_features_table_unlimited": "Onbeperkt",
"license_features_table_value": "Waarde",
"license_instance_mismatch_description": "Deze licentie is momenteel gekoppeld aan een andere Formbricks-instantie. Als deze installatie is herbouwd of verplaatst, vraag dan Formbricks-support om de vorige instantiekoppeling te verbreken.", "license_instance_mismatch_description": "Deze licentie is momenteel gekoppeld aan een andere Formbricks-instantie. Als deze installatie is herbouwd of verplaatst, vraag dan Formbricks-support om de vorige instantiekoppeling te verbreken.",
"license_invalid_description": "De licentiesleutel in je ENTERPRISE_LICENSE_KEY omgevingsvariabele is niet geldig. Controleer op typefouten of vraag een nieuwe sleutel aan.", "license_invalid_description": "De licentiesleutel in je ENTERPRISE_LICENSE_KEY omgevingsvariabele is niet geldig. Controleer op typefouten of vraag een nieuwe sleutel aan.",
"license_status": "Licentiestatus", "license_status": "Licentiestatus",
@@ -1430,6 +1459,7 @@
"error_saving_changes": "Fout bij het opslaan van wijzigingen", "error_saving_changes": "Fout bij het opslaan van wijzigingen",
"even_after_they_submitted_a_response_e_g_feedback_box": "Meerdere reacties toestaan; blijf tonen, zelfs na een reactie (bijv. feedbackbox).", "even_after_they_submitted_a_response_e_g_feedback_box": "Meerdere reacties toestaan; blijf tonen, zelfs na een reactie (bijv. feedbackbox).",
"everyone": "Iedereen", "everyone": "Iedereen",
"expand_preview": "Voorbeeld uitvouwen",
"external_urls_paywall_tooltip": "Upgrade naar een betaald abonnement om externe URL's aan te passen. Dit helpt om phishing te voorkomen.", "external_urls_paywall_tooltip": "Upgrade naar een betaald abonnement om externe URL's aan te passen. Dit helpt om phishing te voorkomen.",
"fallback_missing": "Terugval ontbreekt", "fallback_missing": "Terugval ontbreekt",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",
@@ -1655,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "De responslimiet moet groter zijn dan het aantal ontvangen reacties ({responseCount}).", "response_limit_needs_to_exceed_number_of_received_responses": "De responslimiet moet groter zijn dan het aantal ontvangen reacties ({responseCount}).",
"response_limits_redirections_and_more": "Reactielimieten, omleidingen en meer.", "response_limits_redirections_and_more": "Reactielimieten, omleidingen en meer.",
"response_options": "Reactieopties", "response_options": "Reactieopties",
"reverse_order_occasionally": "Volgorde af en toe omkeren",
"reverse_order_occasionally_except_last": "Volgorde af en toe omkeren behalve laatste",
"roundness": "Rondheid", "roundness": "Rondheid",
"roundness_description": "Bepaalt hoe afgerond de hoeken zijn.", "roundness_description": "Bepaalt hoe afgerond de hoeken zijn.",
"row_used_in_logic_error": "Deze rij wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.", "row_used_in_logic_error": "Deze rij wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",
@@ -1683,6 +1715,7 @@
"show_survey_maximum_of": "Toon onderzoek maximaal", "show_survey_maximum_of": "Toon onderzoek maximaal",
"show_survey_to_users": "Enquête tonen aan % van de gebruikers", "show_survey_to_users": "Enquête tonen aan % van de gebruikers",
"show_to_x_percentage_of_targeted_users": "Toon aan {percentage}% van de getargete gebruikers", "show_to_x_percentage_of_targeted_users": "Toon aan {percentage}% van de getargete gebruikers",
"shrink_preview": "Voorbeeld invouwen",
"simple": "Eenvoudig", "simple": "Eenvoudig",
"six_points": "6 punten", "six_points": "6 punten",
"smiley": "Smiley", "smiley": "Smiley",
@@ -1698,10 +1731,12 @@
"styling_set_to_theme_styles": "Styling ingesteld op themastijlen", "styling_set_to_theme_styles": "Styling ingesteld op themastijlen",
"subheading": "Ondertitel", "subheading": "Ondertitel",
"subtract": "Aftrekken -", "subtract": "Aftrekken -",
"survey_closed_message_heading_required": "Voeg een kop toe aan het aangepaste bericht voor gesloten enquêtes.",
"survey_completed_heading": "Enquête voltooid", "survey_completed_heading": "Enquête voltooid",
"survey_completed_subheading": "Deze gratis en open source-enquête is gesloten", "survey_completed_subheading": "Deze gratis en open source-enquête is gesloten",
"survey_display_settings": "Enquêteweergave-instellingen", "survey_display_settings": "Enquêteweergave-instellingen",
"survey_placement": "Enquête plaatsing", "survey_placement": "Enquête plaatsing",
"survey_preview": "Enquêtevoorbeeld 👀",
"survey_styling": "Vorm styling", "survey_styling": "Vorm styling",
"survey_trigger": "Enquêtetrigger", "survey_trigger": "Enquêtetrigger",
"switch_multi_language_on_to_get_started": "Schakel meertaligheid in om te beginnen 👉", "switch_multi_language_on_to_get_started": "Schakel meertaligheid in om te beginnen 👉",
@@ -3052,7 +3087,7 @@
"preview_survey_question_2_choice_2_label": "Nee, dank je!", "preview_survey_question_2_choice_2_label": "Nee, dank je!",
"preview_survey_question_2_headline": "Wil je op de hoogte blijven?", "preview_survey_question_2_headline": "Wil je op de hoogte blijven?",
"preview_survey_question_2_subheader": "Dit is een voorbeeldbeschrijving.", "preview_survey_question_2_subheader": "Dit is een voorbeeldbeschrijving.",
"preview_survey_question_open_text_headline": "Wil je nog iets delen?", "preview_survey_question_open_text_headline": "Wilt u nog iets anders delen?",
"preview_survey_question_open_text_placeholder": "Typ hier je antwoord...", "preview_survey_question_open_text_placeholder": "Typ hier je antwoord...",
"preview_survey_question_open_text_subheader": "Je feedback helpt ons verbeteren.", "preview_survey_question_open_text_subheader": "Je feedback helpt ons verbeteren.",
"preview_survey_welcome_card_headline": "Welkom!", "preview_survey_welcome_card_headline": "Welkom!",
@@ -3307,7 +3342,7 @@
"workflows": { "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_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!", "coming_soon_title": "We zijn er bijna!",
"follow_up_label": "Is er nog iets dat je wilt toevoegen?", "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?", "follow_up_placeholder": "Welke specifieke taken wil je automatiseren? Zijn er tools of integraties die je wilt meenemen?",
"generate_button": "Genereer workflow", "generate_button": "Genereer workflow",
"heading": "Welke workflow wil je maken?", "heading": "Welke workflow wil je maken?",
+37 -2
View File
@@ -294,6 +294,7 @@
"new": "Novo", "new": "Novo",
"new_version_available": "Formbricks {version} chegou. Atualize agora!", "new_version_available": "Formbricks {version} chegou. Atualize agora!",
"next": "Próximo", "next": "Próximo",
"no_actions_found": "Nenhuma ação encontrada",
"no_background_image_found": "Imagem de fundo não encontrada.", "no_background_image_found": "Imagem de fundo não encontrada.",
"no_code": "Sem código", "no_code": "Sem código",
"no_files_uploaded": "Nenhum arquivo foi enviado", "no_files_uploaded": "Nenhum arquivo foi enviado",
@@ -339,6 +340,7 @@
"please_select_at_least_one_survey": "Por favor, selecione pelo menos uma pesquisa", "please_select_at_least_one_survey": "Por favor, selecione pelo menos uma pesquisa",
"please_select_at_least_one_trigger": "Por favor, selecione pelo menos um gatilho", "please_select_at_least_one_trigger": "Por favor, selecione pelo menos um gatilho",
"please_upgrade_your_plan": "Por favor, atualize seu plano", "please_upgrade_your_plan": "Por favor, atualize seu plano",
"powered_by_formbricks": "Desenvolvido por Formbricks",
"preview": "Prévia", "preview": "Prévia",
"preview_survey": "Prévia da Pesquisa", "preview_survey": "Prévia da Pesquisa",
"privacy": "Política de Privacidade", "privacy": "Política de Privacidade",
@@ -380,6 +382,7 @@
"select": "Selecionar", "select": "Selecionar",
"select_all": "Selecionar tudo", "select_all": "Selecionar tudo",
"select_filter": "Selecionar filtro", "select_filter": "Selecionar filtro",
"select_language": "Selecionar Idioma",
"select_survey": "Selecionar Pesquisa", "select_survey": "Selecionar Pesquisa",
"select_teams": "Selecionar times", "select_teams": "Selecionar times",
"selected": "Selecionado", "selected": "Selecionado",
@@ -850,9 +853,16 @@
"created_by_third_party": "Criado por um Terceiro", "created_by_third_party": "Criado por um Terceiro",
"discord_webhook_not_supported": "Webhooks do Discord não são suportados no momento.", "discord_webhook_not_supported": "Webhooks do Discord não são suportados no momento.",
"empty_webhook_message": "Seus webhooks vão aparecer aqui assim que você adicioná-los. ⏲️", "empty_webhook_message": "Seus webhooks vão aparecer aqui assim que você adicioná-los. ⏲️",
"endpoint_bad_gateway_error": "Gateway inválido (502): Erro de proxy/gateway, serviço inacessível",
"endpoint_gateway_timeout_error": "Tempo limite do gateway esgotado (504): Tempo limite do gateway esgotado, serviço inacessível",
"endpoint_internal_server_error": "Erro interno do servidor (500): O serviço encontrou um erro inesperado",
"endpoint_method_not_allowed_error": "Método não permitido (405): O endpoint existe, mas não aceita solicitações POST",
"endpoint_not_found_error": "Não encontrado (404): O endpoint não existe",
"endpoint_pinged": "Uhul! Conseguimos pingar o webhook!", "endpoint_pinged": "Uhul! Conseguimos pingar o webhook!",
"endpoint_pinged_error": "Não consegui pingar o webhook!", "endpoint_pinged_error": "Não consegui pingar o webhook!",
"endpoint_service_unavailable_error": "Serviço indisponível (503): O serviço está temporariamente indisponível",
"learn_to_verify": "Aprenda como verificar assinaturas de webhook", "learn_to_verify": "Aprenda como verificar assinaturas de webhook",
"no_triggers": "Nenhum Gatilho",
"please_check_console": "Por favor, verifica o console para mais detalhes", "please_check_console": "Por favor, verifica o console para mais detalhes",
"please_enter_a_url": "Por favor, insira uma URL", "please_enter_a_url": "Por favor, insira uma URL",
"response_created": "Resposta Criada", "response_created": "Resposta Criada",
@@ -1071,6 +1081,25 @@
"enterprise_features": "Recursos Empresariais", "enterprise_features": "Recursos Empresariais",
"get_an_enterprise_license_to_get_access_to_all_features": "Adquira uma licença Enterprise para ter acesso a todos os recursos.", "get_an_enterprise_license_to_get_access_to_all_features": "Adquira uma licença Enterprise para ter acesso a todos os recursos.",
"keep_full_control_over_your_data_privacy_and_security": "Mantenha controle total sobre a privacidade e segurança dos seus dados.", "keep_full_control_over_your_data_privacy_and_security": "Mantenha controle total sobre a privacidade e segurança dos seus dados.",
"license_feature_access_control": "Controle de acesso (RBAC)",
"license_feature_audit_logs": "Logs de auditoria",
"license_feature_contacts": "Contatos e Segmentos",
"license_feature_projects": "Workspaces",
"license_feature_quotas": "Cotas",
"license_feature_remove_branding": "Remover identidade visual",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Proteção contra spam",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Autenticação de dois fatores",
"license_feature_whitelabel": "E-mails white-label",
"license_features_table_access": "Acesso",
"license_features_table_description": "Recursos empresariais e limites disponíveis atualmente para esta instância.",
"license_features_table_disabled": "Desabilitado",
"license_features_table_enabled": "Habilitado",
"license_features_table_feature": "Recurso",
"license_features_table_title": "Recursos Licenciados",
"license_features_table_unlimited": "Ilimitado",
"license_features_table_value": "Valor",
"license_instance_mismatch_description": "Esta licença está atualmente vinculada a uma instância diferente do Formbricks. Se esta instalação foi reconstruída ou movida, peça ao suporte do Formbricks para desconectar a vinculação da instância anterior.", "license_instance_mismatch_description": "Esta licença está atualmente vinculada a uma instância diferente do Formbricks. Se esta instalação foi reconstruída ou movida, peça ao suporte do Formbricks para desconectar a vinculação da instância anterior.",
"license_invalid_description": "A chave de licença na sua variável de ambiente ENTERPRISE_LICENSE_KEY não é válida. Verifique se há erros de digitação ou solicite uma nova chave.", "license_invalid_description": "A chave de licença na sua variável de ambiente ENTERPRISE_LICENSE_KEY não é válida. Verifique se há erros de digitação ou solicite uma nova chave.",
"license_status": "Status da licença", "license_status": "Status da licença",
@@ -1430,6 +1459,7 @@
"error_saving_changes": "Erro ao salvar alterações", "error_saving_changes": "Erro ao salvar alterações",
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir múltiplas respostas; continuar mostrando mesmo após uma resposta (ex.: caixa de feedback).", "even_after_they_submitted_a_response_e_g_feedback_box": "Permitir múltiplas respostas; continuar mostrando mesmo após uma resposta (ex.: caixa de feedback).",
"everyone": "Todo mundo", "everyone": "Todo mundo",
"expand_preview": "Expandir prévia",
"external_urls_paywall_tooltip": "Faça upgrade para um plano pago para personalizar URLs externas. Isso nos ajuda a prevenir phishing.", "external_urls_paywall_tooltip": "Faça upgrade para um plano pago para personalizar URLs externas. Isso nos ajuda a prevenir phishing.",
"fallback_missing": "Faltando alternativa", "fallback_missing": "Faltando alternativa",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
@@ -1655,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "O limite de respostas precisa exceder o número de respostas recebidas ({responseCount}).", "response_limit_needs_to_exceed_number_of_received_responses": "O limite de respostas precisa exceder o número de respostas recebidas ({responseCount}).",
"response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.", "response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.",
"response_options": "Opções de Resposta", "response_options": "Opções de Resposta",
"reverse_order_occasionally": "Inverter ordem ocasionalmente",
"reverse_order_occasionally_except_last": "Inverter ordem ocasionalmente exceto o último",
"roundness": "Circularidade", "roundness": "Circularidade",
"roundness_description": "Controla o arredondamento dos cantos.", "roundness_description": "Controla o arredondamento dos cantos.",
"row_used_in_logic_error": "Esta linha é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.", "row_used_in_logic_error": "Esta linha é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
@@ -1683,6 +1715,7 @@
"show_survey_maximum_of": "Mostrar no máximo", "show_survey_maximum_of": "Mostrar no máximo",
"show_survey_to_users": "Mostrar pesquisa para % dos usuários", "show_survey_to_users": "Mostrar pesquisa para % dos usuários",
"show_to_x_percentage_of_targeted_users": "Mostrar para {percentage}% dos usuários segmentados", "show_to_x_percentage_of_targeted_users": "Mostrar para {percentage}% dos usuários segmentados",
"shrink_preview": "Recolher prévia",
"simple": "Simples", "simple": "Simples",
"six_points": "6 pontos", "six_points": "6 pontos",
"smiley": "Sorridente", "smiley": "Sorridente",
@@ -1698,10 +1731,12 @@
"styling_set_to_theme_styles": "Estilo definido para os estilos do tema", "styling_set_to_theme_styles": "Estilo definido para os estilos do tema",
"subheading": "Subtítulo", "subheading": "Subtítulo",
"subtract": "Subtrair -", "subtract": "Subtrair -",
"survey_closed_message_heading_required": "Adicione um título à mensagem personalizada de pesquisa encerrada.",
"survey_completed_heading": "Pesquisa Concluída", "survey_completed_heading": "Pesquisa Concluída",
"survey_completed_subheading": "Essa pesquisa gratuita e de código aberto foi encerrada", "survey_completed_subheading": "Essa pesquisa gratuita e de código aberto foi encerrada",
"survey_display_settings": "Configurações de Exibição da Pesquisa", "survey_display_settings": "Configurações de Exibição da Pesquisa",
"survey_placement": "Posicionamento da Pesquisa", "survey_placement": "Posicionamento da Pesquisa",
"survey_preview": "Prévia da pesquisa 👀",
"survey_styling": "Estilização de Formulários", "survey_styling": "Estilização de Formulários",
"survey_trigger": "Gatilho de Pesquisa", "survey_trigger": "Gatilho de Pesquisa",
"switch_multi_language_on_to_get_started": "Ative o modo multilíngue para começar 👉", "switch_multi_language_on_to_get_started": "Ative o modo multilíngue para começar 👉",
@@ -3052,7 +3087,7 @@
"preview_survey_question_2_choice_2_label": "Não, obrigado!", "preview_survey_question_2_choice_2_label": "Não, obrigado!",
"preview_survey_question_2_headline": "Quer ficar por dentro?", "preview_survey_question_2_headline": "Quer ficar por dentro?",
"preview_survey_question_2_subheader": "Este é um exemplo de descrição.", "preview_survey_question_2_subheader": "Este é um exemplo de descrição.",
"preview_survey_question_open_text_headline": "Tem mais alguma coisa que você gostaria de compartilhar?", "preview_survey_question_open_text_headline": "Há algo mais que você gostaria de compartilhar?",
"preview_survey_question_open_text_placeholder": "Digite sua resposta aqui...", "preview_survey_question_open_text_placeholder": "Digite sua resposta aqui...",
"preview_survey_question_open_text_subheader": "Seu feedback nos ajuda a melhorar.", "preview_survey_question_open_text_subheader": "Seu feedback nos ajuda a melhorar.",
"preview_survey_welcome_card_headline": "Bem-vindo!", "preview_survey_welcome_card_headline": "Bem-vindo!",
@@ -3307,7 +3342,7 @@
"workflows": { "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_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á!", "coming_soon_title": "Estamos quase lá!",
"follow_up_label": "Há algo mais que você gostaria de adicionar?", "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?", "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", "generate_button": "Gerar fluxo de trabalho",
"heading": "Qual fluxo de trabalho você quer criar?", "heading": "Qual fluxo de trabalho você quer criar?",
+36 -1
View File
@@ -294,6 +294,7 @@
"new": "Novo", "new": "Novo",
"new_version_available": "Formbricks {version} está aqui. Atualize agora!", "new_version_available": "Formbricks {version} está aqui. Atualize agora!",
"next": "Seguinte", "next": "Seguinte",
"no_actions_found": "Nenhuma ação encontrada",
"no_background_image_found": "Nenhuma imagem de fundo encontrada.", "no_background_image_found": "Nenhuma imagem de fundo encontrada.",
"no_code": "Sem código", "no_code": "Sem código",
"no_files_uploaded": "Nenhum ficheiro foi carregado", "no_files_uploaded": "Nenhum ficheiro foi carregado",
@@ -339,6 +340,7 @@
"please_select_at_least_one_survey": "Por favor, selecione pelo menos um inquérito", "please_select_at_least_one_survey": "Por favor, selecione pelo menos um inquérito",
"please_select_at_least_one_trigger": "Por favor, selecione pelo menos um gatilho", "please_select_at_least_one_trigger": "Por favor, selecione pelo menos um gatilho",
"please_upgrade_your_plan": "Por favor, atualize o seu plano", "please_upgrade_your_plan": "Por favor, atualize o seu plano",
"powered_by_formbricks": "Desenvolvido por Formbricks",
"preview": "Pré-visualização", "preview": "Pré-visualização",
"preview_survey": "Pré-visualização do inquérito", "preview_survey": "Pré-visualização do inquérito",
"privacy": "Política de Privacidade", "privacy": "Política de Privacidade",
@@ -380,6 +382,7 @@
"select": "Selecionar", "select": "Selecionar",
"select_all": "Selecionar tudo", "select_all": "Selecionar tudo",
"select_filter": "Selecionar filtro", "select_filter": "Selecionar filtro",
"select_language": "Selecionar Idioma",
"select_survey": "Selecionar Inquérito", "select_survey": "Selecionar Inquérito",
"select_teams": "Selecionar equipas", "select_teams": "Selecionar equipas",
"selected": "Selecionado", "selected": "Selecionado",
@@ -850,9 +853,16 @@
"created_by_third_party": "Criado por um Terceiro", "created_by_third_party": "Criado por um Terceiro",
"discord_webhook_not_supported": "Os webhooks do Discord não são atualmente suportados.", "discord_webhook_not_supported": "Os webhooks do Discord não são atualmente suportados.",
"empty_webhook_message": "Os seus webhooks aparecerão aqui assim que os adicionar. ⏲️", "empty_webhook_message": "Os seus webhooks aparecerão aqui assim que os adicionar. ⏲️",
"endpoint_bad_gateway_error": "Gateway inválido (502): Erro de proxy/gateway, serviço inacessível",
"endpoint_gateway_timeout_error": "Tempo limite do gateway excedido (504): Tempo limite do gateway excedido, serviço inacessível",
"endpoint_internal_server_error": "Erro interno do servidor (500): O serviço encontrou um erro inesperado",
"endpoint_method_not_allowed_error": "Método não permitido (405): O endpoint existe, mas não aceita pedidos POST",
"endpoint_not_found_error": "Não encontrado (404): O endpoint não existe",
"endpoint_pinged": "Yay! Conseguimos aceder ao webhook!", "endpoint_pinged": "Yay! Conseguimos aceder ao webhook!",
"endpoint_pinged_error": "Não foi possível aceder ao webhook!", "endpoint_pinged_error": "Não foi possível aceder ao webhook!",
"endpoint_service_unavailable_error": "Serviço indisponível (503): O serviço está temporariamente indisponível",
"learn_to_verify": "Aprenda a verificar assinaturas de webhook", "learn_to_verify": "Aprenda a verificar assinaturas de webhook",
"no_triggers": "Sem Acionadores",
"please_check_console": "Por favor, verifique a consola para mais detalhes", "please_check_console": "Por favor, verifique a consola para mais detalhes",
"please_enter_a_url": "Por favor, insira um URL", "please_enter_a_url": "Por favor, insira um URL",
"response_created": "Resposta Criada", "response_created": "Resposta Criada",
@@ -1071,6 +1081,25 @@
"enterprise_features": "Funcionalidades da Empresa", "enterprise_features": "Funcionalidades da Empresa",
"get_an_enterprise_license_to_get_access_to_all_features": "Obtenha uma licença Enterprise para ter acesso a todas as funcionalidades.", "get_an_enterprise_license_to_get_access_to_all_features": "Obtenha uma licença Enterprise para ter acesso a todas as funcionalidades.",
"keep_full_control_over_your_data_privacy_and_security": "Mantenha controlo total sobre a privacidade e segurança dos seus dados.", "keep_full_control_over_your_data_privacy_and_security": "Mantenha controlo total sobre a privacidade e segurança dos seus dados.",
"license_feature_access_control": "Controlo de acesso (RBAC)",
"license_feature_audit_logs": "Registos de auditoria",
"license_feature_contacts": "Contactos e Segmentos",
"license_feature_projects": "Áreas de trabalho",
"license_feature_quotas": "Quotas",
"license_feature_remove_branding": "Remover marca",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Proteção contra spam",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Autenticação de dois fatores",
"license_feature_whitelabel": "E-mails personalizados",
"license_features_table_access": "Acesso",
"license_features_table_description": "Funcionalidades e limites empresariais atualmente disponíveis para esta instância.",
"license_features_table_disabled": "Desativado",
"license_features_table_enabled": "Ativado",
"license_features_table_feature": "Funcionalidade",
"license_features_table_title": "Funcionalidades Licenciadas",
"license_features_table_unlimited": "Ilimitado",
"license_features_table_value": "Valor",
"license_instance_mismatch_description": "Esta licença está atualmente associada a uma instância Formbricks diferente. Se esta instalação foi reconstruída ou movida, pede ao suporte da Formbricks para desconectar a associação da instância anterior.", "license_instance_mismatch_description": "Esta licença está atualmente associada a uma instância Formbricks diferente. Se esta instalação foi reconstruída ou movida, pede ao suporte da Formbricks para desconectar a associação da instância anterior.",
"license_invalid_description": "A chave de licença na sua variável de ambiente ENTERPRISE_LICENSE_KEY não é válida. Por favor, verifique se existem erros de digitação ou solicite uma nova chave.", "license_invalid_description": "A chave de licença na sua variável de ambiente ENTERPRISE_LICENSE_KEY não é válida. Por favor, verifique se existem erros de digitação ou solicite uma nova chave.",
"license_status": "Estado da licença", "license_status": "Estado da licença",
@@ -1430,6 +1459,7 @@
"error_saving_changes": "Erro ao guardar alterações", "error_saving_changes": "Erro ao guardar alterações",
"even_after_they_submitted_a_response_e_g_feedback_box": "Permitir múltiplas respostas; continuar a mostrar mesmo após uma resposta (por exemplo, Caixa de Feedback).", "even_after_they_submitted_a_response_e_g_feedback_box": "Permitir múltiplas respostas; continuar a mostrar mesmo após uma resposta (por exemplo, Caixa de Feedback).",
"everyone": "Todos", "everyone": "Todos",
"expand_preview": "Expandir pré-visualização",
"external_urls_paywall_tooltip": "Por favor, faz o upgrade para um plano pago para personalizar URLs externos. Isto ajuda-nos a prevenir phishing.", "external_urls_paywall_tooltip": "Por favor, faz o upgrade para um plano pago para personalizar URLs externos. Isto ajuda-nos a prevenir phishing.",
"fallback_missing": "Substituição em falta", "fallback_missing": "Substituição em falta",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} é usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
@@ -1655,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "O limite de respostas precisa exceder o número de respostas recebidas ({responseCount}).", "response_limit_needs_to_exceed_number_of_received_responses": "O limite de respostas precisa exceder o número de respostas recebidas ({responseCount}).",
"response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.", "response_limits_redirections_and_more": "Limites de resposta, redirecionamentos e mais.",
"response_options": "Opções de Resposta", "response_options": "Opções de Resposta",
"reverse_order_occasionally": "Inverter ordem ocasionalmente",
"reverse_order_occasionally_except_last": "Inverter ordem ocasionalmente exceto o último",
"roundness": "Arredondamento", "roundness": "Arredondamento",
"roundness_description": "Controla o arredondamento dos cantos.", "roundness_description": "Controla o arredondamento dos cantos.",
"row_used_in_logic_error": "Esta linha é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.", "row_used_in_logic_error": "Esta linha é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
@@ -1683,6 +1715,7 @@
"show_survey_maximum_of": "Mostrar inquérito máximo de", "show_survey_maximum_of": "Mostrar inquérito máximo de",
"show_survey_to_users": "Mostrar inquérito a % dos utilizadores", "show_survey_to_users": "Mostrar inquérito a % dos utilizadores",
"show_to_x_percentage_of_targeted_users": "Mostrar a {percentage}% dos utilizadores alvo", "show_to_x_percentage_of_targeted_users": "Mostrar a {percentage}% dos utilizadores alvo",
"shrink_preview": "Reduzir pré-visualização",
"simple": "Simples", "simple": "Simples",
"six_points": "6 pontos", "six_points": "6 pontos",
"smiley": "Sorridente", "smiley": "Sorridente",
@@ -1698,10 +1731,12 @@
"styling_set_to_theme_styles": "Estilo definido para estilos do tema", "styling_set_to_theme_styles": "Estilo definido para estilos do tema",
"subheading": "Subtítulo", "subheading": "Subtítulo",
"subtract": "Subtrair -", "subtract": "Subtrair -",
"survey_closed_message_heading_required": "Adiciona um título à mensagem personalizada de inquérito encerrado.",
"survey_completed_heading": "Inquérito Concluído", "survey_completed_heading": "Inquérito Concluído",
"survey_completed_subheading": "Este inquérito gratuito e de código aberto foi encerrado", "survey_completed_subheading": "Este inquérito gratuito e de código aberto foi encerrado",
"survey_display_settings": "Configurações de Exibição do Inquérito", "survey_display_settings": "Configurações de Exibição do Inquérito",
"survey_placement": "Colocação do Inquérito", "survey_placement": "Colocação do Inquérito",
"survey_preview": "Pré-visualização do questionário 👀",
"survey_styling": "Estilo do formulário", "survey_styling": "Estilo do formulário",
"survey_trigger": "Desencadeador de Inquérito", "survey_trigger": "Desencadeador de Inquérito",
"switch_multi_language_on_to_get_started": "Ative o modo multilingue para começar 👉", "switch_multi_language_on_to_get_started": "Ative o modo multilingue para começar 👉",
@@ -3052,7 +3087,7 @@
"preview_survey_question_2_choice_2_label": "Não, obrigado!", "preview_survey_question_2_choice_2_label": "Não, obrigado!",
"preview_survey_question_2_headline": "Quer manter-se atualizado?", "preview_survey_question_2_headline": "Quer manter-se atualizado?",
"preview_survey_question_2_subheader": "Este é um exemplo de descrição.", "preview_survey_question_2_subheader": "Este é um exemplo de descrição.",
"preview_survey_question_open_text_headline": "Mais alguma coisa que gostaria de partilhar?", "preview_survey_question_open_text_headline": "Há mais alguma coisa que gostaria de partilhar?",
"preview_survey_question_open_text_placeholder": "Escreva a sua resposta aqui...", "preview_survey_question_open_text_placeholder": "Escreva a sua resposta aqui...",
"preview_survey_question_open_text_subheader": "O seu feedback ajuda-nos a melhorar.", "preview_survey_question_open_text_subheader": "O seu feedback ajuda-nos a melhorar.",
"preview_survey_welcome_card_headline": "Bem-vindo!", "preview_survey_welcome_card_headline": "Bem-vindo!",
+37 -2
View File
@@ -294,6 +294,7 @@
"new": "Nou", "new": "Nou",
"new_version_available": "Formbricks {version} este disponibil. Actualizați acum!", "new_version_available": "Formbricks {version} este disponibil. Actualizați acum!",
"next": "Următorul", "next": "Următorul",
"no_actions_found": "Nu au fost găsite acțiuni",
"no_background_image_found": "Nu a fost găsită nicio imagine de fundal.", "no_background_image_found": "Nu a fost găsită nicio imagine de fundal.",
"no_code": "Fără Cod", "no_code": "Fără Cod",
"no_files_uploaded": "Nu au fost încărcate fișiere", "no_files_uploaded": "Nu au fost încărcate fișiere",
@@ -339,6 +340,7 @@
"please_select_at_least_one_survey": "Vă rugăm să selectați cel puțin un sondaj", "please_select_at_least_one_survey": "Vă rugăm să selectați cel puțin un sondaj",
"please_select_at_least_one_trigger": "Vă rugăm să selectați cel puțin un declanșator", "please_select_at_least_one_trigger": "Vă rugăm să selectați cel puțin un declanșator",
"please_upgrade_your_plan": "Vă rugăm să faceți upgrade la planul dumneavoastră", "please_upgrade_your_plan": "Vă rugăm să faceți upgrade la planul dumneavoastră",
"powered_by_formbricks": "Oferit de Formbricks",
"preview": "Previzualizare", "preview": "Previzualizare",
"preview_survey": "Previzualizare Chestionar", "preview_survey": "Previzualizare Chestionar",
"privacy": "Politica de Confidențialitate", "privacy": "Politica de Confidențialitate",
@@ -380,6 +382,7 @@
"select": "Selectați", "select": "Selectați",
"select_all": "Selectați toate", "select_all": "Selectați toate",
"select_filter": "Selectați filtrul", "select_filter": "Selectați filtrul",
"select_language": "Selectează limba",
"select_survey": "Selectați chestionar", "select_survey": "Selectați chestionar",
"select_teams": "Selectați echipele", "select_teams": "Selectați echipele",
"selected": "Selectat", "selected": "Selectat",
@@ -850,9 +853,16 @@
"created_by_third_party": "Creat de o Parte Terță", "created_by_third_party": "Creat de o Parte Terță",
"discord_webhook_not_supported": "Webhook-urile Discord nu sunt în prezent suportate.", "discord_webhook_not_supported": "Webhook-urile Discord nu sunt în prezent suportate.",
"empty_webhook_message": "Webhook-urile tale vor apărea aici de îndată ce le vei adăuga. ⏲️", "empty_webhook_message": "Webhook-urile tale vor apărea aici de îndată ce le vei adăuga. ⏲️",
"endpoint_bad_gateway_error": "Gateway invalid (502): Eroare de proxy/gateway, serviciul nu este accesibil",
"endpoint_gateway_timeout_error": "Timp de așteptare gateway depășit (504): Timpul de așteptare al gateway-ului a fost depășit, serviciul nu este accesibil",
"endpoint_internal_server_error": "Eroare internă de server (500): Serviciul a întâmpinat o eroare neașteptată",
"endpoint_method_not_allowed_error": "Metodă nepermisă (405): Endpointul există, dar nu acceptă cereri POST",
"endpoint_not_found_error": "Negăsit (404): Endpointul nu există",
"endpoint_pinged": "Grozav! Am reușit să ping-ui webhooks-ul!", "endpoint_pinged": "Grozav! Am reușit să ping-ui webhooks-ul!",
"endpoint_pinged_error": "Nu pot să ping-ui webhooks-ul!", "endpoint_pinged_error": "Nu pot să ping-ui webhooks-ul!",
"endpoint_service_unavailable_error": "Serviciu indisponibil (503): Serviciul este temporar indisponibil",
"learn_to_verify": "Află cum să verifici semnăturile webhook", "learn_to_verify": "Află cum să verifici semnăturile webhook",
"no_triggers": "Fără declanșatori",
"please_check_console": "Vă rugăm să verificați consola pentru mai multe detalii", "please_check_console": "Vă rugăm să verificați consola pentru mai multe detalii",
"please_enter_a_url": "Vă rugăm să introduceți un URL", "please_enter_a_url": "Vă rugăm să introduceți un URL",
"response_created": "Răspuns creat", "response_created": "Răspuns creat",
@@ -1071,6 +1081,25 @@
"enterprise_features": "Funcții Enterprise", "enterprise_features": "Funcții Enterprise",
"get_an_enterprise_license_to_get_access_to_all_features": "Obțineți o licență Enterprise pentru a avea acces la toate funcționalitățile.", "get_an_enterprise_license_to_get_access_to_all_features": "Obțineți o licență Enterprise pentru a avea acces la toate funcționalitățile.",
"keep_full_control_over_your_data_privacy_and_security": "Mențineți controlul complet asupra confidențialității și securității datelor dumneavoastră.", "keep_full_control_over_your_data_privacy_and_security": "Mențineți controlul complet asupra confidențialității și securității datelor dumneavoastră.",
"license_feature_access_control": "Control acces (RBAC)",
"license_feature_audit_logs": "Jurnale de audit",
"license_feature_contacts": "Contacte și segmente",
"license_feature_projects": "Spații de lucru",
"license_feature_quotas": "Cote",
"license_feature_remove_branding": "Elimină branding-ul",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Protecție spam",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Autentificare cu doi factori",
"license_feature_whitelabel": "E-mailuri white-label",
"license_features_table_access": "Acces",
"license_features_table_description": "Funcționalități și limite enterprise disponibile în prezent pentru această instanță.",
"license_features_table_disabled": "Dezactivat",
"license_features_table_enabled": "Activat",
"license_features_table_feature": "Funcționalitate",
"license_features_table_title": "Funcționalități licențiate",
"license_features_table_unlimited": "Nelimitat",
"license_features_table_value": "Valoare",
"license_instance_mismatch_description": "Această licență este în prezent asociată cu o altă instanță Formbricks. Dacă această instalare a fost reconstruită sau mutată, solicită echipei de suport Formbricks să deconecteze asocierea cu instanța anterioară.", "license_instance_mismatch_description": "Această licență este în prezent asociată cu o altă instanță Formbricks. Dacă această instalare a fost reconstruită sau mutată, solicită echipei de suport Formbricks să deconecteze asocierea cu instanța anterioară.",
"license_invalid_description": "Cheia de licență din variabila de mediu ENTERPRISE_LICENSE_KEY nu este validă. Te rugăm să verifici dacă există greșeli de scriere sau să soliciți o cheie nouă.", "license_invalid_description": "Cheia de licență din variabila de mediu ENTERPRISE_LICENSE_KEY nu este validă. Te rugăm să verifici dacă există greșeli de scriere sau să soliciți o cheie nouă.",
"license_status": "Stare licență", "license_status": "Stare licență",
@@ -1430,6 +1459,7 @@
"error_saving_changes": "Eroare la salvarea modificărilor", "error_saving_changes": "Eroare la salvarea modificărilor",
"even_after_they_submitted_a_response_e_g_feedback_box": "Permite răspunsuri multiple; continuă afișarea chiar și după un răspuns (de exemplu, Caseta de Feedback).", "even_after_they_submitted_a_response_e_g_feedback_box": "Permite răspunsuri multiple; continuă afișarea chiar și după un răspuns (de exemplu, Caseta de Feedback).",
"everyone": "Toată lumea", "everyone": "Toată lumea",
"expand_preview": "Extinde previzualizarea",
"external_urls_paywall_tooltip": "Te rugăm să treci la un plan plătit pentru a personaliza URL-urile externe. Asta ne ajută să prevenim phishing-ul.", "external_urls_paywall_tooltip": "Te rugăm să treci la un plan plătit pentru a personaliza URL-urile externe. Asta ne ajută să prevenim phishing-ul.",
"fallback_missing": "Rezerva lipsă", "fallback_missing": "Rezerva lipsă",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} este folosit în logică întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} este folosit în logică întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
@@ -1655,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Limita răspunsurilor trebuie să depășească numărul de răspunsuri primite ({responseCount}).", "response_limit_needs_to_exceed_number_of_received_responses": "Limita răspunsurilor trebuie să depășească numărul de răspunsuri primite ({responseCount}).",
"response_limits_redirections_and_more": "Limite de răspunsuri, redirecționări și altele.", "response_limits_redirections_and_more": "Limite de răspunsuri, redirecționări și altele.",
"response_options": "Opțiuni răspuns", "response_options": "Opțiuni răspuns",
"reverse_order_occasionally": "Inversare ordine ocazional",
"reverse_order_occasionally_except_last": "Inversare ordine ocazional cu excepția ultimului",
"roundness": "Rotunjire", "roundness": "Rotunjire",
"roundness_description": "Controlează cât de rotunjite sunt colțurile.", "roundness_description": "Controlează cât de rotunjite sunt colțurile.",
"row_used_in_logic_error": "Această linie este folosită în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.", "row_used_in_logic_error": "Această linie este folosită în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
@@ -1683,6 +1715,7 @@
"show_survey_maximum_of": "Afișează sondajul de maxim", "show_survey_maximum_of": "Afișează sondajul de maxim",
"show_survey_to_users": "Afișați sondajul la % din utilizatori", "show_survey_to_users": "Afișați sondajul la % din utilizatori",
"show_to_x_percentage_of_targeted_users": "Afișați la {percentage}% din utilizatorii vizați", "show_to_x_percentage_of_targeted_users": "Afișați la {percentage}% din utilizatorii vizați",
"shrink_preview": "Restrânge previzualizarea",
"simple": "Simplu", "simple": "Simplu",
"six_points": "6 puncte", "six_points": "6 puncte",
"smiley": "Smiley", "smiley": "Smiley",
@@ -1698,10 +1731,12 @@
"styling_set_to_theme_styles": "Stilizare setată la stilurile temei", "styling_set_to_theme_styles": "Stilizare setată la stilurile temei",
"subheading": "Subtitlu", "subheading": "Subtitlu",
"subtract": "Scade -", "subtract": "Scade -",
"survey_closed_message_heading_required": "Adaugă un titlu la mesajul personalizat pentru sondajul închis.",
"survey_completed_heading": "Sondaj Completat", "survey_completed_heading": "Sondaj Completat",
"survey_completed_subheading": "Acest sondaj gratuit și open-source a fost închis", "survey_completed_subheading": "Acest sondaj gratuit și open-source a fost închis",
"survey_display_settings": "Setări de afișare a sondajului", "survey_display_settings": "Setări de afișare a sondajului",
"survey_placement": "Amplasarea sondajului", "survey_placement": "Amplasarea sondajului",
"survey_preview": "Previzualizare chestionar 👀",
"survey_styling": "Stilizare formular", "survey_styling": "Stilizare formular",
"survey_trigger": "Declanșator sondaj", "survey_trigger": "Declanșator sondaj",
"switch_multi_language_on_to_get_started": "Activați opțiunea multi-limbă pentru a începe 👉", "switch_multi_language_on_to_get_started": "Activați opțiunea multi-limbă pentru a începe 👉",
@@ -3052,7 +3087,7 @@
"preview_survey_question_2_choice_2_label": "Nu, mulţumesc!", "preview_survey_question_2_choice_2_label": "Nu, mulţumesc!",
"preview_survey_question_2_headline": "Vrei să fii în temă?", "preview_survey_question_2_headline": "Vrei să fii în temă?",
"preview_survey_question_2_subheader": "Aceasta este o descriere exemplu.", "preview_survey_question_2_subheader": "Aceasta este o descriere exemplu.",
"preview_survey_question_open_text_headline": "Mai vrei să împărtășești ceva?", "preview_survey_question_open_text_headline": "Mai aveți ceva de adăugat?",
"preview_survey_question_open_text_placeholder": "Tastează răspunsul aici...", "preview_survey_question_open_text_placeholder": "Tastează răspunsul aici...",
"preview_survey_question_open_text_subheader": "Feedbackul tău ne ajută să ne îmbunătățim.", "preview_survey_question_open_text_subheader": "Feedbackul tău ne ajută să ne îmbunătățim.",
"preview_survey_welcome_card_headline": "Bun venit!", "preview_survey_welcome_card_headline": "Bun venit!",
@@ -3307,7 +3342,7 @@
"workflows": { "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_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!", "coming_soon_title": "Suntem aproape gata!",
"follow_up_label": "Mai este ceva ce ai vrea să adaugi?", "follow_up_label": "Mai este ceva ce ați dori să adăugi?",
"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?", "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", "generate_button": "Generează workflow",
"heading": "Ce workflow vrei să creezi?", "heading": "Ce workflow vrei să creezi?",
+37 -2
View File
@@ -294,6 +294,7 @@
"new": "Новый", "new": "Новый",
"new_version_available": "Formbricks {version} уже здесь. Обновитесь сейчас!", "new_version_available": "Formbricks {version} уже здесь. Обновитесь сейчас!",
"next": "Далее", "next": "Далее",
"no_actions_found": "Действия не найдены",
"no_background_image_found": "Фоновое изображение не найдено.", "no_background_image_found": "Фоновое изображение не найдено.",
"no_code": "Нет кода", "no_code": "Нет кода",
"no_files_uploaded": "Файлы не были загружены", "no_files_uploaded": "Файлы не были загружены",
@@ -339,6 +340,7 @@
"please_select_at_least_one_survey": "Пожалуйста, выберите хотя бы один опрос", "please_select_at_least_one_survey": "Пожалуйста, выберите хотя бы один опрос",
"please_select_at_least_one_trigger": "Пожалуйста, выберите хотя бы один триггер", "please_select_at_least_one_trigger": "Пожалуйста, выберите хотя бы один триггер",
"please_upgrade_your_plan": "Пожалуйста, обновите ваш тарифный план", "please_upgrade_your_plan": "Пожалуйста, обновите ваш тарифный план",
"powered_by_formbricks": "Работает на Formbricks",
"preview": "Предпросмотр", "preview": "Предпросмотр",
"preview_survey": "Предпросмотр опроса", "preview_survey": "Предпросмотр опроса",
"privacy": "Политика конфиденциальности", "privacy": "Политика конфиденциальности",
@@ -380,6 +382,7 @@
"select": "Выбрать", "select": "Выбрать",
"select_all": "Выбрать все", "select_all": "Выбрать все",
"select_filter": "Выбрать фильтр", "select_filter": "Выбрать фильтр",
"select_language": "Выберите язык",
"select_survey": "Выбрать опрос", "select_survey": "Выбрать опрос",
"select_teams": "Выбрать команды", "select_teams": "Выбрать команды",
"selected": "Выбрано", "selected": "Выбрано",
@@ -850,9 +853,16 @@
"created_by_third_party": "Создано сторонней организацией", "created_by_third_party": "Создано сторонней организацией",
"discord_webhook_not_supported": "В настоящее время webhooks Discord не поддерживаются.", "discord_webhook_not_supported": "В настоящее время webhooks Discord не поддерживаются.",
"empty_webhook_message": "Ваши webhooks появятся здесь, как только вы их добавите. ⏲️", "empty_webhook_message": "Ваши webhooks появятся здесь, как только вы их добавите. ⏲️",
"endpoint_bad_gateway_error": "Ошибка шлюза (502): Ошибка прокси/шлюза, сервис недоступен",
"endpoint_gateway_timeout_error": "Тайм-аут шлюза (504): Тайм-аут шлюза, сервис недоступен",
"endpoint_internal_server_error": "Внутренняя ошибка сервера (500): Сервис столкнулся с непредвиденной ошибкой",
"endpoint_method_not_allowed_error": "Метод не разрешен (405): Конечная точка существует, но не принимает POST-запросы",
"endpoint_not_found_error": "Не найдено (404): Конечная точка не существует",
"endpoint_pinged": "Ура! Нам удалось отправить ping на webhook!", "endpoint_pinged": "Ура! Нам удалось отправить ping на webhook!",
"endpoint_pinged_error": "Не удалось отправить ping на webhook!", "endpoint_pinged_error": "Не удалось отправить ping на webhook!",
"endpoint_service_unavailable_error": "Сервис недоступен (503): Сервис временно недоступен",
"learn_to_verify": "Узнайте, как проверить подписи вебхуков", "learn_to_verify": "Узнайте, как проверить подписи вебхуков",
"no_triggers": "Нет триггеров",
"please_check_console": "Пожалуйста, проверьте консоль для получения подробностей", "please_check_console": "Пожалуйста, проверьте консоль для получения подробностей",
"please_enter_a_url": "Пожалуйста, введите URL", "please_enter_a_url": "Пожалуйста, введите URL",
"response_created": "Ответ создан", "response_created": "Ответ создан",
@@ -1071,6 +1081,25 @@
"enterprise_features": "Функции для предприятий", "enterprise_features": "Функции для предприятий",
"get_an_enterprise_license_to_get_access_to_all_features": "Получите корпоративную лицензию для доступа ко всем функциям.", "get_an_enterprise_license_to_get_access_to_all_features": "Получите корпоративную лицензию для доступа ко всем функциям.",
"keep_full_control_over_your_data_privacy_and_security": "Полный контроль над конфиденциальностью и безопасностью ваших данных.", "keep_full_control_over_your_data_privacy_and_security": "Полный контроль над конфиденциальностью и безопасностью ваших данных.",
"license_feature_access_control": "Управление доступом (RBAC)",
"license_feature_audit_logs": "Журналы аудита",
"license_feature_contacts": "Контакты и сегменты",
"license_feature_projects": "Рабочие пространства",
"license_feature_quotas": "Квоты",
"license_feature_remove_branding": "Удаление брендирования",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Защита от спама",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Двухфакторная аутентификация",
"license_feature_whitelabel": "Электронные письма без брендирования",
"license_features_table_access": "Доступ",
"license_features_table_description": "Корпоративные функции и ограничения, доступные для этого экземпляра.",
"license_features_table_disabled": "Отключено",
"license_features_table_enabled": "Включено",
"license_features_table_feature": "Функция",
"license_features_table_title": "Лицензированные функции",
"license_features_table_unlimited": "Без ограничений",
"license_features_table_value": "Значение",
"license_instance_mismatch_description": "Эта лицензия в данный момент привязана к другому экземпляру Formbricks. Если эта установка была пересобрана или перемещена, обратитесь в службу поддержки Formbricks для отключения предыдущей привязки экземпляра.", "license_instance_mismatch_description": "Эта лицензия в данный момент привязана к другому экземпляру Formbricks. Если эта установка была пересобрана или перемещена, обратитесь в службу поддержки Formbricks для отключения предыдущей привязки экземпляра.",
"license_invalid_description": "Ключ лицензии в переменной окружения ENTERPRISE_LICENSE_KEY недействителен. Проверь, нет ли опечаток, или запроси новый ключ.", "license_invalid_description": "Ключ лицензии в переменной окружения ENTERPRISE_LICENSE_KEY недействителен. Проверь, нет ли опечаток, или запроси новый ключ.",
"license_status": "Статус лицензии", "license_status": "Статус лицензии",
@@ -1430,6 +1459,7 @@
"error_saving_changes": "Ошибка при сохранении изменений", "error_saving_changes": "Ошибка при сохранении изменений",
"even_after_they_submitted_a_response_e_g_feedback_box": "Разрешить несколько ответов; продолжать показывать даже после ответа (например, окно обратной связи).", "even_after_they_submitted_a_response_e_g_feedback_box": "Разрешить несколько ответов; продолжать показывать даже после ответа (например, окно обратной связи).",
"everyone": "Все", "everyone": "Все",
"expand_preview": "Развернуть предпросмотр",
"external_urls_paywall_tooltip": "Пожалуйста, перейдите на платный тариф, чтобы настраивать внешние ссылки. Это помогает нам предотвращать фишинг.", "external_urls_paywall_tooltip": "Пожалуйста, перейдите на платный тариф, чтобы настраивать внешние ссылки. Это помогает нам предотвращать фишинг.",
"fallback_missing": "Запасное значение отсутствует", "fallback_missing": "Запасное значение отсутствует",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} используется в логике вопроса {questionIndex}. Пожалуйста, сначала удалите его из логики.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} используется в логике вопроса {questionIndex}. Пожалуйста, сначала удалите его из логики.",
@@ -1655,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Лимит ответов должен превышать количество полученных ответов ({responseCount}).", "response_limit_needs_to_exceed_number_of_received_responses": "Лимит ответов должен превышать количество полученных ответов ({responseCount}).",
"response_limits_redirections_and_more": "Лимиты ответов, перенаправления и другое.", "response_limits_redirections_and_more": "Лимиты ответов, перенаправления и другое.",
"response_options": "Параметры ответа", "response_options": "Параметры ответа",
"reverse_order_occasionally": "Иногда обращать порядок",
"reverse_order_occasionally_except_last": "Иногда обращать порядок кроме последнего",
"roundness": "Скругление", "roundness": "Скругление",
"roundness_description": "Определяет степень скругления углов.", "roundness_description": "Определяет степень скругления углов.",
"row_used_in_logic_error": "Эта строка используется в логике вопроса {questionIndex}. Пожалуйста, сначала удалите её из логики.", "row_used_in_logic_error": "Эта строка используется в логике вопроса {questionIndex}. Пожалуйста, сначала удалите её из логики.",
@@ -1683,6 +1715,7 @@
"show_survey_maximum_of": "Показать опрос максимум", "show_survey_maximum_of": "Показать опрос максимум",
"show_survey_to_users": "Показать опрос % пользователей", "show_survey_to_users": "Показать опрос % пользователей",
"show_to_x_percentage_of_targeted_users": "Показать {percentage}% целевых пользователей", "show_to_x_percentage_of_targeted_users": "Показать {percentage}% целевых пользователей",
"shrink_preview": "Свернуть предпросмотр",
"simple": "Простой", "simple": "Простой",
"six_points": "6 баллов", "six_points": "6 баллов",
"smiley": "Смайлик", "smiley": "Смайлик",
@@ -1698,10 +1731,12 @@
"styling_set_to_theme_styles": "Оформление установлено в соответствии с темой", "styling_set_to_theme_styles": "Оформление установлено в соответствии с темой",
"subheading": "Подзаголовок", "subheading": "Подзаголовок",
"subtract": "Вычесть -", "subtract": "Вычесть -",
"survey_closed_message_heading_required": "Добавьте заголовок к сообщению о закрытом опросе.",
"survey_completed_heading": "Опрос завершён", "survey_completed_heading": "Опрос завершён",
"survey_completed_subheading": "Этот бесплатный и открытый опрос был закрыт", "survey_completed_subheading": "Этот бесплатный и открытый опрос был закрыт",
"survey_display_settings": "Настройки отображения опроса", "survey_display_settings": "Настройки отображения опроса",
"survey_placement": "Размещение опроса", "survey_placement": "Размещение опроса",
"survey_preview": "Предпросмотр опроса 👀",
"survey_styling": "Оформление формы", "survey_styling": "Оформление формы",
"survey_trigger": "Триггер опроса", "survey_trigger": "Триггер опроса",
"switch_multi_language_on_to_get_started": "Включите многоязычный режим, чтобы начать 👉", "switch_multi_language_on_to_get_started": "Включите многоязычный режим, чтобы начать 👉",
@@ -3052,7 +3087,7 @@
"preview_survey_question_2_choice_2_label": "Нет, спасибо!", "preview_survey_question_2_choice_2_label": "Нет, спасибо!",
"preview_survey_question_2_headline": "Хотите быть в курсе событий?", "preview_survey_question_2_headline": "Хотите быть в курсе событий?",
"preview_survey_question_2_subheader": "Это пример описания.", "preview_survey_question_2_subheader": "Это пример описания.",
"preview_survey_question_open_text_headline": "Есть ли ещё что-то, чем хочешь поделиться?", "preview_survey_question_open_text_headline": "Хотите ли вы чем-то ещё поделиться?",
"preview_survey_question_open_text_placeholder": "Введи свой ответ здесь...", "preview_survey_question_open_text_placeholder": "Введи свой ответ здесь...",
"preview_survey_question_open_text_subheader": "Твой отзыв помогает нам становиться лучше.", "preview_survey_question_open_text_subheader": "Твой отзыв помогает нам становиться лучше.",
"preview_survey_welcome_card_headline": "Добро пожаловать!", "preview_survey_welcome_card_headline": "Добро пожаловать!",
@@ -3307,7 +3342,7 @@
"workflows": { "workflows": {
"coming_soon_description": "Спасибо, что поделился своей идеей воркфлоу с нами! Сейчас мы разрабатываем эту функцию, и твой отзыв поможет нам сделать именно то, что тебе нужно.", "coming_soon_description": "Спасибо, что поделился своей идеей воркфлоу с нами! Сейчас мы разрабатываем эту функцию, и твой отзыв поможет нам сделать именно то, что тебе нужно.",
"coming_soon_title": "Мы почти готовы!", "coming_soon_title": "Мы почти готовы!",
"follow_up_label": "Хочешь что-то ещё добавить?", "follow_up_label": "Хотите ли вы что-нибудь добавить?",
"follow_up_placeholder": "Какие конкретные задачи вы хотите автоматизировать? Какие инструменты или интеграции вам хотелось бы добавить?", "follow_up_placeholder": "Какие конкретные задачи вы хотите автоматизировать? Какие инструменты или интеграции вам хотелось бы добавить?",
"generate_button": "Сгенерировать воркфлоу", "generate_button": "Сгенерировать воркфлоу",
"heading": "Какой воркфлоу ты хочешь создать?", "heading": "Какой воркфлоу ты хочешь создать?",
+37 -2
View File
@@ -294,6 +294,7 @@
"new": "Ny", "new": "Ny",
"new_version_available": "Formbricks {version} är här. Uppgradera nu!", "new_version_available": "Formbricks {version} är här. Uppgradera nu!",
"next": "Nästa", "next": "Nästa",
"no_actions_found": "Inga åtgärder hittades",
"no_background_image_found": "Ingen bakgrundsbild hittades.", "no_background_image_found": "Ingen bakgrundsbild hittades.",
"no_code": "Ingen kod", "no_code": "Ingen kod",
"no_files_uploaded": "Inga filer laddades upp", "no_files_uploaded": "Inga filer laddades upp",
@@ -339,6 +340,7 @@
"please_select_at_least_one_survey": "Vänligen välj minst en enkät", "please_select_at_least_one_survey": "Vänligen välj minst en enkät",
"please_select_at_least_one_trigger": "Vänligen välj minst en utlösare", "please_select_at_least_one_trigger": "Vänligen välj minst en utlösare",
"please_upgrade_your_plan": "Vänligen uppgradera din plan", "please_upgrade_your_plan": "Vänligen uppgradera din plan",
"powered_by_formbricks": "Drivs av Formbricks",
"preview": "Förhandsgranska", "preview": "Förhandsgranska",
"preview_survey": "Förhandsgranska enkät", "preview_survey": "Förhandsgranska enkät",
"privacy": "Integritetspolicy", "privacy": "Integritetspolicy",
@@ -380,6 +382,7 @@
"select": "Välj", "select": "Välj",
"select_all": "Välj alla", "select_all": "Välj alla",
"select_filter": "Välj filter", "select_filter": "Välj filter",
"select_language": "Välj språk",
"select_survey": "Välj enkät", "select_survey": "Välj enkät",
"select_teams": "Välj team", "select_teams": "Välj team",
"selected": "Vald", "selected": "Vald",
@@ -850,9 +853,16 @@
"created_by_third_party": "Skapad av tredje part", "created_by_third_party": "Skapad av tredje part",
"discord_webhook_not_supported": "Discord-webhooks stöds för närvarande inte.", "discord_webhook_not_supported": "Discord-webhooks stöds för närvarande inte.",
"empty_webhook_message": "Dina webhooks visas här så snart du lägger till dem. ⏲️", "empty_webhook_message": "Dina webhooks visas här så snart du lägger till dem. ⏲️",
"endpoint_bad_gateway_error": "Felaktig gateway (502): Proxy-/gatewayfel, tjänsten kan inte nås",
"endpoint_gateway_timeout_error": "Gateway-timeout (504): Gateway-timeout, tjänsten kan inte nås",
"endpoint_internal_server_error": "Internt serverfel (500): Tjänsten stötte på ett oväntat fel",
"endpoint_method_not_allowed_error": "Metoden tillåts inte (405): Endpointen finns, men accepterar inte POST-förfrågningar",
"endpoint_not_found_error": "Hittades inte (404): Endpointen finns inte",
"endpoint_pinged": "Ja! Vi kan nå webhooken!", "endpoint_pinged": "Ja! Vi kan nå webhooken!",
"endpoint_pinged_error": "Kunde inte nå webhooken!", "endpoint_pinged_error": "Kunde inte nå webhooken!",
"endpoint_service_unavailable_error": "Tjänsten är inte tillgänglig (503): Tjänsten är tillfälligt nere",
"learn_to_verify": "Lär dig hur du verifierar webhook-signaturer", "learn_to_verify": "Lär dig hur du verifierar webhook-signaturer",
"no_triggers": "Inga utlösare",
"please_check_console": "Vänligen kontrollera konsolen för mer information", "please_check_console": "Vänligen kontrollera konsolen för mer information",
"please_enter_a_url": "Vänligen ange en URL", "please_enter_a_url": "Vänligen ange en URL",
"response_created": "Svar skapat", "response_created": "Svar skapat",
@@ -1071,6 +1081,25 @@
"enterprise_features": "Enterprise-funktioner", "enterprise_features": "Enterprise-funktioner",
"get_an_enterprise_license_to_get_access_to_all_features": "Skaffa en Enterprise-licens för att få tillgång till alla funktioner.", "get_an_enterprise_license_to_get_access_to_all_features": "Skaffa en Enterprise-licens för att få tillgång till alla funktioner.",
"keep_full_control_over_your_data_privacy_and_security": "Behåll full kontroll över din datasekretess och säkerhet.", "keep_full_control_over_your_data_privacy_and_security": "Behåll full kontroll över din datasekretess och säkerhet.",
"license_feature_access_control": "Åtkomstkontroll (RBAC)",
"license_feature_audit_logs": "Granskningsloggar",
"license_feature_contacts": "Kontakter & Segment",
"license_feature_projects": "Arbetsytor",
"license_feature_quotas": "Kvoter",
"license_feature_remove_branding": "Ta bort varumärkning",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "Skräppostskydd",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "Tvåfaktorsautentisering",
"license_feature_whitelabel": "White-label-mejl",
"license_features_table_access": "Åtkomst",
"license_features_table_description": "Företagsfunktioner och begränsningar som för närvarande är tillgängliga för den här instansen.",
"license_features_table_disabled": "Inaktiverad",
"license_features_table_enabled": "Aktiverad",
"license_features_table_feature": "Funktion",
"license_features_table_title": "Licensierade funktioner",
"license_features_table_unlimited": "Obegränsad",
"license_features_table_value": "Värde",
"license_instance_mismatch_description": "Den här licensen är för närvarande kopplad till en annan Formbricks-instans. Om den här installationen har återuppbyggts eller flyttats, be Formbricks support att koppla bort den tidigare instansbindningen.", "license_instance_mismatch_description": "Den här licensen är för närvarande kopplad till en annan Formbricks-instans. Om den här installationen har återuppbyggts eller flyttats, be Formbricks support att koppla bort den tidigare instansbindningen.",
"license_invalid_description": "Licensnyckeln i din ENTERPRISE_LICENSE_KEY-miljövariabel är ogiltig. Kontrollera om det finns stavfel eller begär en ny nyckel.", "license_invalid_description": "Licensnyckeln i din ENTERPRISE_LICENSE_KEY-miljövariabel är ogiltig. Kontrollera om det finns stavfel eller begär en ny nyckel.",
"license_status": "Licensstatus", "license_status": "Licensstatus",
@@ -1430,6 +1459,7 @@
"error_saving_changes": "Fel vid sparande av ändringar", "error_saving_changes": "Fel vid sparande av ändringar",
"even_after_they_submitted_a_response_e_g_feedback_box": "Tillåt flera svar; fortsätt visa även efter ett svar (t.ex. feedbackruta).", "even_after_they_submitted_a_response_e_g_feedback_box": "Tillåt flera svar; fortsätt visa även efter ett svar (t.ex. feedbackruta).",
"everyone": "Alla", "everyone": "Alla",
"expand_preview": "Expandera förhandsgranskning",
"external_urls_paywall_tooltip": "Uppgradera till ett betalt abonnemang för att anpassa externa URL:er. Detta hjälper oss att förhindra nätfiske.", "external_urls_paywall_tooltip": "Uppgradera till ett betalt abonnemang för att anpassa externa URL:er. Detta hjälper oss att förhindra nätfiske.",
"fallback_missing": "Reservvärde saknas", "fallback_missing": "Reservvärde saknas",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} används i logiken för fråga {questionIndex}. Vänligen ta bort den från logiken först.", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} används i logiken för fråga {questionIndex}. Vänligen ta bort den från logiken först.",
@@ -1655,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "Svarsgränsen måste överstiga antalet mottagna svar ({responseCount}).", "response_limit_needs_to_exceed_number_of_received_responses": "Svarsgränsen måste överstiga antalet mottagna svar ({responseCount}).",
"response_limits_redirections_and_more": "Svarsgränser, omdirigeringar och mer.", "response_limits_redirections_and_more": "Svarsgränser, omdirigeringar och mer.",
"response_options": "Svarsalternativ", "response_options": "Svarsalternativ",
"reverse_order_occasionally": "Vänd ordning ibland",
"reverse_order_occasionally_except_last": "Vänd ordning ibland utom sista",
"roundness": "Rundhet", "roundness": "Rundhet",
"roundness_description": "Styr hur rundade hörnen är.", "roundness_description": "Styr hur rundade hörnen är.",
"row_used_in_logic_error": "Denna rad används i logiken för fråga {questionIndex}. Vänligen ta bort den från logiken först.", "row_used_in_logic_error": "Denna rad används i logiken för fråga {questionIndex}. Vänligen ta bort den från logiken först.",
@@ -1683,6 +1715,7 @@
"show_survey_maximum_of": "Visa enkät maximalt", "show_survey_maximum_of": "Visa enkät maximalt",
"show_survey_to_users": "Visa enkät för % av användare", "show_survey_to_users": "Visa enkät för % av användare",
"show_to_x_percentage_of_targeted_users": "Visa för {percentage}% av målgruppens användare", "show_to_x_percentage_of_targeted_users": "Visa för {percentage}% av målgruppens användare",
"shrink_preview": "Minimera förhandsgranskning",
"simple": "Enkel", "simple": "Enkel",
"six_points": "6 poäng", "six_points": "6 poäng",
"smiley": "Smiley", "smiley": "Smiley",
@@ -1698,10 +1731,12 @@
"styling_set_to_theme_styles": "Styling inställd på temastil", "styling_set_to_theme_styles": "Styling inställd på temastil",
"subheading": "Underrubrik", "subheading": "Underrubrik",
"subtract": "Subtrahera -", "subtract": "Subtrahera -",
"survey_closed_message_heading_required": "Lägg till en rubrik för det anpassade meddelandet när undersökningen är stängd.",
"survey_completed_heading": "Enkät slutförd", "survey_completed_heading": "Enkät slutförd",
"survey_completed_subheading": "Denna gratis och öppenkällkodsenkät har stängts", "survey_completed_subheading": "Denna gratis och öppenkällkodsenkät har stängts",
"survey_display_settings": "Visningsinställningar för enkät", "survey_display_settings": "Visningsinställningar för enkät",
"survey_placement": "Enkätplacering", "survey_placement": "Enkätplacering",
"survey_preview": "Enkätförhandsgranskning 👀",
"survey_styling": "Formulärstil", "survey_styling": "Formulärstil",
"survey_trigger": "Enkätutlösare", "survey_trigger": "Enkätutlösare",
"switch_multi_language_on_to_get_started": "Slå på flerspråkighet för att komma igång 👉", "switch_multi_language_on_to_get_started": "Slå på flerspråkighet för att komma igång 👉",
@@ -3052,7 +3087,7 @@
"preview_survey_question_2_choice_2_label": "Nej, tack!", "preview_survey_question_2_choice_2_label": "Nej, tack!",
"preview_survey_question_2_headline": "Vill du hållas uppdaterad?", "preview_survey_question_2_headline": "Vill du hållas uppdaterad?",
"preview_survey_question_2_subheader": "Det här är ett exempel på en beskrivning.", "preview_survey_question_2_subheader": "Det här är ett exempel på en beskrivning.",
"preview_survey_question_open_text_headline": "Något mer du vill dela med dig av?", "preview_survey_question_open_text_headline": "Finns det något annat du vill dela med dig av?",
"preview_survey_question_open_text_placeholder": "Skriv ditt svar här...", "preview_survey_question_open_text_placeholder": "Skriv ditt svar här...",
"preview_survey_question_open_text_subheader": "Din feedback hjälper oss att bli bättre.", "preview_survey_question_open_text_subheader": "Din feedback hjälper oss att bli bättre.",
"preview_survey_welcome_card_headline": "Välkommen!", "preview_survey_welcome_card_headline": "Välkommen!",
@@ -3307,7 +3342,7 @@
"workflows": { "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_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!", "coming_soon_title": "Vi är nästan där!",
"follow_up_label": "Är det något mer du vill lägga till?", "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?", "follow_up_placeholder": "Vilka specifika uppgifter vill du automatisera? Några verktyg eller integrationer du vill ha med?",
"generate_button": "Skapa arbetsflöde", "generate_button": "Skapa arbetsflöde",
"heading": "Vilket arbetsflöde vill du skapa?", "heading": "Vilket arbetsflöde vill du skapa?",
+37 -2
View File
@@ -294,6 +294,7 @@
"new": "新建", "new": "新建",
"new_version_available": "Formbricks {version} 在 这里。立即 升级!", "new_version_available": "Formbricks {version} 在 这里。立即 升级!",
"next": "下一步", "next": "下一步",
"no_actions_found": "未找到操作",
"no_background_image_found": "未找到 背景 图片。", "no_background_image_found": "未找到 背景 图片。",
"no_code": "无代码", "no_code": "无代码",
"no_files_uploaded": "没有 文件 被 上传", "no_files_uploaded": "没有 文件 被 上传",
@@ -339,6 +340,7 @@
"please_select_at_least_one_survey": "请选择至少 一个调查", "please_select_at_least_one_survey": "请选择至少 一个调查",
"please_select_at_least_one_trigger": "请选择至少 一个触发条件", "please_select_at_least_one_trigger": "请选择至少 一个触发条件",
"please_upgrade_your_plan": "请升级您的计划", "please_upgrade_your_plan": "请升级您的计划",
"powered_by_formbricks": "由 Formbricks 提供支持",
"preview": "预览", "preview": "预览",
"preview_survey": "预览 Survey", "preview_survey": "预览 Survey",
"privacy": "隐私政策", "privacy": "隐私政策",
@@ -380,6 +382,7 @@
"select": "选择", "select": "选择",
"select_all": "选择 全部", "select_all": "选择 全部",
"select_filter": "选择过滤器", "select_filter": "选择过滤器",
"select_language": "选择语言",
"select_survey": "选择 调查", "select_survey": "选择 调查",
"select_teams": "选择 团队", "select_teams": "选择 团队",
"selected": "已选择", "selected": "已选择",
@@ -850,9 +853,16 @@
"created_by_third_party": "由 第三方 创建", "created_by_third_party": "由 第三方 创建",
"discord_webhook_not_supported": "Discord webhooks 目前不 支持。", "discord_webhook_not_supported": "Discord webhooks 目前不 支持。",
"empty_webhook_message": "您的 Webhooks 会在您 添加 后 出现在这里。 ⏲️", "empty_webhook_message": "您的 Webhooks 会在您 添加 后 出现在这里。 ⏲️",
"endpoint_bad_gateway_error": "错误网关 (502):代理/网关错误,服务不可达",
"endpoint_gateway_timeout_error": "网关超时 (504):网关超时,服务不可达",
"endpoint_internal_server_error": "内部服务器错误 (500):服务遇到了意外错误",
"endpoint_method_not_allowed_error": "方法不被允许 (405):该端点存在,但不接受 POST 请求",
"endpoint_not_found_error": "未找到 (404):该端点不存在",
"endpoint_pinged": "太好了! 我们能 ping 该 webhook!", "endpoint_pinged": "太好了! 我们能 ping 该 webhook!",
"endpoint_pinged_error": "无法 ping 该 webhook", "endpoint_pinged_error": "无法 ping 该 webhook",
"endpoint_service_unavailable_error": "服务不可用 (503):服务暂时不可用",
"learn_to_verify": "了解如何验证 webhook 签名", "learn_to_verify": "了解如何验证 webhook 签名",
"no_triggers": "无触发器",
"please_check_console": "请查看控制台以获取更多详情", "please_check_console": "请查看控制台以获取更多详情",
"please_enter_a_url": "请输入一个 URL", "please_enter_a_url": "请输入一个 URL",
"response_created": "创建 响应", "response_created": "创建 响应",
@@ -1071,6 +1081,25 @@
"enterprise_features": "企业 功能", "enterprise_features": "企业 功能",
"get_an_enterprise_license_to_get_access_to_all_features": "获取 企业 许可证 来 访问 所有 功能。", "get_an_enterprise_license_to_get_access_to_all_features": "获取 企业 许可证 来 访问 所有 功能。",
"keep_full_control_over_your_data_privacy_and_security": "保持 对 您 的 数据 隐私 和 安全 的 完全 控制。", "keep_full_control_over_your_data_privacy_and_security": "保持 对 您 的 数据 隐私 和 安全 的 完全 控制。",
"license_feature_access_control": "访问控制(RBAC",
"license_feature_audit_logs": "审计日志",
"license_feature_contacts": "联系人与细分",
"license_feature_projects": "工作空间",
"license_feature_quotas": "配额",
"license_feature_remove_branding": "移除品牌标识",
"license_feature_saml": "SAML 单点登录",
"license_feature_spam_protection": "垃圾信息防护",
"license_feature_sso": "OIDC 单点登录",
"license_feature_two_factor_auth": "双因素认证",
"license_feature_whitelabel": "白标电子邮件",
"license_features_table_access": "访问权限",
"license_features_table_description": "此实例当前可用的企业功能和限制。",
"license_features_table_disabled": "已禁用",
"license_features_table_enabled": "已启用",
"license_features_table_feature": "功能",
"license_features_table_title": "许可功能",
"license_features_table_unlimited": "无限制",
"license_features_table_value": "值",
"license_instance_mismatch_description": "此许可证目前绑定到另一个 Formbricks 实例。如果此安装已重建或迁移,请联系 Formbricks 支持团队解除先前的实例绑定。", "license_instance_mismatch_description": "此许可证目前绑定到另一个 Formbricks 实例。如果此安装已重建或迁移,请联系 Formbricks 支持团队解除先前的实例绑定。",
"license_invalid_description": "你在 ENTERPRISE_LICENSE_KEY 环境变量中填写的许可证密钥无效。请检查是否有拼写错误,或者申请一个新的密钥。", "license_invalid_description": "你在 ENTERPRISE_LICENSE_KEY 环境变量中填写的许可证密钥无效。请检查是否有拼写错误,或者申请一个新的密钥。",
"license_status": "许可证状态", "license_status": "许可证状态",
@@ -1430,6 +1459,7 @@
"error_saving_changes": "保存 更改 时 出错", "error_saving_changes": "保存 更改 时 出错",
"even_after_they_submitted_a_response_e_g_feedback_box": "允许多次回应;即使已提交回应,仍会继续显示(例如,反馈框)。", "even_after_they_submitted_a_response_e_g_feedback_box": "允许多次回应;即使已提交回应,仍会继续显示(例如,反馈框)。",
"everyone": "所有 人", "everyone": "所有 人",
"expand_preview": "展开预览",
"external_urls_paywall_tooltip": "请升级到付费套餐以自定义外部链接。这样有助于我们防范网络钓鱼。", "external_urls_paywall_tooltip": "请升级到付费套餐以自定义外部链接。这样有助于我们防范网络钓鱼。",
"fallback_missing": "备用 缺失", "fallback_missing": "备用 缺失",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{fieldId} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{fieldId} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
@@ -1655,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "限制 响应 需要 超过 收到 的 响应 数量 {responseCount})。", "response_limit_needs_to_exceed_number_of_received_responses": "限制 响应 需要 超过 收到 的 响应 数量 {responseCount})。",
"response_limits_redirections_and_more": "响应 限制 、 重定向 和 更多 。", "response_limits_redirections_and_more": "响应 限制 、 重定向 和 更多 。",
"response_options": "响应 选项", "response_options": "响应 选项",
"reverse_order_occasionally": "偶尔反转顺序",
"reverse_order_occasionally_except_last": "偶尔反转顺序(最后一项除外)",
"roundness": "圆度", "roundness": "圆度",
"roundness_description": "控制圆角的弧度。", "roundness_description": "控制圆角的弧度。",
"row_used_in_logic_error": "\"这个 行 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"", "row_used_in_logic_error": "\"这个 行 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
@@ -1683,6 +1715,7 @@
"show_survey_maximum_of": "显示 调查 最大 一次", "show_survey_maximum_of": "显示 调查 最大 一次",
"show_survey_to_users": "显示 问卷 给 % 的 用户", "show_survey_to_users": "显示 问卷 给 % 的 用户",
"show_to_x_percentage_of_targeted_users": "显示 给 {percentage}% 的 目标 用户", "show_to_x_percentage_of_targeted_users": "显示 给 {percentage}% 的 目标 用户",
"shrink_preview": "收起预览",
"simple": "简单", "simple": "简单",
"six_points": "6 分", "six_points": "6 分",
"smiley": "笑脸", "smiley": "笑脸",
@@ -1698,10 +1731,12 @@
"styling_set_to_theme_styles": "样式 设置 为 主题 风格", "styling_set_to_theme_styles": "样式 设置 为 主题 风格",
"subheading": "子标题", "subheading": "子标题",
"subtract": "减 -", "subtract": "减 -",
"survey_closed_message_heading_required": "请为自定义调查关闭消息添加标题。",
"survey_completed_heading": "调查 完成", "survey_completed_heading": "调查 完成",
"survey_completed_subheading": "此 免费 & 开源 调查 已 关闭", "survey_completed_subheading": "此 免费 & 开源 调查 已 关闭",
"survey_display_settings": "调查显示设置", "survey_display_settings": "调查显示设置",
"survey_placement": "调查 放置", "survey_placement": "调查 放置",
"survey_preview": "问卷预览 👀",
"survey_styling": "表单 样式", "survey_styling": "表单 样式",
"survey_trigger": "调查 触发", "survey_trigger": "调查 触发",
"switch_multi_language_on_to_get_started": "开启多语言以开始使用 👉", "switch_multi_language_on_to_get_started": "开启多语言以开始使用 👉",
@@ -3052,7 +3087,7 @@
"preview_survey_question_2_choice_2_label": "不,谢谢!", "preview_survey_question_2_choice_2_label": "不,谢谢!",
"preview_survey_question_2_headline": "想 了解 最新信息吗?", "preview_survey_question_2_headline": "想 了解 最新信息吗?",
"preview_survey_question_2_subheader": "这是一个示例描述。", "preview_survey_question_2_subheader": "这是一个示例描述。",
"preview_survey_question_open_text_headline": "还有什么想和我们分享的吗?", "preview_survey_question_open_text_headline": "还有其他想分享的内容吗?",
"preview_survey_question_open_text_placeholder": "请在这里输入你的答案...", "preview_survey_question_open_text_placeholder": "请在这里输入你的答案...",
"preview_survey_question_open_text_subheader": "你的反馈能帮助我们改进。", "preview_survey_question_open_text_subheader": "你的反馈能帮助我们改进。",
"preview_survey_welcome_card_headline": "欢迎!", "preview_survey_welcome_card_headline": "欢迎!",
@@ -3307,7 +3342,7 @@
"workflows": { "workflows": {
"coming_soon_description": "感谢你与我们分享你的工作流想法!我们目前正在设计这个功能,你的反馈将帮助我们打造真正适合你的工具。", "coming_soon_description": "感谢你与我们分享你的工作流想法!我们目前正在设计这个功能,你的反馈将帮助我们打造真正适合你的工具。",
"coming_soon_title": "我们快完成啦!", "coming_soon_title": "我们快完成啦!",
"follow_up_label": "还有其他想补充的吗?", "follow_up_label": "还有其他想补充的内容吗?",
"follow_up_placeholder": "您希望自动化哪些具体任务?是否需要包含特定工具或集成?", "follow_up_placeholder": "您希望自动化哪些具体任务?是否需要包含特定工具或集成?",
"generate_button": "生成工作流", "generate_button": "生成工作流",
"heading": "你想创建什么样的工作流?", "heading": "你想创建什么样的工作流?",
+37 -2
View File
@@ -294,6 +294,7 @@
"new": "新增", "new": "新增",
"new_version_available": "Formbricks '{'version'}' 已推出。立即升級!", "new_version_available": "Formbricks '{'version'}' 已推出。立即升級!",
"next": "下一步", "next": "下一步",
"no_actions_found": "找不到動作",
"no_background_image_found": "找不到背景圖片。", "no_background_image_found": "找不到背景圖片。",
"no_code": "無程式碼", "no_code": "無程式碼",
"no_files_uploaded": "沒有上傳任何檔案", "no_files_uploaded": "沒有上傳任何檔案",
@@ -339,6 +340,7 @@
"please_select_at_least_one_survey": "請選擇至少一個問卷", "please_select_at_least_one_survey": "請選擇至少一個問卷",
"please_select_at_least_one_trigger": "請選擇至少一個觸發器", "please_select_at_least_one_trigger": "請選擇至少一個觸發器",
"please_upgrade_your_plan": "請升級您的方案", "please_upgrade_your_plan": "請升級您的方案",
"powered_by_formbricks": "由 Formbricks 提供技術支援",
"preview": "預覽", "preview": "預覽",
"preview_survey": "預覽問卷", "preview_survey": "預覽問卷",
"privacy": "隱私權政策", "privacy": "隱私權政策",
@@ -380,6 +382,7 @@
"select": "選擇", "select": "選擇",
"select_all": "全選", "select_all": "全選",
"select_filter": "選擇篩選器", "select_filter": "選擇篩選器",
"select_language": "選擇語言",
"select_survey": "選擇問卷", "select_survey": "選擇問卷",
"select_teams": "選擇 團隊", "select_teams": "選擇 團隊",
"selected": "已選取", "selected": "已選取",
@@ -850,9 +853,16 @@
"created_by_third_party": "由第三方建立", "created_by_third_party": "由第三方建立",
"discord_webhook_not_supported": "目前不支援 Discord webhooks。", "discord_webhook_not_supported": "目前不支援 Discord webhooks。",
"empty_webhook_message": "您的 Webhook 將在您新增後立即顯示在此處。⏲️", "empty_webhook_message": "您的 Webhook 將在您新增後立即顯示在此處。⏲️",
"endpoint_bad_gateway_error": "錯誤的閘道 (502):代理/閘道錯誤,服務無法連線",
"endpoint_gateway_timeout_error": "閘道逾時 (504):閘道逾時,服務無法連線",
"endpoint_internal_server_error": "內部伺服器錯誤 (500):服務遇到了未預期的錯誤",
"endpoint_method_not_allowed_error": "不允許的方法 (405):該端點存在,但不接受 POST 請求",
"endpoint_not_found_error": "找不到 (404):該端點不存在",
"endpoint_pinged": "耶!我們能夠 ping Webhook", "endpoint_pinged": "耶!我們能夠 ping Webhook",
"endpoint_pinged_error": "無法 ping Webhook", "endpoint_pinged_error": "無法 ping Webhook",
"endpoint_service_unavailable_error": "服務無法使用 (503):服務暫時無法使用",
"learn_to_verify": "了解如何驗證 webhook 簽章", "learn_to_verify": "了解如何驗證 webhook 簽章",
"no_triggers": "無觸發條件",
"please_check_console": "請檢查主控台以取得更多詳細資料", "please_check_console": "請檢查主控台以取得更多詳細資料",
"please_enter_a_url": "請輸入網址", "please_enter_a_url": "請輸入網址",
"response_created": "已建立回應", "response_created": "已建立回應",
@@ -1071,6 +1081,25 @@
"enterprise_features": "企業版功能", "enterprise_features": "企業版功能",
"get_an_enterprise_license_to_get_access_to_all_features": "取得企業授權以存取所有功能。", "get_an_enterprise_license_to_get_access_to_all_features": "取得企業授權以存取所有功能。",
"keep_full_control_over_your_data_privacy_and_security": "完全掌控您的資料隱私權和安全性。", "keep_full_control_over_your_data_privacy_and_security": "完全掌控您的資料隱私權和安全性。",
"license_feature_access_control": "存取控制 (RBAC)",
"license_feature_audit_logs": "稽核日誌",
"license_feature_contacts": "聯絡人與區隔",
"license_feature_projects": "工作區",
"license_feature_quotas": "配額",
"license_feature_remove_branding": "移除品牌標識",
"license_feature_saml": "SAML SSO",
"license_feature_spam_protection": "垃圾訊息防護",
"license_feature_sso": "OIDC SSO",
"license_feature_two_factor_auth": "雙重驗證",
"license_feature_whitelabel": "白標電子郵件",
"license_features_table_access": "存取權限",
"license_features_table_description": "此執行個體目前可使用的企業功能與限制。",
"license_features_table_disabled": "已停用",
"license_features_table_enabled": "已啟用",
"license_features_table_feature": "功能",
"license_features_table_title": "授權功能",
"license_features_table_unlimited": "無限制",
"license_features_table_value": "值",
"license_instance_mismatch_description": "此授權目前綁定至不同的 Formbricks 執行個體。如果此安裝已重建或移動,請聯繫 Formbricks 支援以解除先前執行個體的綁定。", "license_instance_mismatch_description": "此授權目前綁定至不同的 Formbricks 執行個體。如果此安裝已重建或移動,請聯繫 Formbricks 支援以解除先前執行個體的綁定。",
"license_invalid_description": "你在 ENTERPRISE_LICENSE_KEY 環境變數中填寫的授權金鑰無效。請檢查是否有輸入錯誤,或申請新的金鑰。", "license_invalid_description": "你在 ENTERPRISE_LICENSE_KEY 環境變數中填寫的授權金鑰無效。請檢查是否有輸入錯誤,或申請新的金鑰。",
"license_status": "授權狀態", "license_status": "授權狀態",
@@ -1430,6 +1459,7 @@
"error_saving_changes": "儲存變更時發生錯誤", "error_saving_changes": "儲存變更時發生錯誤",
"even_after_they_submitted_a_response_e_g_feedback_box": "允許多次回應;即使已提交回應仍繼續顯示(例如:意見回饋框)。", "even_after_they_submitted_a_response_e_g_feedback_box": "允許多次回應;即使已提交回應仍繼續顯示(例如:意見回饋框)。",
"everyone": "所有人", "everyone": "所有人",
"expand_preview": "展開預覽",
"external_urls_paywall_tooltip": "請升級至付費方案以自訂外部連結。這有助我們防止網路釣魚。", "external_urls_paywall_tooltip": "請升級至付費方案以自訂外部連結。這有助我們防止網路釣魚。",
"fallback_missing": "遺失的回退", "fallback_missing": "遺失的回退",
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'fieldId'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。", "fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'fieldId'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
@@ -1655,6 +1685,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "回應限制必須超過收到的回應數 ('{'responseCount'}')。", "response_limit_needs_to_exceed_number_of_received_responses": "回應限制必須超過收到的回應數 ('{'responseCount'}')。",
"response_limits_redirections_and_more": "回應限制、重新導向等。", "response_limits_redirections_and_more": "回應限制、重新導向等。",
"response_options": "回應選項", "response_options": "回應選項",
"reverse_order_occasionally": "偶爾反轉順序",
"reverse_order_occasionally_except_last": "偶爾反轉順序(最後一項除外)",
"roundness": "圓角", "roundness": "圓角",
"roundness_description": "調整邊角的圓潤程度。", "roundness_description": "調整邊角的圓潤程度。",
"row_used_in_logic_error": "此 row 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。", "row_used_in_logic_error": "此 row 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
@@ -1683,6 +1715,7 @@
"show_survey_maximum_of": "最多顯示問卷", "show_survey_maximum_of": "最多顯示問卷",
"show_survey_to_users": "將問卷顯示給 % 的使用者", "show_survey_to_users": "將問卷顯示給 % 的使用者",
"show_to_x_percentage_of_targeted_users": "顯示給 '{'percentage'}'% 的目標使用者", "show_to_x_percentage_of_targeted_users": "顯示給 '{'percentage'}'% 的目標使用者",
"shrink_preview": "收合預覽",
"simple": "簡單", "simple": "簡單",
"six_points": "6 分", "six_points": "6 分",
"smiley": "表情符號", "smiley": "表情符號",
@@ -1698,10 +1731,12 @@
"styling_set_to_theme_styles": "樣式設定為主題樣式", "styling_set_to_theme_styles": "樣式設定為主題樣式",
"subheading": "副標題", "subheading": "副標題",
"subtract": "減 -", "subtract": "減 -",
"survey_closed_message_heading_required": "請為自訂的問卷關閉訊息新增標題。",
"survey_completed_heading": "問卷已完成", "survey_completed_heading": "問卷已完成",
"survey_completed_subheading": "此免費且開源的問卷已關閉", "survey_completed_subheading": "此免費且開源的問卷已關閉",
"survey_display_settings": "問卷顯示設定", "survey_display_settings": "問卷顯示設定",
"survey_placement": "問卷位置", "survey_placement": "問卷位置",
"survey_preview": "問卷預覽 👀",
"survey_styling": "表單樣式設定", "survey_styling": "表單樣式設定",
"survey_trigger": "問卷觸發器", "survey_trigger": "問卷觸發器",
"switch_multi_language_on_to_get_started": "請開啟多語言功能以開始使用 👉", "switch_multi_language_on_to_get_started": "請開啟多語言功能以開始使用 👉",
@@ -3052,7 +3087,7 @@
"preview_survey_question_2_choice_2_label": "不用了,謝謝!", "preview_survey_question_2_choice_2_label": "不用了,謝謝!",
"preview_survey_question_2_headline": "想要緊跟最新動態嗎?", "preview_survey_question_2_headline": "想要緊跟最新動態嗎?",
"preview_survey_question_2_subheader": "這是一個範例說明。", "preview_survey_question_2_subheader": "這是一個範例說明。",
"preview_survey_question_open_text_headline": "還有什麼想和我們分享的嗎", "preview_survey_question_open_text_headline": "還有其他想分享的嗎?",
"preview_survey_question_open_text_placeholder": "在此輸入您的答案...", "preview_survey_question_open_text_placeholder": "在此輸入您的答案...",
"preview_survey_question_open_text_subheader": "您的回饋能幫助我們進步。", "preview_survey_question_open_text_subheader": "您的回饋能幫助我們進步。",
"preview_survey_welcome_card_headline": "歡迎!", "preview_survey_welcome_card_headline": "歡迎!",
@@ -3307,7 +3342,7 @@
"workflows": { "workflows": {
"coming_soon_description": "感謝你和我們分享你的工作流程想法!我們目前正在設計這個功能,你的回饋將幫助我們打造真正符合你需求的工具。", "coming_soon_description": "感謝你和我們分享你的工作流程想法!我們目前正在設計這個功能,你的回饋將幫助我們打造真正符合你需求的工具。",
"coming_soon_title": "快完成囉!", "coming_soon_title": "快完成囉!",
"follow_up_label": "還有什麼想補充的嗎", "follow_up_label": "還有其他想補充的嗎?",
"follow_up_placeholder": "您希望自動化哪些具體任務?有沒有想要整合的工具或功能?", "follow_up_placeholder": "您希望自動化哪些具體任務?有沒有想要整合的工具或功能?",
"generate_button": "產生工作流程", "generate_button": "產生工作流程",
"heading": "你想建立什麼樣的工作流程?", "heading": "你想建立什麼樣的工作流程?",
@@ -1,5 +1,6 @@
import { Languages } from "lucide-react"; import { Languages } from "lucide-react";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils"; import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types"; import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user"; import { TUserLocale } from "@formbricks/types/user";
import { getEnabledLanguages } from "@/lib/i18n/utils"; import { getEnabledLanguages } from "@/lib/i18n/utils";
@@ -17,7 +18,12 @@ interface LanguageDropdownProps {
locale: TUserLocale; locale: TUserLocale;
} }
export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdownProps) => { export const LanguageDropdown = ({
survey,
setLanguage,
locale,
}: LanguageDropdownProps) => {
const { t } = useTranslation();
const enabledLanguages = getEnabledLanguages(survey.languages ?? []); const enabledLanguages = getEnabledLanguages(survey.languages ?? []);
if (enabledLanguages.length <= 1) { if (enabledLanguages.length <= 1) {
@@ -27,7 +33,7 @@ export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdo
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="secondary" title="Select Language" aria-label="Select Language"> <Button variant="secondary" title={t("common.select_language")} aria-label={t("common.select_language")}>
<Languages className="h-5 w-5" /> <Languages className="h-5 w-5" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -217,7 +217,7 @@ describe("utils", () => {
}); });
describe("logApiError", () => { describe("logApiError", () => {
test("logs API error details", () => { test("logs API error details with method and path", () => {
// Mock the withContext method and its returned error method // Mock the withContext method and its returned error method
const errorMock = vi.fn(); const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({ const withContextMock = vi.fn().mockReturnValue({
@@ -228,7 +228,7 @@ describe("utils", () => {
const originalWithContext = logger.withContext; const originalWithContext = logger.withContext;
logger.withContext = withContextMock; logger.withContext = withContextMock;
const mockRequest = new Request("http://localhost/api/test"); const mockRequest = new Request("http://localhost/api/v2/management/surveys", { method: "POST" });
mockRequest.headers.set("x-request-id", "123"); mockRequest.headers.set("x-request-id", "123");
const error: ApiErrorResponseV2 = { const error: ApiErrorResponseV2 = {
@@ -238,9 +238,11 @@ describe("utils", () => {
logApiError(mockRequest, error); logApiError(mockRequest, error);
// Verify withContext was called with the expected context // Verify withContext was called with the expected context including method and path
expect(withContextMock).toHaveBeenCalledWith({ expect(withContextMock).toHaveBeenCalledWith({
correlationId: "123", correlationId: "123",
method: "POST",
path: "/api/v2/management/surveys",
error, error,
}); });
@@ -275,6 +277,8 @@ describe("utils", () => {
// Verify withContext was called with the expected context // Verify withContext was called with the expected context
expect(withContextMock).toHaveBeenCalledWith({ expect(withContextMock).toHaveBeenCalledWith({
correlationId: "", correlationId: "",
method: "GET",
path: "/api/test",
error, error,
}); });
@@ -285,7 +289,7 @@ describe("utils", () => {
logger.withContext = originalWithContext; logger.withContext = originalWithContext;
}); });
test("log API error details with SENTRY_DSN set", () => { test("log API error details with SENTRY_DSN set includes method and path tags", () => {
// Mock the withContext method and its returned error method // Mock the withContext method and its returned error method
const errorMock = vi.fn(); const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({ const withContextMock = vi.fn().mockReturnValue({
@@ -295,11 +299,23 @@ describe("utils", () => {
// Mock Sentry's captureException method // Mock Sentry's captureException method
vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any); vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any);
// Capture the scope mock for tag verification
const scopeSetTagMock = vi.fn();
vi.mocked(Sentry.withScope).mockImplementation((callback: (scope: any) => void) => {
const mockScope = {
setTag: scopeSetTagMock,
setContext: vi.fn(),
setLevel: vi.fn(),
setExtra: vi.fn(),
};
callback(mockScope);
});
// Replace the original withContext with our mock // Replace the original withContext with our mock
const originalWithContext = logger.withContext; const originalWithContext = logger.withContext;
logger.withContext = withContextMock; logger.withContext = withContextMock;
const mockRequest = new Request("http://localhost/api/test"); const mockRequest = new Request("http://localhost/api/v2/management/surveys", { method: "DELETE" });
mockRequest.headers.set("x-request-id", "123"); mockRequest.headers.set("x-request-id", "123");
const error: ApiErrorResponseV2 = { const error: ApiErrorResponseV2 = {
@@ -309,20 +325,60 @@ describe("utils", () => {
logApiError(mockRequest, error); logApiError(mockRequest, error);
// Verify withContext was called with the expected context // Verify withContext was called with the expected context including method and path
expect(withContextMock).toHaveBeenCalledWith({ expect(withContextMock).toHaveBeenCalledWith({
correlationId: "123", correlationId: "123",
method: "DELETE",
path: "/api/v2/management/surveys",
error, error,
}); });
// Verify error was called on the child logger // Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details"); expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Verify Sentry scope tags include method and path
expect(scopeSetTagMock).toHaveBeenCalledWith("correlationId", "123");
expect(scopeSetTagMock).toHaveBeenCalledWith("method", "DELETE");
expect(scopeSetTagMock).toHaveBeenCalledWith("path", "/api/v2/management/surveys");
// Verify Sentry.captureException was called // Verify Sentry.captureException was called
expect(Sentry.captureException).toHaveBeenCalled(); expect(Sentry.captureException).toHaveBeenCalled();
// Restore the original method // Restore the original method
logger.withContext = originalWithContext; logger.withContext = originalWithContext;
}); });
test("does not send to Sentry for non-internal_server_error types", () => {
// Mock the withContext method and its returned error method
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
error: errorMock,
});
vi.mocked(Sentry.captureException).mockClear();
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
const mockRequest = new Request("http://localhost/api/v2/management/surveys");
mockRequest.headers.set("x-request-id", "456");
const error: ApiErrorResponseV2 = {
type: "not_found",
details: [{ field: "survey", issue: "not found" }],
};
logApiError(mockRequest, error);
// Verify Sentry.captureException was NOT called for non-500 errors
expect(Sentry.captureException).not.toHaveBeenCalled();
// But structured logging should still happen
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Restore the original method
logger.withContext = originalWithContext;
});
}); });
}); });
+8 -1
View File
@@ -6,13 +6,18 @@ import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): void => { export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): void => {
const correlationId = request.headers.get("x-request-id") ?? ""; const correlationId = request.headers.get("x-request-id") ?? "";
const method = request.method;
const url = new URL(request.url);
const path = url.pathname;
// Send the error to Sentry if the DSN is set and the error type is internal_server_error // Send the error to Sentry if the DSN is set and the error type is internal_server_error
// This is useful for tracking down issues without overloading Sentry with errors // This is useful for tracking down issues without overloading Sentry with errors
if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") { if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") {
// Use Sentry scope to add correlation ID as a tag for easy filtering // Use Sentry scope to add correlation ID and request context as tags for easy filtering
Sentry.withScope((scope) => { Sentry.withScope((scope) => {
scope.setTag("correlationId", correlationId); scope.setTag("correlationId", correlationId);
scope.setTag("method", method);
scope.setTag("path", path);
scope.setLevel("error"); scope.setLevel("error");
scope.setExtra("originalError", error); scope.setExtra("originalError", error);
@@ -24,6 +29,8 @@ export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): vo
logger logger
.withContext({ .withContext({
correlationId, correlationId,
method,
path,
error, error,
}) })
.error("API V2 Error Details"); .error("API V2 Error Details");
@@ -84,7 +84,7 @@ describe("helpers", () => {
test("should allow request when rate limit check passes", async () => { test("should allow request when rate limit check passes", async () => {
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true })); (checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
await expect(applyRateLimit(mockConfig, mockIdentifier)).resolves.toBeUndefined(); await expect(applyRateLimit(mockConfig, mockIdentifier)).resolves.toEqual({ allowed: true });
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, mockIdentifier); expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, mockIdentifier);
}); });
@@ -127,7 +127,7 @@ describe("helpers", () => {
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true })); (checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
await expect(applyRateLimit(customConfig, "api-key-identifier")).resolves.toBeUndefined(); await expect(applyRateLimit(customConfig, "api-key-identifier")).resolves.toEqual({ allowed: true });
expect(checkRateLimit).toHaveBeenCalledWith(customConfig, "api-key-identifier"); expect(checkRateLimit).toHaveBeenCalledWith(customConfig, "api-key-identifier");
}); });
@@ -138,7 +138,7 @@ describe("helpers", () => {
const identifiers = ["user-123", "ip-192.168.1.1", "auth-login-hashedip", "api-key-abc123"]; const identifiers = ["user-123", "ip-192.168.1.1", "auth-login-hashedip", "api-key-abc123"];
for (const identifier of identifiers) { for (const identifier of identifiers) {
await expect(applyRateLimit(mockConfig, identifier)).resolves.toBeUndefined(); await expect(applyRateLimit(mockConfig, identifier)).resolves.toEqual({ allowed: true });
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, identifier); expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, identifier);
} }
@@ -161,7 +161,7 @@ describe("helpers", () => {
(hashString as any).mockReturnValue("hashed-ip-123"); (hashString as any).mockReturnValue("hashed-ip-123");
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true })); (checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
await expect(applyIPRateLimit(mockConfig)).resolves.toBeUndefined(); await expect(applyIPRateLimit(mockConfig)).resolves.toEqual({ allowed: true });
expect(getClientIpFromHeaders).toHaveBeenCalledTimes(1); expect(getClientIpFromHeaders).toHaveBeenCalledTimes(1);
expect(hashString).toHaveBeenCalledWith("192.168.1.1"); expect(hashString).toHaveBeenCalledWith("192.168.1.1");
+13 -5
View File
@@ -3,7 +3,7 @@ import { TooManyRequestsError } from "@formbricks/types/errors";
import { hashString } from "@/lib/hash-string"; import { hashString } from "@/lib/hash-string";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip"; import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { checkRateLimit } from "./rate-limit"; import { checkRateLimit } from "./rate-limit";
import { type TRateLimitConfig } from "./types/rate-limit"; import { type TRateLimitConfig, type TRateLimitResponse } from "./types/rate-limit";
/** /**
* Get client identifier for rate limiting with IP hashing * Get client identifier for rate limiting with IP hashing
@@ -31,12 +31,20 @@ export const getClientIdentifier = async (): Promise<string> => {
* @param identifier - Unique identifier for rate limiting (IP hash, user ID, API key, etc.) * @param identifier - Unique identifier for rate limiting (IP hash, user ID, API key, etc.)
* @throws {Error} When rate limit is exceeded or rate limiting system fails * @throws {Error} When rate limit is exceeded or rate limiting system fails
*/ */
export const applyRateLimit = async (config: TRateLimitConfig, identifier: string): Promise<void> => { export const applyRateLimit = async (
config: TRateLimitConfig,
identifier: string
): Promise<TRateLimitResponse> => {
const result = await checkRateLimit(config, identifier); const result = await checkRateLimit(config, identifier);
if (!result.ok || !result.data.allowed) { if (!result.ok || !result.data.allowed) {
throw new TooManyRequestsError("Maximum number of requests reached. Please try again later."); throw new TooManyRequestsError(
"Maximum number of requests reached. Please try again later.",
result.ok ? result.data.retryAfter : undefined
);
} }
return result.data;
}; };
/** /**
@@ -46,7 +54,7 @@ export const applyRateLimit = async (config: TRateLimitConfig, identifier: strin
* @param config - Rate limit configuration to apply * @param config - Rate limit configuration to apply
* @throws {Error} When rate limit is exceeded or IP hashing fails * @throws {Error} When rate limit is exceeded or IP hashing fails
*/ */
export const applyIPRateLimit = async (config: TRateLimitConfig): Promise<void> => { export const applyIPRateLimit = async (config: TRateLimitConfig): Promise<TRateLimitResponse> => {
const identifier = await getClientIdentifier(); const identifier = await getClientIdentifier();
await applyRateLimit(config, identifier); return await applyRateLimit(config, identifier);
}; };
@@ -68,7 +68,7 @@ describe("rateLimitConfigs", () => {
test("should have all API configurations", () => { test("should have all API configurations", () => {
const apiConfigs = Object.keys(rateLimitConfigs.api); const apiConfigs = Object.keys(rateLimitConfigs.api);
expect(apiConfigs).toEqual(["v1", "v2", "client"]); expect(apiConfigs).toEqual(["v1", "v2", "v3", "client"]);
}); });
test("should have all action configurations", () => { test("should have all action configurations", () => {
@@ -127,7 +127,7 @@ describe("rateLimitConfigs", () => {
mockEval.mockResolvedValue([1, 1]); mockEval.mockResolvedValue([1, 1]);
const config = rateLimitConfigs.api.v1; const config = rateLimitConfigs.api.v1;
await expect(applyRateLimit(config, "api-key-123")).resolves.toBeUndefined(); await expect(applyRateLimit(config, "api-key-123")).resolves.toEqual({ allowed: true });
}); });
test("should enforce limits correctly for each config type", async () => { test("should enforce limits correctly for each config type", async () => {
@@ -136,6 +136,7 @@ describe("rateLimitConfigs", () => {
{ config: rateLimitConfigs.auth.signup, identifier: "user-signup" }, { config: rateLimitConfigs.auth.signup, identifier: "user-signup" },
{ config: rateLimitConfigs.api.v1, identifier: "api-v1-key" }, { config: rateLimitConfigs.api.v1, identifier: "api-v1-key" },
{ config: rateLimitConfigs.api.v2, identifier: "api-v2-key" }, { config: rateLimitConfigs.api.v2, identifier: "api-v2-key" },
{ config: rateLimitConfigs.api.v3, identifier: "api-v3-key" },
{ config: rateLimitConfigs.api.client, identifier: "client-api-key" }, { config: rateLimitConfigs.api.client, identifier: "client-api-key" },
{ config: rateLimitConfigs.actions.emailUpdate, identifier: "user-profile" }, { config: rateLimitConfigs.actions.emailUpdate, identifier: "user-profile" },
{ config: rateLimitConfigs.storage.upload, identifier: "storage-upload" }, { config: rateLimitConfigs.storage.upload, identifier: "storage-upload" },
@@ -11,6 +11,7 @@ export const rateLimitConfigs = {
api: { api: {
v1: { interval: 60, allowedPerInterval: 100, namespace: "api:v1" }, // 100 per minute (Management API) v1: { interval: 60, allowedPerInterval: 100, namespace: "api:v1" }, // 100 per minute (Management API)
v2: { interval: 60, allowedPerInterval: 100, namespace: "api:v2" }, // 100 per minute v2: { interval: 60, allowedPerInterval: 100, namespace: "api:v2" }, // 100 per minute
v3: { interval: 60, allowedPerInterval: 100, namespace: "api:v3" }, // 100 per minute
client: { interval: 60, allowedPerInterval: 100, namespace: "api:client" }, // 100 per minute (Client API) client: { interval: 60, allowedPerInterval: 100, namespace: "api:client" }, // 100 per minute (Client API)
}, },
@@ -82,6 +82,7 @@ export const checkRateLimit = async (
const response: TRateLimitResponse = { const response: TRateLimitResponse = {
allowed: isAllowed === 1, allowed: isAllowed === 1,
retryAfter: isAllowed === 1 ? undefined : ttlSeconds,
}; };
// Log rate limit violations for security monitoring // Log rate limit violations for security monitoring
@@ -13,6 +13,7 @@ export type TRateLimitConfig = z.infer<typeof ZRateLimitConfig>;
const ZRateLimitResponse = z.object({ const ZRateLimitResponse = z.object({
allowed: z.boolean().describe("Whether the request is allowed"), allowed: z.boolean().describe("Whether the request is allowed"),
retryAfter: z.int().positive().optional().describe("Seconds until the current rate-limit window resets"),
}); });
export type TRateLimitResponse = z.infer<typeof ZRateLimitResponse>; export type TRateLimitResponse = z.infer<typeof ZRateLimitResponse>;
@@ -6,11 +6,11 @@ const bulkContactEndpoint: ZodOpenApiOperationObject = {
operationId: "uploadBulkContacts", operationId: "uploadBulkContacts",
summary: "Upload Bulk Contacts", summary: "Upload Bulk Contacts",
description: description:
"Uploads contacts in bulk. Each contact in the payload must have an 'email' attribute present in their attributes array. The email attribute is mandatory and must be a valid email format. Without a valid email, the contact will be skipped during processing.", "Uploads contacts in bulk. This endpoint expects the bulk request shape: `contacts` must be an array, and each contact item must contain an `attributes` array of `{ attributeKey, value }` objects. Unlike `POST /management/contacts`, this endpoint does not accept a top-level `attributes` object. Each contact must include an `email` attribute in its `attributes` array, and that email must be valid.",
requestBody: { requestBody: {
required: true, required: true,
description: description:
"The contacts to upload. Each contact must include an 'email' attribute in their attributes array. The email is used as the unique identifier for the contact.", "The contacts to upload. Use the full nested bulk body shown in the example or cURL snippet: `{ environmentId, contacts: [{ attributes: [{ attributeKey: { key, name }, value }] }] }`. Each contact must include an `email` attribute inside its `attributes` array.",
content: { content: {
"application/json": { "application/json": {
schema: ZContactBulkUploadRequest, schema: ZContactBulkUploadRequest,
@@ -6,13 +6,13 @@ export const createContactEndpoint: ZodOpenApiOperationObject = {
operationId: "createContact", operationId: "createContact",
summary: "Create a contact", summary: "Create a contact",
description: description:
"Creates a contact in the database. Each contact must have a valid email address in the attributes. All attribute keys must already exist in the environment. The email is used as the unique identifier along with the environment.", "Creates a single contact in the database. This endpoint expects a top-level `attributes` object. For bulk uploads, use `PUT /management/contacts/bulk`, which expects `contacts[].attributes[]` instead. Each contact must have a valid email address in the attributes. All attribute keys must already exist in the environment. The email is used as the unique identifier along with the environment.",
tags: ["Management API - Contacts"], tags: ["Management API - Contacts"],
requestBody: { requestBody: {
required: true, required: true,
description: description:
"The contact to create. Must include an email attribute and all attribute keys must already exist in the environment.", "The single contact to create. Must include a top-level `attributes` object with an email attribute, and all attribute keys must already exist in the environment.",
content: { content: {
"application/json": { "application/json": {
schema: ZContactCreateRequest, schema: ZContactCreateRequest,
@@ -30,16 +30,16 @@ export const ContactsSecondaryNavigation = async ({
label: t("common.contacts"), label: t("common.contacts"),
href: `/environments/${environmentId}/contacts`, href: `/environments/${environmentId}/contacts`,
}, },
{
id: "segments",
label: t("common.segments"),
href: `/environments/${environmentId}/segments`,
},
{ {
id: "attributes", id: "attributes",
label: t("common.attributes"), label: t("common.attributes"),
href: `/environments/${environmentId}/attributes`, href: `/environments/${environmentId}/attributes`,
}, },
{
id: "segments",
label: t("common.segments"),
href: `/environments/${environmentId}/segments`,
},
]; ];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />; return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
@@ -97,14 +97,13 @@ export const createSegmentAction = authenticatedActionClient.inputSchema(ZSegmen
); );
const ZUpdateSegmentAction = z.object({ const ZUpdateSegmentAction = z.object({
environmentId: ZId,
segmentId: ZId, segmentId: ZId,
data: ZSegmentUpdateInput, data: ZSegmentUpdateInput,
}); });
export const updateSegmentAction = authenticatedActionClient.inputSchema(ZUpdateSegmentAction).action( export const updateSegmentAction = authenticatedActionClient.inputSchema(ZUpdateSegmentAction).action(
withAuditLogging("updated", "segment", async ({ ctx, parsedInput }) => { withAuditLogging("updated", "segment", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId); const organizationId = await getOrganizationIdFromSegmentId(parsedInput.segmentId);
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
organizationId, organizationId,
@@ -75,7 +75,6 @@ export function SegmentSettings({
try { try {
setIsUpdatingSegment(true); setIsUpdatingSegment(true);
const data = await updateSegmentAction({ const data = await updateSegmentAction({
environmentId,
segmentId: segment.id, segmentId: segment.id,
data: { data: {
title: segment.title, title: segment.title,
@@ -134,6 +133,10 @@ export function SegmentSettings({
return true; return true;
} }
if (segment.filters.length === 0) {
return true;
}
// parse the filters to check if they are valid // parse the filters to check if they are valid
const parsedFilters = ZSegmentFilters.safeParse(segment.filters); const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
if (!parsedFilters.success) { if (!parsedFilters.success) {
@@ -124,7 +124,7 @@ export function TargetingCard({
}; };
const handleSaveAsNewSegmentUpdate = async (segmentId: string, data: TSegmentUpdateInput) => { const handleSaveAsNewSegmentUpdate = async (segmentId: string, data: TSegmentUpdateInput) => {
const updatedSegment = await updateSegmentAction({ segmentId, environmentId, data }); const updatedSegment = await updateSegmentAction({ segmentId, data });
return updatedSegment?.data as TSegment; return updatedSegment?.data as TSegment;
}; };
@@ -136,7 +136,7 @@ export function TargetingCard({
const handleSaveSegment = async (data: TSegmentUpdateInput) => { const handleSaveSegment = async (data: TSegmentUpdateInput) => {
try { try {
if (!segment) throw new Error(t("environments.segments.invalid_segment")); if (!segment) throw new Error(t("environments.segments.invalid_segment"));
const result = await updateSegmentAction({ segmentId: segment.id, environmentId, data }); const result = await updateSegmentAction({ segmentId: segment.id, data });
if (result?.serverError) { if (result?.serverError) {
toast.error(getFormattedErrorMessage(result)); toast.error(getFormattedErrorMessage(result));
return; return;
@@ -0,0 +1,73 @@
import { createId } from "@paralleldrive/cuid2";
import { describe, expect, test } from "vitest";
import { ZSegmentCreateInput, ZSegmentFilters, ZSegmentUpdateInput } from "@formbricks/types/segment";
const validFilters = [
{
id: createId(),
connector: null,
resource: {
id: createId(),
root: {
type: "attribute" as const,
contactAttributeKey: "email",
},
value: "user@example.com",
qualifier: {
operator: "equals" as const,
},
},
},
];
describe("segment schema validation", () => {
test("keeps base segment filters compatible with empty arrays", () => {
const result = ZSegmentFilters.safeParse([]);
expect(result.success).toBe(true);
});
test("requires at least one filter when creating a segment", () => {
const result = ZSegmentCreateInput.safeParse({
environmentId: "environmentId",
title: "Power users",
description: "Users with a matching email",
isPrivate: false,
filters: [],
surveyId: "surveyId",
});
expect(result.success).toBe(false);
expect(result.error?.issues[0]?.message).toBe("At least one filter is required");
});
test("accepts segment creation with a valid filter", () => {
const result = ZSegmentCreateInput.safeParse({
environmentId: "environmentId",
title: "Power users",
description: "Users with a matching email",
isPrivate: false,
filters: validFilters,
surveyId: "surveyId",
});
expect(result.success).toBe(true);
});
test("requires at least one filter when updating a segment", () => {
const result = ZSegmentUpdateInput.safeParse({
filters: [],
});
expect(result.success).toBe(false);
expect(result.error?.issues[0]?.message).toBe("At least one filter is required");
});
test("accepts segment updates with a valid filter", () => {
const result = ZSegmentUpdateInput.safeParse({
filters: validFilters,
});
expect(result.success).toBe(true);
});
});
+4 -5
View File
@@ -21,7 +21,6 @@ import { getOrganizationBilling } from "@/modules/survey/lib/survey";
const ZDeleteQuotaAction = z.object({ const ZDeleteQuotaAction = z.object({
quotaId: ZId, quotaId: ZId,
surveyId: ZId,
}); });
const checkQuotasEnabled = async (organizationId: string) => { const checkQuotasEnabled = async (organizationId: string) => {
@@ -37,7 +36,7 @@ const checkQuotasEnabled = async (organizationId: string) => {
export const deleteQuotaAction = authenticatedActionClient.inputSchema(ZDeleteQuotaAction).action( export const deleteQuotaAction = authenticatedActionClient.inputSchema(ZDeleteQuotaAction).action(
withAuditLogging("deleted", "quota", async ({ ctx, parsedInput }) => { withAuditLogging("deleted", "quota", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId); const organizationId = await getOrganizationIdFromQuotaId(parsedInput.quotaId);
await checkQuotasEnabled(organizationId); await checkQuotasEnabled(organizationId);
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -49,7 +48,7 @@ export const deleteQuotaAction = authenticatedActionClient.inputSchema(ZDeleteQu
}, },
{ {
type: "projectTeam", type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId), projectId: await getProjectIdFromQuotaId(parsedInput.quotaId),
minPermission: "readWrite", minPermission: "readWrite",
}, },
], ],
@@ -72,7 +71,7 @@ const ZUpdateQuotaAction = z.object({
export const updateQuotaAction = authenticatedActionClient.inputSchema(ZUpdateQuotaAction).action( export const updateQuotaAction = authenticatedActionClient.inputSchema(ZUpdateQuotaAction).action(
withAuditLogging("updated", "quota", async ({ ctx, parsedInput }) => { withAuditLogging("updated", "quota", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.quota.surveyId); const organizationId = await getOrganizationIdFromQuotaId(parsedInput.quotaId);
await checkQuotasEnabled(organizationId); await checkQuotasEnabled(organizationId);
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -84,7 +83,7 @@ export const updateQuotaAction = authenticatedActionClient.inputSchema(ZUpdateQu
}, },
{ {
type: "projectTeam", type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.quota.surveyId), projectId: await getProjectIdFromQuotaId(parsedInput.quotaId),
minPermission: "readWrite", minPermission: "readWrite",
}, },
], ],
@@ -85,7 +85,6 @@ export const QuotasCard = ({
setIsDeletingQuota(true); setIsDeletingQuota(true);
const deleteQuotaActionResult = await deleteQuotaAction({ const deleteQuotaActionResult = await deleteQuotaAction({
quotaId: quotaId, quotaId: quotaId,
surveyId: localSurvey.id,
}); });
if (deleteQuotaActionResult?.data) { if (deleteQuotaActionResult?.data) {
toast.success(t("environments.surveys.edit.quotas.quota_deleted_successfull_toast")); toast.success(t("environments.surveys.edit.quotas.quota_deleted_successfull_toast"));
@@ -10,6 +10,7 @@ import { getUserManagementAccess } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service"; import { getOrganization } from "@/lib/organization/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromInviteId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils"; import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { updateInvite } from "@/modules/ee/role-management/lib/invite"; import { updateInvite } from "@/modules/ee/role-management/lib/invite";
@@ -31,7 +32,6 @@ export const checkRoleManagementPermission = async (organizationId: string) => {
const ZUpdateInviteAction = z.object({ const ZUpdateInviteAction = z.object({
inviteId: ZUuid, inviteId: ZUuid,
organizationId: ZId,
data: ZInviteUpdateInput, data: ZInviteUpdateInput,
}); });
@@ -39,17 +39,16 @@ export type TUpdateInviteAction = z.infer<typeof ZUpdateInviteAction>;
export const updateInviteAction = authenticatedActionClient.inputSchema(ZUpdateInviteAction).action( export const updateInviteAction = authenticatedActionClient.inputSchema(ZUpdateInviteAction).action(
withAuditLogging("updated", "invite", async ({ ctx, parsedInput }) => { withAuditLogging("updated", "invite", async ({ ctx, parsedInput }) => {
const currentUserMembership = await getMembershipByUserIdOrganizationId( const organizationId = await getOrganizationIdFromInviteId(parsedInput.inviteId);
ctx.user.id,
parsedInput.organizationId const currentUserMembership = await getMembershipByUserIdOrganizationId(ctx.user.id, organizationId);
);
if (!currentUserMembership) { if (!currentUserMembership) {
throw new AuthenticationError("User not a member of this organization"); throw new AuthenticationError("User not a member of this organization");
} }
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
organizationId: parsedInput.organizationId, organizationId,
access: [ access: [
{ {
data: parsedInput.data, data: parsedInput.data,
@@ -68,9 +67,9 @@ export const updateInviteAction = authenticatedActionClient.inputSchema(ZUpdateI
throw new OperationNotAllowedError("Managers can only invite members"); throw new OperationNotAllowedError("Managers can only invite members");
} }
await checkRoleManagementPermission(parsedInput.organizationId); await checkRoleManagementPermission(organizationId);
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId; ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.inviteId = parsedInput.inviteId; ctx.auditLoggingCtx.inviteId = parsedInput.inviteId;
ctx.auditLoggingCtx.oldObject = { ...(await getInvite(parsedInput.inviteId)) }; ctx.auditLoggingCtx.oldObject = { ...(await getInvite(parsedInput.inviteId)) };
@@ -65,7 +65,7 @@ export function EditMembershipRole({
} }
if (inviteId) { if (inviteId) {
await updateInviteAction({ inviteId: inviteId, organizationId, data: { role } }); await updateInviteAction({ inviteId: inviteId, data: { role } });
} }
} catch (error) { } catch (error) {
toast.error(t("common.something_went_wrong_please_try_again")); toast.error(t("common.something_went_wrong_please_try_again"));
@@ -95,7 +95,7 @@ export async function PreviewEmailTemplate({
<EmailTemplateWrapper styling={styling} surveyUrl={url}> <EmailTemplateWrapper styling={styling} surveyUrl={url}>
<ElementHeader headline={headline} subheader={subheader} className="mr-8" /> <ElementHeader headline={headline} subheader={subheader} className="mr-8" />
<Section className="border-input-border-color rounded-custom mt-4 block h-20 w-full border border-solid bg-slate-50" /> <Section className="border-input-border-color rounded-custom mt-4 block h-20 w-full border border-solid bg-slate-50" />
<EmailFooter /> <EmailFooter t={t} />
</EmailTemplateWrapper> </EmailTemplateWrapper>
); );
case TSurveyElementTypeEnum.Consent: case TSurveyElementTypeEnum.Consent:
@@ -124,7 +124,7 @@ export async function PreviewEmailTemplate({
{t("emails.accept")} {t("emails.accept")}
</EmailButton> </EmailButton>
</Container> </Container>
<EmailFooter /> <EmailFooter t={t} />
</EmailTemplateWrapper> </EmailTemplateWrapper>
); );
case TSurveyElementTypeEnum.NPS: case TSurveyElementTypeEnum.NPS:
@@ -172,7 +172,7 @@ export async function PreviewEmailTemplate({
</Row> </Row>
</Section> </Section>
</Container> </Container>
<EmailFooter /> <EmailFooter t={t} />
</Section> </Section>
</EmailTemplateWrapper> </EmailTemplateWrapper>
); );
@@ -193,7 +193,7 @@ export async function PreviewEmailTemplate({
</EmailButton> </EmailButton>
</Container> </Container>
)} )}
<EmailFooter /> <EmailFooter t={t} />
</EmailTemplateWrapper> </EmailTemplateWrapper>
); );
} }
@@ -246,7 +246,7 @@ export async function PreviewEmailTemplate({
</Row> </Row>
</Section> </Section>
</Container> </Container>
<EmailFooter /> <EmailFooter t={t} />
</Section> </Section>
</EmailTemplateWrapper> </EmailTemplateWrapper>
); );
@@ -263,7 +263,7 @@ export async function PreviewEmailTemplate({
</Section> </Section>
))} ))}
</Container> </Container>
<EmailFooter /> <EmailFooter t={t} />
</EmailTemplateWrapper> </EmailTemplateWrapper>
); );
case TSurveyElementTypeEnum.Ranking: case TSurveyElementTypeEnum.Ranking:
@@ -279,7 +279,7 @@ export async function PreviewEmailTemplate({
</Section> </Section>
))} ))}
</Container> </Container>
<EmailFooter /> <EmailFooter t={t} />
</EmailTemplateWrapper> </EmailTemplateWrapper>
); );
case TSurveyElementTypeEnum.MultipleChoiceSingle: case TSurveyElementTypeEnum.MultipleChoiceSingle:
@@ -296,7 +296,7 @@ export async function PreviewEmailTemplate({
</Link> </Link>
))} ))}
</Container> </Container>
<EmailFooter /> <EmailFooter t={t} />
</EmailTemplateWrapper> </EmailTemplateWrapper>
); );
case TSurveyElementTypeEnum.PictureSelection: case TSurveyElementTypeEnum.PictureSelection:
@@ -322,7 +322,7 @@ export async function PreviewEmailTemplate({
) )
)} )}
</Section> </Section>
<EmailFooter /> <EmailFooter t={t} />
</EmailTemplateWrapper> </EmailTemplateWrapper>
); );
case TSurveyElementTypeEnum.Cal: case TSurveyElementTypeEnum.Cal:
@@ -338,7 +338,7 @@ export async function PreviewEmailTemplate({
{t("emails.schedule_your_meeting")} {t("emails.schedule_your_meeting")}
</EmailButton> </EmailButton>
</Container> </Container>
<EmailFooter /> <EmailFooter t={t} />
</EmailTemplateWrapper> </EmailTemplateWrapper>
); );
case TSurveyElementTypeEnum.Date: case TSurveyElementTypeEnum.Date:
@@ -351,7 +351,7 @@ export async function PreviewEmailTemplate({
{t("emails.select_a_date")} {t("emails.select_a_date")}
</Text> </Text>
</Section> </Section>
<EmailFooter /> <EmailFooter t={t} />
</EmailTemplateWrapper> </EmailTemplateWrapper>
); );
case TSurveyElementTypeEnum.Matrix: case TSurveyElementTypeEnum.Matrix:
@@ -392,7 +392,7 @@ export async function PreviewEmailTemplate({
})} })}
</Section> </Section>
</Container> </Container>
<EmailFooter /> <EmailFooter t={t} />
</EmailTemplateWrapper> </EmailTemplateWrapper>
); );
case TSurveyElementTypeEnum.Address: case TSurveyElementTypeEnum.Address:
@@ -407,7 +407,7 @@ export async function PreviewEmailTemplate({
{label} {label}
</Section> </Section>
))} ))}
<EmailFooter /> <EmailFooter t={t} />
</EmailTemplateWrapper> </EmailTemplateWrapper>
); );
@@ -421,7 +421,7 @@ export async function PreviewEmailTemplate({
<Text className="text-slate-400">{t("emails.click_or_drag_to_upload_files")}</Text> <Text className="text-slate-400">{t("emails.click_or_drag_to_upload_files")}</Text>
</Container> </Container>
</Section> </Section>
<EmailFooter /> <EmailFooter t={t} />
</EmailTemplateWrapper> </EmailTemplateWrapper>
); );
} }
@@ -477,11 +477,11 @@ function EmailTemplateWrapper({
); );
} }
function EmailFooter(): React.JSX.Element { function EmailFooter({ t }: { t: TFunction }): React.JSX.Element {
return ( return (
<Container className="m-auto mt-8 text-center"> <Container className="m-auto mt-8 text-center">
<Link className="text-signature-color text-xs" href="https://formbricks.com/" target="_blank"> <Link className="text-signature-color text-xs" href="https://formbricks.com/" target="_blank">
Powered by Formbricks {t("common.powered_by_formbricks")}
</Link> </Link>
</Container> </Container>
); );
@@ -85,9 +85,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
const errMessage = err instanceof Error ? err.message : "Unknown error occurred"; const errMessage = err instanceof Error ? err.message : "Unknown error occurred";
toast.error( toast.error(
`${t("environments.integrations.webhooks.endpoint_pinged_error")} \n ${ `${t("environments.integrations.webhooks.endpoint_pinged_error")} \n ${
errMessage.length < 250 errMessage.length < 250 ? errMessage : t("environments.integrations.webhooks.please_check_console")
? `${t("common.error")}: ${errMessage}`
: t("environments.integrations.webhooks.please_check_console")
}`, }`,
{ className: errMessage.length < 250 ? "break-all" : "" } { className: errMessage.length < 250 ? "break-all" : "" }
); );
@@ -9,21 +9,33 @@ import { timeSince } from "@/lib/time";
import { Badge } from "@/modules/ui/components/badge"; import { Badge } from "@/modules/ui/components/badge";
const renderSelectedSurveysText = (webhook: Webhook, allSurveys: TSurvey[]) => { const renderSelectedSurveysText = (webhook: Webhook, allSurveys: TSurvey[]) => {
let surveyNames: string[];
if (webhook.surveyIds.length === 0) { if (webhook.surveyIds.length === 0) {
const allSurveyNames = allSurveys.map((survey) => survey.name); surveyNames = allSurveys.map((survey) => survey.name);
return <p className="text-slate-400">{allSurveyNames.join(", ")}</p>;
} else { } else {
const selectedSurveyNames = webhook.surveyIds.map((surveyId) => { surveyNames = webhook.surveyIds
const survey = allSurveys.find((survey) => survey.id === surveyId); .map((surveyId) => {
return survey ? survey.name : ""; const survey = allSurveys.find((s) => s.id === surveyId);
}); return survey ? survey.name : "";
return <p className="text-slate-400">{selectedSurveyNames.join(", ")}</p>; })
.filter(Boolean);
} }
if (surveyNames.length === 0) {
return <p className="text-slate-400">-</p>;
}
return (
<p className="truncate text-slate-400" title={surveyNames.join(", ")}>
{surveyNames.join(", ")}
</p>
);
}; };
const renderSelectedTriggersText = (webhook: Webhook, t: TFunction) => { const renderSelectedTriggersText = (webhook: Webhook, t: TFunction) => {
if (webhook.triggers.length === 0) { if (webhook.triggers.length === 0) {
return <p className="text-slate-400">No Triggers</p>; return <p className="text-slate-400">{t("environments.integrations.webhooks.no_triggers")}</p>;
} else { } else {
let cleanedTriggers = webhook.triggers.map((trigger) => { let cleanedTriggers = webhook.triggers.map((trigger) => {
if (trigger === "responseCreated") { if (trigger === "responseCreated") {
@@ -82,7 +82,7 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
setHittingEndpoint(false); setHittingEndpoint(false);
const errMessage = err instanceof Error ? err.message : "Unknown error occurred"; const errMessage = err instanceof Error ? err.message : "Unknown error occurred";
toast.error( toast.error(
`${t("environments.integrations.webhooks.endpoint_pinged_error")} \n ${errMessage.length < 250 ? `${t("common.error")}: ${errMessage}` : t("environments.integrations.webhooks.please_check_console")}`, `${t("environments.integrations.webhooks.endpoint_pinged_error")} \n ${errMessage.length < 250 ? errMessage : t("environments.integrations.webhooks.please_check_console")}`,
{ className: errMessage.length < 250 ? "break-all" : "" } { className: errMessage.length < 250 ? "break-all" : "" }
); );
console.error(t("environments.integrations.webhooks.webhook_test_failed_due_to"), errMessage); console.error(t("environments.integrations.webhooks.webhook_test_failed_due_to"), errMessage);
@@ -300,7 +300,9 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
)} )}
<Button variant="secondary" asChild> <Button variant="secondary" asChild>
<Link href="https://formbricks.com/docs/api/management/webhooks" target="_blank"> <Link
href="https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks"
target="_blank">
{t("common.read_docs")} {t("common.read_docs")}
</Link> </Link>
</Button> </Button>
@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { InvalidInputError } from "@formbricks/types/errors";
import { generateStandardWebhookSignature } from "@/lib/crypto";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { getTranslate } from "@/lingodotdev/server";
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
import { testEndpoint } from "./webhook";
vi.mock("@formbricks/database", () => ({
prisma: {
webhook: {
create: vi.fn(),
delete: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/crypto", () => ({
generateStandardWebhookSignature: vi.fn(() => "signed-payload"),
generateWebhookSecret: vi.fn(() => "generated-secret"),
}));
vi.mock("@/lib/utils/validate-webhook-url", () => ({
validateWebhookUrl: vi.fn(async () => undefined),
}));
vi.mock("@/lingodotdev/server", () => ({
getTranslate: vi.fn(async () => (key: string) => key),
}));
vi.mock("@/modules/integrations/webhooks/lib/utils", () => ({
isDiscordWebhook: vi.fn(() => false),
}));
vi.mock("uuid", () => ({
v7: vi.fn(() => "webhook-message-id"),
}));
describe("testEndpoint", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(generateStandardWebhookSignature).mockReturnValue("signed-payload");
vi.mocked(validateWebhookUrl).mockResolvedValue(undefined);
vi.mocked(getTranslate).mockResolvedValue((key: string) => key);
vi.mocked(isDiscordWebhook).mockReturnValue(false);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
test.each([
[500, "environments.integrations.webhooks.endpoint_internal_server_error"],
[404, "environments.integrations.webhooks.endpoint_not_found_error"],
[405, "environments.integrations.webhooks.endpoint_method_not_allowed_error"],
[502, "environments.integrations.webhooks.endpoint_bad_gateway_error"],
[503, "environments.integrations.webhooks.endpoint_service_unavailable_error"],
[504, "environments.integrations.webhooks.endpoint_gateway_timeout_error"],
])("throws a translated InvalidInputError for blocked status %s", async (statusCode, messageKey) => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
status: statusCode,
}))
);
await expect(testEndpoint("https://example.com/webhook", "secret")).rejects.toThrow(
new InvalidInputError(messageKey)
);
expect(validateWebhookUrl).toHaveBeenCalledWith("https://example.com/webhook");
expect(generateStandardWebhookSignature).toHaveBeenCalled();
expect(getTranslate).toHaveBeenCalled();
});
test("allows non-blocked non-2xx statuses", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
status: 418,
}))
);
await expect(testEndpoint("https://example.com/webhook")).resolves.toBe(true);
expect(getTranslate).not.toHaveBeenCalled();
});
test("rejects Discord webhooks before sending the request", async () => {
vi.mocked(isDiscordWebhook).mockReturnValue(true);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(testEndpoint("https://discord.com/api/webhooks/123")).rejects.toThrow(
"Discord webhooks are currently not supported."
);
expect(fetchMock).not.toHaveBeenCalled();
});
test("throws a timeout error when the request is aborted", async () => {
vi.useFakeTimers();
vi.stubGlobal(
"fetch",
vi.fn((_url, init) => {
const signal = init?.signal as AbortSignal;
return new Promise((_, reject) => {
signal.addEventListener("abort", () => {
const abortError = new Error("The operation was aborted");
abortError.name = "AbortError";
reject(abortError);
});
});
})
);
const requestPromise = testEndpoint("https://example.com/webhook");
const assertion = expect(requestPromise).rejects.toThrow("Request timed out after 5 seconds");
await vi.advanceTimersByTimeAsync(5000);
await assertion;
});
test("wraps unexpected fetch errors", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => Promise.reject(new Error("socket hang up")))
);
await expect(testEndpoint("https://example.com/webhook")).rejects.toThrow(
"Error while fetching the URL: socket hang up"
);
});
});
@@ -12,9 +12,41 @@ import {
import { generateStandardWebhookSignature, generateWebhookSecret } from "@/lib/crypto"; import { generateStandardWebhookSignature, generateWebhookSecret } from "@/lib/crypto";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url"; import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { getTranslate } from "@/lingodotdev/server";
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils"; import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
import { TWebhookInput } from "../types/webhooks"; import { TWebhookInput } from "../types/webhooks";
const getWebhookTestErrorMessage = async (statusCode: number): Promise<string | null> => {
switch (statusCode) {
case 500: {
const t = await getTranslate();
return t("environments.integrations.webhooks.endpoint_internal_server_error");
}
case 404: {
const t = await getTranslate();
return t("environments.integrations.webhooks.endpoint_not_found_error");
}
case 405: {
const t = await getTranslate();
return t("environments.integrations.webhooks.endpoint_method_not_allowed_error");
}
case 502: {
const t = await getTranslate();
return t("environments.integrations.webhooks.endpoint_bad_gateway_error");
}
case 503: {
const t = await getTranslate();
return t("environments.integrations.webhooks.endpoint_service_unavailable_error");
}
case 504: {
const t = await getTranslate();
return t("environments.integrations.webhooks.endpoint_gateway_timeout_error");
}
default:
return null;
}
};
export const updateWebhook = async ( export const updateWebhook = async (
webhookId: string, webhookId: string,
webhookInput: Partial<TWebhookInput> webhookInput: Partial<TWebhookInput>
@@ -132,14 +164,14 @@ export const getWebhooks = async (environmentId: string): Promise<Webhook[]> =>
export const testEndpoint = async (url: string, secret?: string): Promise<boolean> => { export const testEndpoint = async (url: string, secret?: string): Promise<boolean> => {
await validateWebhookUrl(url); await validateWebhookUrl(url);
if (isDiscordWebhook(url)) {
throw new UnknownError("Discord webhooks are currently not supported.");
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try { try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
if (isDiscordWebhook(url)) {
throw new UnknownError("Discord webhooks are currently not supported.");
}
const webhookMessageId = uuidv7(); const webhookMessageId = uuidv7();
const webhookTimestamp = Math.floor(Date.now() / 1000); const webhookTimestamp = Math.floor(Date.now() / 1000);
const body = JSON.stringify({ event: "testEndpoint" }); const body = JSON.stringify({ event: "testEndpoint" });
@@ -165,27 +197,27 @@ export const testEndpoint = async (url: string, secret?: string): Promise<boolea
headers: requestHeaders, headers: requestHeaders,
signal: controller.signal, signal: controller.signal,
}); });
clearTimeout(timeout);
const statusCode = response.status; const statusCode = response.status;
const errorMessage = await getWebhookTestErrorMessage(statusCode);
if (statusCode >= 200 && statusCode < 300) { if (errorMessage) {
return true; throw new InvalidInputError(errorMessage);
} else {
const errorMessage = await response.text().then(
(text) => text.substring(0, 1000) // Limit error message size
);
throw new UnknownError(`Request failed with status code ${statusCode}: ${errorMessage}`);
} }
return true;
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === "AbortError") { if (error instanceof Error && error.name === "AbortError") {
throw new UnknownError("Request timed out after 5 seconds"); throw new UnknownError("Request timed out after 5 seconds");
} }
if (error instanceof UnknownError) {
if (error instanceof InvalidInputError || error instanceof UnknownError) {
throw error; throw error;
} }
throw new UnknownError( throw new UnknownError(
`Error while fetching the URL: ${error instanceof Error ? error.message : "Unknown error occurred"}` `Error while fetching the URL: ${error instanceof Error ? error.message : "Unknown error occurred"}`
); );
} finally {
clearTimeout(timeout);
} }
}; };
@@ -27,14 +27,15 @@ import { deleteInvite, getInvite, inviteUser, refreshInviteExpiration, resendInv
const ZDeleteInviteAction = z.object({ const ZDeleteInviteAction = z.object({
inviteId: ZUuid, inviteId: ZUuid,
organizationId: ZId,
}); });
export const deleteInviteAction = authenticatedActionClient.inputSchema(ZDeleteInviteAction).action( export const deleteInviteAction = authenticatedActionClient.inputSchema(ZDeleteInviteAction).action(
withAuditLogging("deleted", "invite", async ({ ctx, parsedInput }) => { withAuditLogging("deleted", "invite", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromInviteId(parsedInput.inviteId);
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
organizationId: parsedInput.organizationId, organizationId,
access: [ access: [
{ {
type: "organization", type: "organization",
@@ -42,7 +43,7 @@ export const deleteInviteAction = authenticatedActionClient.inputSchema(ZDeleteI
}, },
], ],
}); });
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId; ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.inviteId = parsedInput.inviteId; ctx.auditLoggingCtx.inviteId = parsedInput.inviteId;
ctx.auditLoggingCtx.oldObject = { ...(await getInvite(parsedInput.inviteId)) }; ctx.auditLoggingCtx.oldObject = { ...(await getInvite(parsedInput.inviteId)) };
return await deleteInvite(parsedInput.inviteId); return await deleteInvite(parsedInput.inviteId);
@@ -41,7 +41,7 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
if (!member && invite) { if (!member && invite) {
// This is an invite // This is an invite
const result = await deleteInviteAction({ inviteId: invite?.id, organizationId: organization.id }); const result = await deleteInviteAction({ inviteId: invite?.id });
if (result?.serverError) { if (result?.serverError) {
toast.error(getFormattedErrorMessage(result)); toast.error(getFormattedErrorMessage(result));
setIsDeleting(false); setIsDeleting(false);
@@ -89,7 +89,7 @@ export const InviteMemberModal = ({
<DialogDescription>{t("environments.settings.teams.invite_member_description")}</DialogDescription> <DialogDescription>{t("environments.settings.teams.invite_member_description")}</DialogDescription>
</DialogHeader> </DialogHeader>
<DialogBody className="flex flex-col gap-6" unconstrained> <DialogBody className="flex min-h-0 flex-col gap-6 overflow-y-auto">
{!showTeamAdminRestrictions && ( {!showTeamAdminRestrictions && (
<TabToggle <TabToggle
id="type" id="type"
@@ -1,5 +1,6 @@
"use client"; "use client";
import { useTranslation } from "react-i18next";
import { type JSX, useState } from "react"; import { type JSX, useState } from "react";
import { TActionClass } from "@formbricks/types/action-classes"; import { TActionClass } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
@@ -24,6 +25,7 @@ export const ActionClassesTable = ({
otherEnvActionClasses, otherEnvActionClasses,
otherEnvironment, otherEnvironment,
}: ActionClassesTableProps) => { }: ActionClassesTableProps) => {
const { t } = useTranslation();
const [isActionDetailModalOpen, setIsActionDetailModalOpen] = useState(false); const [isActionDetailModalOpen, setIsActionDetailModalOpen] = useState(false);
const [activeActionClass, setActiveActionClass] = useState<TActionClass>(); const [activeActionClass, setActiveActionClass] = useState<TActionClass>();
@@ -56,7 +58,7 @@ export const ActionClassesTable = ({
)) ))
) : ( ) : (
<div className="py-8 text-center"> <div className="py-8 text-center">
<span className="text-sm text-slate-500">No actions found</span> <span className="text-sm text-slate-500">{t("common.no_actions_found")}</span>
</div> </div>
)} )}
</div> </div>
@@ -38,6 +38,7 @@ export const ActionSettingsTab = ({
setOpen, setOpen,
isReadOnly, isReadOnly,
}: ActionSettingsTabProps) => { }: ActionSettingsTabProps) => {
const actionDocsHref = "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions";
const { createdAt, updatedAt, id, ...restActionClass } = actionClass; const { createdAt, updatedAt, id, ...restActionClass } = actionClass;
const router = useRouter(); const router = useRouter();
const [openDeleteDialog, setOpenDeleteDialog] = useState(false); const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
@@ -146,7 +147,7 @@ export const ActionSettingsTab = ({
<div className="flex justify-between gap-x-2 border-slate-200 pt-4"> <div className="flex justify-between gap-x-2 border-slate-200 pt-4">
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-2">
{!isReadOnly ? ( {isReadOnly ? null : (
<Button <Button
type="button" type="button"
variant="destructive" variant="destructive"
@@ -155,22 +156,22 @@ export const ActionSettingsTab = ({
<TrashIcon /> <TrashIcon />
{t("common.delete")} {t("common.delete")}
</Button> </Button>
) : null} )}
<Button variant="secondary" asChild> <Button variant="secondary" asChild>
<Link href="https://formbricks.com/docs/actions/no-code" target="_blank"> <Link href={actionDocsHref} target="_blank">
{t("common.read_docs")} {t("common.read_docs")}
</Link> </Link>
</Button> </Button>
</div> </div>
{!isReadOnly ? ( {isReadOnly ? null : (
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button type="submit" loading={isUpdatingAction}> <Button type="submit" loading={isUpdatingAction}>
{t("common.save_changes")} {t("common.save_changes")}
</Button> </Button>
</div> </div>
) : null} )}
</div> </div>
</form> </form>
</FormProvider> </FormProvider>
+32 -17
View File
@@ -26,22 +26,27 @@ import { getOrganizationBilling, getSurvey } from "@/modules/survey/lib/survey";
import { getProject } from "./lib/project"; import { getProject } from "./lib/project";
/** /**
* Checks if survey follow-ups are enabled for the given organization. * Checks if survey follow-ups can be added for the given organization.
* * Grandfathers existing follow-ups (allows keeping them even if the org lost access).
* @param { string } organizationId The ID of the organization to check. * Only throws when new follow-ups are being added.
* @returns { Promise<void> } A promise that resolves if the permission is granted.
* @throws { ResourceNotFoundError } If the organization is not found.
* @throws { OperationNotAllowedError } If survey follow-ups are not enabled for the organization.
*/ */
const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<void> => { const checkSurveyFollowUpsPermission = async (
organizationId: string,
newFollowUpIds: string[],
oldFollowUpIds: Set<string>
): Promise<void> => {
const organizationBilling = await getOrganizationBilling(organizationId); const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) { if (!organizationBilling) {
throw new ResourceNotFoundError("Organization", organizationId); throw new ResourceNotFoundError("Organization", organizationId);
} }
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationId); const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationId);
if (!isSurveyFollowUpsEnabled) { if (isSurveyFollowUpsEnabled) return;
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
for (const id of newFollowUpIds) {
if (!oldFollowUpIds.has(id)) {
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
}
} }
}; };
@@ -71,14 +76,19 @@ export const updateSurveyDraftAction = authenticatedActionClient.inputSchema(ZSu
await checkSpamProtectionPermission(organizationId); await checkSpamProtectionPermission(organizationId);
} }
if (survey.followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
ctx.auditLoggingCtx.organizationId = organizationId; ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = survey.id; ctx.auditLoggingCtx.surveyId = survey.id;
const oldObject = await getSurvey(survey.id); const oldObject = await getSurvey(survey.id);
if (survey.followUps.length) {
const oldFollowUpIds = new Set((oldObject?.followUps ?? []).map((f) => f.id));
await checkSurveyFollowUpsPermission(
organizationId,
survey.followUps.map((f) => f.id),
oldFollowUpIds
);
}
await checkExternalUrlsPermission(organizationId, survey, oldObject); await checkExternalUrlsPermission(organizationId, survey, oldObject);
// Use the draft version that skips validation // Use the draft version that skips validation
@@ -116,14 +126,19 @@ export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey)
await checkSpamProtectionPermission(organizationId); await checkSpamProtectionPermission(organizationId);
} }
if (parsedInput.followUps?.length) {
await checkSurveyFollowUpsPermission(organizationId);
}
ctx.auditLoggingCtx.organizationId = organizationId; ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.id; ctx.auditLoggingCtx.surveyId = parsedInput.id;
const oldObject = await getSurvey(parsedInput.id); const oldObject = await getSurvey(parsedInput.id);
if (parsedInput.followUps?.length) {
const oldFollowUpIds = new Set((oldObject?.followUps ?? []).map((f) => f.id));
await checkSurveyFollowUpsPermission(
organizationId,
parsedInput.followUps.map((f) => f.id),
oldFollowUpIds
);
}
// Check external URLs permission (with grandfathering) // Check external URLs permission (with grandfathering)
await checkExternalUrlsPermission(organizationId, parsedInput, oldObject); await checkExternalUrlsPermission(organizationId, parsedInput, oldObject);
const result = await updateSurvey(parsedInput); const result = await updateSurvey(parsedInput);
@@ -129,8 +129,10 @@ export const EditWelcomeCard = ({
allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]} allowedFileExtensions={["png", "jpeg", "jpg", "webp", "heic"]}
environmentId={environmentId} environmentId={environmentId}
onFileUpload={(url: string[] | undefined, _fileType: "image" | "video") => { onFileUpload={(url: string[] | undefined, _fileType: "image" | "video") => {
if (url?.[0]) { if (url?.length) {
updateSurvey({ fileUrl: url[0] }); updateSurvey({ fileUrl: url[0] });
} else {
updateSurvey({ fileUrl: undefined });
} }
}} }}
fileUrl={localSurvey?.welcomeCard?.fileUrl} fileUrl={localSurvey?.welcomeCard?.fileUrl}
@@ -188,6 +188,16 @@ export const MatrixElementForm = ({
label: t("environments.surveys.edit.randomize_all_except_last"), label: t("environments.surveys.edit.randomize_all_except_last"),
show: true, show: true,
}, },
reverseOrderOccasionally: {
id: "reverseOrderOccasionally",
label: t("environments.surveys.edit.reverse_order_occasionally"),
show: true,
},
reverseOrderExceptLast: {
id: "reverseOrderExceptLast",
label: t("environments.surveys.edit.reverse_order_occasionally_except_last"),
show: true,
},
}; };
const [parent] = useAutoAnimate(); const [parent] = useAutoAnimate();
@@ -63,7 +63,7 @@ export const MultipleChoiceElementForm = ({
const elementRef = useRef<HTMLInputElement>(null); const elementRef = useRef<HTMLInputElement>(null);
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages); const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
const surveyLanguages = localSurvey.languages ?? []; const surveyLanguages = localSurvey.languages ?? [];
const shuffleOptionsTypes = { const shuffleOptionsTypes: Record<TShuffleOption, { id: TShuffleOption; label: string; show: boolean }> = {
none: { none: {
id: "none", id: "none",
label: t("environments.surveys.edit.keep_current_order"), label: t("environments.surveys.edit.keep_current_order"),
@@ -79,6 +79,16 @@ export const MultipleChoiceElementForm = ({
label: t("environments.surveys.edit.randomize_all_except_last"), label: t("environments.surveys.edit.randomize_all_except_last"),
show: true, show: true,
}, },
reverseOrderOccasionally: {
id: "reverseOrderOccasionally",
label: t("environments.surveys.edit.reverse_order_occasionally"),
show: element.choices.every((c) => c.id !== "other" && c.id !== "none"),
},
reverseOrderExceptLast: {
id: "reverseOrderExceptLast",
label: t("environments.surveys.edit.reverse_order_occasionally_except_last"),
show: true,
},
}; };
const multipleChoiceOptionDisplayTypeOptions = [ const multipleChoiceOptionDisplayTypeOptions = [
@@ -167,7 +177,10 @@ export const MultipleChoiceElementForm = ({
updateElement(elementIdx, { updateElement(elementIdx, {
choices: newChoices, choices: newChoices,
...(element.shuffleOption === shuffleOptionsTypes.all.id && { ...(element.shuffleOption === shuffleOptionsTypes.all.id && {
shuffleOption: shuffleOptionsTypes.exceptLast.id as TShuffleOption, shuffleOption: shuffleOptionsTypes.exceptLast.id,
}),
...(element.shuffleOption === shuffleOptionsTypes.reverseOrderOccasionally.id && {
shuffleOption: shuffleOptionsTypes.reverseOrderExceptLast.id,
}), }),
}); });
}; };
@@ -193,8 +206,18 @@ export const MultipleChoiceElementForm = ({
setisInvalidValue(null); setisInvalidValue(null);
} }
const hasRemainingSpecialChoices = newChoices.some((c) => c.id === "other" || c.id === "none");
updateElement(elementIdx, { updateElement(elementIdx, {
choices: newChoices, choices: newChoices,
...(!hasRemainingSpecialChoices &&
element.shuffleOption === "reverseOrderExceptLast" && {
shuffleOption: "reverseOrderOccasionally",
}),
...(!hasRemainingSpecialChoices &&
element.shuffleOption === "exceptLast" && {
shuffleOption: "all",
}),
}); });
}; };
@@ -115,6 +115,21 @@ export const RankingElementForm = ({
label: t("environments.surveys.edit.randomize_all"), label: t("environments.surveys.edit.randomize_all"),
show: element.choices.length > 0, show: element.choices.length > 0,
}, },
exceptLast: {
id: "exceptLast",
label: t("environments.surveys.edit.randomize_all_except_last"),
show: true,
},
reverseOrderOccasionally: {
id: "reverseOrderOccasionally",
label: t("environments.surveys.edit.reverse_order_occasionally"),
show: true,
},
reverseOrderExceptLast: {
id: "reverseOrderExceptLast",
label: t("environments.surveys.edit.reverse_order_occasionally_except_last"),
show: true,
},
}; };
useEffect(() => { useEffect(() => {
@@ -4,7 +4,7 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { renderHook } from "@testing-library/react"; import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, test, vi } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
import { z } from "zod"; import type { z } from "zod";
import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes"; import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
import { import {
createActionClassZodResolver, createActionClassZodResolver,
@@ -316,10 +316,6 @@ describe("validation.isEndingCardValid", () => {
const card = { ...baseRedirectUrlCard, label: " " }; const card = { ...baseRedirectUrlCard, label: " " };
expect(validation.isEndingCardValid(card, surveyLanguagesEnabled)).toBe(false); expect(validation.isEndingCardValid(card, surveyLanguagesEnabled)).toBe(false);
}); });
// test("should return false for redirectUrl card if label is undefined", () => {
// const card = { ...baseRedirectUrlCard, label: undefined };
// expect(validation.isEndingCardValid(card, surveyLanguagesEnabled)).toBe(false);
// });
}); });
describe("validation.validateElement", () => { describe("validation.validateElement", () => {
@@ -1029,6 +1025,66 @@ describe("validation.isSurveyValid", () => {
expect(toast.error).not.toHaveBeenCalled(); expect(toast.error).not.toHaveBeenCalled();
}); });
test("should return false and toast error if a link survey has an empty custom survey closed message heading", () => {
const surveyWithEmptyClosedMessageHeading = {
...baseSurvey,
type: "link",
surveyClosedMessage: {
heading: "",
subheading: "Closed for now",
},
} as unknown as TSurvey;
expect(validation.isSurveyValid(surveyWithEmptyClosedMessageHeading, "en", mockT)).toBe(false);
expect(toast.error).toHaveBeenCalledWith(
"environments.surveys.edit.survey_closed_message_heading_required"
);
});
test("should return false and toast error if a link survey has a whitespace-only custom survey closed message heading", () => {
const surveyWithWhitespaceClosedMessageHeading = {
...baseSurvey,
type: "link",
surveyClosedMessage: {
heading: " ",
subheading: "",
},
} as unknown as TSurvey;
expect(validation.isSurveyValid(surveyWithWhitespaceClosedMessageHeading, "en", mockT)).toBe(false);
expect(toast.error).toHaveBeenCalledWith(
"environments.surveys.edit.survey_closed_message_heading_required"
);
});
test("should return true if a link survey has a custom survey closed message heading and no subheading", () => {
const surveyWithHeadingOnlyClosedMessage = {
...baseSurvey,
type: "link",
surveyClosedMessage: {
heading: "Survey closed",
subheading: "",
},
} as unknown as TSurvey;
expect(validation.isSurveyValid(surveyWithHeadingOnlyClosedMessage, "en", mockT)).toBe(true);
expect(toast.error).not.toHaveBeenCalled();
});
test("should return true if a link survey has a custom survey closed message heading and subheading", () => {
const surveyWithClosedMessageContent = {
...baseSurvey,
type: "link",
surveyClosedMessage: {
heading: "Survey closed",
subheading: "Thanks for your interest",
},
} as unknown as TSurvey;
expect(validation.isSurveyValid(surveyWithClosedMessageContent, "en", mockT)).toBe(true);
expect(toast.error).not.toHaveBeenCalled();
});
describe("App Survey Segment Validation", () => { describe("App Survey Segment Validation", () => {
test("should return false and toast error for app survey with invalid segment filters", () => { test("should return false and toast error for app survey with invalid segment filters", () => {
const surveyWithInvalidSegment = { const surveyWithInvalidSegment = {
@@ -151,11 +151,7 @@ export const validationRules = {
for (const field of fieldsToValidate) { for (const field of fieldsToValidate) {
const fieldValue = (element as unknown as Record<string, Record<string, string> | undefined>)[field]; const fieldValue = (element as unknown as Record<string, Record<string, string> | undefined>)[field];
if ( if (fieldValue?.[defaultLanguageCode] !== undefined && fieldValue[defaultLanguageCode].trim() !== "") {
fieldValue &&
typeof fieldValue[defaultLanguageCode] !== "undefined" &&
fieldValue[defaultLanguageCode].trim() !== ""
) {
isValid = isValid && isLabelValidForAllLanguages(fieldValue, languages); isValid = isValid && isLabelValidForAllLanguages(fieldValue, languages);
} }
} }
@@ -203,6 +199,16 @@ const isContentValid = (content: Record<string, string> | undefined, surveyLangu
return !content || isLabelValidForAllLanguages(content, surveyLanguages); return !content || isLabelValidForAllLanguages(content, surveyLanguages);
}; };
const hasValidSurveyClosedMessageHeading = (survey: TSurvey): boolean => {
if (survey.type !== "link" || !survey.surveyClosedMessage) {
return true;
}
const heading = survey.surveyClosedMessage.heading?.trim() ?? "";
return heading.length > 0;
};
export const isWelcomeCardValid = (card: TSurveyWelcomeCard, surveyLanguages: TSurveyLanguage[]): boolean => { export const isWelcomeCardValid = (card: TSurveyWelcomeCard, surveyLanguages: TSurveyLanguage[]): boolean => {
return isContentValid(card.headline, surveyLanguages) && isContentValid(card.subheader, surveyLanguages); return isContentValid(card.headline, surveyLanguages) && isContentValid(card.subheader, surveyLanguages);
}; };
@@ -286,5 +292,10 @@ export const isSurveyValid = (
} }
} }
if (!hasValidSurveyClosedMessageHeading(survey)) {
toast.error(t("environments.surveys.edit.survey_closed_message_heading_required"));
return false;
}
return true; return true;
}; };
@@ -1,5 +1,6 @@
import { Project, SurveyType } from "@prisma/client"; import { Project, SurveyType } from "@prisma/client";
import { type JSX, useState } from "react"; import { type JSX, useState } from "react";
import { useTranslation } from "react-i18next";
import { TProjectStyling } from "@formbricks/types/project"; import { TProjectStyling } from "@formbricks/types/project";
import { TSurveyStyling } from "@formbricks/types/surveys/types"; import { TSurveyStyling } from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
@@ -44,6 +45,7 @@ export const LinkSurveyWrapper = ({
isBrandingEnabled, isBrandingEnabled,
dir = "auto", dir = "auto",
}: LinkSurveyWrapperProps) => { }: LinkSurveyWrapperProps) => {
const { t } = useTranslation();
//for embedded survey strip away all surrounding css //for embedded survey strip away all surrounding css
const [isBackgroundLoaded, setIsBackgroundLoaded] = useState(false); const [isBackgroundLoaded, setIsBackgroundLoaded] = useState(false);
@@ -88,7 +90,7 @@ export const LinkSurveyWrapper = ({
{isPreview && ( {isPreview && (
<div className="fixed left-0 top-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm"> <div className="fixed left-0 top-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
<div /> <div />
Survey Preview 👀 {t("environments.surveys.edit.survey_preview")}
<ResetProgressButton onClick={handleResetSurvey} /> <ResetProgressButton onClick={handleResetSurvey} />
</div> </div>
)} )}
@@ -146,7 +146,7 @@ export const SurveysList = ({
<div> <div>
<div className="flex-col space-y-3" ref={parent}> <div className="flex-col space-y-3" ref={parent}>
<div className="mt-6 grid w-full grid-cols-8 place-items-center gap-3 px-6 pr-8 text-sm text-slate-800"> <div className="mt-6 grid w-full grid-cols-8 place-items-center gap-3 px-6 pr-8 text-sm text-slate-800">
<div className="col-span-2 place-self-start">Name</div> <div className="col-span-2 place-self-start">{t("common.name")}</div>
<div className="col-span-1">{t("common.status")}</div> <div className="col-span-1">{t("common.status")}</div>
<div className="col-span-1">{t("common.responses")}</div> <div className="col-span-1">{t("common.responses")}</div>
<div className="col-span-1">{t("common.type")}</div> <div className="col-span-1">{t("common.type")}</div>
@@ -0,0 +1,324 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { buildWhereClause } from "@/modules/survey/lib/utils";
import { decodeSurveyListPageCursor, encodeSurveyListPageCursor, getSurveyListPage } from "./survey-page";
vi.mock("@/modules/survey/lib/utils", () => ({
buildWhereClause: vi.fn(() => ({ AND: [] })),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
findMany: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
const environmentId = "env_123";
function makeSurveyRow(overrides: Record<string, unknown> = {}) {
return {
id: "survey_1",
name: "Survey 1",
environmentId,
type: "link",
status: "draft",
createdAt: new Date("2025-01-01T00:00:00.000Z"),
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
creator: { name: "Alice" },
singleUse: null,
_count: { responses: 3 },
...overrides,
};
}
describe("survey-page cursor helpers", () => {
test("encodes and decodes an updatedAt cursor", () => {
const encoded = encodeSurveyListPageCursor({
version: 1,
sortBy: "updatedAt",
value: "2025-01-02T00:00:00.000Z",
id: "survey_1",
});
expect(decodeSurveyListPageCursor(encoded, "updatedAt")).toEqual({
version: 1,
sortBy: "updatedAt",
value: "2025-01-02T00:00:00.000Z",
id: "survey_1",
});
});
test("rejects a cursor that does not match the requested sort order", () => {
const encoded = encodeSurveyListPageCursor({
version: 1,
sortBy: "name",
value: "Survey 1",
id: "survey_1",
});
expect(() => decodeSurveyListPageCursor(encoded, "updatedAt")).toThrow(InvalidInputError);
});
});
describe("getSurveyListPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("uses a stable updatedAt order with a next cursor", async () => {
vi.mocked(prisma.survey.findMany).mockResolvedValue([
makeSurveyRow({ id: "survey_2", updatedAt: new Date("2025-01-03T00:00:00.000Z") }),
makeSurveyRow({ id: "survey_1", updatedAt: new Date("2025-01-02T00:00:00.000Z") }),
] as never);
const page = await getSurveyListPage(environmentId, {
limit: 1,
cursor: null,
sortBy: "updatedAt",
});
expect(buildWhereClause).toHaveBeenCalledWith(undefined);
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { environmentId, AND: [] },
select: expect.any(Object),
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
take: 2,
});
expect(page.surveys).toHaveLength(1);
expect(page.surveys[0].responseCount).toBe(3);
expect(page.surveys[0]).not.toHaveProperty("_count");
expect(page.nextCursor).not.toBeNull();
expect(decodeSurveyListPageCursor(page.nextCursor as string, "updatedAt")).toEqual({
version: 1,
sortBy: "updatedAt",
value: "2025-01-03T00:00:00.000Z",
id: "survey_2",
});
});
test("applies a name cursor for forward pagination", async () => {
const cursor = decodeSurveyListPageCursor(
encodeSurveyListPageCursor({
version: 1,
sortBy: "name",
value: "Bravo",
id: "survey_b",
}),
"name"
);
vi.mocked(prisma.survey.findMany).mockResolvedValue([
makeSurveyRow({ id: "survey_c", name: "Charlie" }),
] as never);
await getSurveyListPage(environmentId, {
limit: 2,
cursor,
sortBy: "name",
});
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: {
environmentId,
AND: [],
OR: [{ name: { gt: "Bravo" } }, { name: "Bravo", id: { gt: "survey_b" } }],
},
select: expect.any(Object),
orderBy: [{ name: "asc" }, { id: "asc" }],
take: 3,
});
});
test("paginates relevance by exhausting in-progress surveys before others", async () => {
vi.mocked(prisma.survey.findMany)
.mockResolvedValueOnce([
makeSurveyRow({
id: "survey_in_progress",
status: "inProgress",
updatedAt: new Date("2025-01-03T00:00:00.000Z"),
}),
] as never)
.mockResolvedValueOnce([
makeSurveyRow({
id: "survey_other_1",
status: "completed",
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
}),
makeSurveyRow({
id: "survey_other_2",
status: "paused",
updatedAt: new Date("2025-01-01T00:00:00.000Z"),
}),
] as never);
const page = await getSurveyListPage(environmentId, {
limit: 2,
cursor: null,
sortBy: "relevance",
});
expect(prisma.survey.findMany).toHaveBeenNthCalledWith(1, {
where: {
environmentId,
AND: [],
status: "inProgress",
},
select: expect.any(Object),
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
take: 3,
});
expect(prisma.survey.findMany).toHaveBeenNthCalledWith(2, {
where: {
environmentId,
AND: [],
status: { not: "inProgress" },
},
select: expect.any(Object),
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
take: 2,
});
expect(page.surveys.map((survey) => survey.id)).toEqual(["survey_in_progress", "survey_other_1"]);
expect(decodeSurveyListPageCursor(page.nextCursor as string, "relevance")).toEqual({
version: 1,
sortBy: "relevance",
bucket: "other",
updatedAt: "2025-01-02T00:00:00.000Z",
id: "survey_other_1",
});
});
test("returns an in-progress next cursor when the page fills before switching to other surveys", async () => {
vi.mocked(prisma.survey.findMany)
.mockResolvedValueOnce([
makeSurveyRow({
id: "survey_in_progress",
status: "inProgress",
updatedAt: new Date("2025-01-03T00:00:00.000Z"),
}),
] as never)
.mockResolvedValueOnce([
makeSurveyRow({
id: "survey_other_1",
status: "completed",
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
}),
] as never);
const page = await getSurveyListPage(environmentId, {
limit: 1,
cursor: null,
sortBy: "relevance",
});
expect(page.surveys.map((survey) => survey.id)).toEqual(["survey_in_progress"]);
expect(decodeSurveyListPageCursor(page.nextCursor as string, "relevance")).toEqual({
version: 1,
sortBy: "relevance",
bucket: "inProgress",
updatedAt: "2025-01-03T00:00:00.000Z",
id: "survey_in_progress",
});
});
test("continues relevance pagination from the other bucket cursor", async () => {
const cursor = decodeSurveyListPageCursor(
encodeSurveyListPageCursor({
version: 1,
sortBy: "relevance",
bucket: "other",
updatedAt: "2025-01-02T00:00:00.000Z",
id: "survey_other_1",
}),
"relevance"
);
vi.mocked(prisma.survey.findMany).mockResolvedValue([
makeSurveyRow({
id: "survey_other_2",
status: "completed",
updatedAt: new Date("2025-01-01T00:00:00.000Z"),
}),
] as never);
const page = await getSurveyListPage(environmentId, {
limit: 2,
cursor,
sortBy: "relevance",
});
expect(prisma.survey.findMany).toHaveBeenCalledOnce();
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: {
environmentId,
AND: [],
status: { not: "inProgress" },
OR: [
{ updatedAt: { lt: new Date("2025-01-02T00:00:00.000Z") } },
{
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
id: { lt: "survey_other_1" },
},
],
},
select: expect.any(Object),
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
take: 3,
});
expect(page.surveys.map((survey) => survey.id)).toEqual(["survey_other_2"]);
expect(page.nextCursor).toBeNull();
});
test("wraps Prisma errors as DatabaseError", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("db failed", {
code: "P2025",
clientVersion: "test",
});
vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError);
await expect(
getSurveyListPage(environmentId, {
limit: 1,
cursor: null,
sortBy: "updatedAt",
})
).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting paginated surveys");
});
test("rethrows InvalidInputError unchanged", async () => {
const invalidInputError = new InvalidInputError("bad cursor");
vi.mocked(prisma.survey.findMany).mockRejectedValue(invalidInputError);
await expect(
getSurveyListPage(environmentId, {
limit: 1,
cursor: null,
sortBy: "updatedAt",
})
).rejects.toThrow(invalidInputError);
});
test("rethrows unexpected errors unchanged", async () => {
const unexpectedError = new Error("boom");
vi.mocked(prisma.survey.findMany).mockRejectedValue(unexpectedError);
await expect(
getSurveyListPage(environmentId, {
limit: 1,
cursor: null,
sortBy: "updatedAt",
})
).rejects.toThrow(unexpectedError);
});
});
@@ -0,0 +1,427 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import type { TSurveyFilterCriteria } from "@formbricks/types/surveys/types";
import { buildWhereClause } from "@/modules/survey/lib/utils";
import type { TSurvey } from "../types/surveys";
import { type TSurveyRow, mapSurveyRowsToSurveys, surveySelect } from "./survey-record";
const SURVEY_LIST_CURSOR_VERSION = 1 as const;
const IN_PROGRESS_BUCKET = "inProgress" as const;
const OTHER_BUCKET = "other" as const;
const ZDateCursor = z.object({
version: z.literal(SURVEY_LIST_CURSOR_VERSION),
sortBy: z.enum(["updatedAt", "createdAt"]),
value: z.iso.datetime(),
id: z.string().min(1),
});
const ZNameCursor = z.object({
version: z.literal(SURVEY_LIST_CURSOR_VERSION),
sortBy: z.literal("name"),
value: z.string(),
id: z.string().min(1),
});
const ZRelevanceCursor = z.object({
version: z.literal(SURVEY_LIST_CURSOR_VERSION),
sortBy: z.literal("relevance"),
bucket: z.enum([IN_PROGRESS_BUCKET, OTHER_BUCKET]),
updatedAt: z.iso.datetime(),
id: z.string().min(1),
});
const ZSurveyListPageCursor = z.union([ZDateCursor, ZNameCursor, ZRelevanceCursor]);
export type TSurveyListSort = NonNullable<TSurveyFilterCriteria["sortBy"]>;
export type TSurveyListPageCursor = z.infer<typeof ZSurveyListPageCursor>;
type TStandardSurveyListSort = Exclude<TSurveyListSort, "relevance">;
type TStandardSurveyListCursor = Extract<TSurveyListPageCursor, { sortBy: TStandardSurveyListSort }>;
type TRelevanceSurveyListCursor = Extract<TSurveyListPageCursor, { sortBy: "relevance" }>;
type TRelevanceBucket = typeof IN_PROGRESS_BUCKET | typeof OTHER_BUCKET;
export type TSurveyListPage = {
surveys: TSurvey[];
nextCursor: string | null;
};
type TGetSurveyListPageOptions = {
limit: number;
cursor: TSurveyListPageCursor | null;
sortBy: TSurveyListSort;
filterCriteria?: TSurveyFilterCriteria;
};
type TCursorDirection = "asc" | "desc";
export function normalizeSurveyListSort(sortBy?: TSurveyFilterCriteria["sortBy"]): TSurveyListSort {
return sortBy ?? "updatedAt";
}
export function encodeSurveyListPageCursor(cursor: TSurveyListPageCursor): string {
return Buffer.from(JSON.stringify(cursor), "utf8").toString("base64url");
}
export function decodeSurveyListPageCursor(
encodedCursor: string,
sortBy: TSurveyListSort
): TSurveyListPageCursor {
try {
const decodedJson = Buffer.from(encodedCursor, "base64url").toString("utf8");
const parsedCursor = ZSurveyListPageCursor.parse(JSON.parse(decodedJson));
if (parsedCursor.sortBy !== sortBy) {
throw new InvalidInputError("The cursor does not match the requested sort order.");
}
return parsedCursor;
} catch (error) {
if (error instanceof InvalidInputError) {
throw error;
}
throw new InvalidInputError("The cursor is invalid.");
}
}
function getSurveyOrderBy(sortBy: TStandardSurveyListSort): Prisma.SurveyOrderByWithRelationInput[] {
switch (sortBy) {
case "name":
return [{ name: "asc" }, { id: "asc" }];
case "createdAt":
return [{ createdAt: "desc" }, { id: "desc" }];
case "updatedAt":
default:
return [{ updatedAt: "desc" }, { id: "desc" }];
}
}
function buildDateCursorWhere(
field: "createdAt" | "updatedAt",
cursorValue: string,
cursorId: string,
direction: TCursorDirection
): Prisma.SurveyWhereInput {
const comparisonOperator = direction === "desc" ? "lt" : "gt";
const cursorDate = new Date(cursorValue);
return {
OR: [
{ [field]: { [comparisonOperator]: cursorDate } },
{
[field]: cursorDate,
id: { [comparisonOperator]: cursorId },
},
],
};
}
function buildNameCursorWhere(cursorValue: string, cursorId: string): Prisma.SurveyWhereInput {
return {
OR: [
{ name: { gt: cursorValue } },
{
name: cursorValue,
id: { gt: cursorId },
},
],
};
}
function buildStandardCursorWhere(
sortBy: TStandardSurveyListSort,
cursor: TStandardSurveyListCursor
): Prisma.SurveyWhereInput {
switch (sortBy) {
case "name":
return buildNameCursorWhere(cursor.value, cursor.id);
case "createdAt":
return buildDateCursorWhere("createdAt", cursor.value, cursor.id, "desc");
case "updatedAt":
default:
return buildDateCursorWhere("updatedAt", cursor.value, cursor.id, "desc");
}
}
function buildBaseWhere(
environmentId: string,
filterCriteria?: TSurveyFilterCriteria,
extraWhere?: Prisma.SurveyWhereInput
): Prisma.SurveyWhereInput {
return {
environmentId,
...buildWhereClause(filterCriteria),
...extraWhere,
};
}
function getStandardNextCursor(survey: TSurveyRow, sortBy: TStandardSurveyListSort): TSurveyListPageCursor {
switch (sortBy) {
case "name":
return {
version: SURVEY_LIST_CURSOR_VERSION,
sortBy,
value: survey.name,
id: survey.id,
};
case "createdAt":
return {
version: SURVEY_LIST_CURSOR_VERSION,
sortBy,
value: survey.createdAt.toISOString(),
id: survey.id,
};
case "updatedAt":
default:
return {
version: SURVEY_LIST_CURSOR_VERSION,
sortBy,
value: survey.updatedAt.toISOString(),
id: survey.id,
};
}
}
function getRelevanceNextCursor(survey: TSurveyRow, bucket: TRelevanceBucket): TSurveyListPageCursor {
return {
version: SURVEY_LIST_CURSOR_VERSION,
sortBy: "relevance",
bucket,
updatedAt: survey.updatedAt.toISOString(),
id: survey.id,
};
}
async function findSurveyRows(
environmentId: string,
limit: number,
sortBy: TStandardSurveyListSort,
filterCriteria?: TSurveyFilterCriteria,
cursor?: TStandardSurveyListCursor | null,
extraWhere?: Prisma.SurveyWhereInput
): Promise<TSurveyRow[]> {
const cursorWhere = cursor ? buildStandardCursorWhere(sortBy, cursor) : undefined;
return prisma.survey.findMany({
where: buildBaseWhere(environmentId, filterCriteria, {
...extraWhere,
...cursorWhere,
}),
select: surveySelect,
orderBy: getSurveyOrderBy(sortBy),
take: limit + 1,
});
}
function getLastSurveyRow(rows: TSurveyRow[]): TSurveyRow | null {
return rows.at(-1) ?? null;
}
function getPageRows<T>(rows: T[], limit: number): { pageRows: T[]; hasMore: boolean } {
const hasMore = rows.length > limit;
return {
pageRows: hasMore ? rows.slice(0, limit) : rows,
hasMore,
};
}
function buildSurveyListPage(rows: TSurveyRow[], cursor: TSurveyListPageCursor | null): TSurveyListPage {
return {
surveys: mapSurveyRowsToSurveys(rows),
nextCursor: cursor ? encodeSurveyListPageCursor(cursor) : null,
};
}
async function getStandardSurveyListPage(
environmentId: string,
options: TGetSurveyListPageOptions & { sortBy: TStandardSurveyListSort }
): Promise<TSurveyListPage> {
const surveyRows = await findSurveyRows(
environmentId,
options.limit,
options.sortBy,
options.filterCriteria,
options.cursor as TStandardSurveyListCursor | null
);
const { pageRows, hasMore } = getPageRows(surveyRows, options.limit);
const lastRow = getLastSurveyRow(pageRows);
return buildSurveyListPage(
pageRows,
hasMore && lastRow ? getStandardNextCursor(lastRow, options.sortBy) : null
);
}
async function findRelevanceRows(
environmentId: string,
limit: number,
filterCriteria: TSurveyFilterCriteria | undefined,
bucket: TRelevanceBucket,
cursor: TRelevanceSurveyListCursor | null
): Promise<TSurveyRow[]> {
const statusWhere: Prisma.SurveyWhereInput =
bucket === IN_PROGRESS_BUCKET ? { status: "inProgress" } : { status: { not: "inProgress" } };
const cursorWhere = cursor
? buildDateCursorWhere("updatedAt", cursor.updatedAt, cursor.id, "desc")
: undefined;
return prisma.survey.findMany({
where: buildBaseWhere(environmentId, filterCriteria, {
...statusWhere,
...cursorWhere,
}),
select: surveySelect,
orderBy: getSurveyOrderBy("updatedAt"),
take: limit + 1,
});
}
async function hasMoreRelevanceRowsInOtherBucket(
environmentId: string,
filterCriteria?: TSurveyFilterCriteria
): Promise<boolean> {
const otherRows = await findRelevanceRows(environmentId, 1, filterCriteria, OTHER_BUCKET, null);
return otherRows.length > 0;
}
function getRelevanceCursor(cursor: TSurveyListPageCursor | null): TRelevanceSurveyListCursor | null {
if (cursor && cursor.sortBy !== "relevance") {
throw new InvalidInputError("The cursor does not match the requested sort order.");
}
return cursor;
}
function getRelevanceBucketCursor(
cursor: TRelevanceSurveyListCursor | null,
bucket: TRelevanceBucket
): TRelevanceSurveyListCursor | null {
return cursor?.bucket === bucket ? cursor : null;
}
function shouldReadInProgressBucket(cursor: TRelevanceSurveyListCursor | null): boolean {
return !cursor || cursor.bucket === IN_PROGRESS_BUCKET;
}
function buildRelevancePage(rows: TSurveyRow[], bucket: TRelevanceBucket | null): TSurveyListPage {
const lastRow = getLastSurveyRow(rows);
return buildSurveyListPage(rows, bucket && lastRow ? getRelevanceNextCursor(lastRow, bucket) : null);
}
async function getInProgressRelevanceStep(
environmentId: string,
limit: number,
filterCriteria: TSurveyFilterCriteria | undefined,
cursor: TRelevanceSurveyListCursor | null
): Promise<{ pageRows: TSurveyRow[]; remaining: number; response: TSurveyListPage | null }> {
const inProgressRows = await findRelevanceRows(
environmentId,
limit,
filterCriteria,
IN_PROGRESS_BUCKET,
getRelevanceBucketCursor(cursor, IN_PROGRESS_BUCKET)
);
const { pageRows, hasMore } = getPageRows(inProgressRows, limit);
return {
pageRows,
remaining: limit - pageRows.length,
response: hasMore ? buildRelevancePage(pageRows, IN_PROGRESS_BUCKET) : null,
};
}
async function buildInProgressOnlyRelevancePage(
environmentId: string,
rows: TSurveyRow[],
filterCriteria: TSurveyFilterCriteria | undefined,
cursor: TRelevanceSurveyListCursor | null
): Promise<TSurveyListPage> {
const hasOtherRows =
rows.length > 0 &&
shouldReadInProgressBucket(cursor) &&
(await hasMoreRelevanceRowsInOtherBucket(environmentId, filterCriteria));
return buildRelevancePage(rows, hasOtherRows ? IN_PROGRESS_BUCKET : null);
}
async function getRelevanceSurveyListPage(
environmentId: string,
options: TGetSurveyListPageOptions & { sortBy: "relevance" }
): Promise<TSurveyListPage> {
const relevanceCursor = getRelevanceCursor(options.cursor);
const pageRows: TSurveyRow[] = [];
let remaining = options.limit;
if (shouldReadInProgressBucket(relevanceCursor)) {
const inProgressStep = await getInProgressRelevanceStep(
environmentId,
remaining,
options.filterCriteria,
relevanceCursor
);
pageRows.push(...inProgressStep.pageRows);
if (inProgressStep.response) {
return inProgressStep.response;
}
remaining = inProgressStep.remaining;
}
if (remaining <= 0) {
return await buildInProgressOnlyRelevancePage(
environmentId,
pageRows,
options.filterCriteria,
relevanceCursor
);
}
const otherRows = await findRelevanceRows(
environmentId,
remaining,
options.filterCriteria,
OTHER_BUCKET,
getRelevanceBucketCursor(relevanceCursor, OTHER_BUCKET)
);
const { pageRows: otherPageRows, hasMore: hasMoreOther } = getPageRows(otherRows, remaining);
pageRows.push(...otherPageRows);
return buildRelevancePage(pageRows, hasMoreOther ? OTHER_BUCKET : null);
}
export async function getSurveyListPage(
environmentId: string,
options: TGetSurveyListPageOptions
): Promise<TSurveyListPage> {
try {
if (options.sortBy === "relevance") {
return await getRelevanceSurveyListPage(environmentId, {
...options,
sortBy: "relevance",
});
}
return await getStandardSurveyListPage(environmentId, {
...options,
sortBy: options.sortBy,
});
} catch (error) {
if (error instanceof InvalidInputError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error getting paginated surveys");
throw new DatabaseError(error.message);
}
throw error;
}
}
@@ -0,0 +1,36 @@
import { Prisma } from "@prisma/client";
import type { TSurvey } from "@/modules/survey/list/types/surveys";
export const surveySelect = {
id: true,
createdAt: true,
updatedAt: true,
name: true,
type: true,
creator: {
select: {
name: true,
},
},
status: true,
singleUse: true,
environmentId: true,
_count: {
select: { responses: true },
},
} satisfies Prisma.SurveySelect;
export type TSurveyRow = Prisma.SurveyGetPayload<{ select: typeof surveySelect }>;
export function mapSurveyRowToSurvey(row: TSurveyRow): TSurvey {
const { _count, ...survey } = row;
return {
...survey,
responseCount: _count.responses,
};
}
export function mapSurveyRowsToSurveys(rows: TSurveyRow[]): TSurvey[] {
return rows.map(mapSurveyRowToSurvey);
}
@@ -22,8 +22,8 @@ import {
getSurveyCount, getSurveyCount,
getSurveys, getSurveys,
getSurveysSortedByRelevance, getSurveysSortedByRelevance,
surveySelect,
} from "./survey"; } from "./survey";
import { surveySelect } from "./survey-record";
vi.mock("react", async (importOriginal) => { vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>(); const actual = await importOriginal<typeof import("react")>();
@@ -197,7 +197,19 @@ describe("getSurvey", () => {
const survey = await getSurvey(surveyId); const survey = await getSurvey(surveyId);
expect(survey).toEqual({ ...prismaSurvey, responseCount: 5 }); expect(survey).toEqual({
id: prismaSurvey.id,
createdAt: prismaSurvey.createdAt,
updatedAt: prismaSurvey.updatedAt,
name: prismaSurvey.name,
type: prismaSurvey.type,
creator: prismaSurvey.creator,
status: prismaSurvey.status,
singleUse: prismaSurvey.singleUse,
environmentId: prismaSurvey.environmentId,
responseCount: 5,
});
expect(survey).not.toHaveProperty("_count");
expect(prisma.survey.findUnique).toHaveBeenCalledWith({ expect(prisma.survey.findUnique).toHaveBeenCalledWith({
where: { id: surveyId }, where: { id: surveyId },
select: surveySelect, select: surveySelect,
@@ -234,7 +246,15 @@ describe("getSurveys", () => {
{ ...mockSurveyPrisma, id: "s2", name: "Survey 2", _count: { responses: 2 } }, { ...mockSurveyPrisma, id: "s2", name: "Survey 2", _count: { responses: 2 } },
]; ];
const expectedSurveys: TSurvey[] = mockPrismaSurveys.map((s) => ({ const expectedSurveys: TSurvey[] = mockPrismaSurveys.map((s) => ({
...s, id: s.id,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
name: s.name,
type: s.type,
creator: s.creator,
status: s.status,
singleUse: s.singleUse,
environmentId: s.environmentId,
responseCount: s._count.responses, responseCount: s._count.responses,
})); }));
@@ -243,6 +263,7 @@ describe("getSurveys", () => {
const surveys = await getSurveys(environmentId); const surveys = await getSurveys(environmentId);
expect(surveys).toEqual(expectedSurveys); expect(surveys).toEqual(expectedSurveys);
expect(surveys[0]).not.toHaveProperty("_count");
expect(prisma.survey.findMany).toHaveBeenCalledWith({ expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { environmentId, ...buildWhereClause() }, where: { environmentId, ...buildWhereClause() },
select: surveySelect, select: surveySelect,
@@ -317,8 +338,30 @@ describe("getSurveysSortedByRelevance", () => {
_count: { responses: 5 }, _count: { responses: 5 },
}; };
const expectedInProgressSurvey: TSurvey = { ...mockInProgressPrisma, responseCount: 3 }; const expectedInProgressSurvey: TSurvey = {
const expectedOtherSurvey: TSurvey = { ...mockOtherPrisma, responseCount: 5 }; id: mockInProgressPrisma.id,
createdAt: mockInProgressPrisma.createdAt,
updatedAt: mockInProgressPrisma.updatedAt,
name: mockInProgressPrisma.name,
type: mockInProgressPrisma.type,
creator: mockInProgressPrisma.creator,
status: mockInProgressPrisma.status,
singleUse: mockInProgressPrisma.singleUse,
environmentId: mockInProgressPrisma.environmentId,
responseCount: 3,
};
const expectedOtherSurvey: TSurvey = {
id: mockOtherPrisma.id,
createdAt: mockOtherPrisma.createdAt,
updatedAt: mockOtherPrisma.updatedAt,
name: mockOtherPrisma.name,
type: mockOtherPrisma.type,
creator: mockOtherPrisma.creator,
status: mockOtherPrisma.status,
singleUse: mockOtherPrisma.singleUse,
environmentId: mockOtherPrisma.environmentId,
responseCount: 5,
};
test("should fetch inProgress surveys first, then others if limit not met", async () => { test("should fetch inProgress surveys first, then others if limit not met", async () => {
vi.mocked(prisma.survey.count).mockResolvedValue(1); // 1 inProgress survey vi.mocked(prisma.survey.count).mockResolvedValue(1); // 1 inProgress survey
@@ -329,6 +372,7 @@ describe("getSurveysSortedByRelevance", () => {
const surveys = await getSurveysSortedByRelevance(environmentId, 2, 0); const surveys = await getSurveysSortedByRelevance(environmentId, 2, 0);
expect(surveys).toEqual([expectedInProgressSurvey, expectedOtherSurvey]); expect(surveys).toEqual([expectedInProgressSurvey, expectedOtherSurvey]);
expect(surveys[0]).not.toHaveProperty("_count");
expect(prisma.survey.count).toHaveBeenCalledWith({ expect(prisma.survey.count).toHaveBeenCalledWith({
where: { environmentId, status: "inProgress", ...buildWhereClause() }, where: { environmentId, status: "inProgress", ...buildWhereClause() },
}); });
+25 -57
View File
@@ -17,25 +17,7 @@ import { buildOrderByClause, buildWhereClause } from "@/modules/survey/lib/utils
import { doesEnvironmentExist } from "@/modules/survey/list/lib/environment"; import { doesEnvironmentExist } from "@/modules/survey/list/lib/environment";
import { getProjectWithLanguagesByEnvironmentId } from "@/modules/survey/list/lib/project"; import { getProjectWithLanguagesByEnvironmentId } from "@/modules/survey/list/lib/project";
import { TProjectWithLanguages, TSurvey } from "@/modules/survey/list/types/surveys"; import { TProjectWithLanguages, TSurvey } from "@/modules/survey/list/types/surveys";
import { mapSurveyRowToSurvey, mapSurveyRowsToSurveys, surveySelect } from "./survey-record";
export const surveySelect: Prisma.SurveySelect = {
id: true,
createdAt: true,
updatedAt: true,
name: true,
type: true,
creator: {
select: {
name: true,
},
},
status: true,
singleUse: true,
environmentId: true,
_count: {
select: { responses: true },
},
};
export const getSurveys = reactCache( export const getSurveys = reactCache(
async ( async (
@@ -62,12 +44,7 @@ export const getSurveys = reactCache(
skip: offset, skip: offset,
}); });
return surveysPrisma.map((survey) => { return mapSurveyRowsToSurveys(surveysPrisma);
return {
...survey,
responseCount: survey._count.responses,
};
});
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error getting surveys"); logger.error(error, "Error getting surveys");
@@ -112,12 +89,7 @@ export const getSurveysSortedByRelevance = reactCache(
skip: offset, skip: offset,
}); });
surveys = inProgressSurveys.map((survey) => { surveys = mapSurveyRowsToSurveys(inProgressSurveys);
return {
...survey,
responseCount: survey._count.responses,
};
});
// Determine if additional surveys are needed // Determine if additional surveys are needed
if (offset !== undefined && limit && inProgressSurveys.length < limit) { if (offset !== undefined && limit && inProgressSurveys.length < limit) {
@@ -135,15 +107,7 @@ export const getSurveysSortedByRelevance = reactCache(
skip: newOffset, skip: newOffset,
}); });
surveys = [ surveys = [...surveys, ...mapSurveyRowsToSurveys(additionalSurveys)];
...surveys,
...additionalSurveys.map((survey) => {
return {
...survey,
responseCount: survey._count.responses,
};
}),
];
} }
return surveys; return surveys;
@@ -178,7 +142,7 @@ export const getSurvey = reactCache(async (surveyId: string): Promise<TSurvey |
return null; return null;
} }
return { ...surveyPrisma, responseCount: surveyPrisma?._count.responses }; return mapSurveyRowToSurvey(surveyPrisma);
}); });
export const deleteSurvey = async (surveyId: string): Promise<boolean> => { export const deleteSurvey = async (surveyId: string): Promise<boolean> => {
@@ -605,22 +569,26 @@ export const copySurveyToOtherEnvironment = async (
} }
}; };
export const getSurveyCount = reactCache(async (environmentId: string): Promise<number> => { /** Count surveys in an environment, optionally with the same filter as getSurveys (so total matches list). */
validateInputs([environmentId, z.cuid2()]); export const getSurveyCount = reactCache(
try { async (environmentId: string, filterCriteria?: TSurveyFilterCriteria): Promise<number> => {
const surveyCount = await prisma.survey.count({ validateInputs([environmentId, z.cuid2()]);
where: { try {
environmentId: environmentId, const surveyCount = await prisma.survey.count({
}, where: {
}); environmentId,
...buildWhereClause(filterCriteria),
},
});
return surveyCount; return surveyCount;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error getting survey count"); logger.error(error, "Error getting survey count");
throw new DatabaseError(error.message); throw new DatabaseError(error.message);
}
throw error;
} }
throw error;
} }
}); );
@@ -163,6 +163,9 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
} }
} else { } else {
setIsMultiLanguageActivated(true); setIsMultiLanguageActivated(true);
if (!open) {
setOpen(true);
}
} }
}; };
@@ -50,7 +50,11 @@ export const CodeActionForm = ({ form, isReadOnly }: CodeActionFormProps) => {
formbricks.track(&quot;{watch("key")}&quot;) formbricks.track(&quot;{watch("key")}&quot;)
</span>{" "} </span>{" "}
{t("environments.actions.in_your_code_read_more_in_our")}{" "} {t("environments.actions.in_your_code_read_more_in_our")}{" "}
<a href="https://formbricks.com/docs/actions/code" target="_blank" className="underline"> <a
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions"
target="_blank"
rel="noreferrer"
className="underline">
{t("common.docs")} {t("common.docs")}
</a> </a>
{"."} {"."}
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { ArrowUpFromLineIcon } from "lucide-react"; import { ArrowUpFromLineIcon } from "lucide-react";
import React from "react"; import React from "react";
import { TAllowedFileExtension } from "@formbricks/types/storage"; import { TAllowedFileExtension } from "@formbricks/types/storage";
@@ -33,6 +34,7 @@ export const Uploader = ({
disabled = false, disabled = false,
isStorageConfigured = true, isStorageConfigured = true,
}: UploaderProps) => { }: UploaderProps) => {
const { t } = useTranslation();
return ( return (
<label // NOSONAR - This is a label for a file input, we need the onClick to trigger storage not configured toast <label // NOSONAR - This is a label for a file input, we need the onClick to trigger storage not configured toast
htmlFor={`${id}-${name}`} htmlFor={`${id}-${name}`}
@@ -82,7 +84,7 @@ export const Uploader = ({
<div className="flex flex-col items-center justify-center pb-6 pt-5"> <div className="flex flex-col items-center justify-center pb-6 pt-5">
<ArrowUpFromLineIcon className="h-6 text-slate-500" /> <ArrowUpFromLineIcon className="h-6 text-slate-500" />
<p className={cn("mt-2 text-center text-sm text-slate-500", uploadMore && "text-xs")}> <p className={cn("mt-2 text-center text-sm text-slate-500", uploadMore && "text-xs")}>
<span className="font-semibold">Click or drag to upload files.</span> <span className="font-semibold">{t("common.upload_input_description")}</span>
</p> </p>
<input <input
data-testid="upload-file-input" data-testid="upload-file-input"
@@ -226,7 +226,7 @@ export const PreviewSurvey = ({
{previewMode === "mobile" && ( {previewMode === "mobile" && (
<> <>
<p className="absolute left-0 top-0 m-2 rounded bg-slate-100 px-2 py-1 text-xs text-slate-400"> <p className="absolute left-0 top-0 m-2 rounded bg-slate-100 px-2 py-1 text-xs text-slate-400">
Preview {t("common.preview")}
</p> </p>
<div className="absolute right-0 top-0 m-2"> <div className="absolute right-0 top-0 m-2">
<ResetProgressButton onClick={resetProgress} /> <ResetProgressButton onClick={resetProgress} />
@@ -310,7 +310,7 @@ export const PreviewSurvey = ({
setIsFullScreenPreview(true); setIsFullScreenPreview(true);
} }
}} }}
aria-label={isFullScreenPreview ? "Shrink Preview" : "Expand Preview"}></button> aria-label={isFullScreenPreview ? t("environments.surveys.edit.shrink_preview") : t("environments.surveys.edit.expand_preview")}></button>
</div> </div>
<div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400"> <div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
<p> <p>
@@ -25,6 +25,8 @@ interface ShuffleOptionsTypes {
none?: ShuffleOptionType; none?: ShuffleOptionType;
all?: ShuffleOptionType; all?: ShuffleOptionType;
exceptLast?: ShuffleOptionType; exceptLast?: ShuffleOptionType;
reverseOrderOccasionally?: ShuffleOptionType;
reverseOrderExceptLast?: ShuffleOptionType;
} }
interface ShuffleOptionSelectProps { interface ShuffleOptionSelectProps {
@@ -157,7 +157,7 @@ export const ThemeStylingPreviewSurvey = ({
<div className="h-3 w-3 rounded-full bg-emerald-500"></div> <div className="h-3 w-3 rounded-full bg-emerald-500"></div>
</div> </div>
<div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400"> <div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
<p>{isAppSurvey ? "Your web app" : "Preview"}</p> <p>{isAppSurvey ? t("environments.surveys.edit.your_web_app") : t("common.preview")}</p>
<div className="flex items-center"> <div className="flex items-center">
<ResetProgressButton onClick={resetQuestionProgress} /> <ResetProgressButton onClick={resetQuestionProgress} />
-1
View File
@@ -269,7 +269,6 @@ test.describe("Multi Language Survey Create", async () => {
await page.getByText("Start from scratch").click(); await page.getByText("Start from scratch").click();
await page.getByRole("button", { name: "Create survey", exact: true }).click(); await page.getByRole("button", { name: "Create survey", exact: true }).click();
await page.locator("#multi-lang-toggle").click(); await page.locator("#multi-lang-toggle").click();
await page.getByText("Multiple languages").click();
await page.getByRole("combobox").click(); await page.getByRole("combobox").click();
await page.getByLabel("English (en)").click(); await page.getByLabel("English (en)").click();
await page.getByRole("button", { name: "Confirm" }).click(); await page.getByRole("button", { name: "Confirm" }).click();
+20 -12
View File
@@ -1386,17 +1386,22 @@ paths:
put: put:
operationId: uploadBulkContacts operationId: uploadBulkContacts
summary: Upload Bulk Contacts summary: Upload Bulk Contacts
description: Uploads contacts in bulk. Each contact in the payload must have an description: >-
'email' attribute present in their attributes array. The email attribute Uploads contacts in bulk. This endpoint expects the bulk request
is mandatory and must be a valid email format. Without a valid email, shape: `contacts` must be an array, and each contact item must contain
the contact will be skipped during processing. an `attributes` array of `{ attributeKey, value }` objects. Unlike `POST
/management/contacts`, this endpoint does not accept a top-level `attributes`
object. Each contact must include an `email` attribute in its `attributes`
array, and that email must be valid.
tags: tags:
- Management API - Contacts - Management API - Contacts
requestBody: requestBody:
required: true required: true
description: The contacts to upload. Each contact must include an 'email' description: >-
attribute in their attributes array. The email is used as the unique The contacts to upload. Use the full nested bulk body shown
identifier for the contact. in the example or cURL snippet: `{ environmentId, contacts: [{ attributes:
[{ attributeKey: { key, name }, value }] }] }`. Each contact must include
an `email` attribute inside its `attributes` array.
content: content:
application/json: application/json:
schema: schema:
@@ -1520,16 +1525,19 @@ paths:
post: post:
operationId: createContact operationId: createContact
summary: Create a contact summary: Create a contact
description: Creates a contact in the database. Each contact must have a valid description: Creates a single contact in the database. This endpoint expects
email address in the attributes. All attribute keys must already exist a top-level `attributes` object. For bulk uploads, use `PUT /management/contacts/bulk`,
in the environment. The email is used as the unique identifier along which expects `contacts[].attributes[]` instead. Each contact must have
a valid email address in the attributes. All attribute keys must already
exist in the environment. The email is used as the unique identifier along
with the environment. with the environment.
tags: tags:
- Management API - Contacts - Management API - Contacts
requestBody: requestBody:
required: true required: true
description: The contact to create. Must include an email attribute and all description: The single contact to create. Must include a top-level `attributes`
attribute keys must already exist in the environment. object with an email attribute, and all attribute keys must already exist
in the environment.
content: content:
application/json: application/json:
schema: schema:
+231
View File
@@ -0,0 +1,231 @@
# V3 API — GET Surveys (hand-maintained; not generated by generate-api-specs).
# Implementation: apps/web/app/api/v3/surveys/route.ts
# See apps/web/app/api/v3/README.md and docs/Survey-Server-Actions.md (Part III) for full context.
openapi: 3.1.0
info:
title: Formbricks API v3
description: |
**GET /api/v3/surveys** — authenticate with **session cookie** or **`x-api-key`** (management key with access to the workspace environment).
**Spec location:** `docs/api-v3-reference/openapi.yml` (alongside v2 at `docs/api-v2-reference/openapi.yml`).
**workspaceId (today)**
Query param `workspaceId` is the **environment id** (survey container in the DB). The API uses the name *workspace* because the product is moving toward **Workspace** as the default container; until that exists, resolution is implemented in `workspace-context.ts` (single place to change when Environment is deprecated).
**Auth**
Authenticate with either a session cookie or **`x-api-key`**. In dual-auth mode, V3 checks the API key first when the header is present, otherwise it uses the session path. Unauthenticated callers get **401** before query validation.
**Pagination**
Cursor-based pagination with **limit** + opaque **cursor** token. Responses return `meta.nextCursor`; pass that value back as `cursor` to fetch the next page. Responses also include `meta.totalCount`, the total number of surveys matching the current filters across all pages. There is no `offset` in this contract.
**Filtering**
Filters use explicit operator-style query parameters under the **`filter[...]` family**. This endpoint supports `filter[name][contains]`, `filter[status][in]`, and `filter[type][in]`. Multi-value filters use repeated keys or comma-separated values (e.g. `filter[status][in]=draft&filter[status][in]=inProgress` or `filter[status][in]=draft,inProgress`). Sorting remains a flat `sortBy` query parameter.
**Security**
Missing/forbidden workspace returns **403** with a generic message (not **404**) so resource existence is not leaked. List responses use `private, no-store`.
**OpenAPI**
This YAML is **not** produced by `pnpm generate-api-specs` (that script only builds v2 → `docs/api-v2-reference/openapi.yml`). Update this file when the route contract changes.
**Next steps (out of scope for this spec)**
Additional v3 survey endpoints (single survey, CRUD), frontend cutover from `getSurveysAction`, optional ETag/304, field selection — see Survey-Server-Actions.md Part III.
version: 0.1.0
x-implementation-notes:
route: apps/web/app/api/v3/surveys/route.ts
query-parser: apps/web/app/api/v3/surveys/parse-v3-surveys-list-query.ts
auth: apps/web/app/api/v3/lib/auth.ts
workspace-resolution: apps/web/app/api/v3/lib/workspace-context.ts
openapi-generated: false
pagination-model: cursor
cursor-pagination: supported
paths:
/api/v3/surveys:
get:
operationId: getSurveysV3
summary: List surveys
description: Returns surveys for the workspace. Session cookie or x-api-key.
tags:
- V3 Surveys
parameters:
- in: query
name: workspaceId
required: true
schema:
type: string
format: cuid2
description: |
Workspace identifier. **Today:** pass the **environment id** (the environment that contains the surveys). When Workspace replaces Environment in the data model, clients may pass workspace ids instead; resolution is centralized in workspace-context.
- in: query
name: limit
schema:
type: integer
minimum: 1
maximum: 100
default: 20
description: Page size (max 100)
- in: query
name: cursor
schema:
type: string
description: |
Opaque cursor returned as `meta.nextCursor` from the previous page. Omit on the first request.
- in: query
name: filter[name][contains]
schema:
type: string
maxLength: 512
description: Case-insensitive substring match on survey name (same as in-app list filters).
- in: query
name: filter[status][in]
schema:
type: array
items:
type: string
enum: [draft, inProgress, paused, completed]
style: form
explode: true
description: |
Survey status filter. Repeat the parameter (`filter[status][in]=draft&filter[status][in]=inProgress`) or use comma-separated values (`filter[status][in]=draft,inProgress`). Invalid values → **400**.
- in: query
name: filter[type][in]
schema:
type: array
items:
type: string
enum: [link, app]
style: form
explode: true
description: Survey type filter (`link` / `app`). Same repeat-or-comma rules as `filter[status][in]`.
- in: query
name: sortBy
schema:
type: string
enum: [createdAt, updatedAt, name, relevance]
description: Sort order. Defaults to `updatedAt`. The `cursor` token is bound to the selected sort order.
responses:
"200":
description: Surveys retrieved successfully
headers:
X-Request-Id:
schema: { type: string }
description: Request correlation ID
Cache-Control:
schema: { type: string }
example: "private, no-store"
content:
application/json:
schema:
type: object
required: [data, meta]
properties:
data:
type: array
items:
$ref: "#/components/schemas/SurveyListItem"
meta:
type: object
required: [limit, nextCursor, totalCount]
properties:
limit: { type: integer }
nextCursor:
type: string
nullable: true
description: Opaque cursor for the next page. `null` when there are no more results.
totalCount:
type: integer
minimum: 0
description: Total number of surveys matching the current filters across all pages.
"400":
description: Bad Request
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"401":
description: Not authenticated (no valid session or API key)
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"403":
description: Forbidden — no access, or workspace/environment does not exist (404 not used; avoids existence leak)
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"429":
description: Rate limit exceeded
headers:
Retry-After:
schema: { type: integer }
description: Seconds until the current rate-limit window resets
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
"500":
description: Internal Server Error
content:
application/problem+json:
schema:
$ref: "#/components/schemas/Problem"
security:
- sessionAuth: []
- apiKeyAuth: []
components:
securitySchemes:
sessionAuth:
type: apiKey
in: cookie
name: next-auth.session-token
description: |
NextAuth session JWT cookie. **Development:** often `next-auth.session-token`.
**Production (HTTPS):** often `__Secure-next-auth.session-token`. Send the cookie your browser receives after sign-in.
apiKeyAuth:
type: apiKey
in: header
name: x-api-key
description: |
Management API key; must include **workspaceId** as an allowed environment with read, write, or manage permission.
schemas:
SurveyListItem:
type: object
description: |
Shape from `getSurveys` (`surveySelect` + `responseCount`). Serialized dates are ISO 8601 strings.
Legacy DB rows may include survey **type** values `website` or `web` (see Prisma); filter **type** only accepts `link` | `app`.
properties:
id: { type: string }
name: { type: string }
environmentId: { type: string }
type: { type: string, enum: [link, app, website, web] }
status:
type: string
enum: [draft, inProgress, paused, completed]
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
responseCount: { type: integer }
creator: { type: object, nullable: true, properties: { name: { type: string } } }
Problem:
type: object
description: RFC 9457 Problem Details for HTTP APIs (`application/problem+json`). Responses typically include a machine-readable `code` field alongside `title`, `status`, `detail`, and `requestId`.
required: [title, status, detail, requestId]
properties:
type: { type: string, format: uri }
title: { type: string }
status: { type: integer }
detail: { type: string }
instance: { type: string }
code:
type: string
enum: [bad_request, not_authenticated, forbidden, internal_server_error, too_many_requests]
requestId: { type: string }
details: { type: object }
invalid_params:
type: array
items:
type: object
properties:
name: { type: string }
reason: { type: string }
@@ -1,7 +0,0 @@
---
title: "Multi-language Surveys"
description: "Survey respondents in multiple-languages."
icon: "language"
---
If you'd like to survey users in multiple languages while keeping all results in the same survey, you can make use of [Multi-language Surveys](/xm-and-surveys/surveys/general-features/multi-language-surveys#multi-language-surveys)
+1 -1
View File
@@ -77,6 +77,7 @@ The Enterprise Edition allows us to fund the development of Formbricks sustainab
| Pin-protected surveys | ✅ | ✅ | | Pin-protected surveys | ✅ | ✅ |
| Webhooks | ✅ | ✅ | | Webhooks | ✅ | ✅ |
| Email follow-ups | ✅ | ✅ | | Email follow-ups | ✅ | ✅ |
| Multi-language surveys | ✅ | ✅ |
| Multi-language UI | ✅ | ✅ | | Multi-language UI | ✅ | ✅ |
| All integrations (Slack, Zapier, Notion, etc.) | ✅ | ✅ | | All integrations (Slack, Zapier, Notion, etc.) | ✅ | ✅ |
| Domain Split Configuration | ✅ | ✅ | | Domain Split Configuration | ✅ | ✅ |
@@ -85,7 +86,6 @@ The Enterprise Edition allows us to fund the development of Formbricks sustainab
| Whitelabel email follow-ups | ❌ | ✅ | | Whitelabel email follow-ups | ❌ | ✅ |
| Teams & access roles | ❌ | ✅ | | Teams & access roles | ❌ | ✅ |
| Contact management & segments | ❌ | ✅ | | Contact management & segments | ❌ | ✅ |
| Multi-language surveys | ❌ | ✅ |
| Quota Management | ❌ | ✅ | | Quota Management | ❌ | ✅ |
| Audit Logs | ❌ | ✅ | | Audit Logs | ❌ | ✅ |
| OIDC SSO (AzureAD, Google, OpenID) | ❌ | ✅ | | OIDC SSO (AzureAD, Google, OpenID) | ❌ | ✅ |

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