mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-13 11:29:31 -05:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abc8d80784 | |||
| 1a1f54b606 | |||
| 1380c81bff | |||
| 535c111860 | |||
| 676e31c433 | |||
| 88f17380e1 | |||
| 103775b3b1 | |||
| 3005c44c49 | |||
| 4d03ba2ff7 | |||
| bc25c482ad | |||
| b08f7e4ad9 | |||
| 89a8266ebe | |||
| cad10b8810 | |||
| 1d18b5cb83 |
@@ -173,6 +173,21 @@ AZUREAD_TENANT_ID=
|
||||
# AI_AZURE_API_KEY=
|
||||
# AI_AZURE_API_VERSION=v1
|
||||
|
||||
###########################
|
||||
# Formbricks Hub Embeddings
|
||||
###########################
|
||||
|
||||
# Optional settings for running Formbricks Hub locally with embeddings.
|
||||
# These must be available to the Hub process; apps/web/.env points to this root .env.
|
||||
# Embeddings are disabled unless both EMBEDDING_PROVIDER and EMBEDDING_MODEL are set.
|
||||
# See https://github.com/formbricks/hub/blob/main/.env.example for provider-specific setup.
|
||||
# See https://hub.formbricks.com/api for embeddings-powered semantic search endpoints.
|
||||
# EMBEDDING_PROVIDER=
|
||||
# EMBEDDING_MODEL=
|
||||
# EMBEDDING_PROVIDER_API_KEY=
|
||||
# EMBEDDING_BASE_URL=
|
||||
# EMBEDDING_MAX_CONCURRENT=5
|
||||
|
||||
# OpenID Connect (OIDC) configuration
|
||||
# OIDC_CLIENT_ID=
|
||||
# OIDC_CLIENT_SECRET=
|
||||
|
||||
@@ -12,18 +12,18 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "5.0.2",
|
||||
"@storybook/addon-a11y": "10.3.5",
|
||||
"@storybook/addon-docs": "10.3.5",
|
||||
"@storybook/addon-links": "10.3.5",
|
||||
"@storybook/addon-onboarding": "10.3.5",
|
||||
"@storybook/react-vite": "10.3.5",
|
||||
"@storybook/addon-a11y": "10.3.6",
|
||||
"@storybook/addon-docs": "10.3.6",
|
||||
"@storybook/addon-links": "10.3.6",
|
||||
"@storybook/addon-onboarding": "10.3.6",
|
||||
"@storybook/react-vite": "10.3.6",
|
||||
"@tailwindcss/vite": "4.2.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.2",
|
||||
"@typescript-eslint/parser": "8.57.2",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"eslint-plugin-react-refresh": "0.4.26",
|
||||
"eslint-plugin-storybook": "10.3.5",
|
||||
"storybook": "10.3.5",
|
||||
"vite": "7.3.2"
|
||||
"eslint-plugin-storybook": "10.3.6",
|
||||
"storybook": "10.3.6",
|
||||
"vite": "7.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
|
||||
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||
import {
|
||||
EMAIL_VERIFICATION_DISABLED,
|
||||
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
PASSWORD_RESET_DISABLED,
|
||||
} from "@/lib/constants";
|
||||
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
@@ -71,7 +76,7 @@ const Page = async (props: {
|
||||
: t("common.request_trial_license"),
|
||||
href: IS_FORMBRICKS_CLOUD
|
||||
? `/environments/${params.environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
: ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
|
||||
+12
-7
@@ -5,7 +5,7 @@ import { RotateCcwIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
||||
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
|
||||
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
|
||||
@@ -151,12 +151,17 @@ export const EnterpriseLicenseStatus = ({
|
||||
</Alert>
|
||||
)}
|
||||
<p className="border-t border-slate-100 pt-4 text-sm text-slate-500">
|
||||
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
|
||||
<a
|
||||
className="font-medium text-slate-700 underline hover:text-slate-900"
|
||||
href="mailto:hola@formbricks.com">
|
||||
hola@formbricks.com
|
||||
</a>
|
||||
<Trans
|
||||
i18nKey="environments.settings.enterprise.questions_please_reach_out_to_email"
|
||||
components={{
|
||||
contactLink: (
|
||||
<a
|
||||
className="font-medium text-slate-700 underline hover:text-slate-900"
|
||||
href="mailto:hola@formbricks.com"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||
import { EnterpriseLicenseStatus } from "@/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/components/EnterpriseLicenseStatus";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
@@ -173,7 +173,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link
|
||||
href="https://app.formbricks.com/s/clvupq3y205i5yrm3sm9v1xt5"
|
||||
href={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
referrerPolicy="no-referrer">
|
||||
|
||||
+7
-1
@@ -1,6 +1,11 @@
|
||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||
import { isInstanceAIConfigured } from "@/lib/ai/service";
|
||||
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||
import {
|
||||
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
FB_LOGO_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_STORAGE_CONFIGURED,
|
||||
} from "@/lib/constants";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -80,6 +85,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
fbLogoUrl={FB_LOGO_URL}
|
||||
user={user}
|
||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
|
||||
/>
|
||||
{isMultiOrgEnabled && (
|
||||
<SettingsCard
|
||||
|
||||
+7
-1
@@ -2,7 +2,12 @@ import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/er
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED, RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import {
|
||||
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_STORAGE_CONFIGURED,
|
||||
RESPONSES_PER_PAGE,
|
||||
} from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -72,6 +77,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
|
||||
/>
|
||||
}>
|
||||
<SurveyAnalysisNavigation activeId="responses" />
|
||||
|
||||
+6
-6
@@ -31,6 +31,7 @@ interface SurveyAnalysisCTAProps {
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
isStorageConfigured: boolean;
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
interface ModalState {
|
||||
@@ -47,6 +48,7 @@ export const SurveyAnalysisCTA = ({
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
isStorageConfigured,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: SurveyAnalysisCTAProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -111,12 +113,9 @@ export const SurveyAnalysisCTA = ({
|
||||
const surveyUrl = new URL(`${publicDomain}/s/${survey.id}`);
|
||||
|
||||
if (survey.singleUse?.enabled) {
|
||||
const singleUseLinkParams = await refreshSingleUseId();
|
||||
if (singleUseLinkParams) {
|
||||
surveyUrl.searchParams.set("suId", singleUseLinkParams.suId);
|
||||
if (singleUseLinkParams.suToken) {
|
||||
surveyUrl.searchParams.set("suToken", singleUseLinkParams.suToken);
|
||||
}
|
||||
const newId = await refreshSingleUseId();
|
||||
if (newId) {
|
||||
surveyUrl.searchParams.set("suId", newId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +233,7 @@ export const SurveyAnalysisCTA = ({
|
||||
isReadOnly={isReadOnly}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
projectCustomScripts={project.customHeadScripts}
|
||||
enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl}
|
||||
/>
|
||||
)}
|
||||
<SuccessMessage />
|
||||
|
||||
+3
@@ -54,6 +54,7 @@ interface ShareSurveyModalProps {
|
||||
isReadOnly: boolean;
|
||||
isStorageConfigured: boolean;
|
||||
projectCustomScripts?: string | null;
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
export const ShareSurveyModal = ({
|
||||
@@ -69,6 +70,7 @@ export const ShareSurveyModal = ({
|
||||
isReadOnly,
|
||||
isStorageConfigured,
|
||||
projectCustomScripts,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: ShareSurveyModalProps) => {
|
||||
const environmentId = survey.environmentId;
|
||||
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
|
||||
@@ -108,6 +110,7 @@ export const ShareSurveyModal = ({
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
},
|
||||
disabled: survey.singleUse?.enabled,
|
||||
},
|
||||
|
||||
+17
-52
@@ -2,7 +2,7 @@
|
||||
|
||||
import { CirclePlayIcon, CopyIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -41,7 +41,6 @@ export const AnonymousLinksTab = ({
|
||||
const [isSingleUseLink, setIsSingleUseLink] = useState(survey.singleUse?.enabled ?? false);
|
||||
const [singleUseEncryption, setSingleUseEncryption] = useState(survey.singleUse?.isEncrypted ?? false);
|
||||
const [numberOfLinks, setNumberOfLinks] = useState<number | string>(1);
|
||||
const [customSingleUseId, setCustomSingleUseId] = useState("");
|
||||
|
||||
const [disableLinkModal, setDisableLinkModal] = useState<{
|
||||
open: boolean;
|
||||
@@ -49,6 +48,12 @@ export const AnonymousLinksTab = ({
|
||||
pendingAction: () => Promise<void> | void;
|
||||
} | null>(null);
|
||||
|
||||
const surveyUrlWithCustomSuid = useMemo(() => {
|
||||
const url = new URL(surveyUrl);
|
||||
url.searchParams.set("suId", "CUSTOM-ID");
|
||||
return url.toString();
|
||||
}, [surveyUrl]);
|
||||
|
||||
const resetState = () => {
|
||||
const { singleUse } = survey;
|
||||
const { enabled, isEncrypted } = singleUse ?? {};
|
||||
@@ -176,13 +181,10 @@ export const AnonymousLinksTab = ({
|
||||
});
|
||||
|
||||
if (!!response?.data?.length) {
|
||||
const singleUseLinkParams = response.data;
|
||||
const surveyLinks = singleUseLinkParams.map(({ suId, suToken }) => {
|
||||
const singleUseIds = response.data;
|
||||
const surveyLinks = singleUseIds.map((singleUseId) => {
|
||||
const url = new URL(surveyUrl);
|
||||
url.searchParams.set("suId", suId);
|
||||
if (suToken) {
|
||||
url.searchParams.set("suToken", suToken);
|
||||
}
|
||||
url.searchParams.set("suId", singleUseId);
|
||||
return url.toString();
|
||||
});
|
||||
|
||||
@@ -210,40 +212,6 @@ export const AnonymousLinksTab = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyCustomSingleUseLink = async () => {
|
||||
const trimmedCustomSingleUseId = customSingleUseId.trim();
|
||||
if (!trimmedCustomSingleUseId) {
|
||||
toast.error(t("environments.surveys.share.anonymous_links.custom_single_use_id_required"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await generateSingleUseIdsAction({
|
||||
surveyId: survey.id,
|
||||
isEncrypted: false,
|
||||
count: 1,
|
||||
singleUseId: trimmedCustomSingleUseId,
|
||||
});
|
||||
|
||||
const singleUseLinkParams = response?.data?.[0];
|
||||
if (!singleUseLinkParams) {
|
||||
toast.error(t("environments.surveys.share.anonymous_links.generate_links_error"));
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(surveyUrl);
|
||||
url.searchParams.set("suId", singleUseLinkParams.suId);
|
||||
if (singleUseLinkParams.suToken) {
|
||||
url.searchParams.set("suToken", singleUseLinkParams.suToken);
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(url.toString());
|
||||
toast.success(t("common.copied_to_clipboard"));
|
||||
} catch {
|
||||
toast.error(t("environments.surveys.share.anonymous_links.generate_links_error"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full flex-col justify-between space-y-4">
|
||||
@@ -311,19 +279,16 @@ export const AnonymousLinksTab = ({
|
||||
</Alert>
|
||||
|
||||
<div className="grid w-full grid-cols-6 items-center gap-2">
|
||||
<Input
|
||||
className="col-span-5 bg-white focus:border focus:border-slate-900"
|
||||
value={customSingleUseId}
|
||||
onChange={(event) => setCustomSingleUseId(event.target.value)}
|
||||
placeholder={t(
|
||||
"environments.surveys.share.anonymous_links.custom_single_use_id_placeholder"
|
||||
)}
|
||||
/>
|
||||
<div className="col-span-5 truncate rounded-md border border-slate-200 px-2 py-1">
|
||||
<span className="truncate text-sm text-slate-900">{surveyUrlWithCustomSuid}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={!customSingleUseId.trim()}
|
||||
onClick={handleCopyCustomSingleUseLink}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(surveyUrlWithCustomSuid);
|
||||
toast.success(t("common.copied_to_clipboard"));
|
||||
}}
|
||||
className="col-span-1 gap-1 text-sm">
|
||||
{t("common.copy")}
|
||||
<CopyIcon />
|
||||
|
||||
+3
-1
@@ -34,6 +34,7 @@ interface PersonalLinksTabProps {
|
||||
segments: TSegment[];
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
interface PersonalLinksFormData {
|
||||
@@ -74,6 +75,7 @@ export const PersonalLinksTab = ({
|
||||
surveyId,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: PersonalLinksTabProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -169,7 +171,7 @@ export const PersonalLinksTab = ({
|
||||
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
href: isFormbricksCloud
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
: enterpriseLicenseRequestFormUrl,
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
|
||||
+15
@@ -1106,6 +1106,21 @@ describe("getSurveySummary", () => {
|
||||
expect.objectContaining({ responseIds: expect.any(Array) })
|
||||
);
|
||||
});
|
||||
|
||||
test("does not pass responseIds for date-only filterCriteria", async () => {
|
||||
const filterCriteria: TResponseFilterCriteria = {
|
||||
createdAt: {
|
||||
min: new Date("2024-01-01T00:00:00.000Z"),
|
||||
max: new Date("2024-01-31T23:59:59.999Z"),
|
||||
},
|
||||
};
|
||||
|
||||
await getSurveySummary(mockSurveyId, filterCriteria);
|
||||
|
||||
expect(getDisplayCountBySurveyId).toHaveBeenCalledWith(mockSurveyId, {
|
||||
createdAt: filterCriteria.createdAt,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResponsesForSummary", () => {
|
||||
|
||||
+1
-1
@@ -979,7 +979,7 @@ export const getSurveySummary = reactCache(
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
|
||||
const batchSize = 5000;
|
||||
const hasFilter = Object.keys(filterCriteria ?? {}).length > 0;
|
||||
const hasFilter = Object.keys(filterCriteria ?? {}).some((filterKey) => filterKey !== "createdAt");
|
||||
|
||||
// Use cursor-based pagination instead of count + offset to avoid expensive queries
|
||||
const responses: TSurveySummaryResponse[] = [];
|
||||
|
||||
+7
-1
@@ -4,7 +4,12 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
|
||||
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
|
||||
import { DEFAULT_LOCALE, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_STORAGE_CONFIGURED,
|
||||
} from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
@@ -74,6 +79,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
|
||||
/>
|
||||
}>
|
||||
<SurveyAnalysisNavigation activeId="summary" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PipelineTriggers, Webhook } from "@prisma/client";
|
||||
import { headers } from "next/headers";
|
||||
import type { Agent } from "undici";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
@@ -16,7 +17,7 @@ import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { convertDatesInObject } from "@/lib/time";
|
||||
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
import { createPinnedDispatcher, validateAndResolveWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import { recordResponseCreatedMeterEvent } from "@/modules/ee/billing/lib/metering";
|
||||
@@ -98,11 +99,19 @@ export const POST = async (request: Request) => {
|
||||
// env var as `validateWebhookUrl`: self-hosters who opted into trusting internal URLs also get the
|
||||
// pre-patch redirect-follow behavior for consistency.
|
||||
const redirectMode: RequestRedirect = DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS ? "follow" : "manual";
|
||||
const fetchWithTimeout = (url: string, options: RequestInit, timeout: number = 5000): Promise<Response> => {
|
||||
return Promise.race([
|
||||
fetch(url, { ...options, redirect: redirectMode }),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeout)),
|
||||
]);
|
||||
// Uses AbortSignal to actually cancel the underlying fetch when the timer fires —
|
||||
// a Promise.race would only reject the wrapper while the fetch keeps the socket
|
||||
// open, which then deadlocks dispatcher.close() (graceful drain waits for it).
|
||||
const fetchWithTimeout = (
|
||||
url: string,
|
||||
options: RequestInit & { dispatcher?: Agent },
|
||||
timeout: number = 5000
|
||||
): Promise<Response> => {
|
||||
return fetch(url, {
|
||||
...options,
|
||||
redirect: redirectMode,
|
||||
signal: AbortSignal.timeout(timeout),
|
||||
} as RequestInit & { dispatcher?: Agent });
|
||||
};
|
||||
|
||||
const resolvedResponseData = resolveStorageUrlsInObject(response.data);
|
||||
@@ -144,14 +153,24 @@ export const POST = async (request: Request) => {
|
||||
);
|
||||
}
|
||||
|
||||
return validateWebhookUrl(webhook.url)
|
||||
.then(() =>
|
||||
fetchWithTimeout(webhook.url, {
|
||||
method: "POST",
|
||||
headers: requestHeaders,
|
||||
body,
|
||||
})
|
||||
)
|
||||
return validateAndResolveWebhookUrl(webhook.url)
|
||||
.then(async (address) => {
|
||||
// Pin TCP connect to the validated IP. Without this, undici resolves DNS
|
||||
// again at fetch time and an attacker-controlled domain can rebind to a
|
||||
// private/internal IP after validation passed (TOCTOU SSRF).
|
||||
const dispatcher = address ? createPinnedDispatcher(address) : undefined;
|
||||
try {
|
||||
return await fetchWithTimeout(webhook.url, {
|
||||
method: "POST",
|
||||
headers: requestHeaders,
|
||||
body,
|
||||
dispatcher,
|
||||
});
|
||||
} finally {
|
||||
// destroy() — not close() — force-kills sockets and rejects any in-flight request
|
||||
await dispatcher?.destroy();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
|
||||
});
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
|
||||
type PrismaKnownRequestError = Error & {
|
||||
code: string;
|
||||
meta?: {
|
||||
target?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export const isPrismaKnownRequestError = (error: unknown): error is PrismaKnownRequestError => {
|
||||
if (!(error instanceof Error) || error.name !== "PrismaClientKnownRequestError") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return typeof (error as { code?: unknown }).code === "string";
|
||||
};
|
||||
|
||||
export const isSingleUseIdUniqueConstraintError = (error: PrismaKnownRequestError): boolean => {
|
||||
if (error.code !== PrismaErrorType.UniqueConstraintViolation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Array.isArray(error.meta?.target) && error.meta.target.includes("singleUseId");
|
||||
};
|
||||
@@ -1,115 +0,0 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TResponseInput } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
import { symmetricDecrypt } from "@/lib/crypto";
|
||||
import { validateSurveySingleUseLinkParams } from "@/lib/utils/single-use-surveys";
|
||||
|
||||
type TSingleUseResponseInput = Pick<TResponseInput, "singleUseId" | "meta">;
|
||||
|
||||
type TValidateSingleUseResponseInputResult = { singleUseId: string } | { response: Response } | null;
|
||||
|
||||
export const validateSingleUseResponseInput = (
|
||||
survey: TSurvey,
|
||||
environmentId: string,
|
||||
responseInput: TSingleUseResponseInput
|
||||
): TValidateSingleUseResponseInputResult => {
|
||||
if (survey.type !== "link" || !survey.singleUse?.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!responseInput.singleUseId) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Missing single use id",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (!responseInput.meta?.url) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Missing or invalid URL in response metadata",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(responseInput.meta.url);
|
||||
} catch (error) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Invalid URL in response metadata",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
error: error instanceof Error ? error.message : "Unknown error occurred",
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const suId = url.searchParams.get("suId");
|
||||
const suToken = url.searchParams.get("suToken");
|
||||
|
||||
if (!suId) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Missing single use id",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
logger.error({ surveyId: survey.id, environmentId }, "ENCRYPTION_KEY is not set");
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("An unexpected error occurred.", true),
|
||||
};
|
||||
}
|
||||
|
||||
let canonicalSingleUseId: string | null = null;
|
||||
try {
|
||||
canonicalSingleUseId = validateSurveySingleUseLinkParams({
|
||||
surveyId: survey.id,
|
||||
suId,
|
||||
suToken,
|
||||
isEncrypted: survey.singleUse.isEncrypted,
|
||||
decrypt: (encryptedSingleUseId: string) => symmetricDecrypt(encryptedSingleUseId, ENCRYPTION_KEY),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error, surveyId: survey.id, environmentId }, "Failed to validate single-use id");
|
||||
}
|
||||
|
||||
if (!canonicalSingleUseId || canonicalSingleUseId !== responseInput.singleUseId) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Invalid single use id",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { singleUseId: canonicalSingleUseId };
|
||||
};
|
||||
@@ -177,7 +177,7 @@ describe("authenticateRequest", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when API key has no environment permissions", async () => {
|
||||
test("returns null by default when API key has no environment permissions", async () => {
|
||||
const request = new NextRequest("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
@@ -198,6 +198,72 @@ describe("authenticateRequest", () => {
|
||||
const result = await authenticateRequest(request);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("authenticates a valid API key with no environment permissions when explicitly allowed", async () => {
|
||||
const request = new NextRequest("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
|
||||
const mockApiKeyData = {
|
||||
id: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: "all" as const,
|
||||
createdAt: new Date(),
|
||||
createdBy: "user-id",
|
||||
lastUsedAt: null,
|
||||
label: "Test API Key",
|
||||
apiKeyEnvironments: [],
|
||||
};
|
||||
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
|
||||
|
||||
const result = await authenticateRequest(request, { allowOrganizationOnlyApiKey: true });
|
||||
expect(result).toEqual({
|
||||
type: "apiKey",
|
||||
environmentPermissions: [],
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: "all",
|
||||
});
|
||||
});
|
||||
|
||||
test("authenticates a read-only organization API key with no environment permissions", async () => {
|
||||
const request = new NextRequest("http://localhost/api/v1/management/surveys", {
|
||||
headers: { "x-api-key": "read-only-org-api-key" },
|
||||
});
|
||||
|
||||
const mockApiKeyData = {
|
||||
id: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
createdAt: new Date(),
|
||||
createdBy: "user-id",
|
||||
lastUsedAt: null,
|
||||
label: "Read-only Organization API Key",
|
||||
apiKeyEnvironments: [],
|
||||
};
|
||||
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
|
||||
|
||||
const result = await authenticateRequest(request, { allowOrganizationOnlyApiKey: true });
|
||||
expect(result).toEqual({
|
||||
type: "apiKey",
|
||||
environmentPermissions: [],
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleErrorResponse", () => {
|
||||
|
||||
@@ -9,18 +9,22 @@ import {
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
|
||||
export const authenticateRequest = async (request: NextRequest): Promise<TAuthenticationApiKey | null> => {
|
||||
const apiKey = request.headers.get("x-api-key");
|
||||
if (!apiKey) return null;
|
||||
type AuthenticateApiKeyOptions = {
|
||||
allowOrganizationOnlyApiKey?: boolean;
|
||||
};
|
||||
|
||||
// Get API key with permissions
|
||||
export const authenticateApiKey = async (
|
||||
apiKey: string,
|
||||
options: AuthenticateApiKeyOptions = {}
|
||||
): Promise<TAuthenticationApiKey | null> => {
|
||||
const apiKeyData = await getApiKeyWithPermissions(apiKey);
|
||||
if (!apiKeyData) return null;
|
||||
|
||||
// In the route handlers, we'll do more specific permission checks
|
||||
const environmentIds = apiKeyData.apiKeyEnvironments.map((env) => env.environmentId);
|
||||
if (environmentIds.length === 0) return null;
|
||||
if (!options.allowOrganizationOnlyApiKey && apiKeyData.apiKeyEnvironments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// In the route handlers, we'll do more specific permission checks
|
||||
const authentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
|
||||
@@ -38,6 +42,16 @@ export const authenticateRequest = async (request: NextRequest): Promise<TAuthen
|
||||
return authentication;
|
||||
};
|
||||
|
||||
export const authenticateRequest = async (
|
||||
request: NextRequest,
|
||||
options: AuthenticateApiKeyOptions = {}
|
||||
): Promise<TAuthenticationApiKey | null> => {
|
||||
const apiKey = request.headers.get("x-api-key");
|
||||
if (!apiKey) return null;
|
||||
|
||||
return authenticateApiKey(apiKey, options);
|
||||
};
|
||||
|
||||
export const handleErrorResponse = (error: any): Response => {
|
||||
switch (error.message) {
|
||||
case "NotAuthenticated":
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponseInput } from "@formbricks/types/responses";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
@@ -9,8 +9,6 @@ import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||
import { createResponse, createResponseWithQuotaEvaluation } from "./response";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
let mockIsFormbricksCloud = false;
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
@@ -139,16 +137,6 @@ describe("createResponse", () => {
|
||||
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("should throw UniqueConstraintError on P2002 with singleUseId target", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
meta: { target: ["surveyId", "singleUseId"] },
|
||||
});
|
||||
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
|
||||
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(UniqueConstraintError);
|
||||
});
|
||||
|
||||
test("should throw original error on other Prisma errors", async () => {
|
||||
const genericError = new Error("Generic database error");
|
||||
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
|
||||
|
||||
@@ -2,14 +2,10 @@ import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import {
|
||||
isPrismaKnownRequestError,
|
||||
isSingleUseIdUniqueConstraintError,
|
||||
} from "@/app/api/client/[environmentId]/responses/lib/prisma-error";
|
||||
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
@@ -124,11 +120,7 @@ export const createResponse = async (
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (isPrismaKnownRequestError(error)) {
|
||||
if (isSingleUseIdUniqueConstraintError(error)) {
|
||||
throw new UniqueConstraintError("Response already submitted for this single-use link");
|
||||
}
|
||||
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,15 +2,16 @@ import { headers } from "next/headers";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZEnvironmentId } from "@formbricks/types/environment";
|
||||
import { InvalidInputError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { validateSingleUseResponseInput } from "@/app/api/client/[environmentId]/responses/lib/single-use";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
import { symmetricDecrypt } from "@/lib/crypto";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
@@ -128,16 +129,112 @@ export const POST = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const singleUseValidationResult = validateSingleUseResponseInput(
|
||||
survey,
|
||||
environmentId,
|
||||
responseInputData
|
||||
);
|
||||
if (singleUseValidationResult) {
|
||||
if ("response" in singleUseValidationResult) {
|
||||
return { response: singleUseValidationResult.response };
|
||||
if (survey.type === "link" && survey.singleUse?.enabled) {
|
||||
if (!responseInputData.singleUseId) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Missing single use id",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (!responseInputData.meta?.url) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Missing or invalid URL in response metadata",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(responseInputData.meta.url);
|
||||
} catch (error) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Invalid URL in response metadata",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
error: error instanceof Error ? error.message : "Unknown error occurred",
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const suId = url.searchParams.get("suId");
|
||||
if (!suId) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Missing single use id",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (survey.singleUse.isEncrypted) {
|
||||
if (!ENCRYPTION_KEY) {
|
||||
logger.error({ url: req.url, surveyId: survey.id, environmentId }, "ENCRYPTION_KEY is not set");
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("An unexpected error occurred.", true),
|
||||
};
|
||||
}
|
||||
|
||||
let decryptedSuId: string;
|
||||
try {
|
||||
decryptedSuId = symmetricDecrypt(suId, ENCRYPTION_KEY);
|
||||
} catch {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Invalid single use id",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (decryptedSuId !== responseInputData.singleUseId) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Invalid single use id",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
} else if (responseInputData.singleUseId !== suId) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Invalid single use id",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
responseInputData.singleUseId = singleUseValidationResult.singleUseId;
|
||||
}
|
||||
|
||||
if (!validateFileUploads(responseInputData.data, survey.questions)) {
|
||||
@@ -181,10 +278,6 @@ export const POST = withV1ApiWrapper({
|
||||
return {
|
||||
response: responses.badRequestResponse(error.message),
|
||||
};
|
||||
} else if (error instanceof UniqueConstraintError) {
|
||||
return {
|
||||
response: responses.conflictResponse(error.message, undefined, true),
|
||||
};
|
||||
} else {
|
||||
logger.error({ error, url: req.url }, "Error creating response");
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { EnvironmentType } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { buildApiKeyMeResponse } from "./api-key-response";
|
||||
|
||||
vi.mock("@/lib/environment/service", () => ({
|
||||
getEnvironment: vi.fn(),
|
||||
}));
|
||||
|
||||
const baseAuthentication = {
|
||||
type: "apiKey" as const,
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: false,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
environmentPermissions: [],
|
||||
};
|
||||
|
||||
const environmentPermission = (
|
||||
environmentId: string,
|
||||
permission: "read" | "write" | "manage" = "read"
|
||||
): TAuthenticationApiKey["environmentPermissions"][number] => ({
|
||||
environmentId,
|
||||
permission,
|
||||
environmentType: EnvironmentType.development,
|
||||
projectId: "project-id",
|
||||
projectName: "Project Name",
|
||||
});
|
||||
|
||||
describe("buildApiKeyMeResponse", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns auth metadata for an organization-read API key without environment permissions", async () => {
|
||||
const response = await buildApiKeyMeResponse({
|
||||
...baseAuthentication,
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response?.status).toBe(200);
|
||||
expect(await response?.json()).toEqual({
|
||||
environmentPermissions: [],
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getEnvironment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns auth metadata with permissions for organization-read API keys with multiple environments", async () => {
|
||||
const response = await buildApiKeyMeResponse({
|
||||
...baseAuthentication,
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
environmentPermissions: [
|
||||
environmentPermission("env-1", "read"),
|
||||
environmentPermission("env-2", "write"),
|
||||
],
|
||||
});
|
||||
|
||||
expect(response?.status).toBe(200);
|
||||
expect(await response?.json()).toEqual({
|
||||
environmentPermissions: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
environmentType: EnvironmentType.development,
|
||||
permissions: "read",
|
||||
projectId: "project-id",
|
||||
projectName: "Project Name",
|
||||
},
|
||||
{
|
||||
environmentId: "env-2",
|
||||
environmentType: EnvironmentType.development,
|
||||
permissions: "write",
|
||||
projectId: "project-id",
|
||||
projectName: "Project Name",
|
||||
},
|
||||
],
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getEnvironment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns the legacy environment response for a single environment permission", async () => {
|
||||
const createdAt = new Date("2026-01-01T00:00:00.000Z");
|
||||
const updatedAt = new Date("2026-01-02T00:00:00.000Z");
|
||||
|
||||
vi.mocked(getEnvironment).mockResolvedValue({
|
||||
id: "env-id",
|
||||
type: EnvironmentType.development,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
projectId: "project-id",
|
||||
appSetupCompleted: true,
|
||||
});
|
||||
|
||||
const response = await buildApiKeyMeResponse({
|
||||
...baseAuthentication,
|
||||
environmentPermissions: [environmentPermission("env-id", "read")],
|
||||
});
|
||||
|
||||
expect(response?.status).toBe(200);
|
||||
expect(await response?.json()).toEqual({
|
||||
id: "env-id",
|
||||
type: EnvironmentType.development,
|
||||
createdAt: createdAt.toISOString(),
|
||||
updatedAt: updatedAt.toISOString(),
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "project-id",
|
||||
name: "Project Name",
|
||||
},
|
||||
});
|
||||
expect(getEnvironment).toHaveBeenCalledWith("env-id");
|
||||
});
|
||||
|
||||
test("returns the legacy environment response for an organization-read API key with one environment", async () => {
|
||||
const createdAt = new Date("2026-01-01T00:00:00.000Z");
|
||||
const updatedAt = new Date("2026-01-02T00:00:00.000Z");
|
||||
|
||||
vi.mocked(getEnvironment).mockResolvedValue({
|
||||
id: "env-id",
|
||||
type: EnvironmentType.development,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
projectId: "project-id",
|
||||
appSetupCompleted: true,
|
||||
});
|
||||
|
||||
const response = await buildApiKeyMeResponse({
|
||||
...baseAuthentication,
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
environmentPermissions: [environmentPermission("env-id", "read")],
|
||||
});
|
||||
|
||||
expect(response?.status).toBe(200);
|
||||
expect(await response?.json()).toEqual({
|
||||
id: "env-id",
|
||||
type: EnvironmentType.development,
|
||||
createdAt: createdAt.toISOString(),
|
||||
updatedAt: updatedAt.toISOString(),
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "project-id",
|
||||
name: "Project Name",
|
||||
},
|
||||
});
|
||||
expect(getEnvironment).toHaveBeenCalledWith("env-id");
|
||||
});
|
||||
|
||||
test("returns null when an API key has neither organization read nor exactly one environment", async () => {
|
||||
const response = await buildApiKeyMeResponse(baseAuthentication);
|
||||
|
||||
expect(response).toBeNull();
|
||||
expect(getEnvironment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns null when the single permitted environment no longer exists", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValue(null);
|
||||
|
||||
const response = await buildApiKeyMeResponse({
|
||||
...baseAuthentication,
|
||||
environmentPermissions: [environmentPermission("env-id", "read")],
|
||||
});
|
||||
|
||||
expect(response).toBeNull();
|
||||
expect(getEnvironment).toHaveBeenCalledWith("env-id");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { OrganizationAccessType } from "@formbricks/types/api-key";
|
||||
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { hasOrganizationAccess } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
|
||||
const buildApiKeyMetadataResponse = (authentication: TAuthenticationApiKey) => ({
|
||||
environmentPermissions: authentication.environmentPermissions.map((permission) => ({
|
||||
environmentId: permission.environmentId,
|
||||
environmentType: permission.environmentType,
|
||||
permissions: permission.permission,
|
||||
projectId: permission.projectId,
|
||||
projectName: permission.projectName,
|
||||
})),
|
||||
organizationId: authentication.organizationId,
|
||||
organizationAccess: authentication.organizationAccess,
|
||||
});
|
||||
|
||||
export const buildApiKeyMeResponse = async (
|
||||
authentication: TAuthenticationApiKey
|
||||
): Promise<Response | null> => {
|
||||
const environmentPermissionCount = authentication.environmentPermissions.length;
|
||||
|
||||
if (environmentPermissionCount !== 1) {
|
||||
if (hasOrganizationAccess(authentication, OrganizationAccessType.Read)) {
|
||||
return Response.json(buildApiKeyMetadataResponse(authentication));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const permission = authentication.environmentPermissions[0];
|
||||
const environment = await getEnvironment(permission.environmentId);
|
||||
|
||||
if (!environment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
id: environment.id,
|
||||
type: environment.type,
|
||||
createdAt: environment.createdAt,
|
||||
updatedAt: environment.updatedAt,
|
||||
appSetupCompleted: environment.appSetupCompleted,
|
||||
project: {
|
||||
id: permission.projectId,
|
||||
name: permission.projectName,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,104 +1,13 @@
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { authenticateApiKey } from "@/app/api/v1/auth";
|
||||
import { buildApiKeyMeResponse } from "@/app/api/v1/management/me/lib/api-key-response";
|
||||
import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { CONTROL_HASH } from "@/lib/constants";
|
||||
import { hashSha256, parseApiKeyV2, verifySecret } from "@/lib/crypto";
|
||||
import { publicUserSelect } from "@/lib/user/public-user";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
|
||||
const ALLOWED_PERMISSIONS = ["manage", "read", "write"] as const;
|
||||
|
||||
const apiKeySelect = {
|
||||
id: true,
|
||||
organizationId: true,
|
||||
lastUsedAt: true,
|
||||
apiKeyEnvironments: {
|
||||
select: {
|
||||
environment: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
projectId: true,
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
hashedKey: true,
|
||||
};
|
||||
|
||||
type ApiKeyData = {
|
||||
id: string;
|
||||
hashedKey: string;
|
||||
organizationId: string;
|
||||
lastUsedAt: Date | null;
|
||||
apiKeyEnvironments: Array<{
|
||||
permission: string;
|
||||
environment: {
|
||||
id: string;
|
||||
type: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
projectId: string;
|
||||
appSetupCompleted: boolean;
|
||||
project: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
const validateApiKey = async (apiKey: string): Promise<ApiKeyData | null> => {
|
||||
const v2Parsed = parseApiKeyV2(apiKey);
|
||||
|
||||
if (v2Parsed) {
|
||||
return validateV2ApiKey(v2Parsed);
|
||||
}
|
||||
|
||||
return validateLegacyApiKey(apiKey);
|
||||
};
|
||||
|
||||
const validateV2ApiKey = async (v2Parsed: { secret: string }): Promise<ApiKeyData | null> => {
|
||||
// Step 1: Fast SHA-256 lookup by indexed lookupHash
|
||||
const lookupHash = hashSha256(v2Parsed.secret);
|
||||
|
||||
const apiKeyData = await prisma.apiKey.findUnique({
|
||||
where: { lookupHash },
|
||||
select: apiKeySelect,
|
||||
});
|
||||
|
||||
// Step 2: Security verification with bcrypt
|
||||
// Always perform bcrypt verification to prevent timing attacks
|
||||
// Use a control hash when API key doesn't exist to maintain constant timing
|
||||
const hashToVerify = apiKeyData?.hashedKey || CONTROL_HASH;
|
||||
const isValid = await verifySecret(v2Parsed.secret, hashToVerify);
|
||||
|
||||
if (!apiKeyData || !isValid) return null;
|
||||
|
||||
return apiKeyData;
|
||||
};
|
||||
|
||||
const validateLegacyApiKey = async (apiKey: string): Promise<ApiKeyData | null> => {
|
||||
const hashedKey = hashSha256(apiKey);
|
||||
const result = await prisma.apiKey.findFirst({
|
||||
where: { hashedKey },
|
||||
select: apiKeySelect,
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const checkRateLimit = async (userId: string) => {
|
||||
try {
|
||||
await applyRateLimit(rateLimitConfigs.api.v1, userId);
|
||||
@@ -110,59 +19,23 @@ const checkRateLimit = async (userId: string) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const updateApiKeyUsage = async (apiKeyId: string) => {
|
||||
await prisma.apiKey.update({
|
||||
where: { id: apiKeyId },
|
||||
data: { lastUsedAt: new Date() },
|
||||
});
|
||||
};
|
||||
|
||||
const buildEnvironmentResponse = (apiKeyData: ApiKeyData) => {
|
||||
const env = apiKeyData.apiKeyEnvironments[0].environment;
|
||||
return Response.json({
|
||||
id: env.id,
|
||||
type: env.type,
|
||||
createdAt: env.createdAt,
|
||||
updatedAt: env.updatedAt,
|
||||
appSetupCompleted: env.appSetupCompleted,
|
||||
project: {
|
||||
id: env.projectId,
|
||||
name: env.project.name,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isValidApiKeyEnvironment = (apiKeyData: ApiKeyData): boolean => {
|
||||
return (
|
||||
apiKeyData.apiKeyEnvironments.length === 1 &&
|
||||
ALLOWED_PERMISSIONS.includes(
|
||||
apiKeyData.apiKeyEnvironments[0].permission as (typeof ALLOWED_PERMISSIONS)[number]
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleApiKeyAuthentication = async (apiKey: string) => {
|
||||
const apiKeyData = await validateApiKey(apiKey);
|
||||
const authentication = await authenticateApiKey(apiKey, { allowOrganizationOnlyApiKey: true });
|
||||
|
||||
if (!apiKeyData) {
|
||||
if (!authentication) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
if (!apiKeyData.lastUsedAt || apiKeyData.lastUsedAt <= new Date(Date.now() - 1000 * 30)) {
|
||||
// Fire-and-forget: update lastUsedAt in the background without blocking the response
|
||||
updateApiKeyUsage(apiKeyData.id).catch((error) => {
|
||||
console.error("Failed to update API key usage:", error);
|
||||
});
|
||||
}
|
||||
|
||||
const rateLimitError = await checkRateLimit(apiKeyData.id);
|
||||
const rateLimitError = await checkRateLimit(authentication.apiKeyId);
|
||||
if (rateLimitError) return rateLimitError;
|
||||
|
||||
if (!isValidApiKeyEnvironment(apiKeyData)) {
|
||||
const apiKeyMeResponse = await buildApiKeyMeResponse(authentication);
|
||||
|
||||
if (!apiKeyMeResponse) {
|
||||
return responses.badRequestResponse("You can't use this method with this API key");
|
||||
}
|
||||
|
||||
return buildEnvironmentResponse(apiKeyData);
|
||||
return apiKeyMeResponse;
|
||||
};
|
||||
|
||||
const handleSessionAuthentication = async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { responses } from "@/app/lib/api/response";
|
||||
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { generateSurveySingleUseLinkParamsList } from "@/lib/utils/single-use-surveys";
|
||||
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
@@ -56,22 +56,13 @@ export const GET = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const singleUseLinkParams = generateSurveySingleUseLinkParamsList(
|
||||
limit,
|
||||
survey.id,
|
||||
survey.singleUse.isEncrypted
|
||||
);
|
||||
const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted);
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
// map single use ids to survey links
|
||||
const surveyLinks = singleUseLinkParams.map(({ suId, suToken }) => {
|
||||
const surveyLink = new URL(`${publicDomain}/s/${survey.id}`);
|
||||
surveyLink.searchParams.set("suId", suId);
|
||||
if (suToken) {
|
||||
surveyLink.searchParams.set("suToken", suToken);
|
||||
}
|
||||
return surveyLink.toString();
|
||||
});
|
||||
const surveyLinks = singleUseIds.map(
|
||||
(singleUseId) => `${publicDomain}/s/${survey.id}?suId=${singleUseId}`
|
||||
);
|
||||
|
||||
return {
|
||||
response: responses.successResponse(surveyLinks),
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { getEnvironmentIdsByOrganizationId } from "./environment";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
environment: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getEnvironmentIdsByOrganizationId", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns environment IDs for all projects in an organization", async () => {
|
||||
vi.mocked(prisma.environment.findMany).mockResolvedValue([{ id: "env-1" }, { id: "env-2" }] as any);
|
||||
|
||||
const result = await getEnvironmentIdsByOrganizationId("clh6pzwx90000e9ogjr0mf7so");
|
||||
|
||||
expect(result).toEqual(["env-1", "env-2"]);
|
||||
expect(prisma.environment.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
project: {
|
||||
organizationId: "clh6pzwx90000e9ogjr0mf7so",
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns an empty list when the organization has no environments", async () => {
|
||||
vi.mocked(prisma.environment.findMany).mockResolvedValue([]);
|
||||
|
||||
const result = await getEnvironmentIdsByOrganizationId("clh6pzwx90000e9ogjr0mf7so");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("throws DatabaseError for known Prisma errors", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.environment.findMany).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getEnvironmentIdsByOrganizationId("clh6pzwx90000e9ogjr0mf7so")).rejects.toThrow(
|
||||
DatabaseError
|
||||
);
|
||||
});
|
||||
|
||||
test("rethrows unknown errors", async () => {
|
||||
const error = new Error("boom");
|
||||
vi.mocked(prisma.environment.findMany).mockRejectedValue(error);
|
||||
|
||||
await expect(getEnvironmentIdsByOrganizationId("clh6pzwx90000e9ogjr0mf7so")).rejects.toThrow(error);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
|
||||
export const getEnvironmentIdsByOrganizationId = reactCache(
|
||||
async (organizationId: string): Promise<string[]> => {
|
||||
validateInputs([organizationId, ZId]);
|
||||
|
||||
try {
|
||||
const environments = await prisma.environment.findMany({
|
||||
where: {
|
||||
project: {
|
||||
organizationId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return environments.map((environment) => environment.id);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import {
|
||||
TSurvey,
|
||||
@@ -9,7 +10,8 @@ import { responses } from "@/app/lib/api/response";
|
||||
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
|
||||
import { checkFeaturePermissions } from "./utils";
|
||||
import { getEnvironmentIdsByOrganizationId } from "./environment";
|
||||
import { checkFeaturePermissions, getReadableEnvironmentIds } from "./utils";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/app/lib/api/response", () => ({
|
||||
@@ -34,6 +36,10 @@ vi.mock("@/lib/survey/utils", () => ({
|
||||
getElementsFromBlocks: vi.fn((blocks: any[]) => blocks.flatMap((block: any) => block.elements)),
|
||||
}));
|
||||
|
||||
vi.mock("./environment", () => ({
|
||||
getEnvironmentIdsByOrganizationId: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockOrganization: TOrganization = {
|
||||
id: "test-org",
|
||||
name: "Test Organization",
|
||||
@@ -108,6 +114,109 @@ const baseSurveyData: TSurveyCreateInputWithEnvironmentId = {
|
||||
followUps: [],
|
||||
};
|
||||
|
||||
const baseAuthentication = {
|
||||
type: "apiKey" as const,
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: false,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
environmentPermissions: [],
|
||||
};
|
||||
|
||||
const environmentPermission = (
|
||||
environmentId: string,
|
||||
permission: "read" | "write" | "manage"
|
||||
): TAuthenticationApiKey["environmentPermissions"][number] => ({
|
||||
environmentId,
|
||||
permission,
|
||||
environmentType: "development",
|
||||
projectId: `project-${environmentId}`,
|
||||
projectName: `Project ${environmentId}`,
|
||||
});
|
||||
|
||||
describe("getReadableEnvironmentIds", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns all organization environments when API key has organization read access", async () => {
|
||||
vi.mocked(getEnvironmentIdsByOrganizationId).mockResolvedValue(["env-1", "env-2"]);
|
||||
|
||||
const result = await getReadableEnvironmentIds({
|
||||
...baseAuthentication,
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(["env-1", "env-2"]);
|
||||
expect(getEnvironmentIdsByOrganizationId).toHaveBeenCalledWith("org-id");
|
||||
});
|
||||
|
||||
test("returns an empty list when an organization-read API key belongs to an organization without environments", async () => {
|
||||
vi.mocked(getEnvironmentIdsByOrganizationId).mockResolvedValue([]);
|
||||
|
||||
const result = await getReadableEnvironmentIds({
|
||||
...baseAuthentication,
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(getEnvironmentIdsByOrganizationId).toHaveBeenCalledWith("org-id");
|
||||
});
|
||||
|
||||
test("returns all organization environments when API key has organization write access", async () => {
|
||||
vi.mocked(getEnvironmentIdsByOrganizationId).mockResolvedValue(["env-1"]);
|
||||
|
||||
const result = await getReadableEnvironmentIds({
|
||||
...baseAuthentication,
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: false,
|
||||
write: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(["env-1"]);
|
||||
expect(getEnvironmentIdsByOrganizationId).toHaveBeenCalledWith("org-id");
|
||||
});
|
||||
|
||||
test("returns de-duplicated environment permissions that allow GET without organization access", async () => {
|
||||
const result = await getReadableEnvironmentIds({
|
||||
...baseAuthentication,
|
||||
environmentPermissions: [
|
||||
environmentPermission("env-1", "read"),
|
||||
environmentPermission("env-2", "write"),
|
||||
environmentPermission("env-3", "manage"),
|
||||
environmentPermission("env-1", "read"),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toEqual(["env-1", "env-2", "env-3"]);
|
||||
expect(getEnvironmentIdsByOrganizationId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns null when the API key has no readable access", async () => {
|
||||
const result = await getReadableEnvironmentIds(baseAuthentication);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(getEnvironmentIdsByOrganizationId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkFeaturePermissions", () => {
|
||||
vi.mocked(getExternalUrlsPermission).mockResolvedValue(true);
|
||||
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
import { OrganizationAccessType } from "@formbricks/types/api-key";
|
||||
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TSurvey, TSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { hasOrganizationAccess, hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
|
||||
import { getEnvironmentIdsByOrganizationId } from "./environment";
|
||||
|
||||
export const getReadableEnvironmentIds = async (
|
||||
authentication: TAuthenticationApiKey
|
||||
): Promise<string[] | null> => {
|
||||
if (hasOrganizationAccess(authentication, OrganizationAccessType.Read)) {
|
||||
return getEnvironmentIdsByOrganizationId(authentication.organizationId);
|
||||
}
|
||||
|
||||
const environmentIds = authentication.environmentPermissions
|
||||
.filter((permission) =>
|
||||
hasPermission(authentication.environmentPermissions, permission.environmentId, "GET")
|
||||
)
|
||||
.map((permission) => permission.environmentId);
|
||||
|
||||
const readableEnvironmentIds = Array.from(new Set(environmentIds));
|
||||
|
||||
return readableEnvironmentIds.length > 0 ? readableEnvironmentIds : null;
|
||||
};
|
||||
|
||||
export const checkFeaturePermissions = async (
|
||||
surveyData: TSurveyCreateInputWithEnvironmentId,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
|
||||
import { checkFeaturePermissions } from "@/app/api/v1/management/surveys/lib/utils";
|
||||
import {
|
||||
checkFeaturePermissions,
|
||||
getReadableEnvironmentIds,
|
||||
} from "@/app/api/v1/management/surveys/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import {
|
||||
transformBlocksToQuestions,
|
||||
@@ -17,6 +20,7 @@ import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
import { getSurveys } from "./lib/surveys";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
allowOrganizationOnlyApiKey: true,
|
||||
handler: async ({ req, authentication }) => {
|
||||
if (!authentication || !("apiKeyId" in authentication)) {
|
||||
return { response: responses.notAuthenticatedResponse() };
|
||||
@@ -27,9 +31,10 @@ export const GET = withV1ApiWrapper({
|
||||
const limit = searchParams.has("limit") ? Number(searchParams.get("limit")) : undefined;
|
||||
const offset = searchParams.has("offset") ? Number(searchParams.get("offset")) : undefined;
|
||||
|
||||
const environmentIds = authentication.environmentPermissions.map(
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
const environmentIds = await getReadableEnvironmentIds(authentication);
|
||||
if (!environmentIds) {
|
||||
return { response: responses.unauthorizedResponse() };
|
||||
}
|
||||
|
||||
const surveys = await getSurveys(environmentIds, limit, offset);
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ vi.mock("react", async () => {
|
||||
});
|
||||
|
||||
const contactId = "test-contact-id";
|
||||
const environmentId = "test-env-id";
|
||||
|
||||
describe("doesContactExist", () => {
|
||||
afterEach(() => {
|
||||
@@ -35,23 +36,23 @@ describe("doesContactExist", () => {
|
||||
environmentId: "test-env",
|
||||
});
|
||||
|
||||
const result = await doesContactExist(contactId);
|
||||
const result = await doesContactExist(contactId, environmentId);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: contactId },
|
||||
where: { id: contactId, environmentId },
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("should return false if contact does not exist", async () => {
|
||||
test("should return false if contact does not exist in the environment", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
|
||||
|
||||
const result = await doesContactExist(contactId);
|
||||
const result = await doesContactExist(contactId, environmentId);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: contactId },
|
||||
where: { id: contactId, environmentId },
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
export const doesContactExist = reactCache(async (id: string): Promise<boolean> => {
|
||||
export const doesContactExist = reactCache(async (id: string, environmentId: string): Promise<boolean> => {
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
id,
|
||||
environmentId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -81,7 +81,7 @@ describe("createDisplay", () => {
|
||||
const result = await createDisplay(displayInput);
|
||||
|
||||
expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]);
|
||||
expect(doesContactExist).toHaveBeenCalledWith(contactId);
|
||||
expect(doesContactExist).toHaveBeenCalledWith(contactId, environmentId);
|
||||
expect(prisma.display.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
survey: { connect: { id: surveyId } },
|
||||
@@ -108,14 +108,14 @@ describe("createDisplay", () => {
|
||||
expect(result).toEqual(mockDisplayWithoutContact); // Changed this line
|
||||
});
|
||||
|
||||
test("should create a display even if contact does not exist", async () => {
|
||||
test("should create a display without contact if contact does not exist in the environment", async () => {
|
||||
vi.mocked(doesContactExist).mockResolvedValue(false);
|
||||
vi.mocked(prisma.display.create).mockResolvedValue(mockDisplayWithoutContact); // Expect no contact connection
|
||||
|
||||
const result = await createDisplay(displayInput);
|
||||
|
||||
expect(validateInputs).toHaveBeenCalledWith([displayInput, expect.any(Object)]);
|
||||
expect(doesContactExist).toHaveBeenCalledWith(contactId);
|
||||
expect(doesContactExist).toHaveBeenCalledWith(contactId, environmentId);
|
||||
expect(prisma.display.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
survey: { connect: { id: surveyId } },
|
||||
@@ -142,7 +142,7 @@ describe("createDisplay", () => {
|
||||
vi.mocked(prisma.survey.findUnique).mockResolvedValue(null);
|
||||
|
||||
await expect(createDisplay(displayInput)).rejects.toThrow(new ResourceNotFoundError("Survey", surveyId));
|
||||
expect(doesContactExist).toHaveBeenCalledWith(contactId);
|
||||
expect(doesContactExist).toHaveBeenCalledWith(contactId, environmentId);
|
||||
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: surveyId, environmentId },
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promis
|
||||
const { contactId, surveyId, environmentId } = displayInput;
|
||||
|
||||
try {
|
||||
const contactExists = contactId ? await doesContactExist(contactId) : false;
|
||||
const contactExists = contactId ? await doesContactExist(contactId, environmentId) : false;
|
||||
|
||||
const survey = await prisma.survey.findUnique({
|
||||
where: {
|
||||
|
||||
@@ -13,6 +13,7 @@ vi.mock("@formbricks/database", () => ({
|
||||
}));
|
||||
|
||||
const contactId = "test-contact-id";
|
||||
const environmentId = "test-env-id";
|
||||
const mockContact = {
|
||||
id: contactId,
|
||||
attributes: [
|
||||
@@ -32,10 +33,10 @@ describe("getContact", () => {
|
||||
mockContact as unknown as Awaited<ReturnType<typeof prisma.contact.findUnique>>
|
||||
);
|
||||
|
||||
const result = await getContact(contactId);
|
||||
const result = await getContact(contactId, environmentId);
|
||||
|
||||
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: contactId },
|
||||
where: { id: contactId, environmentId },
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
@@ -55,10 +56,10 @@ describe("getContact", () => {
|
||||
test("should return null when contact is not found", async () => {
|
||||
vi.mocked(prisma.contact.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await getContact(contactId);
|
||||
const result = await getContact(contactId, environmentId);
|
||||
|
||||
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: contactId },
|
||||
where: { id: contactId, environmentId },
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
|
||||
@@ -2,9 +2,16 @@ import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
|
||||
export const getContact = reactCache(async (contactId: string) => {
|
||||
type TContactAttributeResult = {
|
||||
attributeKey: {
|
||||
key: string;
|
||||
};
|
||||
value: string;
|
||||
};
|
||||
|
||||
export const getContact = reactCache(async (contactId: string, environmentId: string) => {
|
||||
const contact = await prisma.contact.findUnique({
|
||||
where: { id: contactId },
|
||||
where: { id: contactId, environmentId },
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
@@ -20,10 +27,13 @@ export const getContact = reactCache(async (contactId: string) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contactAttributes = contact.attributes.reduce<TContactAttributes>((acc, attr) => {
|
||||
acc[attr.attributeKey.key] = attr.value;
|
||||
return acc;
|
||||
}, {});
|
||||
const contactAttributes = contact.attributes.reduce(
|
||||
(acc: TContactAttributes, attr: TContactAttributeResult) => {
|
||||
acc[attr.attributeKey.key] = attr.value;
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
return {
|
||||
id: contact.id,
|
||||
|
||||
@@ -236,6 +236,51 @@ describe("createResponse V2", () => {
|
||||
const result = await createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient);
|
||||
expect(result.tags).toEqual([mockTag]);
|
||||
});
|
||||
|
||||
test("should create response with contact when contact belongs to the environment", async () => {
|
||||
const responseInputWithContact = {
|
||||
...mockResponseInput,
|
||||
contactId,
|
||||
};
|
||||
|
||||
const result = await createResponse(
|
||||
responseInputWithContact,
|
||||
mockTx as unknown as Prisma.TransactionClient
|
||||
);
|
||||
|
||||
expect(getContact).toHaveBeenCalledWith(contactId, environmentId);
|
||||
expect(mockTx.response.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
contact: { connect: { id: contactId } },
|
||||
contactAttributes: mockContact.attributes,
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(result.contact).toEqual({
|
||||
id: contactId,
|
||||
userId,
|
||||
});
|
||||
});
|
||||
|
||||
test("should create response without contact when contact is not found in the environment", async () => {
|
||||
vi.mocked(getContact).mockResolvedValue(null);
|
||||
const responseInputWithContact = {
|
||||
...mockResponseInput,
|
||||
contactId,
|
||||
};
|
||||
|
||||
const result = await createResponse(
|
||||
responseInputWithContact,
|
||||
mockTx as unknown as Prisma.TransactionClient
|
||||
);
|
||||
const createArgs = mockTx.response.create.mock.calls[0][0];
|
||||
|
||||
expect(getContact).toHaveBeenCalledWith(contactId, environmentId);
|
||||
expect(createArgs.data).not.toHaveProperty("contact");
|
||||
expect(createArgs.data).not.toHaveProperty("contactAttributes");
|
||||
expect(result.contact).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createResponseWithQuotaEvaluation V2", () => {
|
||||
|
||||
@@ -6,10 +6,6 @@ import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@fo
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import {
|
||||
isPrismaKnownRequestError,
|
||||
isSingleUseIdUniqueConstraintError,
|
||||
} from "@/app/api/client/[environmentId]/responses/lib/prisma-error";
|
||||
import { responseSelection } from "@/app/api/v1/client/[environmentId]/responses/lib/response";
|
||||
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
@@ -21,7 +17,7 @@ import { getContact } from "./contact";
|
||||
export const createResponseWithQuotaEvaluation = async (
|
||||
responseInput: TResponseInputV2
|
||||
): Promise<TResponseWithQuotaFull> => {
|
||||
const txResponse = await prisma.$transaction(async (tx) => {
|
||||
const txResponse = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
const response = await createResponse(responseInput, tx);
|
||||
|
||||
const quotaResult = await evaluateResponseQuotas({
|
||||
@@ -105,7 +101,7 @@ export const createResponse = async (
|
||||
}
|
||||
|
||||
if (contactId) {
|
||||
contact = await getContact(contactId);
|
||||
contact = await getContact(contactId, environmentId);
|
||||
}
|
||||
|
||||
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
|
||||
@@ -132,9 +128,12 @@ export const createResponse = async (
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (isPrismaKnownRequestError(error)) {
|
||||
if (isSingleUseIdUniqueConstraintError(error)) {
|
||||
throw new UniqueConstraintError("Response already submitted for this single-use link");
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === "P2002") {
|
||||
const target = (error.meta?.target as string[]) ?? [];
|
||||
if (target?.includes("singleUseId")) {
|
||||
throw new UniqueConstraintError("Response already submitted for this single-use link");
|
||||
}
|
||||
}
|
||||
|
||||
throw new DatabaseError(error.message);
|
||||
|
||||
@@ -9,7 +9,6 @@ import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { symmetricDecrypt } from "@/lib/crypto";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { generateSurveySingleUseSignature } from "@/lib/utils/single-use-surveys";
|
||||
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
|
||||
vi.mock("@/lib/i18n/utils", () => ({
|
||||
@@ -53,11 +52,6 @@ vi.mock("@/lib/crypto", () => ({
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
ENCRYPTION_KEY: "test-key",
|
||||
}));
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
ENCRYPTION_KEY: "test-key",
|
||||
},
|
||||
}));
|
||||
|
||||
const mockSurvey: TSurvey = {
|
||||
id: "survey-1",
|
||||
@@ -96,7 +90,6 @@ const mockSurvey: TSurvey = {
|
||||
showLanguageSwitch: false,
|
||||
blocks: [],
|
||||
isCaptureIpEnabled: false,
|
||||
isAutoProgressingEnabled: false,
|
||||
metadata: {},
|
||||
slug: null,
|
||||
};
|
||||
@@ -118,7 +111,6 @@ const mockBillingData: TOrganizationBilling = {
|
||||
usageCycleAnchor: new Date(),
|
||||
stripeCustomerId: "mock-stripe-customer-id",
|
||||
};
|
||||
const validSingleUseId = "cm8f4x9mm0001gx9h5b7d7h3q";
|
||||
|
||||
describe("checkSurveyValidity", () => {
|
||||
beforeEach(() => {
|
||||
@@ -230,14 +222,10 @@ describe("checkSurveyValidity", () => {
|
||||
const result = await checkSurveyValidity(survey, "env-1", { ...mockResponseInput });
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(400);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith(
|
||||
"Missing single use id",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
},
|
||||
true
|
||||
);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return badRequestResponse if singleUse is enabled and meta.url is missing", async () => {
|
||||
@@ -249,14 +237,10 @@ describe("checkSurveyValidity", () => {
|
||||
});
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(400);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith(
|
||||
"Missing or invalid URL in response metadata",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
},
|
||||
true
|
||||
);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing or invalid URL in response metadata", {
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return badRequestResponse if meta.url is invalid", async () => {
|
||||
@@ -270,8 +254,7 @@ describe("checkSurveyValidity", () => {
|
||||
expect(result?.status).toBe(400);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith(
|
||||
"Invalid URL in response metadata",
|
||||
expect.objectContaining({ surveyId: survey.id, environmentId: "env-1" }),
|
||||
true
|
||||
expect.objectContaining({ surveyId: survey.id, environmentId: "env-1" })
|
||||
);
|
||||
});
|
||||
|
||||
@@ -285,20 +268,16 @@ describe("checkSurveyValidity", () => {
|
||||
});
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(400);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith(
|
||||
"Missing single use id",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
},
|
||||
true
|
||||
);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return badRequestResponse if isEncrypted and decrypted suId does not match singleUseId", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } };
|
||||
const url = "https://example.com/?suId=encrypted-id";
|
||||
vi.mocked(symmetricDecrypt).mockReturnValue(validSingleUseId);
|
||||
vi.mocked(symmetricDecrypt).mockReturnValue("decrypted-id");
|
||||
const resultEncryptedMismatch = await checkSurveyValidity(survey, "env-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
@@ -307,20 +286,15 @@ describe("checkSurveyValidity", () => {
|
||||
expect(symmetricDecrypt).toHaveBeenCalledWith("encrypted-id", "test-key");
|
||||
expect(resultEncryptedMismatch).toBeInstanceOf(Response);
|
||||
expect(resultEncryptedMismatch?.status).toBe(400);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith(
|
||||
"Invalid single use id",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
},
|
||||
true
|
||||
);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("Invalid single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return badRequestResponse if not encrypted and suId does not match singleUseId", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
|
||||
const suToken = generateSurveySingleUseSignature(survey.id, "su-2");
|
||||
const url = `https://example.com/?suId=su-2&suToken=${suToken}`;
|
||||
const url = "https://example.com/?suId=su-2";
|
||||
const result = await checkSurveyValidity(survey, "env-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
@@ -328,17 +302,13 @@ describe("checkSurveyValidity", () => {
|
||||
});
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(400);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith(
|
||||
"Invalid single use id",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
},
|
||||
true
|
||||
);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("Invalid single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return badRequestResponse if not encrypted and suToken is missing", async () => {
|
||||
test("should return null if singleUse is enabled, not encrypted, and suId matches singleUseId", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
|
||||
const url = "https://example.com/?suId=su-1";
|
||||
const result = await checkSurveyValidity(survey, "env-1", {
|
||||
@@ -346,39 +316,16 @@ describe("checkSurveyValidity", () => {
|
||||
singleUseId: "su-1",
|
||||
meta: { url },
|
||||
});
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(400);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith(
|
||||
"Invalid single use id",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("should return null if singleUse is enabled, not encrypted, and signed suId matches singleUseId", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
|
||||
const suToken = generateSurveySingleUseSignature(survey.id, "su-1");
|
||||
const url = `https://example.com/?suId=su-1&suToken=${suToken}`;
|
||||
const responseInput = {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
meta: { url },
|
||||
};
|
||||
const result = await checkSurveyValidity(survey, "env-1", responseInput);
|
||||
expect(result).toBeNull();
|
||||
expect(responseInput.singleUseId).toBe("su-1");
|
||||
});
|
||||
|
||||
test("should return null if singleUse is enabled, encrypted, and decrypted suId matches singleUseId", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } };
|
||||
const url = "https://example.com/?suId=encrypted-id";
|
||||
vi.mocked(symmetricDecrypt).mockReturnValue(validSingleUseId);
|
||||
vi.mocked(symmetricDecrypt).mockReturnValue("su-1");
|
||||
const _resultEncryptedMatch = await checkSurveyValidity(survey, "env-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: validSingleUseId,
|
||||
singleUseId: "su-1",
|
||||
meta: { url },
|
||||
});
|
||||
expect(symmetricDecrypt).toHaveBeenCalledWith("encrypted-id", "test-key");
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { validateSingleUseResponseInput } from "@/app/api/client/[environmentId]/responses/lib/single-use";
|
||||
import { getOrganizationBillingByEnvironmentId } from "@/app/api/v2/client/[environmentId]/responses/lib/organization";
|
||||
import { verifyRecaptchaToken } from "@/app/api/v2/client/[environmentId]/responses/lib/recaptcha";
|
||||
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
import { symmetricDecrypt } from "@/lib/crypto";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
|
||||
@@ -19,12 +20,53 @@ export const checkSurveyValidity = async (
|
||||
return responses.badRequestResponse("Survey does not belong to this environment", undefined, true);
|
||||
}
|
||||
|
||||
const singleUseValidationResult = validateSingleUseResponseInput(survey, environmentId, responseInput);
|
||||
if (singleUseValidationResult) {
|
||||
if ("response" in singleUseValidationResult) {
|
||||
return singleUseValidationResult.response;
|
||||
if (survey.type === "link" && survey.singleUse?.enabled) {
|
||||
if (!responseInput.singleUseId) {
|
||||
return responses.badRequestResponse("Missing single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!responseInput.meta?.url) {
|
||||
return responses.badRequestResponse("Missing or invalid URL in response metadata", {
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(responseInput.meta.url);
|
||||
} catch (error) {
|
||||
return responses.badRequestResponse("Invalid URL in response metadata", {
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
error: error instanceof Error ? error.message : "Unknown error occurred",
|
||||
});
|
||||
}
|
||||
const suId = url.searchParams.get("suId");
|
||||
if (!suId) {
|
||||
return responses.badRequestResponse("Missing single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
|
||||
if (survey.singleUse.isEncrypted) {
|
||||
const decryptedSuId = symmetricDecrypt(suId, ENCRYPTION_KEY);
|
||||
if (decryptedSuId !== responseInput.singleUseId) {
|
||||
return responses.badRequestResponse("Invalid single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
} else if (responseInput.singleUseId !== suId) {
|
||||
return responses.badRequestResponse("Invalid single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
responseInput.singleUseId = singleUseValidationResult.singleUseId;
|
||||
}
|
||||
|
||||
if (survey.recaptcha?.enabled) {
|
||||
|
||||
@@ -152,7 +152,7 @@ describe("API Response Utilities", () => {
|
||||
test("should use custom cache control header when provided", () => {
|
||||
const message = "Something went wrong";
|
||||
const customCache = "no-cache";
|
||||
const response = responses.internalServerErrorResponse(message, false, customCache);
|
||||
const response = responses.internalServerErrorResponse(message, false, {}, customCache);
|
||||
|
||||
expect(response.headers.get("Cache-Control")).toBe(customCache);
|
||||
});
|
||||
|
||||
@@ -217,6 +217,7 @@ const successResponse = (data: Object, cors: boolean = false, cache: string = "p
|
||||
const internalServerErrorResponse = (
|
||||
message: string,
|
||||
cors: boolean = false,
|
||||
details: ApiErrorResponse["details"] = {},
|
||||
cache: string = "private, no-store"
|
||||
) => {
|
||||
const headers = {
|
||||
@@ -228,7 +229,7 @@ const internalServerErrorResponse = (
|
||||
{
|
||||
code: "internal_server_error",
|
||||
message,
|
||||
details: {},
|
||||
details,
|
||||
} as ApiErrorResponse,
|
||||
{
|
||||
status: 500,
|
||||
|
||||
@@ -587,6 +587,56 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
expect(mockedQueueAuditEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not allow organization-only API keys by default", async () => {
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
|
||||
const handler = vi.fn();
|
||||
const req = createMockRequest({ url: "https://api.test/api/v1/management/action-classes" });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler });
|
||||
const response = await wrapped(req, undefined);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(authenticateRequest).toHaveBeenCalledWith(req, { allowOrganizationOnlyApiKey: false });
|
||||
});
|
||||
|
||||
test("allows organization-only API keys when the route opts in", async () => {
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
|
||||
const handler = vi.fn().mockResolvedValue({
|
||||
response: responses.successResponse({ data: "test" }),
|
||||
});
|
||||
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler, allowOrganizationOnlyApiKey: true });
|
||||
const response = await wrapped(req, undefined);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalled();
|
||||
expect(authenticateRequest).toHaveBeenCalledWith(req, { allowOrganizationOnlyApiKey: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildAuditLogBaseObject", () => {
|
||||
|
||||
@@ -46,6 +46,11 @@ export interface TWithV1ApiWrapperParams<
|
||||
* the legacy JSON 401. Use this to return a custom response (e.g. RFC 9457 problem+json for V3).
|
||||
*/
|
||||
unauthenticatedResponse?: (req: NextRequest) => Response;
|
||||
/**
|
||||
* Most v1 management routes are environment-scoped. Enable this only for routes that explicitly
|
||||
* support organization-only API keys.
|
||||
*/
|
||||
allowOrganizationOnlyApiKey?: boolean;
|
||||
}
|
||||
|
||||
enum ApiV1RouteTypeEnum {
|
||||
@@ -142,16 +147,17 @@ const setupAuditLog = (
|
||||
*/
|
||||
const handleAuthentication = async (
|
||||
authenticationMethod: AuthenticationMethod,
|
||||
req: NextRequest
|
||||
req: NextRequest,
|
||||
allowOrganizationOnlyApiKey = false
|
||||
): Promise<TApiV1Authentication> => {
|
||||
switch (authenticationMethod) {
|
||||
case AuthenticationMethod.ApiKey:
|
||||
return await authenticateRequest(req);
|
||||
return await authenticateRequest(req, { allowOrganizationOnlyApiKey });
|
||||
case AuthenticationMethod.Session:
|
||||
return await getServerSession(authOptions);
|
||||
case AuthenticationMethod.Both: {
|
||||
const session = await getServerSession(authOptions);
|
||||
return session ?? (await authenticateRequest(req));
|
||||
return session ?? (await authenticateRequest(req, { allowOrganizationOnlyApiKey }));
|
||||
}
|
||||
case AuthenticationMethod.None:
|
||||
return null;
|
||||
@@ -251,7 +257,14 @@ const getRouteType = (
|
||||
export const withV1ApiWrapper = <TResult extends { response: Response; error?: unknown }, TProps = unknown>(
|
||||
params: TWithV1ApiWrapperParams<TResult, TProps>
|
||||
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
|
||||
const { handler, action, targetType, customRateLimitConfig, unauthenticatedResponse } = params;
|
||||
const {
|
||||
handler,
|
||||
action,
|
||||
targetType,
|
||||
customRateLimitConfig,
|
||||
unauthenticatedResponse,
|
||||
allowOrganizationOnlyApiKey,
|
||||
} = params;
|
||||
return async (req: NextRequest, props: TProps): Promise<Response> => {
|
||||
// === Audit Log Setup ===
|
||||
const saveAuditLog = action && targetType;
|
||||
@@ -270,7 +283,7 @@ export const withV1ApiWrapper = <TResult extends { response: Response; error?: u
|
||||
}
|
||||
|
||||
// === Authentication ===
|
||||
const authentication = await handleAuthentication(authenticationMethod, req);
|
||||
const authentication = await handleAuthentication(authenticationMethod, req, allowOrganizationOnlyApiKey);
|
||||
|
||||
if (!authentication && routeType !== ApiV1RouteTypeEnum.Client) {
|
||||
if (unauthenticatedResponse) {
|
||||
|
||||
+27
-29
@@ -87,6 +87,7 @@ checksums:
|
||||
billing_confirmation/upgrade_successful: 57281b5f15480a026a2f67b195d69247
|
||||
c/link_expired: edccebc7cf11b1c6a54e37aedc1f1644
|
||||
c/link_expired_description: dfbafe1d9932fdb2d151e136dd71a38d
|
||||
c/link_expired_heading: edccebc7cf11b1c6a54e37aedc1f1644
|
||||
common/accepted: ea2ed23f35f8b090b5a994ac64ec588a
|
||||
common/account: 01215c12fb1cdb93bd0c84c1382bef56
|
||||
common/account_settings: df8e9882a1f5c75951f3a05ddfed72ba
|
||||
@@ -216,7 +217,6 @@ checksums:
|
||||
common/filter: 626325a05e4c8800f7ede7012b0cadaf
|
||||
common/finish: ffa7a10f71182b48fefed7135bee24fa
|
||||
common/first_name: cf040a5d6a9fd696be400380cc99f54b
|
||||
common/follow_these: 3a730b242bb17a3f95e01bf0dae86885
|
||||
common/formbricks_version: d9967c797f3e49ca0cae78bc0ebd19cb
|
||||
common/full_name: f45991923345e8322c9ff8cd6b7e2b16
|
||||
common/gathering_responses: c5914490ed81bd77f13d411739f0c9ef
|
||||
@@ -330,7 +330,6 @@ checksums:
|
||||
common/placeholder: 88c2c168aff12ca70148fcb5f6b4c7b1
|
||||
common/please_select_at_least_one_survey: fb1cbeb670480115305e23444c347e50
|
||||
common/please_select_at_least_one_trigger: e88e64a1010a039745e80ed2e30951fe
|
||||
common/please_upgrade_your_plan: 03d54a21ecd27723c72a13644837e5ed
|
||||
common/powered_by_formbricks: 1c3e19894583292bfaf686cac84a4960
|
||||
common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d
|
||||
common/privacy: 7459744a63ef8af4e517a09024bd7c08
|
||||
@@ -471,7 +470,7 @@ checksums:
|
||||
common/you_are_downgraded_to_the_community_edition: e3ae56502ff787109cae0997519f628e
|
||||
common/you_are_not_authorized_to_perform_this_action: 1b3255ab740582ddff016a399f8bf302
|
||||
common/you_have_reached_your_limit_of_workspace_limit: 54d754c3267036742f23fb05fd3fcc45
|
||||
common/you_have_reached_your_monthly_response_limit_of: 3824db23ecc3dcd2b1787b98ccfdd5f9
|
||||
common/you_have_reached_your_monthly_response_limit_of_count: b68287d2a673228e6ee403f48aa308ca
|
||||
common/you_will_be_downgraded_to_the_community_edition_on_date: bff35b54c13e2c205dc4c19056261cc0
|
||||
common/your_license_has_expired_please_renew: 3f21ae4a7deab351b143b407ece58254
|
||||
emails/accept: f8cc1de4f5e3c850cfdbbc0ec831ade7
|
||||
@@ -733,6 +732,7 @@ checksums:
|
||||
environments/integrations/create_survey_warning: e35a3f72c3c31a27650fd450b7c73c11
|
||||
environments/integrations/delete_integration: ccc879ccfcf7f85bcfe09f2bc3fa0dd3
|
||||
environments/integrations/delete_integration_confirmation: be7400c43d808b6f56d98b0f435dff3c
|
||||
environments/integrations/follow_these_docs_to_configure_it: 44a7f05d88479b9e54e200c277cceb3d
|
||||
environments/integrations/google_sheet_integration_description: 346102145ef0c7b20c64b44fd4592005
|
||||
environments/integrations/google_sheets/connect_with_google_sheets: ec82132ca0a58920d9f521a0a9845dd1
|
||||
environments/integrations/google_sheets/enter_a_valid_spreadsheet_url_error: 8c9fa8bd3d7872de17c5e69c8b8333e0
|
||||
@@ -811,7 +811,6 @@ checksums:
|
||||
environments/integrations/slack/slack_reconnect_button: 8992a0f250278c116cb26be448b68ba2
|
||||
environments/integrations/slack/slack_reconnect_button_description: e5a4e7f868f76b8892998025f9dd032a
|
||||
environments/integrations/slack_integration_description: 72b33dab89e4bb2cdb72082973e0a11a
|
||||
environments/integrations/to_configure_it: 157de34c0ebe43b498f670e2a7ac470a
|
||||
environments/integrations/webhook_integration_description: 9d02de29b3f2c836e14510f961cbb9cc
|
||||
environments/integrations/webhooks/add_webhook: 20ba6e981d4237490d9da86dade7f7d2
|
||||
environments/integrations/webhooks/add_webhook_description: 85466a73d6a55476319c0c980b6f2aff
|
||||
@@ -866,9 +865,7 @@ checksums:
|
||||
environments/segments/ex_fully_activated_recurring_users: d242c30b7ad8191c19378f597e99e684
|
||||
environments/segments/ex_power_users: 1923f36e6a1f034a591640299f0b5b75
|
||||
environments/segments/filters_reset_successfully: c3893460d8c2970e43d906c272c517e8
|
||||
environments/segments/here: 462e596d55b481e23f29b5ced669624a
|
||||
environments/segments/hide_filters: 6371822c921a146ca7860a2795c94fcd
|
||||
environments/segments/identifying_users: b5f7a6c79971ada956b1c45ef8d86044
|
||||
environments/segments/invalid_segment: f03933d5c91a87f0a4e9e978b2cd27d1
|
||||
environments/segments/invalid_segment_filters: b7b4d979e52d315faecf56c3db8e66cd
|
||||
environments/segments/load_segment: 56e0e2c2a1c3f6b2035376a2240ff453
|
||||
@@ -923,21 +920,20 @@ checksums:
|
||||
environments/segments/segment_id: 875b44f7289197810baf2e1c03de5c77
|
||||
environments/segments/segment_saved_successfully: ce116c8dc576cac49753082eb2ecb413
|
||||
environments/segments/segment_updated_successfully: 2eca3ff5c645bdccd16c306d663114a1
|
||||
environments/segments/segment_used_in_other_surveys_make_changes_here: 64a5cb0e02c4868af68db022717a4f95
|
||||
environments/segments/segments_help_you_target_users_with_same_characteristics_easily: b18b75bda13fe408a467dd625611ea06
|
||||
environments/segments/target_audience: ca47151c4f0ddb348e52ec43ce15eb03
|
||||
environments/segments/this_action_resets_all_filters_in_this_survey: a7b342c25f0d3d6060bfdff38ade0682
|
||||
environments/segments/this_segment_is_used_in_other_surveys: e7976706ae6355fb84bdad64f2033a70
|
||||
environments/segments/title_is_required: e9b1d9309edf85645ab15cc90ae0a70f
|
||||
environments/segments/unknown_filter_type: 67d05a804c1ad54434c0a90f47bb0304
|
||||
environments/segments/unlock_segments_description: 5cb5aff50997e6265807a15ed1456be1
|
||||
environments/segments/unlock_segments_title: 810b329adfd0967070a20f21953d37c0
|
||||
environments/segments/user_targeting_is_currently_only_available_when: 9785f159fb045607b62461f38e8d3aee
|
||||
environments/segments/user_targeting_only_available_when_identifying_users: ed7301d94ba54c003a39a57af736e458
|
||||
environments/segments/value_cannot_be_empty: 99efd449ec19f1ecc5cf0b6807d4f315
|
||||
environments/segments/value_must_be_a_number: 87516b5c69e08741fa8a6ddf64d60deb
|
||||
environments/segments/value_must_be_positive: d17ad009f7845a6fbeddeb2aef532e10
|
||||
environments/segments/view_filters: 791cd4bacb11e3eb0ffccee131270561
|
||||
environments/segments/where: 23aecda7d27f26121b057ec7f7327069
|
||||
environments/segments/with_the_formbricks_sdk: 2b185e6242edb69e1bc6e64e10dfc02a
|
||||
environments/settings/api_keys/add_api_key: 1c11117b1d4665ccdeb68530381c6a9d
|
||||
environments/settings/api_keys/add_permission: 4f0481d26a32aef6137ee6f18aaf8e89
|
||||
environments/settings/api_keys/api_keys_description: 42c2d587834d54f124b9541b32ff7133
|
||||
@@ -1011,8 +1007,8 @@ checksums:
|
||||
environments/settings/billing/unlimited_workspaces: f7433bc693ee6d177e76509277f5c173
|
||||
environments/settings/billing/upgrade: 63c3b52882e0d779859307d672c178c2
|
||||
environments/settings/billing/upgrade_now: 059e020c0eddd549ac6c6369a427915a
|
||||
environments/settings/billing/usage_count_of_limit_used: 0bcf3f084731f9fc7cd0b0777e720567
|
||||
environments/settings/billing/usage_cycle: 4986315c0b486c7490bab6ada2205bee
|
||||
environments/settings/billing/used: 9e2eff0ac536d11a9f8fcb055dd68f2e
|
||||
environments/settings/billing/yearly: 87f43e016c19cb25860f456549a2f431
|
||||
environments/settings/billing/yearly_checkout_unavailable: f7b694de0e554c8583d8aaa4740e01a2
|
||||
environments/settings/billing/your_plan: dc56f0334977d7d5d7d8f1f5801ac54b
|
||||
@@ -1069,7 +1065,7 @@ checksums:
|
||||
environments/settings/enterprise/no_credit_card_no_sales_call_just_test_it: 18f9859cdf12537b7019ecdb0a0a2b53
|
||||
environments/settings/enterprise/on_request: cf9949748c15313a8fd57bf965bec16b
|
||||
environments/settings/enterprise/organization_roles: 731d5028521c2a3a7bdbd7ed215dd861
|
||||
environments/settings/enterprise/questions_please_reach_out_to: ac4be65ffef9349eaeb137c254d3fee7
|
||||
environments/settings/enterprise/questions_please_reach_out_to_email: 62d2e5f69a9bad47c0b975c702603caa
|
||||
environments/settings/enterprise/recheck_license: b913b64f89df184b5059710f4a0b26fa
|
||||
environments/settings/enterprise/recheck_license_failed: dd410acbb8887625cf194189f832dd7c
|
||||
environments/settings/enterprise/recheck_license_instance_mismatch: 655cd1cce2f25b100439d8725c1e72f2
|
||||
@@ -1168,12 +1164,15 @@ checksums:
|
||||
environments/settings/profile/confirm_delete_my_account: b8715ff52c7109ed6b6c31a00e44013d
|
||||
environments/settings/profile/confirm_your_current_password_to_get_started: 7815146bd37e6648a6a6637bf9e0095f
|
||||
environments/settings/profile/delete_account: f5f7b88ff3122fb0d3a330e2b99d7e3d
|
||||
environments/settings/profile/delete_account_confirmation_required: 92d7d248ffde0223744364d4cc4a6e07
|
||||
environments/settings/profile/disable_two_factor_authentication: 2f5396bad4f84ad7160421021042ee12
|
||||
environments/settings/profile/disable_two_factor_authentication_description: f81ac20c3864f1a252a4813b5f9b719a
|
||||
environments/settings/profile/each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator: ecf77d8d17ccf64e1effc6ab131fea56
|
||||
environments/settings/profile/email_change_initiated: b9a69748076f2860d9124f745b75d392
|
||||
environments/settings/profile/email_confirmation_does_not_match: eee9d13af9ca8c1f21b46fee764605ac
|
||||
environments/settings/profile/enable_two_factor_authentication: 476d45754f584b25cc66ab00eccbefaa
|
||||
environments/settings/profile/enter_the_code_from_your_authenticator_app_below: 9bae7024a84c2be6e2725b187e2244f9
|
||||
environments/settings/profile/google_sso_account_deletion_requires_setup: b2b60bb8bd1297f8b78af44b461733f5
|
||||
environments/settings/profile/lost_access: 70292321ff8232218d2261b11c40bc0a
|
||||
environments/settings/profile/or_enter_the_following_code_manually: c209f319f38984d8718cd272a2a60b97
|
||||
environments/settings/profile/organizations_delete_message: 9ca1794c9a63c8d82462abcf7109d31f
|
||||
@@ -1184,6 +1183,8 @@ checksums:
|
||||
environments/settings/profile/save_the_following_backup_codes_in_a_safe_place: a5b9d38083770375f2372f93ac9a7b2b
|
||||
environments/settings/profile/scan_the_qr_code_below_with_your_authenticator_app: 5a6b60928590ce3b6be1bdf1d34cd45e
|
||||
environments/settings/profile/security_description: e833adde4e3e26795e61a93619c6caec
|
||||
environments/settings/profile/sso_reauthentication_failed: 1b2f4047fcec5571c67ee3235ad70853
|
||||
environments/settings/profile/sso_reauthentication_may_be_required_for_deletion: f2e0c238a701bd504a9527113b4f22e4
|
||||
environments/settings/profile/two_factor_authentication: 97a428a54e41d68810a12dbae075f371
|
||||
environments/settings/profile/two_factor_authentication_description: 1429e4eeaea193f15fb508875d4fb601
|
||||
environments/settings/profile/two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app: 308ba145b3dc485ff4f17387e977b1f9
|
||||
@@ -1278,13 +1279,12 @@ checksums:
|
||||
environments/surveys/edit/address_line_2: fc4b5a87de46ac4a28a6616f47a34135
|
||||
environments/surveys/edit/adjust_survey_closed_message: ae6f38c9daf08656362bd84459a312fa
|
||||
environments/surveys/edit/adjust_survey_closed_message_description: e906aebd9af6451a2a39c73287927299
|
||||
environments/surveys/edit/adjust_the_theme_in_the: bccdafda8af5871513266f668b55d690
|
||||
environments/surveys/edit/adjust_theme_in_look_and_feel_settings: 51372cb5ee8d3d42389bc95468866ad1
|
||||
environments/surveys/edit/all_are_true: 05d02c5afac857da530b73dcf18dd8e4
|
||||
environments/surveys/edit/all_other_answers_will_continue_to: 9a5d09eea42ff5fd1c18cc58a14dcabd
|
||||
environments/surveys/edit/all_other_answers_will_continue_to_fallback: 4c0a7ca79f7f59e523803df375f01825
|
||||
environments/surveys/edit/allow_multi_select: 7b4b83f7a0205e2a0a8971671a69a174
|
||||
environments/surveys/edit/allow_multiple_files: dbd99f9d1026e4f7c5a5d03f71ba379d
|
||||
environments/surveys/edit/allow_users_to_select_more_than_one_image: d683e0b538d1366400292a771f3fbd08
|
||||
environments/surveys/edit/and_launch_surveys_in_your_website_or_app: a3edcdb4aea792a27d90aad1930f001a
|
||||
environments/surveys/edit/animation: 66a18eacfb92fc9fc9db188d2dde4f81
|
||||
environments/surveys/edit/any_is_true: 32c9f3998984fd32a2b5bc53f2d97429
|
||||
environments/surveys/edit/app_survey_description: bdfacfce478e97f70b700a1382dfa687
|
||||
@@ -1296,10 +1296,10 @@ checksums:
|
||||
environments/surveys/edit/auto_save_disabled: f7411fb0dcfb8f7b19b85f0be54f2231
|
||||
environments/surveys/edit/auto_save_disabled_tooltip: 77322e1e866b7d29f7641a88bbd3b681
|
||||
environments/surveys/edit/auto_save_on: 1524d466830b00c5d727c701db404963
|
||||
environments/surveys/edit/automatically_close_survey_after: 3e1c400a4b226c875dc8337e3b204d85
|
||||
environments/surveys/edit/automatically_close_survey_after_n_seconds_if_no_response: 3c816c2fa92dd46a8d2ac1a8efb5b17c
|
||||
environments/surveys/edit/automatically_close_the_survey_after_a_certain_number_of_responses: 2beee129dca506f041e5d1e6a1688310
|
||||
environments/surveys/edit/automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds: 1be3819ffa1db67385357ae933d69a7b
|
||||
environments/surveys/edit/automatically_mark_the_survey_as_complete_after: c6ede2a5515a4ca72b36aec2583f43aa
|
||||
environments/surveys/edit/automatically_mark_complete_after_n_responses: 36bd1ecef42ff2292f47f88f5b5cd3bc
|
||||
environments/surveys/edit/back_button_label: 504551d78645d968fcee95e3dfa5586f
|
||||
environments/surveys/edit/background_styling: eb4a06cf54a7271b493fab625d930570
|
||||
environments/surveys/edit/block_duplicated: dc9e9fab2b1cd91f6c265324b34c6376
|
||||
@@ -1357,11 +1357,11 @@ checksums:
|
||||
environments/surveys/edit/columns: 14896556dc1535d70198854757f704ec
|
||||
environments/surveys/edit/company: 9e36d11fa82d1bd4563eef2abc4fcfba
|
||||
environments/surveys/edit/company_logo: aed15a185c680fb2159a75b57e24e885
|
||||
environments/surveys/edit/completed_responses: 2fc333d023c8f9d74a58b6bd61ada411
|
||||
environments/surveys/edit/concat: 9f6420a83aa45c80f53770f18d11505b
|
||||
environments/surveys/edit/conditional_logic: 8da827c6111ff1638af11d37f5a72bf7
|
||||
environments/surveys/edit/confirm_default_language: 67ee675555346c972ae1b5a630351645
|
||||
environments/surveys/edit/confirm_survey_changes: 98228b8f52285a04df33ee59b9b04648
|
||||
environments/surveys/edit/connect_formbricks_and_launch_surveys: 5c77647d9e40d68117386b8ca4826cd7
|
||||
environments/surveys/edit/contact_fields: 0d4e3f4d2eb3481aabe3ac60a692fa74
|
||||
environments/surveys/edit/contains: 41c8c25407527a5336404313f4c8d650
|
||||
environments/surveys/edit/continue_to_settings: b9853a7eedb3ae295088268fe5a44824
|
||||
@@ -1375,7 +1375,6 @@ checksums:
|
||||
environments/surveys/edit/custom_hostname: bc2b1c8de3f9b8ef145b45aeba6ab429
|
||||
environments/surveys/edit/customize_survey_logo: 7f7e26026c88a727228f2d7a00d914e2
|
||||
environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
|
||||
environments/surveys/edit/days_before_showing_this_survey_again: 9ee757e5c3a07844b12ceb406dc65b04
|
||||
environments/surveys/edit/default_language: 06d01d2598419e36ba97d2d8719f849b
|
||||
environments/surveys/edit/delete_anyways: cc8683ab625280eefc9776bd381dc2e8
|
||||
environments/surveys/edit/delete_block: c00617cb0724557e486304276063807a
|
||||
@@ -1481,7 +1480,6 @@ checksums:
|
||||
environments/surveys/edit/hide_progress_bar: 7eefe7db6a051105bded521d94204933
|
||||
environments/surveys/edit/hide_question_settings: 99127cd016db2f7fc80333b36473c0ef
|
||||
environments/surveys/edit/hostname: 9bdaa7692869999df51bb60d58d9ef62
|
||||
environments/surveys/edit/if_you_need_more_please: a7d208c283caf6b93800b809fca80768
|
||||
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 5abd8b702f9fb0e3815c3413d6f8aef6
|
||||
environments/surveys/edit/ignore_global_waiting_time: e08db543ace4935625e0961cc6e60489
|
||||
environments/surveys/edit/ignore_global_waiting_time_description: 37d173a4d537622de40677389238d859
|
||||
@@ -1521,7 +1519,7 @@ checksums:
|
||||
environments/surveys/edit/last_name: 2c9a7de7738ca007ba9023c385149c26
|
||||
environments/surveys/edit/let_people_upload_up_to_25_files_at_the_same_time: 44110eeba2b63049a84d69927846ea3c
|
||||
environments/surveys/edit/limit_the_maximum_file_size: 6ae5944fe490b9acdaaee92b30381ec0
|
||||
environments/surveys/edit/limit_upload_file_size_to: 949c48d25ae45259cc19464e95752d29
|
||||
environments/surveys/edit/limit_upload_file_size_to_mb: 7bf7d8c9e5f3fade66c2651746856ab9
|
||||
environments/surveys/edit/link_survey_description: f45569b5e6b78be6bc02bc6a46da948b
|
||||
environments/surveys/edit/list: 94f13e7ef909a4de9db7abaa1f9f0b61
|
||||
environments/surveys/edit/load_segment: 5341d3de37ff10f7526152e38e25e3c5
|
||||
@@ -1536,7 +1534,8 @@ checksums:
|
||||
environments/surveys/edit/matrix_all_fields: 187240509163b2f52a400a565e57c67f
|
||||
environments/surveys/edit/matrix_rows: 8f41f34e6ca28221cf1ebd948af4c151
|
||||
environments/surveys/edit/max_file_size: 3d35a22048f4d22e24da698fb5fb77d7
|
||||
environments/surveys/edit/max_file_size_limit_is: 78998639cde3587cecb272ba47e05f9e
|
||||
environments/surveys/edit/max_file_size_limit_is_mb: fb130d0c2af0004264de854c2281045f
|
||||
environments/surveys/edit/max_file_size_limit_is_mb_upgrade: 23dab3e89549095a3b6f9f3bba3c9524
|
||||
environments/surveys/edit/missing_first: a0c8802636ade7bac86a0dacba00b8d4
|
||||
environments/surveys/edit/move_question_to_block: e8d7ef1e2f727921cb7f5788849492ad
|
||||
environments/surveys/edit/multiply: 89a0bb629167f97750ae1645a46ced0d
|
||||
@@ -1648,8 +1647,6 @@ checksums:
|
||||
environments/surveys/edit/save_and_close: 6ede705b3f82f30269ff3054a5049e34
|
||||
environments/surveys/edit/scale: 5f55a30a5bdf8f331b56bad9c073473c
|
||||
environments/surveys/edit/search_for_images: 8b1bc3561d126cc49a1ee185c07e7aaf
|
||||
environments/surveys/edit/seconds_after_trigger_the_survey_will_be_closed_if_no_response: 3584be059fe152e93895ef9885f8e8a7
|
||||
environments/surveys/edit/seconds_before_showing_the_survey: 4b03756dd5f06df732bf62b2c7968b82
|
||||
environments/surveys/edit/select_field: 45665a44f7d5707506364f17f28db3bf
|
||||
environments/surveys/edit/select_or_type_value: a99c307b2cc3f9f6f893babd546d7296
|
||||
environments/surveys/edit/select_ordering: c8f632a17fe78d8b7f87e82df9351ff9
|
||||
@@ -1657,7 +1654,7 @@ checksums:
|
||||
environments/surveys/edit/select_type: fa373e47f55ff081982844a853be3a88
|
||||
environments/surveys/edit/send_survey_to_audience_who_match: 8bc5660659f6e28cc19b1961897e9878
|
||||
environments/surveys/edit/send_your_respondents_to_a_page_of_your_choice: 9b77b1afe9a81bdbbb55ae323c5a3175
|
||||
environments/surveys/edit/set_the_global_placement_in_the_look_feel_settings: e34e579e778a918733702edb041ac929
|
||||
environments/surveys/edit/set_global_placement_in_look_feel_settings_hint: 18e030590220d3d8626c8f9ddb4dd855
|
||||
environments/surveys/edit/settings_saved_successfully: 7f6833d9079e404fb3a5b0aa51fdcf17
|
||||
environments/surveys/edit/seven_points: 4ead50fdfda45e8710767e1b1a84bf42
|
||||
environments/surveys/edit/show_block_settings: bad99d99c9908874e45f5c350a88cc79
|
||||
@@ -1667,7 +1664,7 @@ checksums:
|
||||
environments/surveys/edit/show_multiple_times: 05239c532c9c05ef5d2990ba6ce12f60
|
||||
environments/surveys/edit/show_only_once: 31858baf60ebcf193c7e35d9084af0af
|
||||
environments/surveys/edit/show_question_settings: a84698a95df0833a35d653edcdbbe501
|
||||
environments/surveys/edit/show_survey_maximum_of: 721ed61b01a9fc8ce4becb72823bb72e
|
||||
environments/surveys/edit/show_survey_maximum_of_n_times: 7adce73c375fa89cf8268f2fdc02d36d
|
||||
environments/surveys/edit/show_survey_to_users: d5e90fd17babfea978fce826e9df89b0
|
||||
environments/surveys/edit/show_to_x_percentage_of_targeted_users: b745169011fa7e8ca475baa5500c5197
|
||||
environments/surveys/edit/shrink_preview: 42567389520b226f211f94f052197ad8
|
||||
@@ -1703,8 +1700,6 @@ checksums:
|
||||
environments/surveys/edit/this_action_will_remove_all_the_translations_from_this_survey: 3340c89696f10bdc01b9a1047ff0b987
|
||||
environments/surveys/edit/this_will_remove_the_language_and_all_its_translations: 6a71ae70abbd61f13f15323d825a47f6
|
||||
environments/surveys/edit/three_points: d7f299aec752d7d690ef0ab6373327ae
|
||||
environments/surveys/edit/times: 5ab156c13df6bfd75c0b17ad0a92c78a
|
||||
environments/surveys/edit/to_keep_the_placement_over_all_surveys_consistent_you_can: 7a078e6a39d4c30b465137d2b6ef3e67
|
||||
environments/surveys/edit/translated: 5b9d805410310b726f12bacb06da44e3
|
||||
environments/surveys/edit/trigger_survey_when_one_of_the_actions_is_fired: 8570291668ec9879d204f10e861112db
|
||||
environments/surveys/edit/try_lollipop_or_mountain: c550a0f07b3ae40a237e30a4314a249c
|
||||
@@ -1779,8 +1774,9 @@ checksums:
|
||||
environments/surveys/edit/visibility_and_recontact: c27cb4ff3a4262266902a335c3ad5d84
|
||||
environments/surveys/edit/visibility_and_recontact_description: 2969ab679e1f6111dd96e95cee26e219
|
||||
environments/surveys/edit/visible: 54ea1310fe55664c24a712eb17070fbd
|
||||
environments/surveys/edit/wait: 014d18ade977bf08d75b995076596708
|
||||
environments/surveys/edit/wait_a_few_seconds_after_the_trigger_before_showing_the_survey: 13d5521cf73be5afeba71f5db5847919
|
||||
environments/surveys/edit/wait_n_days_before_showing_this_survey_again: e83a6536a5bd9a1b13115d8bc34ba6cf
|
||||
environments/surveys/edit/wait_n_seconds_before_showing_the_survey: 1e5ec00f0392e7640f3ce9f5a6c67e4f
|
||||
environments/surveys/edit/waiting_time_across_surveys: 6873c18d51830e2cadef67cce6a2c95c
|
||||
environments/surveys/edit/waiting_time_across_surveys_description: 6edafaeb3ccd8cadde81175776636c8e
|
||||
environments/surveys/edit/welcome_message: 986a434e3895c8ee0b267df95cc40051
|
||||
@@ -2287,11 +2283,13 @@ checksums:
|
||||
organizations/workspaces/new/settings/workspace_settings_title: a4ec3549507071e1f7d3c834b019fcce
|
||||
s/check_inbox_or_spam: c48ac1f7b76052881bb3b6d10615152d
|
||||
s/completed: 98a9cd97b409933edf1991e7d022bea9
|
||||
s/completed_heading: 0e4bbce9985f25eb673d9a054c8d5334
|
||||
s/create_your_own: 27976ec69029d6dd52d146a9b5765bc6
|
||||
s/enter_pin: 90bccdc2e51ee2550842287d1f02c999
|
||||
s/just_curious: d5e5c40e97fcfdab563707ab0de10862
|
||||
s/link_invalid: bd63a73fa5eecad2dfd0687cfed02114
|
||||
s/paused: 65ec150837ca033f5a0fb5d4482e0e4b
|
||||
s/paused_heading: edb1f7b7219e1c9b7aa67159090d6991
|
||||
s/please_try_again_with_the_original_link: ff903c908d199ae654436972dc0576f9
|
||||
s/preview_survey_questions: 2cbdd526f6652d5ee0337e23477d3396
|
||||
s/question_preview: 9d8fbc0150fc10ba851beba2d4f4d9f3
|
||||
@@ -2315,7 +2313,7 @@ checksums:
|
||||
setup/invite/add_another_member: 02947deaa4710893794f3cc6e160c2b4
|
||||
setup/invite/continue: 3cfba90b4600131e82fc4260c568d044
|
||||
setup/invite/failed_to_invite: dc5ce0dcdf0df978f1102b729dc15584
|
||||
setup/invite/invitation_sent_to: 5a79e59f2048ee2f8b660715dcb98191
|
||||
setup/invite/invitation_sent_to_email: 0c70bfd61508aea1d447e1a9dcb8bb8a
|
||||
setup/invite/invite_your_organization_members: 98de00cb78b11297679f1565e0c24517
|
||||
setup/invite/life_s_no_fun_alone: e4e080c6957a10bf218fbd1b5d8cdecc
|
||||
setup/invite/skip: b7f28dfa2f58b80b149bb82b392d0291
|
||||
|
||||
@@ -155,6 +155,9 @@ export const DEBUG = env.DEBUG === "1";
|
||||
// Enterprise License constant
|
||||
export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;
|
||||
|
||||
export const ENTERPRISE_LICENSE_REQUEST_FORM_URL =
|
||||
"https://app.formbricks.com/s/trvp8tzy5uvsps9rc9qi9l9w?delivery=onpremise&source=ce";
|
||||
|
||||
export const REDIS_URL = env.REDIS_URL;
|
||||
export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1";
|
||||
export const TELEMETRY_DISABLED = env.TELEMETRY_DISABLED === "1";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { TDisplay, TDisplayFilters, TDisplayWithContact } from "@formbricks/types/displays";
|
||||
import { TDisplay, TDisplayFilters, TDisplayWithContact, ZDisplayFilters } from "@formbricks/types/displays";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
@@ -18,18 +18,31 @@ export const selectDisplay = {
|
||||
|
||||
export const getDisplayCountBySurveyId = reactCache(
|
||||
async (surveyId: string, filters?: TDisplayFilters): Promise<number> => {
|
||||
validateInputs([surveyId, ZId]);
|
||||
validateInputs([surveyId, ZId], [filters, ZDisplayFilters.optional()]);
|
||||
|
||||
if (filters?.responseIds?.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const displayCount = await prisma.display.count({
|
||||
where: {
|
||||
surveyId: surveyId,
|
||||
surveyId,
|
||||
...(filters?.createdAt && {
|
||||
createdAt: {
|
||||
gte: filters.createdAt.min,
|
||||
lte: filters.createdAt.max,
|
||||
},
|
||||
}),
|
||||
...(filters?.responseIds && {
|
||||
response: {
|
||||
is: {
|
||||
id: {
|
||||
in: filters.responseIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
return displayCount;
|
||||
|
||||
@@ -4,9 +4,14 @@ import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||
import { getDisplaysByContactId, getDisplaysBySurveyIdWithContact } from "../service";
|
||||
import {
|
||||
getDisplayCountBySurveyId,
|
||||
getDisplaysByContactId,
|
||||
getDisplaysBySurveyIdWithContact,
|
||||
} from "../service";
|
||||
|
||||
const mockContactId = "clqnj99r9000008lebgf8734j";
|
||||
const mockResponseIds = ["clqnfg59i000208i426pb4wcv", "clqnfg59i000208i426pb4wcw"];
|
||||
|
||||
const mockDisplaysForContact = [
|
||||
{
|
||||
@@ -45,6 +50,74 @@ const mockDisplaysWithContact = [
|
||||
},
|
||||
];
|
||||
|
||||
describe("getDisplayCountBySurveyId", () => {
|
||||
describe("Happy Path", () => {
|
||||
test("counts displays by surveyId", async () => {
|
||||
vi.mocked(prisma.display.count).mockResolvedValue(5);
|
||||
|
||||
const result = await getDisplayCountBySurveyId(mockSurveyId);
|
||||
|
||||
expect(result).toBe(5);
|
||||
expect(prisma.display.count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
surveyId: mockSurveyId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("combines createdAt and responseIds filters", async () => {
|
||||
const createdAt = {
|
||||
min: new Date("2024-01-01T00:00:00.000Z"),
|
||||
max: new Date("2024-01-31T23:59:59.999Z"),
|
||||
};
|
||||
vi.mocked(prisma.display.count).mockResolvedValue(2);
|
||||
|
||||
const result = await getDisplayCountBySurveyId(mockSurveyId, {
|
||||
createdAt,
|
||||
responseIds: mockResponseIds,
|
||||
});
|
||||
|
||||
expect(result).toBe(2);
|
||||
expect(prisma.display.count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
surveyId: mockSurveyId,
|
||||
createdAt: {
|
||||
gte: createdAt.min,
|
||||
lte: createdAt.max,
|
||||
},
|
||||
response: {
|
||||
is: {
|
||||
id: {
|
||||
in: mockResponseIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 0 without querying when responseIds filter is empty", async () => {
|
||||
const result = await getDisplayCountBySurveyId(mockSurveyId, { responseIds: [] });
|
||||
|
||||
expect(result).toBe(0);
|
||||
expect(prisma.display.count).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.display.count).mockRejectedValue(errToThrow);
|
||||
|
||||
await expect(getDisplayCountBySurveyId(mockSurveyId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDisplaysByContactId", () => {
|
||||
describe("Happy Path", () => {
|
||||
test("returns displays for a contact ordered by createdAt desc", async () => {
|
||||
|
||||
@@ -2,13 +2,7 @@ import * as cuid2 from "@paralleldrive/cuid2";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import * as crypto from "@/lib/crypto";
|
||||
import { env } from "@/lib/env";
|
||||
import {
|
||||
generateSurveySingleUseId,
|
||||
generateSurveySingleUseIds,
|
||||
generateSurveySingleUseLinkParams,
|
||||
validateSurveySingleUseLinkParams,
|
||||
validateSurveySingleUseSignature,
|
||||
} from "./single-use-surveys";
|
||||
import { generateSurveySingleUseId, generateSurveySingleUseIds } from "./single-use-surveys";
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
symmetricEncrypt: vi.fn(),
|
||||
@@ -118,87 +112,4 @@ describe("Single Use Surveys", () => {
|
||||
expect(createIdMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("signed single-use links", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(env).ENCRYPTION_KEY = "test-encryption-key";
|
||||
});
|
||||
|
||||
test("generates and validates signed custom single-use IDs", () => {
|
||||
const params = generateSurveySingleUseLinkParams("survey-1", false, "CUSTOM-ID");
|
||||
|
||||
expect(params.suId).toBe("CUSTOM-ID");
|
||||
expect(params.suToken).toBeDefined();
|
||||
expect(validateSurveySingleUseSignature("survey-1", params.suId, params.suToken)).toBe(true);
|
||||
expect(
|
||||
validateSurveySingleUseLinkParams({
|
||||
surveyId: "survey-1",
|
||||
suId: params.suId,
|
||||
suToken: params.suToken,
|
||||
isEncrypted: false,
|
||||
decrypt: vi.fn(),
|
||||
})
|
||||
).toBe("CUSTOM-ID");
|
||||
});
|
||||
|
||||
test("rejects tampered signed custom single-use IDs", () => {
|
||||
const params = generateSurveySingleUseLinkParams("survey-1", false, "CUSTOM-ID");
|
||||
|
||||
expect(validateSurveySingleUseSignature("survey-2", params.suId, params.suToken)).toBe(false);
|
||||
expect(validateSurveySingleUseSignature("survey-1", "OTHER-ID", params.suToken)).toBe(false);
|
||||
expect(validateSurveySingleUseSignature("survey-1", params.suId, "invalid-token")).toBe(false);
|
||||
expect(validateSurveySingleUseSignature("survey-1", params.suId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateSurveySingleUseLinkParams", () => {
|
||||
test("returns decrypted CUID for encrypted single-use IDs", () => {
|
||||
const decrypt = vi.fn().mockReturnValue("decrypted-cuid");
|
||||
vi.mocked(cuid2.isCuid).mockReturnValueOnce(true);
|
||||
|
||||
const result = validateSurveySingleUseLinkParams({
|
||||
surveyId: "survey-1",
|
||||
suId: "encrypted-cuid",
|
||||
isEncrypted: true,
|
||||
decrypt,
|
||||
});
|
||||
|
||||
expect(result).toBe("decrypted-cuid");
|
||||
expect(decrypt).toHaveBeenCalledWith("encrypted-cuid");
|
||||
expect(cuid2.isCuid).toHaveBeenCalledWith("decrypted-cuid");
|
||||
});
|
||||
|
||||
test("rejects encrypted single-use IDs that decrypt to invalid CUIDs", () => {
|
||||
const decrypt = vi.fn().mockReturnValue("invalid-id");
|
||||
vi.mocked(cuid2.isCuid).mockReturnValueOnce(false);
|
||||
|
||||
const result = validateSurveySingleUseLinkParams({
|
||||
surveyId: "survey-1",
|
||||
suId: "encrypted-cuid",
|
||||
isEncrypted: true,
|
||||
decrypt,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(decrypt).toHaveBeenCalledWith("encrypted-cuid");
|
||||
expect(cuid2.isCuid).toHaveBeenCalledWith("invalid-id");
|
||||
});
|
||||
|
||||
test("rejects encrypted single-use IDs when decryption fails", () => {
|
||||
const decrypt = vi.fn(() => {
|
||||
throw new Error("Invalid encrypted payload");
|
||||
});
|
||||
|
||||
const result = validateSurveySingleUseLinkParams({
|
||||
surveyId: "survey-1",
|
||||
suId: "malformed-encrypted-cuid",
|
||||
isEncrypted: true,
|
||||
decrypt,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(decrypt).toHaveBeenCalledWith("malformed-encrypted-cuid");
|
||||
expect(cuid2.isCuid).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,7 @@
|
||||
import { createId, isCuid } from "@paralleldrive/cuid2";
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { symmetricEncrypt } from "@/lib/crypto";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
const SINGLE_USE_SIGNATURE_PAYLOAD_PREFIX = "formbricks.single-use.v1";
|
||||
|
||||
export type TSurveySingleUseLinkParams = {
|
||||
suId: string;
|
||||
suToken?: string;
|
||||
};
|
||||
|
||||
const getSingleUseSigningKey = (): string => {
|
||||
if (!env.ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
return env.ENCRYPTION_KEY;
|
||||
};
|
||||
|
||||
// generate encrypted single use id for the survey
|
||||
export const generateSurveySingleUseId = (isEncrypted: boolean): string => {
|
||||
const cuid = createId();
|
||||
@@ -42,87 +26,3 @@ export const generateSurveySingleUseIds = (count: number, isEncrypted: boolean):
|
||||
|
||||
return singleUseIds;
|
||||
};
|
||||
|
||||
export const generateSurveySingleUseSignature = (surveyId: string, singleUseId: string): string => {
|
||||
const payload = `${SINGLE_USE_SIGNATURE_PAYLOAD_PREFIX}:${surveyId}:${singleUseId}`;
|
||||
|
||||
return createHmac("sha256", getSingleUseSigningKey()).update(payload).digest("hex");
|
||||
};
|
||||
|
||||
export const validateSurveySingleUseSignature = (
|
||||
surveyId: string,
|
||||
singleUseId: string,
|
||||
signature?: string | null
|
||||
): boolean => {
|
||||
if (!signature) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedSignature = generateSurveySingleUseSignature(surveyId, singleUseId);
|
||||
const expected = Buffer.from(expectedSignature);
|
||||
const received = Buffer.from(signature);
|
||||
|
||||
return expected.length === received.length && timingSafeEqual(expected, received);
|
||||
};
|
||||
|
||||
export const generateSurveySingleUseLinkParams = (
|
||||
surveyId: string,
|
||||
isEncrypted: boolean,
|
||||
singleUseId?: string
|
||||
): TSurveySingleUseLinkParams => {
|
||||
if (isEncrypted) {
|
||||
return { suId: generateSurveySingleUseId(true) };
|
||||
}
|
||||
|
||||
const suId = singleUseId?.trim() || generateSurveySingleUseId(false);
|
||||
|
||||
return {
|
||||
suId,
|
||||
suToken: generateSurveySingleUseSignature(surveyId, suId),
|
||||
};
|
||||
};
|
||||
|
||||
export const generateSurveySingleUseLinkParamsList = (
|
||||
count: number,
|
||||
surveyId: string,
|
||||
isEncrypted: boolean
|
||||
): TSurveySingleUseLinkParams[] => {
|
||||
const singleUseLinkParams: TSurveySingleUseLinkParams[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
singleUseLinkParams.push(generateSurveySingleUseLinkParams(surveyId, isEncrypted));
|
||||
}
|
||||
|
||||
return singleUseLinkParams;
|
||||
};
|
||||
|
||||
export const validateSurveySingleUseLinkParams = ({
|
||||
surveyId,
|
||||
suId,
|
||||
suToken,
|
||||
isEncrypted,
|
||||
decrypt,
|
||||
}: {
|
||||
surveyId: string;
|
||||
suId?: string | null;
|
||||
suToken?: string | null;
|
||||
isEncrypted: boolean;
|
||||
decrypt: (encryptedSingleUseId: string) => string;
|
||||
}): string | null => {
|
||||
const trimmedSuId = suId?.trim();
|
||||
|
||||
if (!trimmedSuId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isEncrypted) {
|
||||
try {
|
||||
const decryptedSingleUseId = decrypt(trimmedSuId);
|
||||
return isCuid(decryptedSingleUseId) ? decryptedSingleUseId : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return validateSurveySingleUseSignature(surveyId, trimmedSuId, suToken) ? trimmedSuId : null;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import dns from "node:dns";
|
||||
import type { Agent } from "undici";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { validateWebhookUrl } from "./validate-webhook-url";
|
||||
import {
|
||||
createPinnedDispatcher,
|
||||
validateAndResolveWebhookUrl,
|
||||
validateWebhookUrl,
|
||||
} from "./validate-webhook-url";
|
||||
|
||||
vi.mock("node:dns", () => ({
|
||||
default: {
|
||||
@@ -372,4 +377,125 @@ describe("validateWebhookUrl", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateAndResolveWebhookUrl returns pinned address", () => {
|
||||
test("returns IPv4 literal as { ip, family: 4 }", async () => {
|
||||
await expect(validateAndResolveWebhookUrl("https://93.184.216.34/webhook")).resolves.toEqual({
|
||||
ip: "93.184.216.34",
|
||||
family: 4,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns IPv6 literal stripped of brackets as { ip, family: 6 }", async () => {
|
||||
await expect(
|
||||
validateAndResolveWebhookUrl("https://[2606:2800:220:1:248:1893:25c8:1946]/webhook")
|
||||
).resolves.toEqual({
|
||||
ip: "2606:2800:220:1:248:1893:25c8:1946",
|
||||
family: 6,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns first resolved IPv4 for hostnames", async () => {
|
||||
setupDnsResolution(["93.184.216.34", "23.23.23.23"]);
|
||||
await expect(validateAndResolveWebhookUrl("https://example.com/webhook")).resolves.toEqual({
|
||||
ip: "93.184.216.34",
|
||||
family: 4,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns IPv6 when only IPv6 is resolvable", async () => {
|
||||
setupDnsResolution(null, ["2606:2800:220:1:248:1893:25c8:1946"]);
|
||||
await expect(validateAndResolveWebhookUrl("https://example.com/webhook")).resolves.toEqual({
|
||||
ip: "2606:2800:220:1:248:1893:25c8:1946",
|
||||
family: 6,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null for blocked hostname when DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS is enabled", async () => {
|
||||
vi.doMock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
|
||||
}));
|
||||
const { validateAndResolveWebhookUrl: fn } = await import("./validate-webhook-url");
|
||||
await expect(fn("http://localhost/webhook")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPinnedDispatcher", () => {
|
||||
test("returns an undici Agent instance", async () => {
|
||||
const { Agent } = await import("undici");
|
||||
const dispatcher = createPinnedDispatcher({ ip: "93.184.216.34", family: 4 });
|
||||
expect(dispatcher).toBeInstanceOf(Agent);
|
||||
await dispatcher.close();
|
||||
});
|
||||
|
||||
// Reach into the Agent's connect options to grab the lookup function we
|
||||
// installed. This is implementation-coupled but the only way to assert the
|
||||
// pinning behavior without spinning up a real socket. If undici changes
|
||||
// internals and this stops finding the lookup, the integration-style test
|
||||
// below still verifies the end-to-end behavior.
|
||||
const extractLookup = (
|
||||
agent: Agent
|
||||
):
|
||||
| ((
|
||||
host: string,
|
||||
opts: { all?: boolean },
|
||||
cb: (
|
||||
err: NodeJS.ErrnoException | null,
|
||||
address: string | { address: string; family: number }[],
|
||||
family?: number
|
||||
) => void
|
||||
) => void)
|
||||
| undefined => {
|
||||
const symbols = Object.getOwnPropertySymbols(agent);
|
||||
for (const sym of symbols) {
|
||||
const value = (agent as unknown as Record<symbol, unknown>)[sym];
|
||||
if (value && typeof value === "object" && "connect" in value) {
|
||||
const connect = (value as { connect?: { lookup?: unknown } }).connect;
|
||||
if (connect && typeof connect.lookup === "function") {
|
||||
return connect.lookup as never;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
test("lookup returns the pinned IP regardless of which hostname is queried (all=true)", async () => {
|
||||
const dispatcher = createPinnedDispatcher({ ip: "93.184.216.34", family: 4 });
|
||||
const lookup = extractLookup(dispatcher);
|
||||
|
||||
// If we couldn't reach into the Agent, skip the deep assertion — the
|
||||
// integration test still covers the contract.
|
||||
if (!lookup) {
|
||||
await dispatcher.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await new Promise<{ address: string; family: number }[]>((resolve, reject) => {
|
||||
lookup("attacker-rebound.example.com", { all: true }, (err, addresses) => {
|
||||
if (err) reject(err);
|
||||
else resolve(addresses as { address: string; family: number }[]);
|
||||
});
|
||||
});
|
||||
expect(result).toEqual([{ address: "93.184.216.34", family: 4 }]);
|
||||
await dispatcher.close();
|
||||
});
|
||||
|
||||
test("lookup honours legacy (err, address, family) form when all is not set", async () => {
|
||||
const dispatcher = createPinnedDispatcher({ ip: "2606:4700::1", family: 6 });
|
||||
const lookup = extractLookup(dispatcher);
|
||||
if (!lookup) {
|
||||
await dispatcher.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await new Promise<{ address: string; family: number }>((resolve, reject) => {
|
||||
lookup("anything.example", {}, (err, address, family) => {
|
||||
if (err) reject(err);
|
||||
else resolve({ address: address as string, family: family ?? -1 });
|
||||
});
|
||||
});
|
||||
expect(result).toEqual({ address: "2606:4700::1", family: 6 });
|
||||
await dispatcher.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
||||
import { createPinnedDispatcher } from "./validate-webhook-url";
|
||||
|
||||
// Real DNS, no node:dns mock. The whole point of this file is to prove that
|
||||
// the pinned dispatcher bypasses DNS entirely — so the hostname we use must
|
||||
// genuinely fail to resolve in real life.
|
||||
vi.unmock("node:dns");
|
||||
|
||||
vi.mock("../constants", () => ({
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: false,
|
||||
}));
|
||||
|
||||
describe("DNS rebinding TOCTOU — pinned dispatcher", () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
const visited: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
visited.push(req.headers.host ?? "");
|
||||
res.writeHead(200, { "content-type": "text/plain" });
|
||||
res.end("hit-pinned-target");
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
port = (server.address() as AddressInfo).port;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
test("baseline: fetch to *.invalid hostname fails (real DNS cannot resolve it)", async () => {
|
||||
// RFC 2606 reserves the .invalid TLD — guaranteed to never resolve.
|
||||
// This proves DNS is what fetch normally relies on.
|
||||
await expect(
|
||||
fetch(`http://attacker-rebind.invalid:${port}/`).catch((e: Error) => {
|
||||
throw new Error(e.message);
|
||||
})
|
||||
).rejects.toThrow(/fetch failed/i);
|
||||
});
|
||||
|
||||
test("with pinned dispatcher: connection lands on pinned IP even though hostname is unresolvable", async () => {
|
||||
// Simulates: validate resolved attacker.com to a public IP (here represented
|
||||
// by 127.0.0.1 — the local test server). Attacker then rebinds DNS so a
|
||||
// second lookup would return something different (or nothing). The pinned
|
||||
// dispatcher means there *is* no second lookup — undici uses our IP.
|
||||
const dispatcher = createPinnedDispatcher({ ip: "127.0.0.1", family: 4 });
|
||||
try {
|
||||
const response = await fetch(`http://attacker-rebind.invalid:${port}/`, {
|
||||
// RequestInit doesn't type `dispatcher` — undici accepts it at runtime.
|
||||
dispatcher,
|
||||
} as RequestInit & { dispatcher: typeof dispatcher });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.text()).resolves.toBe("hit-pinned-target");
|
||||
// The Host header preserves the original hostname (TLS SNI parity);
|
||||
// only the TCP target was rerouted via the pin.
|
||||
expect(visited.at(-1)).toContain("attacker-rebind.invalid");
|
||||
} finally {
|
||||
await dispatcher.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import "server-only";
|
||||
import dns from "node:dns";
|
||||
import { Agent } from "undici";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "../constants";
|
||||
|
||||
@@ -67,13 +68,15 @@ const isPrivateIPv6 = (ip: string): boolean => {
|
||||
return PRIVATE_IPV6_PREFIXES.some((prefix) => normalized.startsWith(prefix));
|
||||
};
|
||||
|
||||
const isPrivateIP = (ip: string): boolean => {
|
||||
return isPrivateIPv4(ip) || isPrivateIPv6(ip);
|
||||
const isPrivateIP = (ip: string, family: 4 | 6): boolean => {
|
||||
return family === 4 ? isPrivateIPv4(ip) : isPrivateIPv6(ip);
|
||||
};
|
||||
|
||||
const DNS_TIMEOUT_MS = 3000;
|
||||
|
||||
const resolveHostnameToIPs = (hostname: string): Promise<string[]> => {
|
||||
export type ResolvedAddress = { ip: string; family: 4 | 6 };
|
||||
|
||||
const resolveHostnameToAddresses = (hostname: string): Promise<ResolvedAddress[]> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
|
||||
@@ -89,16 +92,16 @@ const resolveHostnameToIPs = (hostname: string): Promise<string[]> => {
|
||||
}, DNS_TIMEOUT_MS);
|
||||
|
||||
dns.resolve(hostname, (errV4, ipv4Addresses) => {
|
||||
const ipv4 = errV4 ? [] : ipv4Addresses;
|
||||
const ipv4: ResolvedAddress[] = errV4 ? [] : ipv4Addresses.map((ip) => ({ ip, family: 4 as const }));
|
||||
|
||||
dns.resolve6(hostname, (errV6, ipv6Addresses) => {
|
||||
const ipv6 = errV6 ? [] : ipv6Addresses;
|
||||
const allAddresses = [...ipv4, ...ipv6];
|
||||
const ipv6: ResolvedAddress[] = errV6 ? [] : ipv6Addresses.map((ip) => ({ ip, family: 6 as const }));
|
||||
const all = [...ipv4, ...ipv6];
|
||||
|
||||
if (allAddresses.length === 0) {
|
||||
if (all.length === 0) {
|
||||
settle(reject, new Error(`DNS resolution failed for hostname: ${hostname}`));
|
||||
} else {
|
||||
settle(resolve, allAddresses);
|
||||
settle(resolve, all);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -114,59 +117,35 @@ const stripIPv6Brackets = (hostname: string): string => {
|
||||
|
||||
const IPV4_LITERAL = /^\d{1,3}(?:\.\d{1,3}){3}$/;
|
||||
|
||||
/**
|
||||
* Validates a webhook URL to prevent Server-Side Request Forgery (SSRF).
|
||||
*
|
||||
* Checks performed:
|
||||
* 1. URL must be well-formed
|
||||
* 2. Protocol must be HTTPS or HTTP
|
||||
* 3. Hostname must not be a known internal name (localhost, metadata endpoints)
|
||||
* 4. IP literal hostnames are checked directly against private ranges
|
||||
* 5. Domain hostnames are resolved via DNS; all resulting IPs must be public
|
||||
*
|
||||
* @throws {InvalidInputError} when the URL fails any validation check
|
||||
*/
|
||||
export const validateWebhookUrl = async (url: string): Promise<void> => {
|
||||
const parseWebhookUrl = (url: string): URL => {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new InvalidInputError("Invalid webhook URL format");
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
throw new InvalidInputError("Webhook URL must use HTTPS or HTTP protocol");
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const hostname = parsed.hostname;
|
||||
|
||||
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) {
|
||||
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
|
||||
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
|
||||
}
|
||||
}
|
||||
|
||||
// Direct IP literal — validate without DNS resolution
|
||||
const validateIpLiteral = (hostname: string): ResolvedAddress | null => {
|
||||
const isIPv4Literal = IPV4_LITERAL.test(hostname);
|
||||
const isIPv6Literal = hostname.startsWith("[");
|
||||
if (!isIPv4Literal && !isIPv6Literal) return null;
|
||||
|
||||
if (isIPv4Literal || isIPv6Literal) {
|
||||
const ip = isIPv6Literal ? stripIPv6Brackets(hostname) : hostname;
|
||||
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isPrivateIP(ip)) {
|
||||
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
|
||||
}
|
||||
return;
|
||||
const ip = isIPv6Literal ? stripIPv6Brackets(hostname) : hostname;
|
||||
const family: 4 | 6 = isIPv4Literal ? 4 : 6;
|
||||
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isPrivateIP(ip, family)) {
|
||||
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
|
||||
}
|
||||
return { ip, family };
|
||||
};
|
||||
|
||||
// Skip DNS resolution for localhost-like hostnames when internal URLs are allowed since these are resolved via /etc/hosts and not DNS
|
||||
if (DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Domain name — resolve DNS and validate every resolved IP
|
||||
let resolvedIPs: string[];
|
||||
const resolveHostnameOrThrow = async (hostname: string): Promise<ResolvedAddress[]> => {
|
||||
try {
|
||||
resolvedIPs = await resolveHostnameToIPs(hostname);
|
||||
return await resolveHostnameToAddresses(hostname);
|
||||
} catch (error) {
|
||||
const isTimeout = error instanceof Error && error.message.includes("timed out");
|
||||
throw new InvalidInputError(
|
||||
@@ -175,12 +154,94 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
|
||||
: `Could not resolve webhook URL hostname: ${hostname}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates a webhook URL and returns a resolved address pinned for delivery.
|
||||
*
|
||||
* Returns the IP literal or first DNS-resolved address. Returns `null` only when
|
||||
* `DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS` is enabled for a known internal hostname
|
||||
* (localhost etc.) — in that case the caller skips IP pinning so /etc/hosts works.
|
||||
*
|
||||
* Pinning the returned address into the fetch dispatcher closes the TOCTOU window
|
||||
* where DNS could rebind between this validation and the subsequent HTTP request.
|
||||
*
|
||||
* @throws {InvalidInputError} when the URL fails any validation check
|
||||
*/
|
||||
export const validateAndResolveWebhookUrl = async (url: string): Promise<ResolvedAddress | null> => {
|
||||
const parsed = parseWebhookUrl(url);
|
||||
const hostname = parsed.hostname;
|
||||
const isBlockedName = BLOCKED_HOSTNAMES.has(hostname.toLowerCase());
|
||||
|
||||
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isBlockedName) {
|
||||
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
|
||||
}
|
||||
|
||||
const literal = validateIpLiteral(hostname);
|
||||
if (literal) return literal;
|
||||
|
||||
// Skip DNS for localhost-like hostnames when internal URLs are allowed (resolved via /etc/hosts)
|
||||
if (DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isBlockedName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolved = await resolveHostnameOrThrow(hostname);
|
||||
|
||||
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) {
|
||||
for (const ip of resolvedIPs) {
|
||||
if (isPrivateIP(ip)) {
|
||||
for (const addr of resolved) {
|
||||
if (isPrivateIP(addr.ip, addr.family)) {
|
||||
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pin to the first resolved address. All addresses already passed the public-IP
|
||||
// check above, so any choice is safe.
|
||||
return resolved[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates a webhook URL to prevent Server-Side Request Forgery (SSRF).
|
||||
* Thin wrapper around {@link validateAndResolveWebhookUrl} for callers that only
|
||||
* need validation (e.g. webhook create/update) and discard the resolved address.
|
||||
*
|
||||
* @throws {InvalidInputError} when the URL fails any validation check
|
||||
*/
|
||||
export const validateWebhookUrl = async (url: string): Promise<void> => {
|
||||
await validateAndResolveWebhookUrl(url);
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds an undici Agent that pins outgoing TCP connections to the given IP/family,
|
||||
* regardless of what hostname the URL resolves to at fetch time. Use the address
|
||||
* returned by {@link validateAndResolveWebhookUrl} so the IP that was validated is
|
||||
* the IP that gets connected to — closes the DNS-rebinding TOCTOU window.
|
||||
*
|
||||
* TLS SNI/cert validation still uses the original hostname from the URL.
|
||||
*/
|
||||
export const createPinnedDispatcher = (address: ResolvedAddress): Agent => {
|
||||
return new Agent({
|
||||
connect: {
|
||||
// undici calls `lookup(host, { all: true, ... }, cb)`, so honor both forms:
|
||||
// when `all` is true we must return an array; otherwise the legacy
|
||||
// (err, address, family) signature. Returning the wrong form yields
|
||||
// "Invalid IP address: undefined" at connect time.
|
||||
lookup: (_hostname, options, callback) => {
|
||||
if (options && typeof options === "object" && (options as { all?: boolean }).all) {
|
||||
(
|
||||
callback as (
|
||||
err: NodeJS.ErrnoException | null,
|
||||
addresses: { address: string; family: number }[]
|
||||
) => void
|
||||
)(null, [{ address: address.ip, family: address.family }]);
|
||||
return;
|
||||
}
|
||||
(callback as (err: NodeJS.ErrnoException | null, address: string, family: number) => void)(
|
||||
null,
|
||||
address.ip,
|
||||
address.family
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
+26
-34
@@ -241,10 +241,12 @@
|
||||
"failed_to_load_organizations": "Fehler beim Laden der Organisationen",
|
||||
"failed_to_load_workspaces": "Projekte konnten nicht geladen werden",
|
||||
"field_placeholder": "{{field}}-Platzhalter",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filter",
|
||||
"finish": "Fertigstellen",
|
||||
"first_name": "Vorname",
|
||||
"follow_these": "Folge diesen",
|
||||
"formbricks_version": "Formbricks Version",
|
||||
"full_name": "Name",
|
||||
"gathering_responses": "Antworten sammeln",
|
||||
@@ -358,7 +360,6 @@
|
||||
"placeholder": "Platzhalter",
|
||||
"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_upgrade_your_plan": "Bitte aktualisieren Sie Ihren Plan",
|
||||
"powered_by_formbricks": "Bereitgestellt von Formbricks",
|
||||
"preview": "Vorschau",
|
||||
"privacy": "Datenschutz",
|
||||
@@ -499,7 +500,7 @@
|
||||
"you_are_downgraded_to_the_community_edition": "Du wurdest auf die Community Edition herabgestuft.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Du bist nicht berechtigt, diese Aktion durchzuführen.",
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Sie haben Ihr Limit von {projectLimit} Workspaces erreicht.",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Du hast dein monatliches Antwortlimit erreicht",
|
||||
"you_have_reached_your_monthly_response_limit_of_count": "Du hast dein monatliches Antwortlimit von {{count}} erreicht.",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Du wirst am {date} auf die Community Edition herabgestuft.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
},
|
||||
@@ -774,6 +775,7 @@
|
||||
"create_survey_warning": "Du musst eine Umfrage erstellen, um diese Integration einrichten zu können",
|
||||
"delete_integration": "Integration löschen",
|
||||
"delete_integration_confirmation": "Bist Du sicher, dass Du diese Integration löschen möchtest?",
|
||||
"follow_these_docs_to_configure_it": "Folge dieser <docsLink>Dokumentation</docsLink>, um es zu konfigurieren.",
|
||||
"google_sheet_integration_description": "Synchronisiere deine Tabelle mit Umfragedaten",
|
||||
"google_sheets": {
|
||||
"connect_with_google_sheets": "Mit Google Sheets verbinden",
|
||||
@@ -858,7 +860,6 @@
|
||||
"slack_reconnect_button_description": "<b>Hinweis:</b> Wir haben kürzlich unsere Slack-Integration geändert, um auch private Kanäle zu unterstützen. Bitte verbinden Sie Ihren Slack-Workspace erneut."
|
||||
},
|
||||
"slack_integration_description": "Verbinde deinen Slack Arbeitsbereich sofort mit Formbricks",
|
||||
"to_configure_it": "es zu konfigurieren.",
|
||||
"webhook_integration_description": "Webhooks basierend auf Aktionen in deinen Umfragen auslösen",
|
||||
"webhooks": {
|
||||
"add_webhook": "Webhook hinzufügen",
|
||||
@@ -917,9 +918,7 @@
|
||||
"ex_fully_activated_recurring_users": "Beispiel: Wiederkehrende Nutzer",
|
||||
"ex_power_users": "Ex-Power-User",
|
||||
"filters_reset_successfully": "Filter erfolgreich zurückgesetzt",
|
||||
"here": "hier",
|
||||
"hide_filters": "Filter ausblenden",
|
||||
"identifying_users": "Benutzer identifizieren",
|
||||
"invalid_segment": "Ungültiges Segment",
|
||||
"invalid_segment_filters": "Ungültige Filter. Bitte überprüfe die Filter und versuche es erneut.",
|
||||
"load_segment": "Segment laden",
|
||||
@@ -974,21 +973,20 @@
|
||||
"segment_id": "Segment-ID",
|
||||
"segment_saved_successfully": "Segment erfolgreich gespeichert",
|
||||
"segment_updated_successfully": "Segment erfolgreich aktualisiert",
|
||||
"segment_used_in_other_surveys_make_changes_here": "Dieses Segment wird in anderen Umfragen verwendet. Nimm Änderungen <segmentsLink>hier</segmentsLink> vor.",
|
||||
"segments_help_you_target_users_with_same_characteristics_easily": "Segmente helfen dir, Nutzer mit denselben Merkmalen zu erreichen",
|
||||
"target_audience": "Zielgruppe",
|
||||
"this_action_resets_all_filters_in_this_survey": "Diese Aktion setzt alle Filter in dieser Umfrage zurück",
|
||||
"this_segment_is_used_in_other_surveys": "Dieser Abschnitt wird in anderen Umfragen verwendet. Änderungen vornehmen",
|
||||
"title_is_required": "Der Titel ist erforderlich.",
|
||||
"unknown_filter_type": "Unbekannter Filtertyp",
|
||||
"unlock_segments_description": "Organisiere Kontakte in Segmente, um spezifische Nutzergruppen anzusprechen",
|
||||
"unlock_segments_title": "Segmente mit einem höheren Plan freischalten",
|
||||
"user_targeting_is_currently_only_available_when": "Benutzerzielgruppen sind derzeit nur verfügbar, wenn",
|
||||
"user_targeting_only_available_when_identifying_users": "Nutzer-Targeting ist derzeit nur verfügbar, wenn du <docsLink>Nutzer identifizierst</docsLink> mit dem Formbricks SDK.",
|
||||
"value_cannot_be_empty": "Wert darf nicht leer sein.",
|
||||
"value_must_be_a_number": "Wert muss eine Zahl sein.",
|
||||
"value_must_be_positive": "Wert muss eine positive Zahl sein.",
|
||||
"view_filters": "Filter anzeigen",
|
||||
"where": "Wo",
|
||||
"with_the_formbricks_sdk": "mit dem Formbricks SDK"
|
||||
"where": "Wo"
|
||||
},
|
||||
"settings": {
|
||||
"api_keys": {
|
||||
@@ -1067,8 +1065,8 @@
|
||||
"unlimited_workspaces": "Unbegrenzte Projekte",
|
||||
"upgrade": "Upgrade",
|
||||
"upgrade_now": "Jetzt upgraden",
|
||||
"usage_count_of_limit_used": "{{current}} / {{limit}} <muted>verbraucht</muted>",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "verwendet",
|
||||
"yearly": "Jährlich",
|
||||
"yearly_checkout_unavailable": "Die jährliche Abrechnung ist noch nicht verfügbar. Füge zuerst eine Zahlungsmethode bei einem monatlichen Plan hinzu oder kontaktiere den Support.",
|
||||
"your_plan": "Dein Tarif"
|
||||
@@ -1129,7 +1127,7 @@
|
||||
"no_credit_card_no_sales_call_just_test_it": "Keine Kreditkarte. Kein Verkaufsgespräch. Einfach testen :)",
|
||||
"on_request": "Auf Anfrage",
|
||||
"organization_roles": "Organisationsrollen (Admin, Editor, Entwickler, etc.)",
|
||||
"questions_please_reach_out_to": "Fragen? Bitte melde Dich bei",
|
||||
"questions_please_reach_out_to_email": "Fragen? Melde dich gerne bei <contactLink>hola@formbricks.com</contactLink>",
|
||||
"recheck_license": "Lizenz erneut prüfen",
|
||||
"recheck_license_failed": "Lizenzprüfung fehlgeschlagen. Der Lizenzserver ist möglicherweise nicht erreichbar.",
|
||||
"recheck_license_instance_mismatch": "Diese Lizenz ist an eine andere Formbricks-Instanz gebunden. Bitte den Formbricks-Support, die vorherige Bindung zu entfernen.",
|
||||
@@ -1355,13 +1353,12 @@
|
||||
"address_line_2": "Adresszeile 2",
|
||||
"adjust_survey_closed_message": "'Umfrage geschlossen'-Nachricht anpassen",
|
||||
"adjust_survey_closed_message_description": "Ändere die Nachricht, die Besucher sehen, wenn die Umfrage geschlossen ist.",
|
||||
"adjust_the_theme_in_the": "Passe das Thema an in den",
|
||||
"adjust_theme_in_look_and_feel_settings": "Passe das Theme in den <lookFeelLink>Look & Feel</lookFeelLink> Einstellungen an.",
|
||||
"all_are_true": "alle sind wahr",
|
||||
"all_other_answers_will_continue_to": "Alle anderen Antworten werden weiterhin",
|
||||
"all_other_answers_will_continue_to_fallback": "Alle anderen Antworten werden weiterhin <fallbackSelect />",
|
||||
"allow_multi_select": "Mehrfachauswahl erlauben",
|
||||
"allow_multiple_files": "Mehrere Dateien zulassen",
|
||||
"allow_users_to_select_more_than_one_image": "Erlaube Nutzern, mehr als ein Bild auszuwählen",
|
||||
"and_launch_surveys_in_your_website_or_app": "und Umfragen auf deiner Website oder App starten.",
|
||||
"animation": "Animation",
|
||||
"any_is_true": "mindestens eine ist wahr",
|
||||
"app_survey_description": "Bette eine Umfrage in deine Web-App oder Website ein, um Antworten zu sammeln.",
|
||||
@@ -1373,10 +1370,10 @@
|
||||
"auto_save_disabled": "Automatisches Speichern deaktiviert",
|
||||
"auto_save_disabled_tooltip": "Ihre Umfrage wird nur im Entwurfsmodus automatisch gespeichert. So wird sichergestellt, dass öffentliche Umfragen nicht unbeabsichtigt aktualisiert werden.",
|
||||
"auto_save_on": "Automatisches Speichern an",
|
||||
"automatically_close_survey_after": "Umfrage automatisch schließen nach",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "Umfrage automatisch nach <autoCloseInput /> Sekunden nach dem Auslöser schließen, wenn keine Antwort erfolgt.",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Schließe die Umfrage automatisch nach einer bestimmten Anzahl von Antworten.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Schließe die Umfrage automatisch, wenn der Benutzer nach einer bestimmten Anzahl von Sekunden nicht antwortet.",
|
||||
"automatically_mark_the_survey_as_complete_after": "Umfrage automatisch als abgeschlossen markieren nach",
|
||||
"automatically_mark_complete_after_n_responses": "Umfrage automatisch als abgeschlossen markieren nach <autoCompleteInput /> vollständigen Antworten.",
|
||||
"back_button_label": "Zurück\"- Button ",
|
||||
"background_styling": "Hintergrundgestaltung",
|
||||
"block_duplicated": "Block dupliziert.",
|
||||
@@ -1434,11 +1431,11 @@
|
||||
"columns": "Spalten",
|
||||
"company": "Firma",
|
||||
"company_logo": "Firmenlogo",
|
||||
"completed_responses": "Abgeschlossene Antworten.",
|
||||
"concat": "Verketten +",
|
||||
"conditional_logic": "Bedingte Logik",
|
||||
"confirm_default_language": "Standardsprache bestätigen",
|
||||
"confirm_survey_changes": "Änderungen der Umfrage bestätigen",
|
||||
"connect_formbricks_and_launch_surveys": "Verbinde Formbricks und starte Umfragen auf deiner Website oder in deiner App.",
|
||||
"contact_fields": "Kontaktfelder",
|
||||
"contains": "enthält",
|
||||
"continue_to_settings": "Weiter zu den Einstellungen",
|
||||
@@ -1452,7 +1449,6 @@
|
||||
"custom_hostname": "Benutzerdefinierter Hostname",
|
||||
"customize_survey_logo": "Umfragelogo anpassen",
|
||||
"darken_or_lighten_background_of_your_choice": "Hintergrund deiner Wahl abdunkeln oder aufhellen.",
|
||||
"days_before_showing_this_survey_again": "oder mehr Tage müssen zwischen der zuletzt angezeigten Umfrage und der Anzeige dieser Umfrage vergehen.",
|
||||
"default_language": "Standardsprache",
|
||||
"delete_anyways": "Trotzdem löschen",
|
||||
"delete_block": "Block löschen",
|
||||
@@ -1558,7 +1554,6 @@
|
||||
"hide_progress_bar": "Fortschrittsbalken ausblenden",
|
||||
"hide_question_settings": "Frageeinstellungen ausblenden",
|
||||
"hostname": "Hostname",
|
||||
"if_you_need_more_please": "Wenn Sie mehr benötigen, bitte",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Immer anzeigen, wenn ausgelöst, bis eine Antwort oder Teilantwort übermittelt wurde.",
|
||||
"ignore_global_waiting_time": "Abkühlphase ignorieren",
|
||||
"ignore_global_waiting_time_description": "Diese Umfrage kann angezeigt werden, wenn ihre Bedingungen erfüllt sind, auch wenn kürzlich eine andere Umfrage angezeigt wurde.",
|
||||
@@ -1598,7 +1593,7 @@
|
||||
"last_name": "Nachname",
|
||||
"let_people_upload_up_to_25_files_at_the_same_time": "Erlaube bis zu 25 Dateien gleichzeitig hochzuladen.",
|
||||
"limit_the_maximum_file_size": "Begrenzen Sie die maximale Dateigröße für Uploads.",
|
||||
"limit_upload_file_size_to": "Upload-Dateigröße begrenzen auf",
|
||||
"limit_upload_file_size_to_mb": "Datei-Upload-Größe auf <fileSizeInput /> MB begrenzen",
|
||||
"link_survey_description": "Teile einen Link zu einer Umfrageseite oder bette ihn in eine Webseite oder E-Mail ein.",
|
||||
"list": "Liste",
|
||||
"load_segment": "Segment laden",
|
||||
@@ -1613,7 +1608,8 @@
|
||||
"matrix_all_fields": "Alle Felder",
|
||||
"matrix_rows": "Zeilen",
|
||||
"max_file_size": "Maximale Dateigröße",
|
||||
"max_file_size_limit_is": "Die maximale Dateigrößenbeschränkung beträgt",
|
||||
"max_file_size_limit_is_mb": "Maximale Dateigröße beträgt {{maxSize}} MB.",
|
||||
"max_file_size_limit_is_mb_upgrade": "Maximale Dateigröße beträgt {{maxSize}} MB. Wenn du mehr benötigst, <upgradeLink>upgrade deinen Plan</upgradeLink>.",
|
||||
"missing_first": "Fehlende zuerst",
|
||||
"move_question_to_block": "Frage in Block verschieben",
|
||||
"multiply": "Multiplizieren *",
|
||||
@@ -1727,8 +1723,6 @@
|
||||
"save_and_close": "Speichern & Schließen",
|
||||
"scale": "Scale",
|
||||
"search_for_images": "Nach Bildern suchen",
|
||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "Sekunden nach dem Auslösen wird die Umfrage geschlossen, wenn keine Antwort erfolgt.",
|
||||
"seconds_before_showing_the_survey": "Sekunden, bevor die Umfrage angezeigt wird.",
|
||||
"select_field": "Feld auswählen",
|
||||
"select_or_type_value": "Auswählen oder Wert eingeben",
|
||||
"select_ordering": "Anordnung auswählen",
|
||||
@@ -1736,7 +1730,7 @@
|
||||
"select_type": "Typ auswählen",
|
||||
"send_survey_to_audience_who_match": "Umfrage an das Publikum senden, das übereinstimmt...",
|
||||
"send_your_respondents_to_a_page_of_your_choice": "Schicke deine Befragten auf eine Seite deiner Wahl.",
|
||||
"set_the_global_placement_in_the_look_feel_settings": "Stelle die globale Platzierung in den Look & Feel-Einstellungen ein.",
|
||||
"set_global_placement_in_look_feel_settings_hint": "Um die Platzierung über alle Umfragen hinweg konsistent zu halten, kannst du <lookFeelLink>die globale Platzierung in den Look & Feel Einstellungen festlegen.</lookFeelLink>",
|
||||
"settings_saved_successfully": "Einstellungen erfolgreich gespeichert",
|
||||
"seven_points": "7 Punkte",
|
||||
"show_block_settings": "Block-Einstellungen anzeigen",
|
||||
@@ -1746,7 +1740,7 @@
|
||||
"show_multiple_times": "Begrenzte Anzahl von Malen anzeigen",
|
||||
"show_only_once": "Nur einmal anzeigen",
|
||||
"show_question_settings": "Frageeinstellungen anzeigen",
|
||||
"show_survey_maximum_of": "Umfrage maximal anzeigen von",
|
||||
"show_survey_maximum_of_n_times": "Umfrage maximal <displayLimitInput /> Mal anzeigen.",
|
||||
"show_survey_to_users": "Umfrage % der Nutzer anzeigen",
|
||||
"show_to_x_percentage_of_targeted_users": "Zeige {percentage}% der Zielbenutzer",
|
||||
"shrink_preview": "Vorschau verkleinern",
|
||||
@@ -1782,8 +1776,6 @@
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "Diese Aktion entfernt alle Übersetzungen aus dieser Umfrage.",
|
||||
"this_will_remove_the_language_and_all_its_translations": "Dies entfernt diese Sprache und alle zugehörigen Übersetzungen aus dieser Umfrage. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"three_points": "3 Punkte",
|
||||
"times": "Zeiten",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Um die Platzierung über alle Umfragen hinweg konsistent zu halten, kannst du",
|
||||
"translated": "Übersetzt",
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "Umfrage auslösen, wenn eine der Aktionen ausgeführt wird...",
|
||||
"try_lollipop_or_mountain": "Versuch 'Lolli' oder 'Berge'...",
|
||||
@@ -1860,8 +1852,9 @@
|
||||
"visibility_and_recontact": "Sichtbarkeit & erneute Kontaktaufnahme",
|
||||
"visibility_and_recontact_description": "Steuern Sie, wann diese Umfrage erscheinen kann und wie oft sie erneut erscheinen kann.",
|
||||
"visible": "Sichtbar",
|
||||
"wait": "Warte",
|
||||
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Warte ein paar Sekunden nach dem Auslöser, bevor Du die Umfrage anzeigst",
|
||||
"wait_n_days_before_showing_this_survey_again": "Warte <daysInput /> oder mehr Tage zwischen der zuletzt angezeigten Umfrage und dem Anzeigen dieser Umfrage.",
|
||||
"wait_n_seconds_before_showing_the_survey": "Warte <delayInput /> Sekunden, bevor du die Umfrage anzeigst.",
|
||||
"waiting_time_across_surveys": "Abkühlphase (umfrageübergreifend)",
|
||||
"waiting_time_across_surveys_description": "Um Umfragemüdigkeit zu vermeiden, wähle aus, wie diese Umfrage mit der workspace-weiten Abkühlphase interagiert.",
|
||||
"welcome_message": "Willkommensnachricht",
|
||||
@@ -1925,10 +1918,8 @@
|
||||
"search_by_survey_name": "Nach Umfragenamen suchen",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
|
||||
"custom_single_use_id_placeholder": "CUSTOM-ID",
|
||||
"custom_single_use_id_required": "Enter a custom single-use ID.",
|
||||
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
|
||||
"custom_single_use_id_description": "Wenn du die Einmal-ID nicht verschlüsselst, funktioniert jeder Wert für “suid=...” für eine Antwort.",
|
||||
"custom_single_use_id_title": "Sie können im URL beliebige Werte als Einmal-ID festlegen.",
|
||||
"custom_start_point": "Benutzerdefinierter Startpunkt",
|
||||
"data_prefilling": "Daten-Prefilling",
|
||||
"description": "Antworten, die von diesen Links kommen, werden anonym",
|
||||
@@ -2219,6 +2210,7 @@
|
||||
"custom_scripts_warning": "Skripte werden mit vollem Browser-Zugriff ausgeführt. Fügen Sie nur Skripte aus vertrauenswürdigen Quellen hinzu.",
|
||||
"delete_workspace": "Projekt löschen",
|
||||
"delete_workspace_confirmation": "Sind Sie sicher, dass Sie {projectName} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"delete_workspace_confirmation_name": "Bitte gib {projectName} in das folgende Feld ein, um die endgültige Löschung dieses Projekts zu bestätigen:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "{projectName} inkl. aller Umfragen, Antworten, Personen, Aktionen und Attribute löschen.",
|
||||
"delete_workspace_settings_description": "Projekt mit allen Umfragen, Antworten, Personen, Aktionen und Attributen löschen. Das kann nicht rückgängig gemacht werden.",
|
||||
"error_saving_workspace_information": "Fehler beim Speichern der Projektinformationen",
|
||||
@@ -2471,7 +2463,7 @@
|
||||
"add_another_member": "Füge ein weiteres Mitglied hinzu",
|
||||
"continue": "Weitermachen",
|
||||
"failed_to_invite": "Einladung fehlgeschlagen",
|
||||
"invitation_sent_to": "Einladung gesendet an",
|
||||
"invitation_sent_to_email": "Einladung gesendet an {{email}}!",
|
||||
"invite_your_organization_members": "Lade deine Organisationsmitglieder ein",
|
||||
"life_s_no_fun_alone": "Allein macht das Leben keinen Spaß.",
|
||||
"skip": "Überspringen",
|
||||
|
||||
+26
-34
@@ -241,10 +241,12 @@
|
||||
"failed_to_load_organizations": "Failed to load organizations",
|
||||
"failed_to_load_workspaces": "Failed to load workspaces",
|
||||
"field_placeholder": "{{field}} Placeholder",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filter",
|
||||
"finish": "Finish",
|
||||
"first_name": "First Name",
|
||||
"follow_these": "Follow these",
|
||||
"formbricks_version": "Formbricks Version",
|
||||
"full_name": "Full name",
|
||||
"gathering_responses": "Gathering responses",
|
||||
@@ -358,7 +360,6 @@
|
||||
"placeholder": "Placeholder",
|
||||
"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": "Powered by Formbricks",
|
||||
"preview": "Preview",
|
||||
"privacy": "Privacy Policy",
|
||||
@@ -499,7 +500,7 @@
|
||||
"you_are_downgraded_to_the_community_edition": "You are downgraded to the Community Edition.",
|
||||
"you_are_not_authorized_to_perform_this_action": "You are not authorized to perform this action.",
|
||||
"you_have_reached_your_limit_of_workspace_limit": "You have reached your limit of {projectLimit} workspaces.",
|
||||
"you_have_reached_your_monthly_response_limit_of": "You have reached your monthly response limit of",
|
||||
"you_have_reached_your_monthly_response_limit_of_count": "You have reached your monthly response limit of {{count}}.",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "You will be downgraded to the Community Edition on {date}.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
},
|
||||
@@ -774,6 +775,7 @@
|
||||
"create_survey_warning": "You have to create a survey to be able to setup this integration",
|
||||
"delete_integration": "Delete Integration",
|
||||
"delete_integration_confirmation": "Are you sure you want to delete this integration?",
|
||||
"follow_these_docs_to_configure_it": "Follow these <docsLink>docs</docsLink> to configure it.",
|
||||
"google_sheet_integration_description": "Instantly populate your spreadsheets with survey data",
|
||||
"google_sheets": {
|
||||
"connect_with_google_sheets": "Connect with Google Sheets",
|
||||
@@ -858,7 +860,6 @@
|
||||
"slack_reconnect_button_description": "<b>Note:</b> We recently changed our Slack integration to also support private channels. Please reconnect your Slack workspace."
|
||||
},
|
||||
"slack_integration_description": "Instantly connect your Slack Workspace with Formbricks",
|
||||
"to_configure_it": "to configure it.",
|
||||
"webhook_integration_description": "Trigger Webhooks based on actions in your surveys",
|
||||
"webhooks": {
|
||||
"add_webhook": "Add Webhook",
|
||||
@@ -917,9 +918,7 @@
|
||||
"ex_fully_activated_recurring_users": "Ex. Fully activated recurring users",
|
||||
"ex_power_users": "Ex. Power Users",
|
||||
"filters_reset_successfully": "Filters reset successfully",
|
||||
"here": "here",
|
||||
"hide_filters": "Hide filters",
|
||||
"identifying_users": "identifying users",
|
||||
"invalid_segment": "Invalid segment",
|
||||
"invalid_segment_filters": "Invalid filters. Please check the filters and try again.",
|
||||
"load_segment": "Load Segment",
|
||||
@@ -974,21 +973,20 @@
|
||||
"segment_id": "Segment ID",
|
||||
"segment_saved_successfully": "Segment saved successfully",
|
||||
"segment_updated_successfully": "Segment updated successfully",
|
||||
"segment_used_in_other_surveys_make_changes_here": "This segment is used in other surveys. Make changes <segmentsLink>here</segmentsLink>.",
|
||||
"segments_help_you_target_users_with_same_characteristics_easily": "Segments help you target users with the same characteristics easily",
|
||||
"target_audience": "Target Audience",
|
||||
"this_action_resets_all_filters_in_this_survey": "This action resets all filters in this survey.",
|
||||
"this_segment_is_used_in_other_surveys": "This segment is used in other surveys. Make changes",
|
||||
"title_is_required": "Title is required.",
|
||||
"unknown_filter_type": "Unknown filter type",
|
||||
"unlock_segments_description": "Organize contacts into segments to target specific user groups",
|
||||
"unlock_segments_title": "Unlock segments with a higher plan",
|
||||
"user_targeting_is_currently_only_available_when": "User targeting is currently only available when",
|
||||
"user_targeting_only_available_when_identifying_users": "User targeting is currently only available when <docsLink>identifying users</docsLink> with the Formbricks SDK.",
|
||||
"value_cannot_be_empty": "Value cannot be empty.",
|
||||
"value_must_be_a_number": "Value must be a number.",
|
||||
"value_must_be_positive": "Value must be a positive number.",
|
||||
"view_filters": "View filters",
|
||||
"where": "Where",
|
||||
"with_the_formbricks_sdk": "with the Formbricks SDK"
|
||||
"where": "Where"
|
||||
},
|
||||
"settings": {
|
||||
"api_keys": {
|
||||
@@ -1067,8 +1065,8 @@
|
||||
"unlimited_workspaces": "Unlimited Workspaces",
|
||||
"upgrade": "Upgrade",
|
||||
"upgrade_now": "Upgrade now",
|
||||
"usage_count_of_limit_used": "{{current}} / {{limit}} <muted>used</muted>",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "used",
|
||||
"yearly": "Yearly",
|
||||
"yearly_checkout_unavailable": "Yearly checkout is not available yet. Add a payment method on a monthly plan first or contact support.",
|
||||
"your_plan": "Your plan"
|
||||
@@ -1129,7 +1127,7 @@
|
||||
"no_credit_card_no_sales_call_just_test_it": "No credit card. No sales call. Just test it :)",
|
||||
"on_request": "On request",
|
||||
"organization_roles": "Organization Roles (Admin, Editor, Developer, etc.)",
|
||||
"questions_please_reach_out_to": "Questions? Please reach out to",
|
||||
"questions_please_reach_out_to_email": "Questions? Please reach out to <contactLink>hola@formbricks.com</contactLink>",
|
||||
"recheck_license": "Recheck license",
|
||||
"recheck_license_failed": "License check failed. The license server may be unreachable.",
|
||||
"recheck_license_instance_mismatch": "This license is bound to a different Formbricks instance. Ask Formbricks support to disconnect the previous binding.",
|
||||
@@ -1355,13 +1353,12 @@
|
||||
"address_line_2": "Address Line 2",
|
||||
"adjust_survey_closed_message": "Adjust “Survey Closed” message",
|
||||
"adjust_survey_closed_message_description": "Change the message visitors see when the survey is closed.",
|
||||
"adjust_the_theme_in_the": "Adjust the theme in the",
|
||||
"adjust_theme_in_look_and_feel_settings": "Adjust the theme in the <lookFeelLink>Look & Feel</lookFeelLink> Settings.",
|
||||
"all_are_true": "all are true",
|
||||
"all_other_answers_will_continue_to": "All other answers will continue to",
|
||||
"all_other_answers_will_continue_to_fallback": "All other answers will continue to <fallbackSelect />",
|
||||
"allow_multi_select": "Allow multi-select",
|
||||
"allow_multiple_files": "Allow multiple files",
|
||||
"allow_users_to_select_more_than_one_image": "Allow users to select more than one image",
|
||||
"and_launch_surveys_in_your_website_or_app": "and launch surveys in your website or app.",
|
||||
"animation": "Animation",
|
||||
"any_is_true": "any is true",
|
||||
"app_survey_description": "Embed a survey in your web app or website to collect responses.",
|
||||
@@ -1373,10 +1370,10 @@
|
||||
"auto_save_disabled": "Auto-save disabled",
|
||||
"auto_save_disabled_tooltip": "Your survey is only auto-saved when in draft. This assures public surveys are not unintentionally updated.",
|
||||
"auto_save_on": "Auto-save on",
|
||||
"automatically_close_survey_after": "Automatically close survey after",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "Automatically close survey after <autoCloseInput /> seconds after trigger if no response.",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Automatically close the survey after a certain number of responses.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Automatically close the survey if the user does not respond after certain number of seconds.",
|
||||
"automatically_mark_the_survey_as_complete_after": "Automatically mark the survey as complete after",
|
||||
"automatically_mark_complete_after_n_responses": "Automatically mark the survey as complete after <autoCompleteInput /> completed responses.",
|
||||
"back_button_label": "“Back” Button Label",
|
||||
"background_styling": "Background styling",
|
||||
"block_duplicated": "Block duplicated.",
|
||||
@@ -1434,11 +1431,11 @@
|
||||
"columns": "Columns",
|
||||
"company": "Company",
|
||||
"company_logo": "Company logo",
|
||||
"completed_responses": "completed responses.",
|
||||
"concat": "Concat +",
|
||||
"conditional_logic": "Conditional Logic",
|
||||
"confirm_default_language": "Confirm default language",
|
||||
"confirm_survey_changes": "Confirm Survey Changes",
|
||||
"connect_formbricks_and_launch_surveys": "Connect Formbricks and launch surveys in your website or app.",
|
||||
"contact_fields": "Contact Fields",
|
||||
"contains": "Contains",
|
||||
"continue_to_settings": "Continue to Settings",
|
||||
@@ -1452,7 +1449,6 @@
|
||||
"custom_hostname": "Custom hostname",
|
||||
"customize_survey_logo": "Customize the survey logo",
|
||||
"darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.",
|
||||
"days_before_showing_this_survey_again": "or more days to pass between the last shown survey and showing this survey.",
|
||||
"default_language": "Default language",
|
||||
"delete_anyways": "Delete anyways",
|
||||
"delete_block": "Delete block",
|
||||
@@ -1558,7 +1554,6 @@
|
||||
"hide_progress_bar": "Hide progress bar",
|
||||
"hide_question_settings": "Hide Question settings",
|
||||
"hostname": "Hostname",
|
||||
"if_you_need_more_please": "If you need more, please",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Keep showing whenever triggered until a response or partial response is submitted.",
|
||||
"ignore_global_waiting_time": "Ignore Cooldown Period",
|
||||
"ignore_global_waiting_time_description": "This survey can show whenever its conditions are met, even if another survey was shown recently.",
|
||||
@@ -1598,7 +1593,7 @@
|
||||
"last_name": "Last Name",
|
||||
"let_people_upload_up_to_25_files_at_the_same_time": "Let people upload up to 25 files at the same time.",
|
||||
"limit_the_maximum_file_size": "Limit the maximum file size for uploads.",
|
||||
"limit_upload_file_size_to": "Limit upload file size to",
|
||||
"limit_upload_file_size_to_mb": "Limit upload file size to <fileSizeInput /> MB",
|
||||
"link_survey_description": "Share a link to a survey page or embed it in a web page or email.",
|
||||
"list": "List",
|
||||
"load_segment": "Load segment",
|
||||
@@ -1613,7 +1608,8 @@
|
||||
"matrix_all_fields": "All fields",
|
||||
"matrix_rows": "Rows",
|
||||
"max_file_size": "Max file size",
|
||||
"max_file_size_limit_is": "Max file size limit is",
|
||||
"max_file_size_limit_is_mb": "Max file size limit is {{maxSize}} MB.",
|
||||
"max_file_size_limit_is_mb_upgrade": "Max file size limit is {{maxSize}} MB. If you need more, please <upgradeLink>upgrade your plan</upgradeLink>.",
|
||||
"missing_first": "Missing first",
|
||||
"move_question_to_block": "Move question to block",
|
||||
"multiply": "Multiply *",
|
||||
@@ -1727,8 +1723,6 @@
|
||||
"save_and_close": "Save & Close",
|
||||
"scale": "Scale",
|
||||
"search_for_images": "Search for images",
|
||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "seconds after trigger the survey will be closed if no response",
|
||||
"seconds_before_showing_the_survey": "seconds before showing the survey.",
|
||||
"select_field": "Select field",
|
||||
"select_or_type_value": "Select or type value",
|
||||
"select_ordering": "Select ordering",
|
||||
@@ -1736,7 +1730,7 @@
|
||||
"select_type": "Select type",
|
||||
"send_survey_to_audience_who_match": "Send survey to audience who match…",
|
||||
"send_your_respondents_to_a_page_of_your_choice": "Send your respondents to a page of your choice.",
|
||||
"set_the_global_placement_in_the_look_feel_settings": "Set the global placement in the Look & Feel settings.",
|
||||
"set_global_placement_in_look_feel_settings_hint": "To keep the placement over all surveys consistent, you can <lookFeelLink>set the global placement in the Look & Feel settings.</lookFeelLink>",
|
||||
"settings_saved_successfully": "Settings saved successfully",
|
||||
"seven_points": "7 points",
|
||||
"show_block_settings": "Show Block settings",
|
||||
@@ -1746,7 +1740,7 @@
|
||||
"show_multiple_times": "Show a limited number of times",
|
||||
"show_only_once": "Show only once",
|
||||
"show_question_settings": "Show Question settings",
|
||||
"show_survey_maximum_of": "Show survey maximum of",
|
||||
"show_survey_maximum_of_n_times": "Show survey maximum of <displayLimitInput /> times.",
|
||||
"show_survey_to_users": "Show survey to % of users",
|
||||
"show_to_x_percentage_of_targeted_users": "Show to {percentage}% of targeted users",
|
||||
"shrink_preview": "Shrink Preview",
|
||||
@@ -1782,8 +1776,6 @@
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "This action will remove all the translations from this survey.",
|
||||
"this_will_remove_the_language_and_all_its_translations": "This will remove this language and all its translations from this survey. This action cannot be undone.",
|
||||
"three_points": "3 points",
|
||||
"times": "times",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "To keep the placement over all surveys consistent, you can",
|
||||
"translated": "Translated",
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "Trigger survey when one of the actions is fired…",
|
||||
"try_lollipop_or_mountain": "Try “lollipop” or “mountain”…",
|
||||
@@ -1860,8 +1852,9 @@
|
||||
"visibility_and_recontact": "Visibility & Recontact",
|
||||
"visibility_and_recontact_description": "Control when this survey can appear and how often it can reappear.",
|
||||
"visible": "Visible",
|
||||
"wait": "Wait",
|
||||
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Wait a few seconds after the trigger before showing the survey",
|
||||
"wait_n_days_before_showing_this_survey_again": "Wait <daysInput /> or more days to pass between the last shown survey and showing this survey.",
|
||||
"wait_n_seconds_before_showing_the_survey": "Wait <delayInput /> seconds before showing the survey.",
|
||||
"waiting_time_across_surveys": "Cooldown Period (across surveys)",
|
||||
"waiting_time_across_surveys_description": "To prevent survey fatigue, choose how this survey interacts with the workspace-wide Cooldown Period.",
|
||||
"welcome_message": "Welcome message",
|
||||
@@ -1925,10 +1918,8 @@
|
||||
"search_by_survey_name": "Search by survey name",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
|
||||
"custom_single_use_id_placeholder": "CUSTOM-ID",
|
||||
"custom_single_use_id_required": "Enter a custom single-use ID.",
|
||||
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
|
||||
"custom_single_use_id_description": "If you do not encrypt single-use IDs, any value for “suid=…” works for one response.",
|
||||
"custom_single_use_id_title": "You can set any value as single-use ID in the URL.",
|
||||
"custom_start_point": "Custom start point",
|
||||
"data_prefilling": "Data prefilling",
|
||||
"description": "Responses coming from these links will be anonymous",
|
||||
@@ -2219,6 +2210,7 @@
|
||||
"custom_scripts_warning": "Scripts execute with full browser access. Only add scripts from trusted sources.",
|
||||
"delete_workspace": "Delete Workspace",
|
||||
"delete_workspace_confirmation": "Are you sure you want to delete {projectName}? This action cannot be undone.",
|
||||
"delete_workspace_confirmation_name": "Please enter {projectName} in the following field to confirm the definitive deletion of this workspace:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Delete {projectName} including all surveys, responses, people, actions and attributes.",
|
||||
"delete_workspace_settings_description": "Delete workspace with all surveys, responses, people, actions and attributes. This cannot be undone.",
|
||||
"error_saving_workspace_information": "Error saving workspace information",
|
||||
@@ -2471,7 +2463,7 @@
|
||||
"add_another_member": "Add another member",
|
||||
"continue": "Continue",
|
||||
"failed_to_invite": "Failed to invite",
|
||||
"invitation_sent_to": "Invitation sent to",
|
||||
"invitation_sent_to_email": "Invitation sent to {{email}}!",
|
||||
"invite_your_organization_members": "Invite your Organization members",
|
||||
"life_s_no_fun_alone": "Life is no fun alone.",
|
||||
"skip": "Skip",
|
||||
|
||||
+26
-34
@@ -241,10 +241,12 @@
|
||||
"failed_to_load_organizations": "Error al cargar organizaciones",
|
||||
"failed_to_load_workspaces": "Error al cargar los proyectos",
|
||||
"field_placeholder": "Marcador de posición de {{field}}",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filtro",
|
||||
"finish": "Finalizar",
|
||||
"first_name": "Nombre",
|
||||
"follow_these": "Sigue estos",
|
||||
"formbricks_version": "Versión de Formbricks",
|
||||
"full_name": "Nombre completo",
|
||||
"gathering_responses": "Recopilando respuestas",
|
||||
@@ -358,7 +360,6 @@
|
||||
"placeholder": "Marcador de posición",
|
||||
"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_upgrade_your_plan": "Por favor, actualiza tu plan",
|
||||
"powered_by_formbricks": "Desarrollado por Formbricks",
|
||||
"preview": "Vista previa",
|
||||
"privacy": "Política de privacidad",
|
||||
@@ -499,7 +500,7 @@
|
||||
"you_are_downgraded_to_the_community_edition": "Has sido degradado a la edición Community.",
|
||||
"you_are_not_authorized_to_perform_this_action": "No tienes autorización para realizar esta acción.",
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Has alcanzado tu límite de {projectLimit} espacios de trabajo.",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Has alcanzado tu límite mensual de respuestas de",
|
||||
"you_have_reached_your_monthly_response_limit_of_count": "Has alcanzado tu límite mensual de respuestas de {{count}}.",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Serás degradado a la edición Community el {date}.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
},
|
||||
@@ -774,6 +775,7 @@
|
||||
"create_survey_warning": "Tienes que crear una encuesta para poder configurar esta integración",
|
||||
"delete_integration": "Eliminar integración",
|
||||
"delete_integration_confirmation": "¿Estás seguro de que quieres eliminar esta integración?",
|
||||
"follow_these_docs_to_configure_it": "Sigue esta <docsLink>documentación</docsLink> para configurarlo.",
|
||||
"google_sheet_integration_description": "Rellena instantáneamente tus hojas de cálculo con datos de encuestas",
|
||||
"google_sheets": {
|
||||
"connect_with_google_sheets": "Conectar con Google Sheets",
|
||||
@@ -858,7 +860,6 @@
|
||||
"slack_reconnect_button_description": "<b>Nota:</b> Recientemente cambiamos nuestra integración de Slack para también admitir canales privados. Por favor, reconecta tu espacio de trabajo de Slack."
|
||||
},
|
||||
"slack_integration_description": "Conecta instantáneamente tu espacio de trabajo de Slack con Formbricks",
|
||||
"to_configure_it": "para configurarlo.",
|
||||
"webhook_integration_description": "Activa webhooks basados en acciones en tus encuestas",
|
||||
"webhooks": {
|
||||
"add_webhook": "Añadir webhook",
|
||||
@@ -917,9 +918,7 @@
|
||||
"ex_fully_activated_recurring_users": "Ej. Usuarios recurrentes completamente activados",
|
||||
"ex_power_users": "Ej. Usuarios avanzados",
|
||||
"filters_reset_successfully": "Filtros restablecidos correctamente",
|
||||
"here": "aquí",
|
||||
"hide_filters": "Ocultar filtros",
|
||||
"identifying_users": "identificando usuarios",
|
||||
"invalid_segment": "Segmento no válido",
|
||||
"invalid_segment_filters": "Filtros no válidos. Por favor, comprueba los filtros e inténtalo de nuevo.",
|
||||
"load_segment": "Cargar segmento",
|
||||
@@ -974,21 +973,20 @@
|
||||
"segment_id": "ID del segmento",
|
||||
"segment_saved_successfully": "Segmento guardado con éxito",
|
||||
"segment_updated_successfully": "¡Segmento actualizado con éxito!",
|
||||
"segment_used_in_other_surveys_make_changes_here": "Este segmento se usa en otras encuestas. Realiza cambios <segmentsLink>aquí</segmentsLink>.",
|
||||
"segments_help_you_target_users_with_same_characteristics_easily": "Los segmentos te ayudan a dirigirte fácilmente a usuarios con las mismas características",
|
||||
"target_audience": "Público objetivo",
|
||||
"this_action_resets_all_filters_in_this_survey": "Esta acción restablece todos los filtros en esta encuesta.",
|
||||
"this_segment_is_used_in_other_surveys": "Este segmento se usa en otras encuestas. Hacer cambios",
|
||||
"title_is_required": "El título es obligatorio.",
|
||||
"unknown_filter_type": "Tipo de filtro desconocido",
|
||||
"unlock_segments_description": "Organiza contactos en segmentos para dirigirte a grupos específicos de usuarios",
|
||||
"unlock_segments_title": "Desbloquea segmentos con un plan superior",
|
||||
"user_targeting_is_currently_only_available_when": "La segmentación de usuarios actualmente solo está disponible cuando",
|
||||
"user_targeting_only_available_when_identifying_users": "La segmentación de usuarios solo está disponible cuando <docsLink>identificas usuarios</docsLink> con el SDK de Formbricks.",
|
||||
"value_cannot_be_empty": "El valor no puede estar vacío.",
|
||||
"value_must_be_a_number": "El valor debe ser un número.",
|
||||
"value_must_be_positive": "El valor debe ser un número positivo.",
|
||||
"view_filters": "Ver filtros",
|
||||
"where": "Donde",
|
||||
"with_the_formbricks_sdk": "con el SDK de Formbricks"
|
||||
"where": "Donde"
|
||||
},
|
||||
"settings": {
|
||||
"api_keys": {
|
||||
@@ -1067,8 +1065,8 @@
|
||||
"unlimited_workspaces": "Proyectos ilimitados",
|
||||
"upgrade": "Actualizar",
|
||||
"upgrade_now": "Actualizar ahora",
|
||||
"usage_count_of_limit_used": "{{current}} / {{limit}} <muted>usados</muted>",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "usados",
|
||||
"yearly": "Anual",
|
||||
"yearly_checkout_unavailable": "El pago anual aún no está disponible. Primero añade un método de pago en un plan mensual o contacta con soporte.",
|
||||
"your_plan": "Tu plan"
|
||||
@@ -1129,7 +1127,7 @@
|
||||
"no_credit_card_no_sales_call_just_test_it": "Sin tarjeta de crédito. Sin llamada de ventas. Solo pruébalo :)",
|
||||
"on_request": "Bajo petición",
|
||||
"organization_roles": "Roles de organización (administrador, editor, desarrollador, etc.)",
|
||||
"questions_please_reach_out_to": "¿Preguntas? Por favor, contacta con",
|
||||
"questions_please_reach_out_to_email": "¿Tienes preguntas? Escríbenos a <contactLink>hola@formbricks.com</contactLink>",
|
||||
"recheck_license": "Volver a comprobar licencia",
|
||||
"recheck_license_failed": "Error al comprobar la licencia. Es posible que el servidor de licencias no esté disponible.",
|
||||
"recheck_license_instance_mismatch": "Esta licencia está vinculada a una instancia diferente de Formbricks. Solicita al soporte de Formbricks que desconecte la vinculación anterior.",
|
||||
@@ -1355,13 +1353,12 @@
|
||||
"address_line_2": "Línea de dirección 2",
|
||||
"adjust_survey_closed_message": "Ajustar mensaje 'Encuesta cerrada'",
|
||||
"adjust_survey_closed_message_description": "Cambiar el mensaje que ven los visitantes cuando la encuesta está cerrada.",
|
||||
"adjust_the_theme_in_the": "Ajustar el tema en el",
|
||||
"adjust_theme_in_look_and_feel_settings": "Ajusta el tema en la configuración de <lookFeelLink>Aspecto</lookFeelLink>.",
|
||||
"all_are_true": "todas son verdaderas",
|
||||
"all_other_answers_will_continue_to": "Todas las demás respuestas continuarán",
|
||||
"all_other_answers_will_continue_to_fallback": "Todas las demás respuestas seguirán usando <fallbackSelect />",
|
||||
"allow_multi_select": "Permitir selección múltiple",
|
||||
"allow_multiple_files": "Permitir múltiples archivos",
|
||||
"allow_users_to_select_more_than_one_image": "Permitir a los usuarios seleccionar más de una imagen",
|
||||
"and_launch_surveys_in_your_website_or_app": "y lanzar encuestas en tu sitio web o aplicación.",
|
||||
"animation": "Animación",
|
||||
"any_is_true": "alguna es verdadera",
|
||||
"app_survey_description": "Integra una encuesta en tu aplicación web o sitio web para recopilar respuestas.",
|
||||
@@ -1373,10 +1370,10 @@
|
||||
"auto_save_disabled": "Guardado automático desactivado",
|
||||
"auto_save_disabled_tooltip": "Su encuesta solo se guarda automáticamente cuando está en borrador. Esto asegura que las encuestas públicas no se actualicen involuntariamente.",
|
||||
"auto_save_on": "Guardado automático activado",
|
||||
"automatically_close_survey_after": "Cerrar automáticamente la encuesta después de",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "Cerrar automáticamente la encuesta después de <autoCloseInput /> segundos tras activarse si no hay respuesta.",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Cerrar automáticamente la encuesta después de un cierto número de respuestas.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Cerrar automáticamente la encuesta si el usuario no responde después de cierto número de segundos.",
|
||||
"automatically_mark_the_survey_as_complete_after": "Marcar automáticamente la encuesta como completa después de",
|
||||
"automatically_mark_complete_after_n_responses": "Marcar automáticamente la encuesta como completada después de <autoCompleteInput /> respuestas completadas.",
|
||||
"back_button_label": "Etiqueta del botón \"Atrás\"",
|
||||
"background_styling": "Estilo del fondo",
|
||||
"block_duplicated": "Bloque duplicado.",
|
||||
@@ -1434,11 +1431,11 @@
|
||||
"columns": "Columnas",
|
||||
"company": "Empresa",
|
||||
"company_logo": "Logotipo de la empresa",
|
||||
"completed_responses": "respuestas completadas.",
|
||||
"concat": "Concatenar +",
|
||||
"conditional_logic": "Lógica condicional",
|
||||
"confirm_default_language": "Confirmar idioma predeterminado",
|
||||
"confirm_survey_changes": "Confirmar cambios en la encuesta",
|
||||
"connect_formbricks_and_launch_surveys": "Conecta Formbricks y lanza encuestas en tu sitio web o aplicación.",
|
||||
"contact_fields": "Campos de contacto",
|
||||
"contains": "Contiene",
|
||||
"continue_to_settings": "Continuar a ajustes",
|
||||
@@ -1452,7 +1449,6 @@
|
||||
"custom_hostname": "Nombre de host personalizado",
|
||||
"customize_survey_logo": "Personalizar el logotipo de la encuesta",
|
||||
"darken_or_lighten_background_of_your_choice": "Oscurece o aclara el fondo de tu elección.",
|
||||
"days_before_showing_this_survey_again": "o más días deben transcurrir entre la última encuesta mostrada y la visualización de esta encuesta.",
|
||||
"default_language": "Idioma predeterminado",
|
||||
"delete_anyways": "Eliminar de todos modos",
|
||||
"delete_block": "Eliminar bloque",
|
||||
@@ -1558,7 +1554,6 @@
|
||||
"hide_progress_bar": "Ocultar barra de progreso",
|
||||
"hide_question_settings": "Ocultar ajustes de la pregunta",
|
||||
"hostname": "Nombre de host",
|
||||
"if_you_need_more_please": "Si necesitas más, por favor",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Seguir mostrando cada vez que se active hasta que se envíe una respuesta o respuesta parcial.",
|
||||
"ignore_global_waiting_time": "Ignorar periodo de espera",
|
||||
"ignore_global_waiting_time_description": "Esta encuesta puede mostrarse siempre que se cumplan sus condiciones, incluso si otra encuesta se mostró recientemente.",
|
||||
@@ -1598,7 +1593,7 @@
|
||||
"last_name": "Apellido",
|
||||
"let_people_upload_up_to_25_files_at_the_same_time": "Permitir que las personas suban hasta 25 archivos al mismo tiempo.",
|
||||
"limit_the_maximum_file_size": "Limita el tamaño máximo de archivo para las subidas.",
|
||||
"limit_upload_file_size_to": "Limitar el tamaño de archivo de subida a",
|
||||
"limit_upload_file_size_to_mb": "Limitar el tamaño de archivo de carga a <fileSizeInput /> MB",
|
||||
"link_survey_description": "Comparte un enlace a una página de encuesta o incrústala en una página web o correo electrónico.",
|
||||
"list": "Lista",
|
||||
"load_segment": "Cargar segmento",
|
||||
@@ -1613,7 +1608,8 @@
|
||||
"matrix_all_fields": "Todos los campos",
|
||||
"matrix_rows": "Filas",
|
||||
"max_file_size": "Tamaño máximo de archivo",
|
||||
"max_file_size_limit_is": "El límite de tamaño máximo de archivo es",
|
||||
"max_file_size_limit_is_mb": "El límite máximo de tamaño de archivo es {{maxSize}} MB.",
|
||||
"max_file_size_limit_is_mb_upgrade": "El límite máximo de tamaño de archivo es {{maxSize}} MB. Si necesitas más, por favor <upgradeLink>mejora tu plan</upgradeLink>.",
|
||||
"missing_first": "Faltantes primero",
|
||||
"move_question_to_block": "Mover pregunta al bloque",
|
||||
"multiply": "Multiplicar *",
|
||||
@@ -1727,8 +1723,6 @@
|
||||
"save_and_close": "Guardar y cerrar",
|
||||
"scale": "Escala",
|
||||
"search_for_images": "Buscar imágenes",
|
||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "segundos después de activarse, la encuesta se cerrará si no hay respuesta",
|
||||
"seconds_before_showing_the_survey": "segundos antes de mostrar la encuesta.",
|
||||
"select_field": "Seleccionar campo",
|
||||
"select_or_type_value": "Selecciona o escribe un valor",
|
||||
"select_ordering": "Seleccionar ordenación",
|
||||
@@ -1736,7 +1730,7 @@
|
||||
"select_type": "Seleccionar tipo",
|
||||
"send_survey_to_audience_who_match": "Enviar encuesta a la audiencia que coincida con...",
|
||||
"send_your_respondents_to_a_page_of_your_choice": "Envía a tus encuestados a una página de tu elección.",
|
||||
"set_the_global_placement_in_the_look_feel_settings": "Establece la ubicación global en los ajustes de apariencia.",
|
||||
"set_global_placement_in_look_feel_settings_hint": "Para mantener la ubicación consistente en todas las encuestas, puedes <lookFeelLink>establecer la ubicación global en la configuración de Aspecto.</lookFeelLink>",
|
||||
"settings_saved_successfully": "Ajustes guardados correctamente.",
|
||||
"seven_points": "7 puntos",
|
||||
"show_block_settings": "Mostrar ajustes del bloque",
|
||||
@@ -1746,7 +1740,7 @@
|
||||
"show_multiple_times": "Mostrar un número limitado de veces",
|
||||
"show_only_once": "Mostrar solo una vez",
|
||||
"show_question_settings": "Mostrar ajustes de la pregunta",
|
||||
"show_survey_maximum_of": "Mostrar encuesta un máximo de",
|
||||
"show_survey_maximum_of_n_times": "Mostrar la encuesta un máximo de <displayLimitInput /> veces.",
|
||||
"show_survey_to_users": "Mostrar encuesta al % de usuarios",
|
||||
"show_to_x_percentage_of_targeted_users": "Mostrar al {percentage} % de usuarios objetivo",
|
||||
"shrink_preview": "Contraer vista previa",
|
||||
@@ -1782,8 +1776,6 @@
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "Esta acción eliminará todas las traducciones de esta encuesta.",
|
||||
"this_will_remove_the_language_and_all_its_translations": "Esto eliminará este idioma y todas sus traducciones de esta encuesta. Esta acción no se puede deshacer.",
|
||||
"three_points": "3 puntos",
|
||||
"times": "veces",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Para mantener la ubicación coherente en todas las encuestas, puedes",
|
||||
"translated": "Traducido",
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "Activar encuesta cuando se dispare una de las acciones...",
|
||||
"try_lollipop_or_mountain": "Prueba 'piruleta' o 'montaña'...",
|
||||
@@ -1860,8 +1852,9 @@
|
||||
"visibility_and_recontact": "Visibilidad y recontacto",
|
||||
"visibility_and_recontact_description": "Controla cuándo puede aparecer esta encuesta y con qué frecuencia puede volver a aparecer.",
|
||||
"visible": "Visible",
|
||||
"wait": "Esperar",
|
||||
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Esperar unos segundos después del disparador antes de mostrar la encuesta",
|
||||
"wait_n_days_before_showing_this_survey_again": "Esperar <daysInput /> o más días entre la última encuesta mostrada y esta encuesta.",
|
||||
"wait_n_seconds_before_showing_the_survey": "Esperar <delayInput /> segundos antes de mostrar la encuesta.",
|
||||
"waiting_time_across_surveys": "Periodo de espera (entre encuestas)",
|
||||
"waiting_time_across_surveys_description": "Para evitar la fatiga de encuestas, elige cómo interactúa esta encuesta con el periodo de espera general del espacio de trabajo.",
|
||||
"welcome_message": "Mensaje de bienvenida",
|
||||
@@ -1925,10 +1918,8 @@
|
||||
"search_by_survey_name": "Buscar por nombre de encuesta",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
|
||||
"custom_single_use_id_placeholder": "CUSTOM-ID",
|
||||
"custom_single_use_id_required": "Enter a custom single-use ID.",
|
||||
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
|
||||
"custom_single_use_id_description": "Si no cifras el ID de un solo uso, cualquier valor para “suid=...” funciona para una respuesta.",
|
||||
"custom_single_use_id_title": "Puedes establecer cualquier valor como ID de uso único en la URL.",
|
||||
"custom_start_point": "Punto de inicio personalizado",
|
||||
"data_prefilling": "Prellenado de datos",
|
||||
"description": "Las respuestas procedentes de estos enlaces serán anónimas",
|
||||
@@ -2219,6 +2210,7 @@
|
||||
"custom_scripts_warning": "Los scripts se ejecutan con acceso completo al navegador. Solo añade scripts de fuentes confiables.",
|
||||
"delete_workspace": "Eliminar proyecto",
|
||||
"delete_workspace_confirmation": "¿Estás seguro de que quieres eliminar {projectName}? Esta acción no se puede deshacer.",
|
||||
"delete_workspace_confirmation_name": "Por favor, introduce {projectName} en el siguiente campo para confirmar la eliminación definitiva de este proyecto:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Eliminar {projectName} incluyendo todas las encuestas, respuestas, personas, acciones y atributos.",
|
||||
"delete_workspace_settings_description": "Eliminar proyecto con todas las encuestas, respuestas, personas, acciones y atributos. Esto no se puede deshacer.",
|
||||
"error_saving_workspace_information": "Error al guardar la información del proyecto",
|
||||
@@ -2471,7 +2463,7 @@
|
||||
"add_another_member": "Añadir otro miembro",
|
||||
"continue": "Continuar",
|
||||
"failed_to_invite": "Error al invitar",
|
||||
"invitation_sent_to": "Invitación enviada a",
|
||||
"invitation_sent_to_email": "¡Invitación enviada a {{email}}!",
|
||||
"invite_your_organization_members": "Invita a los miembros de tu organización",
|
||||
"life_s_no_fun_alone": "La vida no es divertida en solitario.",
|
||||
"skip": "Omitir",
|
||||
|
||||
+26
-34
@@ -241,10 +241,12 @@
|
||||
"failed_to_load_organizations": "Échec du chargement des organisations",
|
||||
"failed_to_load_workspaces": "Échec du chargement des projets",
|
||||
"field_placeholder": "Espace réservé {{field}}",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filtre",
|
||||
"finish": "Terminer",
|
||||
"first_name": "Prénom",
|
||||
"follow_these": "Suivez ceci",
|
||||
"formbricks_version": "Version de Formbricks",
|
||||
"full_name": "Nom complet",
|
||||
"gathering_responses": "Collecte des réponses",
|
||||
@@ -358,7 +360,6 @@
|
||||
"placeholder": "Remplaçant",
|
||||
"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_upgrade_your_plan": "Veuillez mettre à niveau votre plan",
|
||||
"powered_by_formbricks": "Propulsé par Formbricks",
|
||||
"preview": "Aperçu",
|
||||
"privacy": "Politique de confidentialité",
|
||||
@@ -499,7 +500,7 @@
|
||||
"you_are_downgraded_to_the_community_edition": "Vous êtes rétrogradé à l'édition communautaire.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Vous n'êtes pas autorisé à effectuer cette action.",
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Vous avez atteint votre limite de {projectLimit} espaces de travail.",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Vous avez atteint votre limite de réponses mensuelle de",
|
||||
"you_have_reached_your_monthly_response_limit_of_count": "Vous avez atteint votre limite mensuelle de {{count}} réponses.",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Vous serez rétrogradé à l'édition communautaire le {date}.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
},
|
||||
@@ -774,6 +775,7 @@
|
||||
"create_survey_warning": "Vous devez créer une enquête pour pouvoir configurer cette intégration.",
|
||||
"delete_integration": "Supprimer l'intégration",
|
||||
"delete_integration_confirmation": "Êtes-vous sûr de vouloir supprimer cette intégration ?",
|
||||
"follow_these_docs_to_configure_it": "Suis cette <docsLink>documentation</docsLink> pour le configurer.",
|
||||
"google_sheet_integration_description": "Renseignez instantanément vos feuilles de calcul à l'aide de données issues d'enquêtes.",
|
||||
"google_sheets": {
|
||||
"connect_with_google_sheets": "Se connecter à Google Sheets",
|
||||
@@ -858,7 +860,6 @@
|
||||
"slack_reconnect_button_description": "<b>Remarque :</b> Nous avons récemment modifié notre intégration Slack pour prendre en charge les canaux privés. Veuillez reconnecter votre espace de travail Slack."
|
||||
},
|
||||
"slack_integration_description": "Connectez instantanément votre espace de travail Slack à Formbricks.",
|
||||
"to_configure_it": "pour le configurer.",
|
||||
"webhook_integration_description": "Déclenchez des webhooks en fonction des actions effectuées dans vos enquêtes.",
|
||||
"webhooks": {
|
||||
"add_webhook": "Ajouter un Webhook",
|
||||
@@ -917,9 +918,7 @@
|
||||
"ex_fully_activated_recurring_users": "Ex. Utilisateurs récurrents entièrement activés",
|
||||
"ex_power_users": "Ex. Utilisateurs avancés",
|
||||
"filters_reset_successfully": "Filtres réinitialisés avec succès",
|
||||
"here": "ici",
|
||||
"hide_filters": "Cacher les filtres",
|
||||
"identifying_users": "identification des utilisateurs",
|
||||
"invalid_segment": "Segment invalide",
|
||||
"invalid_segment_filters": "Filtres invalides. Veuillez vérifier les filtres et réessayer.",
|
||||
"load_segment": "Charger le segment",
|
||||
@@ -974,21 +973,20 @@
|
||||
"segment_id": "ID de segment",
|
||||
"segment_saved_successfully": "Segment enregistré avec succès",
|
||||
"segment_updated_successfully": "Segment mis à jour avec succès !",
|
||||
"segment_used_in_other_surveys_make_changes_here": "Ce segment est utilisé dans d'autres sondages. Fais les modifications <segmentsLink>ici</segmentsLink>.",
|
||||
"segments_help_you_target_users_with_same_characteristics_easily": "Les segments permettent de cibler facilement les utilisateurs ayant les mêmes caractéristiques.",
|
||||
"target_audience": "Public cible",
|
||||
"this_action_resets_all_filters_in_this_survey": "Cette action réinitialise tous les filtres de cette enquête.",
|
||||
"this_segment_is_used_in_other_surveys": "Ce segment est utilisé dans d'autres enquêtes. Apportez des modifications.",
|
||||
"title_is_required": "Le titre est requis.",
|
||||
"unknown_filter_type": "Type de filtre inconnu",
|
||||
"unlock_segments_description": "Organisez les contacts en segments pour cibler des groupes d'utilisateurs spécifiques",
|
||||
"unlock_segments_title": "Débloquez des segments avec un plan supérieur.",
|
||||
"user_targeting_is_currently_only_available_when": "La ciblage des utilisateurs est actuellement disponible uniquement lorsque",
|
||||
"user_targeting_only_available_when_identifying_users": "Le ciblage d'utilisateurs n'est actuellement disponible que lors de <docsLink>l'identification des utilisateurs</docsLink> avec le SDK Formbricks.",
|
||||
"value_cannot_be_empty": "La valeur ne peut pas être vide.",
|
||||
"value_must_be_a_number": "La valeur doit être un nombre.",
|
||||
"value_must_be_positive": "La valeur doit être un nombre positif.",
|
||||
"view_filters": "Filtres de vue",
|
||||
"where": "Où",
|
||||
"with_the_formbricks_sdk": "avec le SDK Formbricks"
|
||||
"where": "Où"
|
||||
},
|
||||
"settings": {
|
||||
"api_keys": {
|
||||
@@ -1067,8 +1065,8 @@
|
||||
"unlimited_workspaces": "Projets illimités",
|
||||
"upgrade": "Mise à niveau",
|
||||
"upgrade_now": "Passer à la formule supérieure maintenant",
|
||||
"usage_count_of_limit_used": "{{current}} / {{limit}} <muted>utilisés</muted>",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "utilisé(s)",
|
||||
"yearly": "Annuel",
|
||||
"yearly_checkout_unavailable": "Le paiement annuel n'est pas encore disponible. Ajoute d'abord un moyen de paiement sur un forfait mensuel ou contacte le support.",
|
||||
"your_plan": "Ton offre"
|
||||
@@ -1129,7 +1127,7 @@
|
||||
"no_credit_card_no_sales_call_just_test_it": "Aucune carte de crédit. Aucun appel de vente. Testez-le simplement :)",
|
||||
"on_request": "Sur demande",
|
||||
"organization_roles": "Rôles d'organisation (Administrateur, Éditeur, Développeur, etc.)",
|
||||
"questions_please_reach_out_to": "Des questions ? Veuillez contacter",
|
||||
"questions_please_reach_out_to_email": "Des questions ? N'hésite pas à nous contacter à <contactLink>hola@formbricks.com</contactLink>",
|
||||
"recheck_license": "Revérifier la licence",
|
||||
"recheck_license_failed": "La vérification de la licence a échoué. Le serveur de licences est peut-être inaccessible.",
|
||||
"recheck_license_instance_mismatch": "Cette licence est liée à une autre instance Formbricks. Demande au support Formbricks de déconnecter la liaison précédente.",
|
||||
@@ -1355,13 +1353,12 @@
|
||||
"address_line_2": "Ligne d'adresse 2",
|
||||
"adjust_survey_closed_message": "Ajuster le message \"Sondage fermé\"",
|
||||
"adjust_survey_closed_message_description": "Modifiez le message que les visiteurs voient lorsque l'enquête est fermée.",
|
||||
"adjust_the_theme_in_the": "Ajustez le thème dans le",
|
||||
"adjust_theme_in_look_and_feel_settings": "Ajuste le thème dans les paramètres <lookFeelLink>Apparence et ressenti</lookFeelLink>.",
|
||||
"all_are_true": "toutes sont vraies",
|
||||
"all_other_answers_will_continue_to": "Toutes les autres réponses continueront à",
|
||||
"all_other_answers_will_continue_to_fallback": "Toutes les autres réponses continueront à <fallbackSelect />",
|
||||
"allow_multi_select": "Autoriser la sélection multiple",
|
||||
"allow_multiple_files": "Autoriser plusieurs fichiers",
|
||||
"allow_users_to_select_more_than_one_image": "Permettre aux utilisateurs de sélectionner plusieurs images",
|
||||
"and_launch_surveys_in_your_website_or_app": "et lancez des enquêtes sur votre site web ou votre application.",
|
||||
"animation": "Animation",
|
||||
"any_is_true": "au moins une est vraie",
|
||||
"app_survey_description": "Intégrez une enquête dans votre application web ou votre site web pour collecter des réponses.",
|
||||
@@ -1373,10 +1370,10 @@
|
||||
"auto_save_disabled": "Sauvegarde automatique désactivée",
|
||||
"auto_save_disabled_tooltip": "Votre sondage n'est sauvegardé automatiquement que lorsqu'il est en brouillon. Cela garantit que les sondages publics ne sont pas mis à jour involontairement.",
|
||||
"auto_save_on": "Sauvegarde automatique activée",
|
||||
"automatically_close_survey_after": "Fermer automatiquement l'enquête après",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "Fermer automatiquement le sondage après <autoCloseInput /> secondes si aucune réponse n'est donnée après le déclenchement.",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fermer automatiquement l'enquête après un certain nombre de réponses.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fermer automatiquement l'enquête si l'utilisateur ne répond pas après un certain nombre de secondes.",
|
||||
"automatically_mark_the_survey_as_complete_after": "Marquer automatiquement l'enquête comme terminée après",
|
||||
"automatically_mark_complete_after_n_responses": "Marquer automatiquement le sondage comme terminé après <autoCompleteInput /> réponses complètes.",
|
||||
"back_button_label": "Label du bouton \"Retour''",
|
||||
"background_styling": "Style d'arrière-plan",
|
||||
"block_duplicated": "Bloc dupliqué.",
|
||||
@@ -1434,11 +1431,11 @@
|
||||
"columns": "Colonnes",
|
||||
"company": "Société",
|
||||
"company_logo": "Logo de l'entreprise",
|
||||
"completed_responses": "Réponses terminées",
|
||||
"concat": "Concat +",
|
||||
"conditional_logic": "Logique conditionnelle",
|
||||
"confirm_default_language": "Confirmer la langue par défaut",
|
||||
"confirm_survey_changes": "Confirmer les modifications de l'enquête",
|
||||
"connect_formbricks_and_launch_surveys": "Connecte Formbricks et lance des sondages sur ton site web ou dans ton app.",
|
||||
"contact_fields": "Champs de contact",
|
||||
"contains": "Contient",
|
||||
"continue_to_settings": "Continuer vers les paramètres",
|
||||
@@ -1452,7 +1449,6 @@
|
||||
"custom_hostname": "Nom d'hôte personnalisé",
|
||||
"customize_survey_logo": "Personnaliser le logo de l'enquête",
|
||||
"darken_or_lighten_background_of_your_choice": "Assombrir ou éclaircir l'arrière-plan de votre choix.",
|
||||
"days_before_showing_this_survey_again": "ou plus de jours doivent s'écouler entre le dernier sondage affiché et l'affichage de ce sondage.",
|
||||
"default_language": "Langue par défaut",
|
||||
"delete_anyways": "Supprimer quand même",
|
||||
"delete_block": "Supprimer le bloc",
|
||||
@@ -1558,7 +1554,6 @@
|
||||
"hide_progress_bar": "Cacher la barre de progression",
|
||||
"hide_question_settings": "Masquer les paramètres de la question",
|
||||
"hostname": "Nom d'hôte",
|
||||
"if_you_need_more_please": "Si vous avez besoin de plus, veuillez",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuer à afficher à chaque déclenchement jusqu'à ce qu'une réponse ou une réponse partielle soit soumise.",
|
||||
"ignore_global_waiting_time": "Ignorer la période de refroidissement",
|
||||
"ignore_global_waiting_time_description": "Cette enquête peut s'afficher chaque fois que ses conditions sont remplies, même si une autre enquête a été affichée récemment.",
|
||||
@@ -1598,7 +1593,7 @@
|
||||
"last_name": "Nom de famille",
|
||||
"let_people_upload_up_to_25_files_at_the_same_time": "Permettre aux utilisateurs de télécharger jusqu'à 25 fichiers en même temps.",
|
||||
"limit_the_maximum_file_size": "Limiter la taille maximale des fichiers pour les téléversements.",
|
||||
"limit_upload_file_size_to": "Limiter la taille de téléversement des fichiers à",
|
||||
"limit_upload_file_size_to_mb": "Limiter la taille des fichiers téléchargés à <fileSizeInput /> Mo",
|
||||
"link_survey_description": "Partagez un lien vers une page d'enquête ou intégrez-le dans une page web ou un e-mail.",
|
||||
"list": "Liste",
|
||||
"load_segment": "Segment de chargement",
|
||||
@@ -1613,7 +1608,8 @@
|
||||
"matrix_all_fields": "Tous les champs",
|
||||
"matrix_rows": "Lignes",
|
||||
"max_file_size": "Taille maximale du fichier",
|
||||
"max_file_size_limit_is": "La limite de taille maximale du fichier est",
|
||||
"max_file_size_limit_is_mb": "La taille maximale des fichiers est de {{maxSize}} Mo.",
|
||||
"max_file_size_limit_is_mb_upgrade": "La taille maximale des fichiers est de {{maxSize}} Mo. Si tu as besoin de plus, <upgradeLink>améliore ton forfait</upgradeLink>.",
|
||||
"missing_first": "Manquantes en premier",
|
||||
"move_question_to_block": "Déplacer la question vers le bloc",
|
||||
"multiply": "Multiplier *",
|
||||
@@ -1727,8 +1723,6 @@
|
||||
"save_and_close": "Enregistrer et fermer",
|
||||
"scale": "Échelle",
|
||||
"search_for_images": "Rechercher des images",
|
||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "Les secondes après le déclenchement, l'enquête sera fermée si aucune réponse n'est donnée.",
|
||||
"seconds_before_showing_the_survey": "secondes avant de montrer l'enquête.",
|
||||
"select_field": "Sélectionner un champ",
|
||||
"select_or_type_value": "Sélectionnez ou saisissez une valeur",
|
||||
"select_ordering": "Choisir l'ordre",
|
||||
@@ -1736,7 +1730,7 @@
|
||||
"select_type": "Choisir le type",
|
||||
"send_survey_to_audience_who_match": "Envoyer l'enquête au public qui correspond...",
|
||||
"send_your_respondents_to_a_page_of_your_choice": "Envoyez vos répondants vers une page de votre choix.",
|
||||
"set_the_global_placement_in_the_look_feel_settings": "Définissez le placement global dans les paramètres d'apparence.",
|
||||
"set_global_placement_in_look_feel_settings_hint": "Pour garder un placement cohérent sur tous les sondages, tu peux <lookFeelLink>définir le placement global dans les paramètres Apparence et ressenti.</lookFeelLink>",
|
||||
"settings_saved_successfully": "Paramètres enregistrés avec succès",
|
||||
"seven_points": "7 points",
|
||||
"show_block_settings": "Afficher les paramètres du bloc",
|
||||
@@ -1746,7 +1740,7 @@
|
||||
"show_multiple_times": "Afficher un nombre limité de fois",
|
||||
"show_only_once": "Afficher une seule fois",
|
||||
"show_question_settings": "Afficher les paramètres de la question",
|
||||
"show_survey_maximum_of": "Afficher le maximum du sondage de",
|
||||
"show_survey_maximum_of_n_times": "Afficher le sondage au maximum <displayLimitInput /> fois.",
|
||||
"show_survey_to_users": "Afficher l'enquête à % des utilisateurs",
|
||||
"show_to_x_percentage_of_targeted_users": "Afficher à {percentage}% des utilisateurs ciblés",
|
||||
"shrink_preview": "Réduire l'aperçu",
|
||||
@@ -1782,8 +1776,6 @@
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "Cette action supprimera toutes les traductions de cette enquête.",
|
||||
"this_will_remove_the_language_and_all_its_translations": "Cela supprimera cette langue et toutes ses traductions de ce questionnaire. Cette action est irréversible.",
|
||||
"three_points": "3 points",
|
||||
"times": "fois",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Pour maintenir la cohérence du placement sur tous les sondages, vous pouvez",
|
||||
"translated": "Traduit",
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "Déclencher l'enquête lorsqu'une des actions est déclenchée...",
|
||||
"try_lollipop_or_mountain": "Essayez 'sucette' ou 'montagne'...",
|
||||
@@ -1860,8 +1852,9 @@
|
||||
"visibility_and_recontact": "Visibilité et recontact",
|
||||
"visibility_and_recontact_description": "Contrôlez quand cette enquête peut apparaître et à quelle fréquence elle peut réapparaître.",
|
||||
"visible": "Visible",
|
||||
"wait": "Attendre",
|
||||
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Attendez quelques secondes après le déclencheur avant de montrer l'enquête.",
|
||||
"wait_n_days_before_showing_this_survey_again": "Attendre <daysInput /> jours ou plus entre le dernier sondage affiché et l'affichage de celui-ci.",
|
||||
"wait_n_seconds_before_showing_the_survey": "Attendre <delayInput /> secondes avant d'afficher le sondage.",
|
||||
"waiting_time_across_surveys": "Période de refroidissement (entre les sondages)",
|
||||
"waiting_time_across_surveys_description": "Pour éviter la fatigue liée aux sondages, choisissez comment ce sondage interagit avec la période de refroidissement globale de l'espace de travail.",
|
||||
"welcome_message": "Message de bienvenue",
|
||||
@@ -1925,10 +1918,8 @@
|
||||
"search_by_survey_name": "Recherche par nom d'enquête",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
|
||||
"custom_single_use_id_placeholder": "CUSTOM-ID",
|
||||
"custom_single_use_id_required": "Enter a custom single-use ID.",
|
||||
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
|
||||
"custom_single_use_id_description": "Si vous ne chiffrez pas l'ID à usage unique, n'importe quelle valeur pour “suid=...” fonctionne pour une réponse.",
|
||||
"custom_single_use_id_title": "Vous pouvez définir n'importe quelle valeur comme identifiant à usage unique dans l'URL.",
|
||||
"custom_start_point": "Point de départ personnalisé",
|
||||
"data_prefilling": "Préremplissage des données",
|
||||
"description": "Les réponses provenant de ces liens seront anonymes",
|
||||
@@ -2219,6 +2210,7 @@
|
||||
"custom_scripts_warning": "Les scripts s'exécutent avec un accès complet au navigateur. Ajoutez uniquement des scripts provenant de sources fiables.",
|
||||
"delete_workspace": "Supprimer le projet",
|
||||
"delete_workspace_confirmation": "Êtes-vous sûr de vouloir supprimer {projectName} ? Cette action ne peut pas être annulée.",
|
||||
"delete_workspace_confirmation_name": "Veuillez entrer {projectName} dans le champ suivant pour confirmer la suppression définitive de ce projet :",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Supprimer {projectName} y compris toutes les enquêtes, réponses, personnes, actions et attributs.",
|
||||
"delete_workspace_settings_description": "Supprimer le projet avec toutes les enquêtes, réponses, personnes, actions et attributs. Cette opération est irréversible.",
|
||||
"error_saving_workspace_information": "Erreur lors de l'enregistrement des informations du projet",
|
||||
@@ -2471,7 +2463,7 @@
|
||||
"add_another_member": "Ajouter un autre membre",
|
||||
"continue": "Continuer",
|
||||
"failed_to_invite": "Échec de l'invitation",
|
||||
"invitation_sent_to": "Invitation envoyée à",
|
||||
"invitation_sent_to_email": "Invitation envoyée à {{email}} !",
|
||||
"invite_your_organization_members": "Invitez les membres de votre organisation",
|
||||
"life_s_no_fun_alone": "La vie n'est pas amusante seule.",
|
||||
"skip": "Sauter",
|
||||
|
||||
+26
-34
@@ -241,10 +241,12 @@
|
||||
"failed_to_load_organizations": "Nem sikerült betölteni a szervezeteket",
|
||||
"failed_to_load_workspaces": "Nem sikerült a munkaterületek betöltése",
|
||||
"field_placeholder": "{{field}} helykitöltője",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Szűrő",
|
||||
"finish": "Befejezés",
|
||||
"first_name": "Keresztnév",
|
||||
"follow_these": "Ezek követése",
|
||||
"formbricks_version": "Formbricks verziója",
|
||||
"full_name": "Teljes név",
|
||||
"gathering_responses": "Válaszok összegyűjtése",
|
||||
@@ -358,7 +360,6 @@
|
||||
"placeholder": "Helykitöltő",
|
||||
"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_upgrade_your_plan": "Váltson magasabb csomagra",
|
||||
"powered_by_formbricks": "A gépházban: Formbricks",
|
||||
"preview": "Előnézet",
|
||||
"privacy": "Adatvédelmi irányelvek",
|
||||
@@ -499,7 +500,7 @@
|
||||
"you_are_downgraded_to_the_community_edition": "Visszaváltott a közösségi kiadásra.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Nincs felhatalmazva ennek a műveletnek a végrehajtásához.",
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Elérte a(z) {projectLimit} munkaterületből álló korlátot.",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Elérte a havi válaszkorlátját ennek:",
|
||||
"you_have_reached_your_monthly_response_limit_of_count": "Elérte havi válaszlimitjét, amely {{count}} darab.",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Vissza lesz állítva a közösségi kiadásra ekkor: {date}.",
|
||||
"your_license_has_expired_please_renew": "A vállalati licence lejárt. Újítsa meg, hogy továbbra is használhassa a vállalati funkciókat."
|
||||
},
|
||||
@@ -774,6 +775,7 @@
|
||||
"create_survey_warning": "Létre kell hoznia egy kérdőívet, hogy képes legyen beállítani ezt az integrációt",
|
||||
"delete_integration": "Integráció törlése",
|
||||
"delete_integration_confirmation": "Biztosan törölni szeretné ezt az integrációt?",
|
||||
"follow_these_docs_to_configure_it": "Kövesse ezt a <docsLink>dokumentációt</docsLink> a konfiguráláshoz.",
|
||||
"google_sheet_integration_description": "A táblázatok azonnali feltöltése a kérdőív adataival",
|
||||
"google_sheets": {
|
||||
"connect_with_google_sheets": "Kapcsolódás a Google Táblázatokhoz",
|
||||
@@ -858,7 +860,6 @@
|
||||
"slack_reconnect_button_description": "<b>Megjegyzés:</b> nemrég megváltoztattuk a Slack-integrációnkat, hogy a személyes csatornákat is támogassa. Csatlakoztassa újra a Slack-munkaterületét."
|
||||
},
|
||||
"slack_integration_description": "A Slack-munkaterület azonnali csatlakoztatása a Formbrickshez",
|
||||
"to_configure_it": "a beállításához.",
|
||||
"webhook_integration_description": "Webhorgok aktiválása a kérdőívekben lévő műveletek alapján",
|
||||
"webhooks": {
|
||||
"add_webhook": "Webhorog hozzáadása",
|
||||
@@ -917,9 +918,7 @@
|
||||
"ex_fully_activated_recurring_users": "Például: Teljesen aktivált visszatérő felhasználók",
|
||||
"ex_power_users": "Például: Képzett felhasználók",
|
||||
"filters_reset_successfully": "A szűrők sikeresen visszaállítva",
|
||||
"here": "ide",
|
||||
"hide_filters": "Szűrők elrejtése",
|
||||
"identifying_users": "felhasználók azonosítása",
|
||||
"invalid_segment": "Érvénytelen szakasz",
|
||||
"invalid_segment_filters": "Érvénytelen szűrők. Ellenőrizze a szűrőket, és próbálja meg újra.",
|
||||
"load_segment": "Szakasz betöltése",
|
||||
@@ -974,21 +973,20 @@
|
||||
"segment_id": "Szakaszazonosító",
|
||||
"segment_saved_successfully": "A szakasz sikeresen elmentve",
|
||||
"segment_updated_successfully": "A szakasz sikeresen frissítve",
|
||||
"segment_used_in_other_surveys_make_changes_here": "Ez a szegmens más felmérésekben is használatos. Módosításokat <segmentsLink>itt</segmentsLink> végezhet.",
|
||||
"segments_help_you_target_users_with_same_characteristics_easily": "A szakaszok segítik a hasonló jellemzőkkel rendelkező felhasználók könnyű megcélzását",
|
||||
"target_audience": "Célközönség",
|
||||
"this_action_resets_all_filters_in_this_survey": "Ez a művelet visszaállítja az összes szűrőt ebben a kérdőívben.",
|
||||
"this_segment_is_used_in_other_surveys": "Ez a szakasz más kutatásokban is használva van. Változtatások elvégzése",
|
||||
"title_is_required": "A cím megadása kötelező.",
|
||||
"unknown_filter_type": "Ismeretlen szűrőtípus",
|
||||
"unlock_segments_description": "Partnerek szakaszokba szervezése meghatározott felhasználói csoportok megcélzásához",
|
||||
"unlock_segments_title": "Szakaszok feloldása egy magasabb csomaggal",
|
||||
"user_targeting_is_currently_only_available_when": "A felhasználók megcélzása jelenleg csak akkor érhető el, ha",
|
||||
"user_targeting_only_available_when_identifying_users": "A felhasználói célzás jelenleg csak akkor érhető el, ha <docsLink>azonosítja a felhasználókat</docsLink> a Formbricks SDK használatával.",
|
||||
"value_cannot_be_empty": "Az érték nem lehet üres.",
|
||||
"value_must_be_a_number": "Az értéknek számnak kell lennie.",
|
||||
"value_must_be_positive": "Az értéknek pozitív számnak kell lennie.",
|
||||
"view_filters": "Szűrők megtekintése",
|
||||
"where": "Ahol",
|
||||
"with_the_formbricks_sdk": "a Formbricks SDK-val"
|
||||
"where": "Ahol"
|
||||
},
|
||||
"settings": {
|
||||
"api_keys": {
|
||||
@@ -1067,8 +1065,8 @@
|
||||
"unlimited_workspaces": "Korlátlan munkaterület",
|
||||
"upgrade": "Frissítés",
|
||||
"upgrade_now": "Frissítés most",
|
||||
"usage_count_of_limit_used": "{{current}} / {{limit}} <muted>felhasználva</muted>",
|
||||
"usage_cycle": "Használati ciklus",
|
||||
"used": "használva",
|
||||
"yearly": "Évente",
|
||||
"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"
|
||||
@@ -1129,7 +1127,7 @@
|
||||
"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",
|
||||
"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_email": "Kérdése van? Kérjük, vegye fel a kapcsolatot velünk: <contactLink>hola@formbricks.com</contactLink>",
|
||||
"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_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.",
|
||||
@@ -1355,13 +1353,12 @@
|
||||
"address_line_2": "Cím 2. sora",
|
||||
"adjust_survey_closed_message": "A „Kérdőív lezárva” üzenet módosítása",
|
||||
"adjust_survey_closed_message_description": "Annak az üzenetnek a megváltoztatása, amelyet a látogatók akkor látnak, amikor a kérdőív lezárul.",
|
||||
"adjust_the_theme_in_the": "A téma beállítása ebben:",
|
||||
"adjust_theme_in_look_and_feel_settings": "A témát a <lookFeelLink>Megjelenés és Élmény</lookFeelLink> beállításokban módosíthatja.",
|
||||
"all_are_true": "az összes igaz",
|
||||
"all_other_answers_will_continue_to": "Az összes többi válasz továbbra is",
|
||||
"all_other_answers_will_continue_to_fallback": "Minden más válasz továbbra is <fallbackSelect />",
|
||||
"allow_multi_select": "Több választás engedélyezése",
|
||||
"allow_multiple_files": "Több fájl engedélyezése",
|
||||
"allow_users_to_select_more_than_one_image": "Lehetővé tétel a felhasználóknak, hogy egynél több képet válasszanak ki",
|
||||
"and_launch_surveys_in_your_website_or_app": "és kérdőívek indítása a webhelyén vagy az alkalmazásában.",
|
||||
"animation": "Animáció",
|
||||
"any_is_true": "bármelyik igaz",
|
||||
"app_survey_description": "Egy kérdőív beágyazása a webalkalmazásába vagy webhelyére a válaszok gyűjtéséhez.",
|
||||
@@ -1373,10 +1370,10 @@
|
||||
"auto_save_disabled": "Az automatikus mentés letiltva",
|
||||
"auto_save_disabled_tooltip": "A kérdőív csak akkor kerül automatikusan mentésre, ha piszkozatban van. Ez biztosítja, hogy a nyilvános kérdőívek ne legyenek véletlenül frissítve.",
|
||||
"auto_save_on": "Automatikus mentés bekapcsolva",
|
||||
"automatically_close_survey_after": "Kérdőív automatikus lezárása ezután:",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "A felmérés automatikus bezárása <autoCloseInput /> másodperc elteltével az aktiválás után, amennyiben nem érkezik válasz.",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "A kérdőív automatikus lezárása egy bizonyos számú válasz után.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "A kérdőív automatikus lezárása, ha a felhasználó nem válaszol egy bizonyos másodpercnyi idő után.",
|
||||
"automatically_mark_the_survey_as_complete_after": "A kérdőív automatikus megjelölése kitöltöttként ezután:",
|
||||
"automatically_mark_complete_after_n_responses": "A felmérés automatikus befejezettként való megjelölése <autoCompleteInput /> kitöltött válasz után.",
|
||||
"back_button_label": "A „Vissza” gomb címkéje",
|
||||
"background_styling": "Háttér stílusának beállítása",
|
||||
"block_duplicated": "A blokk kettőzve.",
|
||||
@@ -1434,11 +1431,11 @@
|
||||
"columns": "Oszlopok",
|
||||
"company": "Vállalat",
|
||||
"company_logo": "Vállalat logója",
|
||||
"completed_responses": "befejezett válasz.",
|
||||
"concat": "Összefűzés +",
|
||||
"conditional_logic": "Feltételes logika",
|
||||
"confirm_default_language": "Alapértelmezett nyelv megerősítése",
|
||||
"confirm_survey_changes": "Kérdőív változtatásainak megerősítése",
|
||||
"connect_formbricks_and_launch_surveys": "Csatlakoztassa a Formbricks-et, és indítson felméréseket webhelyén vagy alkalmazásában.",
|
||||
"contact_fields": "Kapcsolatfelvételi mezők",
|
||||
"contains": "Tartalmazza",
|
||||
"continue_to_settings": "Folytatás a beállításokhoz",
|
||||
@@ -1452,7 +1449,6 @@
|
||||
"custom_hostname": "Egyéni gépnév",
|
||||
"customize_survey_logo": "A kérdőív logójának személyre szabása",
|
||||
"darken_or_lighten_background_of_your_choice": "A választási lehetőség hátterének sötétítése vagy világosítása.",
|
||||
"days_before_showing_this_survey_again": "vagy több napnak kell eltelnie az utolsó megjelenített kérdőív és ezen kérdőív megjelenése között.",
|
||||
"default_language": "Alapértelmezett nyelv",
|
||||
"delete_anyways": "Törlés mindenképp",
|
||||
"delete_block": "Blokk törlése",
|
||||
@@ -1558,7 +1554,6 @@
|
||||
"hide_progress_bar": "Folyamatjelző elrejtése",
|
||||
"hide_question_settings": "Kérdésbeállítások elrejtése",
|
||||
"hostname": "Gépnév",
|
||||
"if_you_need_more_please": "Ha többre van szüksége, akkor",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Továbbra is megjelenítés minden egyes aktiváláskor, amíg választ vagy részleges választ nem küldenek be.",
|
||||
"ignore_global_waiting_time": "Várakozási időszak figyelmen kívül hagyása",
|
||||
"ignore_global_waiting_time_description": "Ez a kérdőív akkor jelenhet meg, ha a feltételei teljesülnek, még akkor is, ha egy másik kérdőív jelent meg nemrég.",
|
||||
@@ -1598,7 +1593,7 @@
|
||||
"last_name": "Vezetéknév",
|
||||
"let_people_upload_up_to_25_files_at_the_same_time": "Lehetővé tétel a személyek számára, hogy egyszerre legfeljebb 25 fájlt töltsenek fel.",
|
||||
"limit_the_maximum_file_size": "A legnagyobb fájlméret korlátozása a feltöltéseknél.",
|
||||
"limit_upload_file_size_to": "Feltöltési fájlméret korlátozása erre:",
|
||||
"limit_upload_file_size_to_mb": "A feltöltött fájlméret korlátozása <fileSizeInput /> MB-ra",
|
||||
"link_survey_description": "Egy kérdőív oldalára mutató hivatkozás megosztása vagy a kérdőív beágyazása egy weboldalba vagy e-mailbe.",
|
||||
"list": "Lista",
|
||||
"load_segment": "Szakasz betöltése",
|
||||
@@ -1613,7 +1608,8 @@
|
||||
"matrix_all_fields": "Összes mező",
|
||||
"matrix_rows": "Sorok",
|
||||
"max_file_size": "Legnagyobb fájlméret",
|
||||
"max_file_size_limit_is": "A legnagyobb fájlméretkorlát",
|
||||
"max_file_size_limit_is_mb": "A maximális fájlméret {{maxSize}} MB.",
|
||||
"max_file_size_limit_is_mb_upgrade": "A maximális fájlméret {{maxSize}} MB. Amennyiben többre van szüksége, kérjük, <upgradeLink>frissítse csomagját</upgradeLink>.",
|
||||
"missing_first": "Hiányzik az első",
|
||||
"move_question_to_block": "Kérdés áthelyezése egy blokkba",
|
||||
"multiply": "Szorzás *",
|
||||
@@ -1727,8 +1723,6 @@
|
||||
"save_and_close": "Mentés és bezárás",
|
||||
"scale": "Méretezés",
|
||||
"search_for_images": "Képek keresése",
|
||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "másodperccel az aktiváló után a kérdőív le lesz zárva, ha nincs válasz",
|
||||
"seconds_before_showing_the_survey": "másodperc a kérdőív megjelenítése előtt.",
|
||||
"select_field": "Mező kiválasztása",
|
||||
"select_or_type_value": "Érték kiválasztása vagy beírása",
|
||||
"select_ordering": "Sorrend kiválasztása",
|
||||
@@ -1736,7 +1730,7 @@
|
||||
"select_type": "Típus kiválasztása",
|
||||
"send_survey_to_audience_who_match": "Kérdőív küldése az erre illeszkedő közönségnek…",
|
||||
"send_your_respondents_to_a_page_of_your_choice": "A válaszadók küldése a választási lehetőség oldalára.",
|
||||
"set_the_global_placement_in_the_look_feel_settings": "A globális elhelyezés beállítása a megjelenítési beállításokban.",
|
||||
"set_global_placement_in_look_feel_settings_hint": "A felmérések elhelyezkedésének konzisztens megőrzése érdekében <lookFeelLink>beállíthatja a globális elhelyezést a Megjelenés és Élmény beállításokban.</lookFeelLink>",
|
||||
"settings_saved_successfully": "A beállítások sikeresen elmentve",
|
||||
"seven_points": "7 pont",
|
||||
"show_block_settings": "Blokkbeállítások megjelenítése",
|
||||
@@ -1746,7 +1740,7 @@
|
||||
"show_multiple_times": "Megjelenítés korlátozott számú alkalommal",
|
||||
"show_only_once": "Megjelenítés csak egyszer",
|
||||
"show_question_settings": "Kérdésbeállítások megjelenítése",
|
||||
"show_survey_maximum_of": "Kérdőív megjelenítése legfeljebb:",
|
||||
"show_survey_maximum_of_n_times": "A felmérés megjelenítése legfeljebb <displayLimitInput /> alkalommal.",
|
||||
"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",
|
||||
"shrink_preview": "Előnézet összecsukása",
|
||||
@@ -1782,8 +1776,6 @@
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "Ez a művelet eltávolítja az összes fordítást ebből a kérdőívből.",
|
||||
"this_will_remove_the_language_and_all_its_translations": "Ez el fogja távolítani ezt a nyelvet és annak összes fordítását ebből a kérdőívből. Ezt a műveletet nem lehet visszavonni.",
|
||||
"three_points": "3 pont",
|
||||
"times": "alkalom",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Ahhoz, hogy következetesen megtartsa az elhelyezést az összes kérdőívnél, az alábbiakat teheti:",
|
||||
"translated": "Lefordítva",
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "A kérdőív aktiválása, ha a műveletek egyikét elindítják…",
|
||||
"try_lollipop_or_mountain": "A „nyalóka” vagy „hegy” kipróbálása…",
|
||||
@@ -1860,8 +1852,9 @@
|
||||
"visibility_and_recontact": "Láthatóság és újbóli kapcsolatfelvétel",
|
||||
"visibility_and_recontact_description": "Annak vezérlése, hogy ez a kérdőív mikor jelenhet meg és milyen gyakran jelenhet meg újra.",
|
||||
"visible": "Látható",
|
||||
"wait": "Várakozás",
|
||||
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Várakozás néhány másodpercig az aktiválás után, mielőtt megjelenítené a kérdőívet",
|
||||
"wait_n_days_before_showing_this_survey_again": "Várjon <daysInput /> vagy több napot az utolsó megjelenített felmérés és ezen felmérés megjelenítése között.",
|
||||
"wait_n_seconds_before_showing_the_survey": "Várjon <delayInput /> másodpercet a felmérés megjelenítése előtt.",
|
||||
"waiting_time_across_surveys": "Várakozási időszak (kérdőívek között)",
|
||||
"waiting_time_across_surveys_description": "A kérdőívekbe való belefáradás megakadályozásához válassza ki, hogy ez a kérdőív hogyan lép kölcsönhatásba a munkaterület-szintű várakozási időszakkal.",
|
||||
"welcome_message": "Üdvözlő üzenet",
|
||||
@@ -1925,10 +1918,8 @@
|
||||
"search_by_survey_name": "Keresés kérdőívnév alapján",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
|
||||
"custom_single_use_id_placeholder": "CUSTOM-ID",
|
||||
"custom_single_use_id_required": "Enter a custom single-use ID.",
|
||||
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
|
||||
"custom_single_use_id_description": "Ha nem titkosítja az egyszer használatos azonosítókat, akkor a „suid=…” bármilyen értéke működik egy válasznál.",
|
||||
"custom_single_use_id_title": "Bármilyen értéket beállíthat egyszer használatos azonosítóként az URL-ben.",
|
||||
"custom_start_point": "Egyéni kezdési pont",
|
||||
"data_prefilling": "Adatok előre kitöltése",
|
||||
"description": "Az ezekről a hivatkozásokról érkező válaszok névtelenek lesznek",
|
||||
@@ -2219,6 +2210,7 @@
|
||||
"custom_scripts_warning": "A parancsfájlok teljes böngésző-hozzáféréssel kerülnek végrehajtásra. Csak megbízható forrásokból származó parancsfájlokat adjon hozzá.",
|
||||
"delete_workspace": "Munkaterület törlése",
|
||||
"delete_workspace_confirmation": "Biztosan törölni szeretné a(z) {projectName} munkaterületet? Ezt a műveletet nem lehet visszavonni.",
|
||||
"delete_workspace_confirmation_name": "Adja meg a(z) {projectName} munkaterület nevét a következő mezőben a munkaterület végleges törlésének megerősítéséhez:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "A(z) {projectName} munkaterület törlése, beleértve az összes kérdőívet, választ, személyt, műveletet és attribútumot is.",
|
||||
"delete_workspace_settings_description": "A munkaterület törlése az összes kérdőívvel, válasszal, személlyel, művelettel és attribútummal együtt. Ezt nem lehet visszavonni.",
|
||||
"error_saving_workspace_information": "Hiba a munkaterület-információk mentésekor",
|
||||
@@ -2471,7 +2463,7 @@
|
||||
"add_another_member": "Másik tag hozzáadása",
|
||||
"continue": "Folytatás",
|
||||
"failed_to_invite": "Nem sikerült meghívni",
|
||||
"invitation_sent_to": "A meghívó elküldve ide:",
|
||||
"invitation_sent_to_email": "Meghívó elküldve a következő címre: {{email}}!",
|
||||
"invite_your_organization_members": "Szervezeti tagok meghívása",
|
||||
"life_s_no_fun_alone": "Az élet nem szórakoztató egyedül.",
|
||||
"skip": "Kihagyás",
|
||||
|
||||
+26
-34
@@ -241,10 +241,12 @@
|
||||
"failed_to_load_organizations": "組織の読み込みに失敗しました",
|
||||
"failed_to_load_workspaces": "ワークスペースの読み込みに失敗しました",
|
||||
"field_placeholder": "{{field}} プレースホルダー",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "フィルター",
|
||||
"finish": "完了",
|
||||
"first_name": "名",
|
||||
"follow_these": "こちらの手順に従って",
|
||||
"formbricks_version": "Formbricksバージョン",
|
||||
"full_name": "氏名",
|
||||
"gathering_responses": "回答を収集しています",
|
||||
@@ -358,7 +360,6 @@
|
||||
"placeholder": "プレースホルダー",
|
||||
"please_select_at_least_one_survey": "少なくとも1つのフォームを選択してください",
|
||||
"please_select_at_least_one_trigger": "少なくとも1つのトリガーを選択してください",
|
||||
"please_upgrade_your_plan": "プランをアップグレードしてください",
|
||||
"powered_by_formbricks": "Powered by Formbricks",
|
||||
"preview": "プレビュー",
|
||||
"privacy": "プライバシーポリシー",
|
||||
@@ -499,7 +500,7 @@
|
||||
"you_are_downgraded_to_the_community_edition": "コミュニティ版にダウングレードされました。",
|
||||
"you_are_not_authorized_to_perform_this_action": "このアクションを実行する権限がありません。",
|
||||
"you_have_reached_your_limit_of_workspace_limit": "ワークスペースの上限である{projectLimit}件に達しました。",
|
||||
"you_have_reached_your_monthly_response_limit_of": "月間回答数の上限に達しました",
|
||||
"you_have_reached_your_monthly_response_limit_of_count": "月間の回答制限{{count}}件に達しました。",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "コミュニティ版へのダウングレードは {date} に行われます。",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
},
|
||||
@@ -774,6 +775,7 @@
|
||||
"create_survey_warning": "この連携を設定するには、まずフォームを作成してください",
|
||||
"delete_integration": "連携を削除",
|
||||
"delete_integration_confirmation": "この連携を削除してもよろしいですか?",
|
||||
"follow_these_docs_to_configure_it": "設定方法については<docsLink>ドキュメント</docsLink>をご覧ください。",
|
||||
"google_sheet_integration_description": "アンケートデータを即座にスプレッドシートに反映する",
|
||||
"google_sheets": {
|
||||
"connect_with_google_sheets": "Google スプレッドシートと接続",
|
||||
@@ -858,7 +860,6 @@
|
||||
"slack_reconnect_button_description": "<b>注意:</b> Slack 連携はプライベートチャンネルもサポートするように変更されました。ワークスペースを再接続してください。"
|
||||
},
|
||||
"slack_integration_description": "FormbricksをSlackワークスペースと即座に接続する",
|
||||
"to_configure_it": "で設定してください。",
|
||||
"webhook_integration_description": "アンケート内のアクションに基づいてWebhookをトリガーする",
|
||||
"webhooks": {
|
||||
"add_webhook": "Webhook を追加",
|
||||
@@ -917,9 +918,7 @@
|
||||
"ex_fully_activated_recurring_users": "例: 完全に有効化された継続ユーザー",
|
||||
"ex_power_users": "例: パワーユーザー",
|
||||
"filters_reset_successfully": "フィルターをリセットしました",
|
||||
"here": "こちら",
|
||||
"hide_filters": "フィルターを非表示",
|
||||
"identifying_users": "ユーザーの識別",
|
||||
"invalid_segment": "無効なセグメント",
|
||||
"invalid_segment_filters": "無効なフィルターです。設定を確認してもう一度お試しください。",
|
||||
"load_segment": "セグメントを読み込み",
|
||||
@@ -974,21 +973,20 @@
|
||||
"segment_id": "セグメントID",
|
||||
"segment_saved_successfully": "セグメントを保存しました",
|
||||
"segment_updated_successfully": "セグメントを更新しました!",
|
||||
"segment_used_in_other_surveys_make_changes_here": "このセグメントは他のアンケートで使用されています。変更は<segmentsLink>こちら</segmentsLink>から行ってください。",
|
||||
"segments_help_you_target_users_with_same_characteristics_easily": "セグメントを使うと、同じ特性を持つユーザーを簡単にターゲティングできます",
|
||||
"target_audience": "ターゲットオーディエンス",
|
||||
"this_action_resets_all_filters_in_this_survey": "この操作はこのフォームのすべてのフィルターをリセットします。",
|
||||
"this_segment_is_used_in_other_surveys": "このセグメントは他のフォームでも使用されています。変更する",
|
||||
"title_is_required": "タイトルは必須です。",
|
||||
"unknown_filter_type": "不明なフィルタータイプ",
|
||||
"unlock_segments_description": "連絡先をセグメントに整理し、特定のユーザーグループをターゲティングします",
|
||||
"unlock_segments_title": "上位プランでセグメントをアンロック",
|
||||
"user_targeting_is_currently_only_available_when": "ユーザーターゲティングは現在、利用条件を満たす場合のみ利用可能です",
|
||||
"user_targeting_only_available_when_identifying_users": "ユーザーターゲティングは、Formbricks SDKで<docsLink>ユーザーを識別</docsLink>している場合のみ利用可能です。",
|
||||
"value_cannot_be_empty": "値は空にできません。",
|
||||
"value_must_be_a_number": "値は数値である必要があります。",
|
||||
"value_must_be_positive": "値は正の数である必要があります。",
|
||||
"view_filters": "フィルターを表示",
|
||||
"where": "条件",
|
||||
"with_the_formbricks_sdk": "Formbricks SDK を利用して"
|
||||
"where": "条件"
|
||||
},
|
||||
"settings": {
|
||||
"api_keys": {
|
||||
@@ -1067,8 +1065,8 @@
|
||||
"unlimited_workspaces": "無制限ワークスペース",
|
||||
"upgrade": "アップグレード",
|
||||
"upgrade_now": "今すぐアップグレード",
|
||||
"usage_count_of_limit_used": "{{current}} / {{limit}} <muted>使用中</muted>",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "使用済み",
|
||||
"yearly": "年間",
|
||||
"yearly_checkout_unavailable": "年間プランのチェックアウトはまだご利用いただけません。まず月間プランでお支払い方法を追加するか、サポートにお問い合わせください。",
|
||||
"your_plan": "ご利用プラン"
|
||||
@@ -1129,7 +1127,7 @@
|
||||
"no_credit_card_no_sales_call_just_test_it": "クレジットカード不要。営業電話もありません。ただテストしてください :)",
|
||||
"on_request": "リクエストに応じて",
|
||||
"organization_roles": "組織ロール(管理者、編集者、開発者など)",
|
||||
"questions_please_reach_out_to": "質問はありますか?こちらまでお問い合わせください",
|
||||
"questions_please_reach_out_to_email": "ご質問は<contactLink>hola@formbricks.com</contactLink>までお気軽にお問い合わせください",
|
||||
"recheck_license": "ライセンスを再確認",
|
||||
"recheck_license_failed": "ライセンスの確認に失敗しました。ライセンスサーバーに接続できない可能性があります。",
|
||||
"recheck_license_instance_mismatch": "このライセンスは別のFormbricksインスタンスに紐付けられています。Formbricksサポートに連絡して、以前の紐付けを解除してもらってください。",
|
||||
@@ -1355,13 +1353,12 @@
|
||||
"address_line_2": "住所2",
|
||||
"adjust_survey_closed_message": "「フォームはクローズしました」メッセージを調整",
|
||||
"adjust_survey_closed_message_description": "フォームがクローズしたときに訪問者が見るメッセージを変更します。",
|
||||
"adjust_the_theme_in_the": "テーマを",
|
||||
"adjust_theme_in_look_and_feel_settings": "テーマは<lookFeelLink>外観</lookFeelLink>設定で調整できます。",
|
||||
"all_are_true": "すべてが真である",
|
||||
"all_other_answers_will_continue_to": "他のすべての回答は引き続き",
|
||||
"all_other_answers_will_continue_to_fallback": "その他の回答は引き続き<fallbackSelect />",
|
||||
"allow_multi_select": "複数選択を許可",
|
||||
"allow_multiple_files": "複数のファイルを許可",
|
||||
"allow_users_to_select_more_than_one_image": "ユーザーが複数の画像を選択できるようにする",
|
||||
"and_launch_surveys_in_your_website_or_app": "ウェブサイトやアプリでフォームを公開できます。",
|
||||
"animation": "アニメーション",
|
||||
"any_is_true": "いずれかが真",
|
||||
"app_survey_description": "回答を収集するために、ウェブアプリまたはウェブサイトにフォームを埋め込みます。",
|
||||
@@ -1373,10 +1370,10 @@
|
||||
"auto_save_disabled": "自動保存が無効",
|
||||
"auto_save_disabled_tooltip": "アンケートは下書き状態の時のみ自動保存されます。これにより、公開中のアンケートが意図せず更新されることを防ぎます。",
|
||||
"auto_save_on": "自動保存オン",
|
||||
"automatically_close_survey_after": "フォームを自動的に閉じる",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "トリガー後、反応がない場合は<autoCloseInput />秒後に自動的にアンケートを閉じます。",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "一定の回答数に達した後にフォームを自動的に閉じます。",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "ユーザーが一定秒数応答しない場合、フォームを自動的に閉じます。",
|
||||
"automatically_mark_the_survey_as_complete_after": "フォームを自動的に完了としてマークする",
|
||||
"automatically_mark_complete_after_n_responses": "<autoCompleteInput />件の回答が完了した後、自動的にアンケートを完了としてマークします。",
|
||||
"back_button_label": "「戻る」ボタンのラベル",
|
||||
"background_styling": "背景のスタイル設定",
|
||||
"block_duplicated": "ブロックが複製されました。",
|
||||
@@ -1434,11 +1431,11 @@
|
||||
"columns": "列",
|
||||
"company": "会社",
|
||||
"company_logo": "会社のロゴ",
|
||||
"completed_responses": "完了した回答",
|
||||
"concat": "連結 +",
|
||||
"conditional_logic": "条件付きロジック",
|
||||
"confirm_default_language": "デフォルト言語を確認",
|
||||
"confirm_survey_changes": "フォームの変更を確認",
|
||||
"connect_formbricks_and_launch_surveys": "Formbricksを接続して、ウェブサイトやアプリでアンケートを開始しましょう。",
|
||||
"contact_fields": "連絡先フィールド",
|
||||
"contains": "を含む",
|
||||
"continue_to_settings": "設定に進む",
|
||||
@@ -1452,7 +1449,6 @@
|
||||
"custom_hostname": "カスタムホスト名",
|
||||
"customize_survey_logo": "アンケートのロゴをカスタマイズする",
|
||||
"darken_or_lighten_background_of_your_choice": "お好みの背景を暗くしたり明るくしたりします。",
|
||||
"days_before_showing_this_survey_again": "最後に表示されたアンケートとこのアンケートを表示するまでに、この日数以上の期間を空ける必要があります。",
|
||||
"default_language": "デフォルト言語",
|
||||
"delete_anyways": "削除する",
|
||||
"delete_block": "ブロックを削除",
|
||||
@@ -1558,7 +1554,6 @@
|
||||
"hide_progress_bar": "プログレスバーを非表示",
|
||||
"hide_question_settings": "質問設定を非表示",
|
||||
"hostname": "ホスト名",
|
||||
"if_you_need_more_please": "さらに必要な場合は、",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "回答または一部の回答が送信されるまで、トリガーされるたびに表示し続けます。",
|
||||
"ignore_global_waiting_time": "クールダウン期間を無視",
|
||||
"ignore_global_waiting_time_description": "このフォームは、最近別のフォームが表示されていても、条件が満たされればいつでも表示できます。",
|
||||
@@ -1598,7 +1593,7 @@
|
||||
"last_name": "姓",
|
||||
"let_people_upload_up_to_25_files_at_the_same_time": "一度に最大25個のファイルをアップロードできるようにする。",
|
||||
"limit_the_maximum_file_size": "アップロードの最大ファイルサイズを制限します。",
|
||||
"limit_upload_file_size_to": "アップロードファイルサイズの上限",
|
||||
"limit_upload_file_size_to_mb": "アップロードファイルサイズを<fileSizeInput /> MBに制限",
|
||||
"link_survey_description": "フォームページへのリンクを共有するか、ウェブページやメールに埋め込みます。",
|
||||
"list": "リスト",
|
||||
"load_segment": "セグメントを読み込み",
|
||||
@@ -1613,7 +1608,8 @@
|
||||
"matrix_all_fields": "すべてのフィールド",
|
||||
"matrix_rows": "行",
|
||||
"max_file_size": "最大ファイルサイズ",
|
||||
"max_file_size_limit_is": "最大ファイルサイズの上限は",
|
||||
"max_file_size_limit_is_mb": "最大ファイルサイズは{{maxSize}} MBです。",
|
||||
"max_file_size_limit_is_mb_upgrade": "最大ファイルサイズは{{maxSize}} MBです。それ以上が必要な場合は、<upgradeLink>プランをアップグレード</upgradeLink>してください。",
|
||||
"missing_first": "未翻訳を優先",
|
||||
"move_question_to_block": "質問をブロックに移動",
|
||||
"multiply": "乗算 *",
|
||||
@@ -1727,8 +1723,6 @@
|
||||
"save_and_close": "保存して閉じる",
|
||||
"scale": "尺度",
|
||||
"search_for_images": "画像を検索",
|
||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "トリガーから数秒後に回答がない場合、フォームは閉じられます",
|
||||
"seconds_before_showing_the_survey": "秒後にフォームを表示します。",
|
||||
"select_field": "フィールドを選択",
|
||||
"select_or_type_value": "値を選択または入力",
|
||||
"select_ordering": "順序を選択",
|
||||
@@ -1736,7 +1730,7 @@
|
||||
"select_type": "タイプを選択",
|
||||
"send_survey_to_audience_who_match": "一致するオーディエンスにフォームを送信...",
|
||||
"send_your_respondents_to_a_page_of_your_choice": "回答者をお好みのページに送信します。",
|
||||
"set_the_global_placement_in_the_look_feel_settings": "「デザイン」設定でグローバルな配置を設定します。",
|
||||
"set_global_placement_in_look_feel_settings_hint": "すべてのアンケートで配置を統一するには、<lookFeelLink>外観設定でグローバル配置を設定</lookFeelLink>できます。",
|
||||
"settings_saved_successfully": "設定を正常に保存しました。",
|
||||
"seven_points": "7点",
|
||||
"show_block_settings": "ブロック設定を表示",
|
||||
@@ -1746,7 +1740,7 @@
|
||||
"show_multiple_times": "限られた回数表示する",
|
||||
"show_only_once": "一度だけ表示",
|
||||
"show_question_settings": "質問設定を表示",
|
||||
"show_survey_maximum_of": "フォームの最大表示回数",
|
||||
"show_survey_maximum_of_n_times": "アンケートの表示回数を最大<displayLimitInput />回に制限します。",
|
||||
"show_survey_to_users": "ユーザーの {percentage}% にフォームを表示",
|
||||
"show_to_x_percentage_of_targeted_users": "ターゲットユーザーの {percentage}% に表示",
|
||||
"shrink_preview": "プレビューを縮小",
|
||||
@@ -1782,8 +1776,6 @@
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "このアクションは、このフォームからすべての翻訳を削除します。",
|
||||
"this_will_remove_the_language_and_all_its_translations": "この言語とすべての翻訳がこのアンケートから削除されます。この操作は元に戻せません。",
|
||||
"three_points": "3点",
|
||||
"times": "回",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "すべてのフォームの配置を一貫させるために、",
|
||||
"translated": "翻訳済み",
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "以下のアクションのいずれかが発火したときにフォームをトリガーします...",
|
||||
"try_lollipop_or_mountain": "「lollipop」や「mountain」を試してみてください...",
|
||||
@@ -1860,8 +1852,9 @@
|
||||
"visibility_and_recontact": "表示と再接触",
|
||||
"visibility_and_recontact_description": "このフォームがいつ表示され、どのくらいの頻度で再表示できるかをコントロールします。",
|
||||
"visible": "表示",
|
||||
"wait": "待つ",
|
||||
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "トリガーから数秒待ってからフォームを表示します",
|
||||
"wait_n_days_before_showing_this_survey_again": "前回のアンケート表示から<daysInput />日以上経過してから、このアンケートを表示します。",
|
||||
"wait_n_seconds_before_showing_the_survey": "アンケートを表示するまで<delayInput />秒待機します。",
|
||||
"waiting_time_across_surveys": "クールダウン期間(アンケート全体)",
|
||||
"waiting_time_across_surveys_description": "アンケート疲れを防ぐため、このアンケートがワークスペース全体のクールダウン期間とどのように連動するかを選択してください。",
|
||||
"welcome_message": "ウェルカムメッセージ",
|
||||
@@ -1925,10 +1918,8 @@
|
||||
"search_by_survey_name": "フォーム名で検索",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
|
||||
"custom_single_use_id_placeholder": "CUSTOM-ID",
|
||||
"custom_single_use_id_required": "Enter a custom single-use ID.",
|
||||
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
|
||||
"custom_single_use_id_description": "シングルユースIDを暗号化しない場合、「suid=...」の任意の値で1回の回答が可能になります。",
|
||||
"custom_single_use_id_title": "URLで任意の値を単一使用IDとして設定できます。",
|
||||
"custom_start_point": "カスタム開始点",
|
||||
"data_prefilling": "データの事前入力",
|
||||
"description": "これらのリンクからの回答は匿名になります",
|
||||
@@ -2219,6 +2210,7 @@
|
||||
"custom_scripts_warning": "スクリプトはブラウザへの完全なアクセス権で実行されます。信頼できるソースからのスクリプトのみを追加してください。",
|
||||
"delete_workspace": "ワークスペースを削除",
|
||||
"delete_workspace_confirmation": "{projectName}を削除してもよろしいですか?このアクションは元に戻せません。",
|
||||
"delete_workspace_confirmation_name": "このワークスペースの完全な削除を確認するには、以下のフィールドに {projectName} と入力してください:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "{projectName}をすべてのフォーム、回答、人物、アクション、属性を含めて削除します。",
|
||||
"delete_workspace_settings_description": "すべてのフォーム、回答、人物、アクション、属性を含むワークスペースを削除します。この操作は元に戻せません。",
|
||||
"error_saving_workspace_information": "ワークスペース情報の保存中にエラーが発生しました",
|
||||
@@ -2471,7 +2463,7 @@
|
||||
"add_another_member": "別のメンバーを追加",
|
||||
"continue": "続行",
|
||||
"failed_to_invite": "招待に失敗しました",
|
||||
"invitation_sent_to": "招待状を送信しました",
|
||||
"invitation_sent_to_email": "{{email}}に招待を送信しました!",
|
||||
"invite_your_organization_members": "組織のメンバーを招待",
|
||||
"life_s_no_fun_alone": "人生は一人では楽しくない。",
|
||||
"skip": "スキップ",
|
||||
|
||||
+26
-34
@@ -241,10 +241,12 @@
|
||||
"failed_to_load_organizations": "Laden van organisaties mislukt",
|
||||
"failed_to_load_workspaces": "Laden van werkruimtes mislukt",
|
||||
"field_placeholder": "Tijdelijke aanduiding voor {{field}}",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filter",
|
||||
"finish": "Finish",
|
||||
"first_name": "Voornaam",
|
||||
"follow_these": "Volg deze",
|
||||
"formbricks_version": "Formbricks-versie",
|
||||
"full_name": "Volledige naam",
|
||||
"gathering_responses": "Reacties verzamelen",
|
||||
@@ -358,7 +360,6 @@
|
||||
"placeholder": "Tijdelijke aanduiding",
|
||||
"please_select_at_least_one_survey": "Selecteer ten minste één enquête",
|
||||
"please_select_at_least_one_trigger": "Selecteer ten minste één trigger",
|
||||
"please_upgrade_your_plan": "Upgrade je abonnement",
|
||||
"powered_by_formbricks": "Mogelijk gemaakt door Formbricks",
|
||||
"preview": "Voorbeeld",
|
||||
"privacy": "Privacybeleid",
|
||||
@@ -499,7 +500,7 @@
|
||||
"you_are_downgraded_to_the_community_edition": "Je bent gedowngraded naar de Community-editie.",
|
||||
"you_are_not_authorized_to_perform_this_action": "U bent niet geautoriseerd om deze actie uit te voeren.",
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Je hebt je limiet van {projectLimit} werkruimtes bereikt.",
|
||||
"you_have_reached_your_monthly_response_limit_of": "U heeft uw maandelijkse responslimiet bereikt van",
|
||||
"you_have_reached_your_monthly_response_limit_of_count": "Je hebt je maandelijkse responslimiet van {{count}} bereikt.",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Je wordt gedowngraded naar de Community-editie op {date}.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
},
|
||||
@@ -774,6 +775,7 @@
|
||||
"create_survey_warning": "Om deze integratie te kunnen opzetten, moet u een enquête aanmaken",
|
||||
"delete_integration": "Integratie verwijderen",
|
||||
"delete_integration_confirmation": "Weet u zeker dat u deze integratie wilt verwijderen?",
|
||||
"follow_these_docs_to_configure_it": "Volg deze <docsLink>docs</docsLink> om het te configureren.",
|
||||
"google_sheet_integration_description": "Vul uw spreadsheets direct in met enquêtegegevens",
|
||||
"google_sheets": {
|
||||
"connect_with_google_sheets": "Maak verbinding met Google Spreadsheets",
|
||||
@@ -858,7 +860,6 @@
|
||||
"slack_reconnect_button_description": "<b>Opmerking:</b> We hebben onlangs onze Slack-integratie gewijzigd om ook privékanalen te ondersteunen. Maak opnieuw verbinding met uw Slack-werkruimte."
|
||||
},
|
||||
"slack_integration_description": "Verbind uw Slack Workspace onmiddellijk met Formbricks",
|
||||
"to_configure_it": "om het te configureren.",
|
||||
"webhook_integration_description": "Activeer webhooks op basis van acties in uw enquêtes",
|
||||
"webhooks": {
|
||||
"add_webhook": "Webhook toevoegen",
|
||||
@@ -917,9 +918,7 @@
|
||||
"ex_fully_activated_recurring_users": "Ex. Volledig geactiveerde terugkerende gebruikers",
|
||||
"ex_power_users": "Ex. Hoofdgebruikers",
|
||||
"filters_reset_successfully": "Filters zijn opnieuw ingesteld",
|
||||
"here": "hier",
|
||||
"hide_filters": "Verberg filters",
|
||||
"identifying_users": "identificeren van gebruikers",
|
||||
"invalid_segment": "Ongeldig segment",
|
||||
"invalid_segment_filters": "Ongeldige filters. Controleer de filters en probeer het opnieuw.",
|
||||
"load_segment": "Laadsegment",
|
||||
@@ -974,21 +973,20 @@
|
||||
"segment_id": "Segment-ID",
|
||||
"segment_saved_successfully": "Segment succesvol opgeslagen",
|
||||
"segment_updated_successfully": "Segment succesvol bijgewerkt!",
|
||||
"segment_used_in_other_surveys_make_changes_here": "Dit segment wordt gebruikt in andere enquêtes. Breng wijzigingen aan <segmentsLink>hier</segmentsLink>.",
|
||||
"segments_help_you_target_users_with_same_characteristics_easily": "Met segmenten kunt u eenvoudig gebruikers met dezelfde kenmerken targeten",
|
||||
"target_audience": "Doelgroep",
|
||||
"this_action_resets_all_filters_in_this_survey": "Met deze actie worden alle filters in deze enquête opnieuw ingesteld.",
|
||||
"this_segment_is_used_in_other_surveys": "Dit segment wordt in andere onderzoeken gebruikt. Breng wijzigingen aan",
|
||||
"title_is_required": "Titel is vereist.",
|
||||
"unknown_filter_type": "Onbekend filtertype",
|
||||
"unlock_segments_description": "Organiseer contacten in segmenten om specifieke gebruikersgroepen te targeten",
|
||||
"unlock_segments_title": "Ontgrendel segmenten met een hoger plan",
|
||||
"user_targeting_is_currently_only_available_when": "Gebruikerstargeting is momenteel alleen beschikbaar wanneer",
|
||||
"user_targeting_only_available_when_identifying_users": "Gebruikerstargeting is momenteel alleen beschikbaar wanneer je <docsLink>gebruikers identificeert</docsLink> met de Formbricks SDK.",
|
||||
"value_cannot_be_empty": "Waarde kan niet leeg zijn.",
|
||||
"value_must_be_a_number": "Waarde moet een getal zijn.",
|
||||
"value_must_be_positive": "Waarde moet een positief getal zijn.",
|
||||
"view_filters": "Bekijk filters",
|
||||
"where": "Waar",
|
||||
"with_the_formbricks_sdk": "met de Formbricks SDK"
|
||||
"where": "Waar"
|
||||
},
|
||||
"settings": {
|
||||
"api_keys": {
|
||||
@@ -1067,8 +1065,8 @@
|
||||
"unlimited_workspaces": "Onbeperkt werkruimtes",
|
||||
"upgrade": "Upgraden",
|
||||
"upgrade_now": "Nu upgraden",
|
||||
"usage_count_of_limit_used": "{{current}} / {{limit}} <muted>gebruikt</muted>",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "gebruikt",
|
||||
"yearly": "Jaarlijks",
|
||||
"yearly_checkout_unavailable": "Jaarlijkse checkout is nog niet beschikbaar. Voeg eerst een betaalmethode toe bij een maandelijks abonnement of neem contact op met support.",
|
||||
"your_plan": "Jouw abonnement"
|
||||
@@ -1129,7 +1127,7 @@
|
||||
"no_credit_card_no_sales_call_just_test_it": "Geen creditcard. Geen verkoopgesprek. Gewoon testen :)",
|
||||
"on_request": "Op aanvraag",
|
||||
"organization_roles": "Organisatierollen (beheerder, redacteur, ontwikkelaar, etc.)",
|
||||
"questions_please_reach_out_to": "Vragen? Neem contact op met",
|
||||
"questions_please_reach_out_to_email": "Vragen? Neem contact op via <contactLink>hola@formbricks.com</contactLink>",
|
||||
"recheck_license": "Licentie opnieuw controleren",
|
||||
"recheck_license_failed": "Licentiecontrole mislukt. De licentieserver is mogelijk niet bereikbaar.",
|
||||
"recheck_license_instance_mismatch": "Deze licentie is gekoppeld aan een andere Formbricks-instantie. Vraag Formbricks-support om de vorige koppeling te verbreken.",
|
||||
@@ -1355,13 +1353,12 @@
|
||||
"address_line_2": "Adresregel 2",
|
||||
"adjust_survey_closed_message": "Pas het bericht 'Enquête gesloten' aan",
|
||||
"adjust_survey_closed_message_description": "Wijzig het bericht dat bezoekers zien wanneer de enquête wordt gesloten.",
|
||||
"adjust_the_theme_in_the": "Pas het thema aan in de",
|
||||
"adjust_theme_in_look_and_feel_settings": "Pas het thema aan in de <lookFeelLink>Look & Feel</lookFeelLink> instellingen.",
|
||||
"all_are_true": "alle zijn waar",
|
||||
"all_other_answers_will_continue_to": "Alle andere antwoorden blijven hetzelfde",
|
||||
"all_other_answers_will_continue_to_fallback": "Alle andere antwoorden zullen blijven <fallbackSelect />",
|
||||
"allow_multi_select": "Multi-select toestaan",
|
||||
"allow_multiple_files": "Meerdere bestanden toestaan",
|
||||
"allow_users_to_select_more_than_one_image": "Sta gebruikers toe meer dan één afbeelding te selecteren",
|
||||
"and_launch_surveys_in_your_website_or_app": "en start enquêtes op uw website of app.",
|
||||
"animation": "Animatie",
|
||||
"any_is_true": "een is waar",
|
||||
"app_survey_description": "Sluit een enquête in uw web-app of website in om reacties te verzamelen.",
|
||||
@@ -1373,10 +1370,10 @@
|
||||
"auto_save_disabled": "Automatisch opslaan uitgeschakeld",
|
||||
"auto_save_disabled_tooltip": "Uw enquête wordt alleen automatisch opgeslagen wanneer deze een concept is. Dit zorgt ervoor dat openbare enquêtes niet onbedoeld worden bijgewerkt.",
|
||||
"auto_save_on": "Automatisch opslaan aan",
|
||||
"automatically_close_survey_after": "Sluit de enquête daarna automatisch af",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "Sluit de enquête automatisch na <autoCloseInput /> seconden na activatie als er geen reactie komt.",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Sluit de enquête automatisch af na een bepaald aantal reacties.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Sluit de enquête automatisch af als de gebruiker na een bepaald aantal seconden niet reageert.",
|
||||
"automatically_mark_the_survey_as_complete_after": "Markeer de enquête daarna automatisch als voltooid",
|
||||
"automatically_mark_complete_after_n_responses": "Markeer de enquête automatisch als voltooid na <autoCompleteInput /> voltooide reacties.",
|
||||
"back_button_label": "Knoplabel 'Terug'",
|
||||
"background_styling": "Achtergrondstijl",
|
||||
"block_duplicated": "Blok gedupliceerd.",
|
||||
@@ -1434,11 +1431,11 @@
|
||||
"columns": "Kolommen",
|
||||
"company": "Bedrijf",
|
||||
"company_logo": "Bedrijfslogo",
|
||||
"completed_responses": "voltooide reacties.",
|
||||
"concat": "Concat +",
|
||||
"conditional_logic": "Voorwaardelijke logica",
|
||||
"confirm_default_language": "Bevestig de standaardtaal",
|
||||
"confirm_survey_changes": "Bevestig enquêtewijzigingen",
|
||||
"connect_formbricks_and_launch_surveys": "Verbind Formbricks en lanceer enquêtes op je website of in je app.",
|
||||
"contact_fields": "Contactvelden",
|
||||
"contains": "Bevat",
|
||||
"continue_to_settings": "Ga verder naar Instellingen",
|
||||
@@ -1452,7 +1449,6 @@
|
||||
"custom_hostname": "Aangepaste hostnaam",
|
||||
"customize_survey_logo": "Pas het enquêtelogo aan",
|
||||
"darken_or_lighten_background_of_your_choice": "Maak de achtergrond naar keuze donkerder of lichter.",
|
||||
"days_before_showing_this_survey_again": "of meer dagen moeten verstrijken tussen de laatst getoonde enquête en het tonen van deze enquête.",
|
||||
"default_language": "Standaardtaal",
|
||||
"delete_anyways": "Toch verwijderen",
|
||||
"delete_block": "Blok verwijderen",
|
||||
@@ -1558,7 +1554,6 @@
|
||||
"hide_progress_bar": "Voortgangsbalk verbergen",
|
||||
"hide_question_settings": "Vraaginstellingen verbergen",
|
||||
"hostname": "Hostnaam",
|
||||
"if_you_need_more_please": "Als je meer nodig hebt,",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Blijf tonen wanneer geactiveerd totdat een antwoord of gedeeltelijk antwoord is ingediend.",
|
||||
"ignore_global_waiting_time": "Afkoelperiode negeren",
|
||||
"ignore_global_waiting_time_description": "Deze enquête kan worden getoond wanneer aan de voorwaarden wordt voldaan, zelfs als er onlangs een andere enquête is getoond.",
|
||||
@@ -1598,7 +1593,7 @@
|
||||
"last_name": "Achternaam",
|
||||
"let_people_upload_up_to_25_files_at_the_same_time": "Laat mensen maximaal 25 bestanden tegelijk uploaden.",
|
||||
"limit_the_maximum_file_size": "Beperk de maximale bestandsgrootte voor uploads.",
|
||||
"limit_upload_file_size_to": "Beperk uploadbestandsgrootte tot",
|
||||
"limit_upload_file_size_to_mb": "Beperk de uploadbestandsgrootte tot <fileSizeInput /> MB",
|
||||
"link_survey_description": "Deel een link naar een enquêtepagina of sluit deze in op een webpagina of e-mail.",
|
||||
"list": "Lijst",
|
||||
"load_segment": "Laadsegment",
|
||||
@@ -1613,7 +1608,8 @@
|
||||
"matrix_all_fields": "Alle velden",
|
||||
"matrix_rows": "Rijen",
|
||||
"max_file_size": "Maximale bestandsgrootte",
|
||||
"max_file_size_limit_is": "Maximale bestandsgroottelimiet is",
|
||||
"max_file_size_limit_is_mb": "De maximale bestandsgrootte is {{maxSize}} MB.",
|
||||
"max_file_size_limit_is_mb_upgrade": "De maximale bestandsgrootte is {{maxSize}} MB. Als je meer nodig hebt, <upgradeLink>upgrade dan je abonnement</upgradeLink>.",
|
||||
"missing_first": "Ontbrekende eerst",
|
||||
"move_question_to_block": "Vraag naar blok verplaatsen",
|
||||
"multiply": "Vermenigvuldig *",
|
||||
@@ -1727,8 +1723,6 @@
|
||||
"save_and_close": "Opslaan en sluiten",
|
||||
"scale": "Schaal",
|
||||
"search_for_images": "Zoek naar afbeeldingen",
|
||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "seconden na trigger wordt de enquête gesloten als er geen reactie is",
|
||||
"seconds_before_showing_the_survey": "seconden voordat de enquête wordt weergegeven.",
|
||||
"select_field": "Selecteer veld",
|
||||
"select_or_type_value": "Selecteer of typ een waarde",
|
||||
"select_ordering": "Selecteer bestellen",
|
||||
@@ -1736,7 +1730,7 @@
|
||||
"select_type": "Selecteer type",
|
||||
"send_survey_to_audience_who_match": "Enquête verzenden naar doelgroep die overeenkomt met...",
|
||||
"send_your_respondents_to_a_page_of_your_choice": "Stuur uw respondenten naar een pagina naar keuze.",
|
||||
"set_the_global_placement_in_the_look_feel_settings": "Stel de globale plaatsing in de Look & Feel-instellingen in.",
|
||||
"set_global_placement_in_look_feel_settings_hint": "Om de plaatsing consistent te houden over alle enquêtes, kun je <lookFeelLink>de globale plaatsing instellen in de Look & Feel instellingen.</lookFeelLink>",
|
||||
"settings_saved_successfully": "Instellingen succesvol opgeslagen.",
|
||||
"seven_points": "7 punten",
|
||||
"show_block_settings": "Blokinstellingen tonen",
|
||||
@@ -1746,7 +1740,7 @@
|
||||
"show_multiple_times": "Toon een beperkt aantal keren",
|
||||
"show_only_once": "Slechts één keer weergeven",
|
||||
"show_question_settings": "Vraaginstellingen tonen",
|
||||
"show_survey_maximum_of": "Toon onderzoek maximaal",
|
||||
"show_survey_maximum_of_n_times": "Toon de enquête maximaal <displayLimitInput /> keer.",
|
||||
"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",
|
||||
"shrink_preview": "Voorbeeld invouwen",
|
||||
@@ -1782,8 +1776,6 @@
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "Met deze actie worden alle vertalingen uit deze enquête verwijderd.",
|
||||
"this_will_remove_the_language_and_all_its_translations": "Dit verwijdert deze taal en alle vertalingen uit deze enquête. Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"three_points": "3 punten",
|
||||
"times": "keer",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Om de plaatsing over alle enquêtes consistent te houden, kunt u dat doen",
|
||||
"translated": "Vertaald",
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "Enquête activeren wanneer een van de acties wordt afgevuurd...",
|
||||
"try_lollipop_or_mountain": "Probeer 'lollipop' of 'berg'...",
|
||||
@@ -1860,8 +1852,9 @@
|
||||
"visibility_and_recontact": "Zichtbaarheid & opnieuw contact",
|
||||
"visibility_and_recontact_description": "Bepaal wanneer deze enquête kan verschijnen en hoe vaak deze opnieuw kan verschijnen.",
|
||||
"visible": "Zichtbaar",
|
||||
"wait": "Wachten",
|
||||
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Wacht een paar seconden na de trigger voordat u de enquête weergeeft",
|
||||
"wait_n_days_before_showing_this_survey_again": "Wacht <daysInput /> of meer dagen tussen de laatst getoonde enquête en het tonen van deze enquête.",
|
||||
"wait_n_seconds_before_showing_the_survey": "Wacht <delayInput /> seconden voordat je de enquête toont.",
|
||||
"waiting_time_across_surveys": "Afkoelperiode (voor alle enquêtes)",
|
||||
"waiting_time_across_surveys_description": "Om enquêtemoeheid te voorkomen, kies hoe deze enquête omgaat met de workspace-brede afkoelperiode.",
|
||||
"welcome_message": "Welkomstbericht",
|
||||
@@ -1925,10 +1918,8 @@
|
||||
"search_by_survey_name": "Zoek op enquêtenaam",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
|
||||
"custom_single_use_id_placeholder": "CUSTOM-ID",
|
||||
"custom_single_use_id_required": "Enter a custom single-use ID.",
|
||||
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
|
||||
"custom_single_use_id_description": "Als u de eenmalige ID niet versleutelt, werkt elke waarde voor “suid=...” voor één antwoord.",
|
||||
"custom_single_use_id_title": "U kunt elke waarde instellen als ID voor eenmalig gebruik in de URL.",
|
||||
"custom_start_point": "Aangepast startpunt",
|
||||
"data_prefilling": "Gegevens vooraf invullen",
|
||||
"description": "Reacties afkomstig van deze links zijn anoniem",
|
||||
@@ -2219,6 +2210,7 @@
|
||||
"custom_scripts_warning": "Scripts worden uitgevoerd met volledige browsertoegang. Voeg alleen scripts toe van vertrouwde bronnen.",
|
||||
"delete_workspace": "Project verwijderen",
|
||||
"delete_workspace_confirmation": "Weet u zeker dat u {projectName} wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||
"delete_workspace_confirmation_name": "Voer {projectName} in het volgende veld in om de definitieve verwijdering van dit project te bevestigen:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Verwijder {projectName} incl. alle enquêtes, reacties, mensen, acties en attributen.",
|
||||
"delete_workspace_settings_description": "Verwijder project met alle enquêtes, reacties, mensen, acties en attributen. Dit kan niet ongedaan worden gemaakt.",
|
||||
"error_saving_workspace_information": "Fout bij opslaan van projectinformatie",
|
||||
@@ -2471,7 +2463,7 @@
|
||||
"add_another_member": "Nog een lid toevoegen",
|
||||
"continue": "Doorgaan",
|
||||
"failed_to_invite": "Kan niet uitnodigen",
|
||||
"invitation_sent_to": "Uitnodiging verzonden naar",
|
||||
"invitation_sent_to_email": "Uitnodiging verzonden naar {{email}}!",
|
||||
"invite_your_organization_members": "Nodig uw organisatieleden uit",
|
||||
"life_s_no_fun_alone": "Het leven is niet leuk alleen.",
|
||||
"skip": "Overslaan",
|
||||
|
||||
+26
-34
@@ -241,10 +241,12 @@
|
||||
"failed_to_load_organizations": "Falha ao carregar organizações",
|
||||
"failed_to_load_workspaces": "Falha ao carregar projetos",
|
||||
"field_placeholder": "Espaço reservado de {{field}}",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filtro",
|
||||
"finish": "Terminar",
|
||||
"first_name": "Primeiro nome",
|
||||
"follow_these": "Siga esses",
|
||||
"formbricks_version": "Versão do Formbricks",
|
||||
"full_name": "Nome completo",
|
||||
"gathering_responses": "Recolhendo respostas",
|
||||
@@ -358,7 +360,6 @@
|
||||
"placeholder": "Espaço reservado",
|
||||
"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_upgrade_your_plan": "Por favor, atualize seu plano",
|
||||
"powered_by_formbricks": "Desenvolvido por Formbricks",
|
||||
"preview": "Prévia",
|
||||
"privacy": "Política de Privacidade",
|
||||
@@ -499,7 +500,7 @@
|
||||
"you_are_downgraded_to_the_community_edition": "Você foi rebaixado para a Edição Comunitária.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Você não tem autorização para realizar essa ação.",
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Você atingiu seu limite de {projectLimit} espaços de trabalho.",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Você atingiu o limite mensal de respostas de",
|
||||
"you_have_reached_your_monthly_response_limit_of_count": "Você atingiu seu limite mensal de respostas de {{count}}.",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Você será rebaixado para a Edição Comunitária em {date}.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
},
|
||||
@@ -774,6 +775,7 @@
|
||||
"create_survey_warning": "Você tem que criar uma pesquisa para poder configurar essa integração",
|
||||
"delete_integration": "Excluir Integração",
|
||||
"delete_integration_confirmation": "Tem certeza de que quer deletar essa integração?",
|
||||
"follow_these_docs_to_configure_it": "Siga esta <docsLink>documentação</docsLink> para configurá-la.",
|
||||
"google_sheet_integration_description": "Preencha suas planilhas com dados de pesquisa instantaneamente",
|
||||
"google_sheets": {
|
||||
"connect_with_google_sheets": "Conectar com o Google Sheets",
|
||||
@@ -858,7 +860,6 @@
|
||||
"slack_reconnect_button_description": "<b>Observação:</b> Recentemente, alteramos nossa integração com o Slack para também suportar canais privados. Por favor, reconecte seu workspace do Slack."
|
||||
},
|
||||
"slack_integration_description": "Conecte instantaneamente seu Workspace do Slack com o Formbricks",
|
||||
"to_configure_it": "configurar isso.",
|
||||
"webhook_integration_description": "Dispare Webhooks com base nas ações nas suas pesquisas",
|
||||
"webhooks": {
|
||||
"add_webhook": "Adicionar Webhook",
|
||||
@@ -917,9 +918,7 @@
|
||||
"ex_fully_activated_recurring_users": "Ex. Usuários recorrentes totalmente ativados",
|
||||
"ex_power_users": "Usuários Avançados",
|
||||
"filters_reset_successfully": "Filtros redefinidos com sucesso",
|
||||
"here": "aqui",
|
||||
"hide_filters": "Esconder filtros",
|
||||
"identifying_users": "identificando usuários",
|
||||
"invalid_segment": "Segmento inválido",
|
||||
"invalid_segment_filters": "Filtros inválidos. Por favor, verifique os filtros e tente novamente.",
|
||||
"load_segment": "Segmento de Carga",
|
||||
@@ -974,21 +973,20 @@
|
||||
"segment_id": "ID do segmento",
|
||||
"segment_saved_successfully": "Segmento salvo com sucesso",
|
||||
"segment_updated_successfully": "Segmento atualizado com sucesso!",
|
||||
"segment_used_in_other_surveys_make_changes_here": "Este segmento é usado em outras pesquisas. Faça alterações <segmentsLink>aqui</segmentsLink>.",
|
||||
"segments_help_you_target_users_with_same_characteristics_easily": "Segmentos ajudam você a direcionar usuários com as mesmas características facilmente",
|
||||
"target_audience": "Público-alvo",
|
||||
"this_action_resets_all_filters_in_this_survey": "Essa ação reseta todos os filtros dessa pesquisa.",
|
||||
"this_segment_is_used_in_other_surveys": "Esse segmento é usado em outras pesquisas. Faça alterações",
|
||||
"title_is_required": "É necessário um título.",
|
||||
"unknown_filter_type": "Tipo de filtro desconhecido",
|
||||
"unlock_segments_description": "Organize contatos em segmentos para direcionar grupos específicos de usuários",
|
||||
"unlock_segments_title": "Desbloqueie segmentos com um plano superior",
|
||||
"user_targeting_is_currently_only_available_when": "A segmentação de usuários está disponível apenas quando",
|
||||
"user_targeting_only_available_when_identifying_users": "A segmentação de usuários está disponível apenas ao <docsLink>identificar usuários</docsLink> com o SDK do Formbricks.",
|
||||
"value_cannot_be_empty": "O valor não pode estar vazio.",
|
||||
"value_must_be_a_number": "O valor deve ser um número.",
|
||||
"value_must_be_positive": "O valor deve ser um número positivo.",
|
||||
"view_filters": "Ver filtros",
|
||||
"where": "Onde",
|
||||
"with_the_formbricks_sdk": "com o SDK do Formbricks."
|
||||
"where": "Onde"
|
||||
},
|
||||
"settings": {
|
||||
"api_keys": {
|
||||
@@ -1067,8 +1065,8 @@
|
||||
"unlimited_workspaces": "Projetos ilimitados",
|
||||
"upgrade": "Atualizar",
|
||||
"upgrade_now": "Fazer upgrade agora",
|
||||
"usage_count_of_limit_used": "{{current}} / {{limit}} <muted>usado</muted>",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "usado",
|
||||
"yearly": "Anual",
|
||||
"yearly_checkout_unavailable": "O checkout anual ainda não está disponível. Adicione um método de pagamento em um plano mensal primeiro ou entre em contato com o suporte.",
|
||||
"your_plan": "Seu plano"
|
||||
@@ -1129,7 +1127,7 @@
|
||||
"no_credit_card_no_sales_call_just_test_it": "Sem cartão de crédito. Sem ligação de vendas. Só teste :)",
|
||||
"on_request": "Quando solicitado",
|
||||
"organization_roles": "Funções na Organização (Admin, Editor, Desenvolvedor, etc.)",
|
||||
"questions_please_reach_out_to": "Perguntas? Entre em contato com",
|
||||
"questions_please_reach_out_to_email": "Dúvidas? Entre em contato com <contactLink>hola@formbricks.com</contactLink>",
|
||||
"recheck_license": "Verificar licença novamente",
|
||||
"recheck_license_failed": "Falha na verificação da licença. O servidor de licenças pode estar inacessível.",
|
||||
"recheck_license_instance_mismatch": "Esta licença está vinculada a uma instância diferente do Formbricks. Peça ao suporte do Formbricks para desconectar a vinculação anterior.",
|
||||
@@ -1355,13 +1353,12 @@
|
||||
"address_line_2": "Complemento",
|
||||
"adjust_survey_closed_message": "Ajustar mensagem 'Pesquisa Encerrada''",
|
||||
"adjust_survey_closed_message_description": "Mude a mensagem que os visitantes veem quando a pesquisa está fechada.",
|
||||
"adjust_the_theme_in_the": "Ajuste o tema no",
|
||||
"adjust_theme_in_look_and_feel_settings": "Ajuste o tema nas configurações de <lookFeelLink>Aparência</lookFeelLink>.",
|
||||
"all_are_true": "todas são verdadeiras",
|
||||
"all_other_answers_will_continue_to": "Todas as outras respostas continuarão a",
|
||||
"all_other_answers_will_continue_to_fallback": "Todas as outras respostas continuarão a <fallbackSelect />",
|
||||
"allow_multi_select": "Permitir seleção múltipla",
|
||||
"allow_multiple_files": "Permitir vários arquivos",
|
||||
"allow_users_to_select_more_than_one_image": "Permitir que os usuários selecionem mais de uma imagem",
|
||||
"and_launch_surveys_in_your_website_or_app": "e lançar pesquisas no seu site ou app.",
|
||||
"animation": "animação",
|
||||
"any_is_true": "qualquer uma é verdadeira",
|
||||
"app_survey_description": "Embuta uma pesquisa no seu app ou site para coletar respostas.",
|
||||
@@ -1373,10 +1370,10 @@
|
||||
"auto_save_disabled": "Salvamento automático desativado",
|
||||
"auto_save_disabled_tooltip": "Sua pesquisa só é salva automaticamente quando está em rascunho. Isso garante que pesquisas públicas não sejam atualizadas involuntariamente.",
|
||||
"auto_save_on": "Salvamento automático ativado",
|
||||
"automatically_close_survey_after": "Fechar pesquisa automaticamente após",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "Fechar automaticamente a pesquisa após <autoCloseInput /> segundos do acionamento se não houver resposta.",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente a pesquisa depois de um certo número de respostas.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Feche automaticamente a pesquisa se o usuário não responder depois de alguns segundos.",
|
||||
"automatically_mark_the_survey_as_complete_after": "Marcar automaticamente a pesquisa como concluída após",
|
||||
"automatically_mark_complete_after_n_responses": "Marcar automaticamente a pesquisa como concluída após <autoCompleteInput /> respostas completas.",
|
||||
"back_button_label": "Voltar",
|
||||
"background_styling": "Estilo do plano de fundo",
|
||||
"block_duplicated": "Bloco duplicado.",
|
||||
@@ -1434,11 +1431,11 @@
|
||||
"columns": "colunas",
|
||||
"company": "empresa",
|
||||
"company_logo": "Logo da empresa",
|
||||
"completed_responses": "Respostas concluídas.",
|
||||
"concat": "Concatenar +",
|
||||
"conditional_logic": "Lógica Condicional",
|
||||
"confirm_default_language": "Confirmar idioma padrão",
|
||||
"confirm_survey_changes": "Confirmar Alterações na Pesquisa",
|
||||
"connect_formbricks_and_launch_surveys": "Conecte o Formbricks e lance pesquisas no seu site ou aplicativo.",
|
||||
"contact_fields": "Campos de Contato",
|
||||
"contains": "contém",
|
||||
"continue_to_settings": "Continuar para Configurações",
|
||||
@@ -1452,7 +1449,6 @@
|
||||
"custom_hostname": "Hostname personalizado",
|
||||
"customize_survey_logo": "Personalizar o logo da pesquisa",
|
||||
"darken_or_lighten_background_of_your_choice": "Escureça ou clareie o fundo da sua escolha.",
|
||||
"days_before_showing_this_survey_again": "ou mais dias devem passar entre a última pesquisa exibida e a exibição desta pesquisa.",
|
||||
"default_language": "Idioma padrão",
|
||||
"delete_anyways": "Excluir mesmo assim",
|
||||
"delete_block": "Excluir bloco",
|
||||
@@ -1558,7 +1554,6 @@
|
||||
"hide_progress_bar": "Esconder barra de progresso",
|
||||
"hide_question_settings": "Ocultar configurações da pergunta",
|
||||
"hostname": "nome do host",
|
||||
"if_you_need_more_please": "Se você precisar de mais, por favor",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continue mostrando sempre que acionado até que uma resposta ou resposta parcial seja enviada.",
|
||||
"ignore_global_waiting_time": "Ignorar período de espera",
|
||||
"ignore_global_waiting_time_description": "Esta pesquisa pode ser mostrada sempre que suas condições forem atendidas, mesmo que outra pesquisa tenha sido mostrada recentemente.",
|
||||
@@ -1598,7 +1593,7 @@
|
||||
"last_name": "Sobrenome",
|
||||
"let_people_upload_up_to_25_files_at_the_same_time": "Deixe as pessoas fazerem upload de até 25 arquivos ao mesmo tempo.",
|
||||
"limit_the_maximum_file_size": "Limitar o tamanho máximo de arquivo para uploads.",
|
||||
"limit_upload_file_size_to": "Limitar tamanho de arquivo de upload para",
|
||||
"limit_upload_file_size_to_mb": "Limitar o tamanho do arquivo enviado a <fileSizeInput /> MB",
|
||||
"link_survey_description": "Compartilhe um link para a página da pesquisa ou incorpore-a em uma página da web ou e-mail.",
|
||||
"list": "Lista",
|
||||
"load_segment": "segmento de carga",
|
||||
@@ -1613,7 +1608,8 @@
|
||||
"matrix_all_fields": "Todos os campos",
|
||||
"matrix_rows": "Linhas",
|
||||
"max_file_size": "Tamanho máximo do arquivo",
|
||||
"max_file_size_limit_is": "O limite de tamanho máximo do arquivo é",
|
||||
"max_file_size_limit_is_mb": "O tamanho máximo do arquivo é {{maxSize}} MB.",
|
||||
"max_file_size_limit_is_mb_upgrade": "O tamanho máximo do arquivo é {{maxSize}} MB. Se você precisar de mais, por favor <upgradeLink>faça upgrade do seu plano</upgradeLink>.",
|
||||
"missing_first": "Faltantes primeiro",
|
||||
"move_question_to_block": "Mover pergunta para o bloco",
|
||||
"multiply": "Multiplicar *",
|
||||
@@ -1727,8 +1723,6 @@
|
||||
"save_and_close": "Salvar e Fechar",
|
||||
"scale": "escala",
|
||||
"search_for_images": "Buscar imagens",
|
||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "segundos após acionar, a pesquisa será encerrada se não houver resposta",
|
||||
"seconds_before_showing_the_survey": "segundos antes de mostrar a pesquisa.",
|
||||
"select_field": "Selecionar campo",
|
||||
"select_or_type_value": "Selecionar ou digitar valor",
|
||||
"select_ordering": "Selecionar pedido",
|
||||
@@ -1736,7 +1730,7 @@
|
||||
"select_type": "Selecionar tipo",
|
||||
"send_survey_to_audience_who_match": "Enviar pesquisa para o público que corresponde...",
|
||||
"send_your_respondents_to_a_page_of_your_choice": "Envie seus respondentes para uma página de sua escolha.",
|
||||
"set_the_global_placement_in_the_look_feel_settings": "Defina o posicionamento global nas configurações de Aparência.",
|
||||
"set_global_placement_in_look_feel_settings_hint": "Para manter o posicionamento consistente em todas as pesquisas, você pode <lookFeelLink>definir o posicionamento global nas configurações de Aparência.</lookFeelLink>",
|
||||
"settings_saved_successfully": "Configurações salvas com sucesso",
|
||||
"seven_points": "7 pontos",
|
||||
"show_block_settings": "Mostrar configurações do bloco",
|
||||
@@ -1746,7 +1740,7 @@
|
||||
"show_multiple_times": "Mostrar um número limitado de vezes",
|
||||
"show_only_once": "Mostrar só uma vez",
|
||||
"show_question_settings": "Mostrar configurações da pergunta",
|
||||
"show_survey_maximum_of": "Mostrar no máximo",
|
||||
"show_survey_maximum_of_n_times": "Mostrar a pesquisa no máximo <displayLimitInput /> vezes.",
|
||||
"show_survey_to_users": "Mostrar pesquisa para % dos usuários",
|
||||
"show_to_x_percentage_of_targeted_users": "Mostrar para {percentage}% dos usuários segmentados",
|
||||
"shrink_preview": "Recolher prévia",
|
||||
@@ -1782,8 +1776,6 @@
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "Essa ação vai remover todas as traduções dessa pesquisa.",
|
||||
"this_will_remove_the_language_and_all_its_translations": "Isso removerá este idioma e todas as suas traduções desta pesquisa. Esta ação não pode ser desfeita.",
|
||||
"three_points": "3 pontos",
|
||||
"times": "times",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Para manter a colocação consistente em todas as pesquisas, você pode",
|
||||
"translated": "Traduzido",
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "Disparar pesquisa quando uma das ações for executada...",
|
||||
"try_lollipop_or_mountain": "Tenta 'pirulito' ou 'montanha'...",
|
||||
@@ -1860,8 +1852,9 @@
|
||||
"visibility_and_recontact": "Visibilidade e recontato",
|
||||
"visibility_and_recontact_description": "Controle quando esta pesquisa pode aparecer e com que frequência pode reaparecer.",
|
||||
"visible": "Visível",
|
||||
"wait": "Espera",
|
||||
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Espera alguns segundos depois do gatilho antes de mostrar a pesquisa",
|
||||
"wait_n_days_before_showing_this_survey_again": "Aguardar <daysInput /> ou mais dias entre a última pesquisa exibida e a exibição desta pesquisa.",
|
||||
"wait_n_seconds_before_showing_the_survey": "Aguardar <delayInput /> segundos antes de exibir a pesquisa.",
|
||||
"waiting_time_across_surveys": "Período de espera (entre pesquisas)",
|
||||
"waiting_time_across_surveys_description": "Para evitar fadiga de pesquisas, escolha como esta pesquisa interage com o período de espera geral do workspace.",
|
||||
"welcome_message": "Mensagem de boas-vindas",
|
||||
@@ -1925,10 +1918,8 @@
|
||||
"search_by_survey_name": "Buscar pelo nome da pesquisa",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
|
||||
"custom_single_use_id_placeholder": "CUSTOM-ID",
|
||||
"custom_single_use_id_required": "Enter a custom single-use ID.",
|
||||
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
|
||||
"custom_single_use_id_description": "Se você não criptografar o ID de uso único, qualquer valor para “suid=...” funciona para uma resposta.",
|
||||
"custom_single_use_id_title": "Você pode definir qualquer valor como ID de uso único na URL.",
|
||||
"custom_start_point": "Ponto de início personalizado",
|
||||
"data_prefilling": "preenchimento automático de dados",
|
||||
"description": "Respostas vindas desses links serão anônimas",
|
||||
@@ -2219,6 +2210,7 @@
|
||||
"custom_scripts_warning": "Os scripts são executados com acesso total ao navegador. Adicione apenas scripts de fontes confiáveis.",
|
||||
"delete_workspace": "Excluir projeto",
|
||||
"delete_workspace_confirmation": "Tem certeza de que deseja excluir {projectName}? Essa ação não pode ser desfeita.",
|
||||
"delete_workspace_confirmation_name": "Por favor, insira {projectName} no campo abaixo para confirmar a exclusão definitiva deste projeto:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Excluir {projectName} incluindo todas as pesquisas, respostas, pessoas, ações e atributos.",
|
||||
"delete_workspace_settings_description": "Excluir projeto com todas as pesquisas, respostas, pessoas, ações e atributos. Isso não pode ser desfeito.",
|
||||
"error_saving_workspace_information": "Erro ao salvar informações do projeto",
|
||||
@@ -2471,7 +2463,7 @@
|
||||
"add_another_member": "Adicionar mais um membro",
|
||||
"continue": "Continuar",
|
||||
"failed_to_invite": "Falha ao convidar",
|
||||
"invitation_sent_to": "Convite enviado para",
|
||||
"invitation_sent_to_email": "Convite enviado para {{email}}!",
|
||||
"invite_your_organization_members": "Convide os membros da sua organização",
|
||||
"life_s_no_fun_alone": "A vida não tem graça sozinho.",
|
||||
"skip": "Pular",
|
||||
|
||||
+26
-34
@@ -241,10 +241,12 @@
|
||||
"failed_to_load_organizations": "Falha ao carregar organizações",
|
||||
"failed_to_load_workspaces": "Falha ao carregar projetos",
|
||||
"field_placeholder": "Espaço reservado de {{field}}",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filtro",
|
||||
"finish": "Concluir",
|
||||
"first_name": "Primeiro nome",
|
||||
"follow_these": "Siga estes",
|
||||
"formbricks_version": "Versão do Formbricks",
|
||||
"full_name": "Nome completo",
|
||||
"gathering_responses": "A recolher respostas",
|
||||
@@ -358,7 +360,6 @@
|
||||
"placeholder": "Espaço reservado",
|
||||
"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_upgrade_your_plan": "Por favor, atualize o seu plano",
|
||||
"powered_by_formbricks": "Desenvolvido por Formbricks",
|
||||
"preview": "Pré-visualização",
|
||||
"privacy": "Política de Privacidade",
|
||||
@@ -499,7 +500,7 @@
|
||||
"you_are_downgraded_to_the_community_edition": "Foi rebaixado para a Edição Comunitária.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Não está autorizado a realizar esta ação.",
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Atingiu o seu limite de {projectLimit} áreas de trabalho.",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Atingiu o seu limite mensal de respostas de",
|
||||
"you_have_reached_your_monthly_response_limit_of_count": "Atingiste o teu limite mensal de respostas de {{count}}.",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Será rebaixado para a Edição Comunitária em {date}.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
},
|
||||
@@ -774,6 +775,7 @@
|
||||
"create_survey_warning": "Tem de criar um inquérito para poder configurar esta integração",
|
||||
"delete_integration": "Eliminar Integração",
|
||||
"delete_integration_confirmation": "Tem a certeza de que deseja eliminar esta integração?",
|
||||
"follow_these_docs_to_configure_it": "Segue esta <docsLink>documentação</docsLink> para configurar.",
|
||||
"google_sheet_integration_description": "Preencha instantaneamente as suas folhas de cálculo com dados de inquéritos",
|
||||
"google_sheets": {
|
||||
"connect_with_google_sheets": "Conectar com o Google Sheets",
|
||||
@@ -858,7 +860,6 @@
|
||||
"slack_reconnect_button_description": "<b>Nota:</b> Recentemente alterámos a nossa integração com o Slack para também suportar canais privados. Por favor, reconecte o seu espaço de trabalho do Slack."
|
||||
},
|
||||
"slack_integration_description": "Conecte instantaneamente o seu Workspace do Slack com o Formbricks",
|
||||
"to_configure_it": "para configurá-lo.",
|
||||
"webhook_integration_description": "Acione Webhooks com base em ações nos seus inquéritos",
|
||||
"webhooks": {
|
||||
"add_webhook": "Adicionar Webhook",
|
||||
@@ -917,9 +918,7 @@
|
||||
"ex_fully_activated_recurring_users": "Ex. Utilizadores recorrentes totalmente ativados",
|
||||
"ex_power_users": "Ex. Utilizadores avançados",
|
||||
"filters_reset_successfully": "Filtros redefinidos com sucesso",
|
||||
"here": "aqui",
|
||||
"hide_filters": "Ocultar filtros",
|
||||
"identifying_users": "identificar utilizadores",
|
||||
"invalid_segment": "Segmento inválido",
|
||||
"invalid_segment_filters": "Filtros inválidos. Por favor, verifique os filtros e tente novamente.",
|
||||
"load_segment": "Carregar Segmento",
|
||||
@@ -974,21 +973,20 @@
|
||||
"segment_id": "ID do Segmento",
|
||||
"segment_saved_successfully": "Segmento guardado com sucesso",
|
||||
"segment_updated_successfully": "Segmento atualizado com sucesso!",
|
||||
"segment_used_in_other_surveys_make_changes_here": "Este segmento é utilizado noutros inquéritos. Faz alterações <segmentsLink>aqui</segmentsLink>.",
|
||||
"segments_help_you_target_users_with_same_characteristics_easily": "Os segmentos ajudam-no a direcionar utilizadores com as mesmas características facilmente",
|
||||
"target_audience": "Público-alvo",
|
||||
"this_action_resets_all_filters_in_this_survey": "Esta ação redefine todos os filtros nesta pesquisa.",
|
||||
"this_segment_is_used_in_other_surveys": "Este segmento é utilizado noutros questionários. Faça alterações",
|
||||
"title_is_required": "Título é obrigatório.",
|
||||
"unknown_filter_type": "Tipo de filtro desconhecido",
|
||||
"unlock_segments_description": "Organize contactos em segmentos para direcionar grupos de utilizadores específicos",
|
||||
"unlock_segments_title": "Desbloqueie os segmentos com um plano superior",
|
||||
"user_targeting_is_currently_only_available_when": "A segmentação de utilizadores está atualmente disponível apenas quando",
|
||||
"user_targeting_only_available_when_identifying_users": "A segmentação de utilizadores está atualmente disponível apenas ao <docsLink>identificar utilizadores</docsLink> com o SDK Formbricks.",
|
||||
"value_cannot_be_empty": "O valor não pode estar vazio.",
|
||||
"value_must_be_a_number": "O valor deve ser um número.",
|
||||
"value_must_be_positive": "O valor deve ser um número positivo.",
|
||||
"view_filters": "Ver filtros",
|
||||
"where": "Onde",
|
||||
"with_the_formbricks_sdk": "com o SDK Formbricks"
|
||||
"where": "Onde"
|
||||
},
|
||||
"settings": {
|
||||
"api_keys": {
|
||||
@@ -1067,8 +1065,8 @@
|
||||
"unlimited_workspaces": "Projetos ilimitados",
|
||||
"upgrade": "Atualizar",
|
||||
"upgrade_now": "Fazer upgrade agora",
|
||||
"usage_count_of_limit_used": "{{current}} / {{limit}} <muted>utilizados</muted>",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "utilizado",
|
||||
"yearly": "Anual",
|
||||
"yearly_checkout_unavailable": "O pagamento anual ainda não está disponível. Adiciona primeiro um método de pagamento num plano mensal ou contacta o suporte.",
|
||||
"your_plan": "O teu plano"
|
||||
@@ -1129,7 +1127,7 @@
|
||||
"no_credit_card_no_sales_call_just_test_it": "Sem cartão de crédito. Sem chamada de vendas. Apenas teste :)",
|
||||
"on_request": "A pedido",
|
||||
"organization_roles": "Funções da Organização (Administrador, Editor, Programador, etc.)",
|
||||
"questions_please_reach_out_to": "Questões? Por favor entre em contacto com",
|
||||
"questions_please_reach_out_to_email": "Tens dúvidas? Contacta-nos através de <contactLink>hola@formbricks.com</contactLink>",
|
||||
"recheck_license": "Verificar licença novamente",
|
||||
"recheck_license_failed": "A verificação da licença falhou. O servidor de licenças pode estar inacessível.",
|
||||
"recheck_license_instance_mismatch": "Esta licença está associada a uma instância Formbricks diferente. Pede ao suporte da Formbricks para desconectar a associação anterior.",
|
||||
@@ -1355,13 +1353,12 @@
|
||||
"address_line_2": "Endereço Linha 2",
|
||||
"adjust_survey_closed_message": "Ajustar mensagem de 'Inquérito Fechado'",
|
||||
"adjust_survey_closed_message_description": "Alterar a mensagem que os visitantes veem quando o inquérito está fechado.",
|
||||
"adjust_the_theme_in_the": "Ajustar o tema no",
|
||||
"adjust_theme_in_look_and_feel_settings": "Ajusta o tema nas definições de <lookFeelLink>Aparência</lookFeelLink>.",
|
||||
"all_are_true": "todas são verdadeiras",
|
||||
"all_other_answers_will_continue_to": "Todas as outras respostas continuarão a",
|
||||
"all_other_answers_will_continue_to_fallback": "Todas as outras respostas continuarão a <fallbackSelect />",
|
||||
"allow_multi_select": "Permitir seleção múltipla",
|
||||
"allow_multiple_files": "Permitir vários ficheiros",
|
||||
"allow_users_to_select_more_than_one_image": "Permitir aos utilizadores selecionar mais do que uma imagem",
|
||||
"and_launch_surveys_in_your_website_or_app": "e lance inquéritos no seu site ou aplicação.",
|
||||
"animation": "Animação",
|
||||
"any_is_true": "qualquer uma é verdadeira",
|
||||
"app_survey_description": "Incorpore um inquérito na sua aplicação web ou site para recolher respostas.",
|
||||
@@ -1373,10 +1370,10 @@
|
||||
"auto_save_disabled": "Guardar automático desativado",
|
||||
"auto_save_disabled_tooltip": "O seu inquérito só é guardado automaticamente quando está em rascunho. Isto garante que os inquéritos públicos não sejam atualizados involuntariamente.",
|
||||
"auto_save_on": "Guardar automático ativado",
|
||||
"automatically_close_survey_after": "Fechar automaticamente o inquérito após",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "Fechar automaticamente o inquérito após <autoCloseInput /> segundos depois do acionamento se não houver resposta.",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente o inquérito após um certo número de respostas",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fechar automaticamente o inquérito se o utilizador não responder após um certo número de segundos.",
|
||||
"automatically_mark_the_survey_as_complete_after": "Marcar automaticamente o inquérito como concluído após",
|
||||
"automatically_mark_complete_after_n_responses": "Marcar automaticamente o inquérito como concluído após <autoCompleteInput /> respostas completas.",
|
||||
"back_button_label": "Rótulo do botão \"Voltar\"",
|
||||
"background_styling": "Estilo de fundo",
|
||||
"block_duplicated": "Bloco duplicado.",
|
||||
@@ -1434,11 +1431,11 @@
|
||||
"columns": "Colunas",
|
||||
"company": "Empresa",
|
||||
"company_logo": "Logotipo da empresa",
|
||||
"completed_responses": "Respostas concluídas",
|
||||
"concat": "Concatenar +",
|
||||
"conditional_logic": "Lógica Condicional",
|
||||
"confirm_default_language": "Confirmar idioma padrão",
|
||||
"confirm_survey_changes": "Confirmar Alterações do Inquérito",
|
||||
"connect_formbricks_and_launch_surveys": "Conecta o Formbricks e lança inquéritos no teu website ou aplicação.",
|
||||
"contact_fields": "Campos de Contacto",
|
||||
"contains": "Contém",
|
||||
"continue_to_settings": "Continuar para Definições",
|
||||
@@ -1452,7 +1449,6 @@
|
||||
"custom_hostname": "Nome do host personalizado",
|
||||
"customize_survey_logo": "Personalizar o logótipo do inquérito",
|
||||
"darken_or_lighten_background_of_your_choice": "Escurecer ou clarear o fundo da sua escolha.",
|
||||
"days_before_showing_this_survey_again": "ou mais dias a decorrer entre o último inquérito apresentado e a apresentação deste inquérito.",
|
||||
"default_language": "Idioma predefinido",
|
||||
"delete_anyways": "Eliminar mesmo assim",
|
||||
"delete_block": "Eliminar bloco",
|
||||
@@ -1558,7 +1554,6 @@
|
||||
"hide_progress_bar": "Ocultar barra de progresso",
|
||||
"hide_question_settings": "Ocultar definições da pergunta",
|
||||
"hostname": "Nome do host",
|
||||
"if_you_need_more_please": "Se precisar de mais, por favor",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuar a mostrar sempre que acionado até que uma resposta ou resposta parcial seja submetida.",
|
||||
"ignore_global_waiting_time": "Ignorar período de espera",
|
||||
"ignore_global_waiting_time_description": "Este inquérito pode ser mostrado sempre que as suas condições forem cumpridas, mesmo que outro inquérito tenha sido mostrado recentemente.",
|
||||
@@ -1598,7 +1593,7 @@
|
||||
"last_name": "Apelido",
|
||||
"let_people_upload_up_to_25_files_at_the_same_time": "Permitir que as pessoas carreguem até 25 ficheiros ao mesmo tempo.",
|
||||
"limit_the_maximum_file_size": "Limitar o tamanho máximo de ficheiro para carregamentos.",
|
||||
"limit_upload_file_size_to": "Limitar o tamanho de ficheiro de carregamento para",
|
||||
"limit_upload_file_size_to_mb": "Limitar o tamanho do ficheiro carregado a <fileSizeInput /> MB",
|
||||
"link_survey_description": "Partilhe um link para uma página de inquérito ou incorpore-o numa página web ou email.",
|
||||
"list": "Lista",
|
||||
"load_segment": "Carregar segmento",
|
||||
@@ -1613,7 +1608,8 @@
|
||||
"matrix_all_fields": "Todos os campos",
|
||||
"matrix_rows": "Linhas",
|
||||
"max_file_size": "Tamanho máximo de ficheiro",
|
||||
"max_file_size_limit_is": "O limite de tamanho máximo de ficheiro é",
|
||||
"max_file_size_limit_is_mb": "O limite máximo de tamanho de ficheiro é {{maxSize}} MB.",
|
||||
"max_file_size_limit_is_mb_upgrade": "O limite máximo de tamanho de ficheiro é {{maxSize}} MB. Se precisas de mais, <upgradeLink>faz upgrade do teu plano</upgradeLink>.",
|
||||
"missing_first": "Em falta primeiro",
|
||||
"move_question_to_block": "Mover pergunta para o bloco",
|
||||
"multiply": "Multiplicar *",
|
||||
@@ -1727,8 +1723,6 @@
|
||||
"save_and_close": "Guardar e Fechar",
|
||||
"scale": "Escala",
|
||||
"search_for_images": "Procurar imagens",
|
||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "segundos após o acionamento o inquérito será fechado se não houver resposta",
|
||||
"seconds_before_showing_the_survey": "segundos antes de mostrar o inquérito.",
|
||||
"select_field": "Selecionar campo",
|
||||
"select_or_type_value": "Selecionar ou digitar valor",
|
||||
"select_ordering": "Selecionar ordem",
|
||||
@@ -1736,7 +1730,7 @@
|
||||
"select_type": "Selecionar tipo",
|
||||
"send_survey_to_audience_who_match": "Enviar inquérito para o público que corresponde...",
|
||||
"send_your_respondents_to_a_page_of_your_choice": "Envie os seus respondentes para uma página à sua escolha.",
|
||||
"set_the_global_placement_in_the_look_feel_settings": "Definir a colocação global nas definições de Aparência.",
|
||||
"set_global_placement_in_look_feel_settings_hint": "Para manter a posição consistente em todos os inquéritos, podes <lookFeelLink>definir a posição global nas definições de Aparência.</lookFeelLink>",
|
||||
"settings_saved_successfully": "Definições guardadas com sucesso",
|
||||
"seven_points": "7 pontos",
|
||||
"show_block_settings": "Mostrar definições do bloco",
|
||||
@@ -1746,7 +1740,7 @@
|
||||
"show_multiple_times": "Mostrar um número limitado de vezes",
|
||||
"show_only_once": "Mostrar apenas uma vez",
|
||||
"show_question_settings": "Mostrar definições da pergunta",
|
||||
"show_survey_maximum_of": "Mostrar inquérito máximo de",
|
||||
"show_survey_maximum_of_n_times": "Mostrar inquérito no máximo <displayLimitInput /> vezes.",
|
||||
"show_survey_to_users": "Mostrar inquérito a % dos utilizadores",
|
||||
"show_to_x_percentage_of_targeted_users": "Mostrar a {percentage}% dos utilizadores alvo",
|
||||
"shrink_preview": "Reduzir pré-visualização",
|
||||
@@ -1782,8 +1776,6 @@
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "Esta ação irá remover todas as traduções deste inquérito.",
|
||||
"this_will_remove_the_language_and_all_its_translations": "Isto irá remover este idioma e todas as suas traduções deste inquérito. Esta ação não pode ser revertida.",
|
||||
"three_points": "3 pontos",
|
||||
"times": "tempos",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Para manter a colocação consistente em todos os questionários, pode",
|
||||
"translated": "Traduzido",
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "Desencadear inquérito quando uma das ações for disparada...",
|
||||
"try_lollipop_or_mountain": "Experimente 'cão' ou 'planta'...",
|
||||
@@ -1860,8 +1852,9 @@
|
||||
"visibility_and_recontact": "Visibilidade e Recontacto",
|
||||
"visibility_and_recontact_description": "Controlar quando este inquérito pode aparecer e com que frequência pode reaparecer.",
|
||||
"visible": "Visível",
|
||||
"wait": "Aguardar",
|
||||
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Aguarde alguns segundos após o gatilho antes de mostrar o inquérito",
|
||||
"wait_n_days_before_showing_this_survey_again": "Aguardar <daysInput /> ou mais dias entre o último inquérito mostrado e a exibição deste inquérito.",
|
||||
"wait_n_seconds_before_showing_the_survey": "Aguardar <delayInput /> segundos antes de mostrar o inquérito.",
|
||||
"waiting_time_across_surveys": "Período de espera (entre inquéritos)",
|
||||
"waiting_time_across_surveys_description": "Para prevenir fadiga de inquéritos, escolha como este inquérito interage com o período de espera geral do espaço de trabalho.",
|
||||
"welcome_message": "Mensagem de boas-vindas",
|
||||
@@ -1925,10 +1918,8 @@
|
||||
"search_by_survey_name": "Pesquisar por nome do inquérito",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
|
||||
"custom_single_use_id_placeholder": "CUSTOM-ID",
|
||||
"custom_single_use_id_required": "Enter a custom single-use ID.",
|
||||
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
|
||||
"custom_single_use_id_description": "Se não encriptar o ID de utilização única, qualquer valor para \"suid=...\" funciona para uma resposta.",
|
||||
"custom_single_use_id_title": "Pode definir qualquer valor como ID de uso único no URL.",
|
||||
"custom_start_point": "Ponto de início personalizado",
|
||||
"data_prefilling": "Pré-preenchimento de dados",
|
||||
"description": "Respostas provenientes destes links serão anónimas",
|
||||
@@ -2219,6 +2210,7 @@
|
||||
"custom_scripts_warning": "Os scripts são executados com acesso total ao navegador. Adicione apenas scripts de fontes fidedignas.",
|
||||
"delete_workspace": "Eliminar projeto",
|
||||
"delete_workspace_confirmation": "Tem a certeza de que pretende eliminar {projectName}? Esta ação não pode ser desfeita.",
|
||||
"delete_workspace_confirmation_name": "Por favor, insira {projectName} no campo seguinte para confirmar a eliminação definitiva deste projeto:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Eliminar {projectName} incluindo todos os inquéritos, respostas, pessoas, ações e atributos.",
|
||||
"delete_workspace_settings_description": "Eliminar projeto com todos os inquéritos, respostas, pessoas, ações e atributos. Isto não pode ser desfeito.",
|
||||
"error_saving_workspace_information": "Erro ao guardar informações do projeto",
|
||||
@@ -2471,7 +2463,7 @@
|
||||
"add_another_member": "Adicionar outro membro",
|
||||
"continue": "Continuar",
|
||||
"failed_to_invite": "Falha ao convidar",
|
||||
"invitation_sent_to": "Convite enviado para",
|
||||
"invitation_sent_to_email": "Convite enviado para {{email}}!",
|
||||
"invite_your_organization_members": "Convide os membros da sua organização",
|
||||
"life_s_no_fun_alone": "A vida não é divertida sozinho.",
|
||||
"skip": "Saltar",
|
||||
|
||||
+26
-34
@@ -241,10 +241,12 @@
|
||||
"failed_to_load_organizations": "Nu s-a reușit încărcarea organizațiilor",
|
||||
"failed_to_load_workspaces": "Nu s-au putut încărca workspaces",
|
||||
"field_placeholder": "Substituent {{field}}",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filtru",
|
||||
"finish": "Finalizează",
|
||||
"first_name": "Prenume",
|
||||
"follow_these": "Urmați acestea",
|
||||
"formbricks_version": "Versiunea Formbricks",
|
||||
"full_name": "Nume complet",
|
||||
"gathering_responses": "Culegere răspunsuri",
|
||||
@@ -358,7 +360,6 @@
|
||||
"placeholder": "Marcaj substituent",
|
||||
"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_upgrade_your_plan": "Vă rugăm să faceți upgrade la planul dumneavoastră",
|
||||
"powered_by_formbricks": "Oferit de Formbricks",
|
||||
"preview": "Previzualizare",
|
||||
"privacy": "Politica de Confidențialitate",
|
||||
@@ -499,7 +500,7 @@
|
||||
"you_are_downgraded_to_the_community_edition": "Ai fost retrogradat la ediția Community.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Nu sunteți autorizat să efectuați această acțiune.",
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Ați atins limita de {projectLimit} spații de lucru.",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Ați atins limita lunară de răspunsuri de",
|
||||
"you_have_reached_your_monthly_response_limit_of_count": "Ai atins limita lunară de răspunsuri de {{count}}.",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Vei fi retrogradat la ediția Community pe {date}.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
},
|
||||
@@ -774,6 +775,7 @@
|
||||
"create_survey_warning": "Trebuie să creezi un sondaj pentru a putea configura această integrare",
|
||||
"delete_integration": "Șterge integrarea",
|
||||
"delete_integration_confirmation": "Sigur doriți să ștergeți această integrare?",
|
||||
"follow_these_docs_to_configure_it": "Urmează această <docsLink>documentație</docsLink> pentru a o configura.",
|
||||
"google_sheet_integration_description": "Completați instantaneu foile de calcul cu datele chestionarului",
|
||||
"google_sheets": {
|
||||
"connect_with_google_sheets": "Conectează-te cu Google Sheets",
|
||||
@@ -858,7 +860,6 @@
|
||||
"slack_reconnect_button_description": "<b>Notă:</b> Am schimbat recent integrarea noastră Slack pentru a susține și canalele private. Vă rugăm să reconectați spațiul de lucru Slack."
|
||||
},
|
||||
"slack_integration_description": "Conectați instantaneu Workspace-ul dvs. Slack cu Formbricks",
|
||||
"to_configure_it": "pentru a-l configura.",
|
||||
"webhook_integration_description": "Declanșează Webhook-uri pe baza acțiunilor din chestionarele tale",
|
||||
"webhooks": {
|
||||
"add_webhook": "Adaugă Webhook",
|
||||
@@ -917,9 +918,7 @@
|
||||
"ex_fully_activated_recurring_users": "Ex. Utilizatori recurenți complet activați",
|
||||
"ex_power_users": "Ex. Utilizatori avansați",
|
||||
"filters_reset_successfully": "Filtre resetate cu succes",
|
||||
"here": "aici",
|
||||
"hide_filters": "Ascunde filtrele",
|
||||
"identifying_users": "identificarea utilizatorilor",
|
||||
"invalid_segment": "Segment nevalid",
|
||||
"invalid_segment_filters": "Filtre invalide. Verificați filtrele și încercați din nou.",
|
||||
"load_segment": "Încarcă Segment",
|
||||
@@ -974,21 +973,20 @@
|
||||
"segment_id": "ID segment",
|
||||
"segment_saved_successfully": "Segment salvat cu succes",
|
||||
"segment_updated_successfully": "Segment actualizat cu succes!",
|
||||
"segment_used_in_other_surveys_make_changes_here": "Acest segment este utilizat în alte sondaje. Fă modificările <segmentsLink>aici</segmentsLink>.",
|
||||
"segments_help_you_target_users_with_same_characteristics_easily": "Segmentele vă ajută să vizați utilizatorii cu aceleași caracteristici ușor",
|
||||
"target_audience": "Audiență Țintă",
|
||||
"this_action_resets_all_filters_in_this_survey": "Acțiunea aceasta resetează toate filtrele în acest sondaj.",
|
||||
"this_segment_is_used_in_other_surveys": "Acest segment este utilizat în alte sondaje. Faceți modificări",
|
||||
"title_is_required": "Titlul este obligatoriu.",
|
||||
"unknown_filter_type": "Tip de filtru necunoscut",
|
||||
"unlock_segments_description": "Organizează contactele în segmente pentru a viza grupuri de utilizatori specifici",
|
||||
"unlock_segments_title": "Deblocați segmentele cu un plan superior.",
|
||||
"user_targeting_is_currently_only_available_when": "Targetarea utilizatorilor este disponibilă în prezent doar atunci când",
|
||||
"user_targeting_only_available_when_identifying_users": "Targetarea utilizatorilor este disponibilă în prezent doar atunci când <docsLink>identifici utilizatorii</docsLink> cu SDK-ul Formbricks.",
|
||||
"value_cannot_be_empty": "Valoarea nu poate fi goală.",
|
||||
"value_must_be_a_number": "Valoarea trebuie să fie un număr.",
|
||||
"value_must_be_positive": "Valoarea trebuie să fie un număr pozitiv.",
|
||||
"view_filters": "Vizualizați filtrele",
|
||||
"where": "Unde",
|
||||
"with_the_formbricks_sdk": "cu SDK Formbricks"
|
||||
"where": "Unde"
|
||||
},
|
||||
"settings": {
|
||||
"api_keys": {
|
||||
@@ -1067,8 +1065,8 @@
|
||||
"unlimited_workspaces": "Workspaces nelimitate",
|
||||
"upgrade": "Actualizare",
|
||||
"upgrade_now": "Actualizează acum",
|
||||
"usage_count_of_limit_used": "{{current}} / {{limit}} <muted>utilizate</muted>",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "utilizat",
|
||||
"yearly": "Anual",
|
||||
"yearly_checkout_unavailable": "Plata anuală nu este disponibilă încă. Adaugă mai întâi o metodă de plată pe un abonament lunar sau contactează asistența.",
|
||||
"your_plan": "Planul tău"
|
||||
@@ -1129,7 +1127,7 @@
|
||||
"no_credit_card_no_sales_call_just_test_it": "Nu este nevoie de card de credit. Fără apeluri de vânzări. Doar testează-l :)",
|
||||
"on_request": "La cerere",
|
||||
"organization_roles": "Roluri organizaționale (Administrator, Editor, Dezvoltator, etc.)",
|
||||
"questions_please_reach_out_to": "Întrebări? Vă rugăm să trimiteți mesaj către",
|
||||
"questions_please_reach_out_to_email": "Ai întrebări? Te rugăm să ne contactezi la <contactLink>hola@formbricks.com</contactLink>",
|
||||
"recheck_license": "Verifică din nou licența",
|
||||
"recheck_license_failed": "Verificarea licenței a eșuat. Serverul de licențe poate fi indisponibil.",
|
||||
"recheck_license_instance_mismatch": "Această licență este asociată cu o altă instanță Formbricks. Solicită echipei de suport Formbricks să deconecteze asocierea anterioară.",
|
||||
@@ -1355,13 +1353,12 @@
|
||||
"address_line_2": "Adresă Linie 2",
|
||||
"adjust_survey_closed_message": "Ajustați mesajul 'Sondaj Închis'",
|
||||
"adjust_survey_closed_message_description": "Schimbați mesajul pe care îl văd vizitatorii atunci când sondajul este închis.",
|
||||
"adjust_the_theme_in_the": "Ajustați tema în",
|
||||
"adjust_theme_in_look_and_feel_settings": "Ajustează tema în setările <lookFeelLink>Aspect și Experiență</lookFeelLink>.",
|
||||
"all_are_true": "toate sunt adevărate",
|
||||
"all_other_answers_will_continue_to": "Toate celelalte răspunsuri vor continua să",
|
||||
"all_other_answers_will_continue_to_fallback": "Toate celelalte răspunsuri vor continua să <fallbackSelect />",
|
||||
"allow_multi_select": "Permite selectare multiplă",
|
||||
"allow_multiple_files": "Permite fișiere multiple",
|
||||
"allow_users_to_select_more_than_one_image": "Permite utilizatorilor să selecteze mai mult de o imagine",
|
||||
"and_launch_surveys_in_your_website_or_app": "și lansați chestionare pe site-ul sau în aplicația dvs.",
|
||||
"animation": "Animație",
|
||||
"any_is_true": "oricare este adevărată",
|
||||
"app_survey_description": "Incorporați un chestionar în aplicația web sau pe site-ul dvs. pentru a colecta răspunsuri.",
|
||||
@@ -1373,10 +1370,10 @@
|
||||
"auto_save_disabled": "Salvare automată dezactivată",
|
||||
"auto_save_disabled_tooltip": "Chestionarul dvs. este salvat automat doar când este în ciornă. Acest lucru asigură că sondajele publice nu sunt actualizate neintenționat.",
|
||||
"auto_save_on": "Salvare automată activată",
|
||||
"automatically_close_survey_after": "Închideți automat sondajul după",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "Închide automat sondajul după <autoCloseInput /> secunde de la declanșare dacă nu există răspuns.",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Închideți automat sondajul după un număr anumit de răspunsuri.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Închideți automat sondajul dacă utilizatorul nu răspunde după un anumit număr de secunde.",
|
||||
"automatically_mark_the_survey_as_complete_after": "Marcați automat sondajul ca finalizat după",
|
||||
"automatically_mark_complete_after_n_responses": "Marchează automat sondajul ca finalizat după <autoCompleteInput /> răspunsuri completate.",
|
||||
"back_button_label": "Etichetă buton \"Înapoi\"",
|
||||
"background_styling": "Stilizare fundal",
|
||||
"block_duplicated": "Bloc duplicat.",
|
||||
@@ -1434,11 +1431,11 @@
|
||||
"columns": "Coloane",
|
||||
"company": "Companie",
|
||||
"company_logo": "Sigla companiei",
|
||||
"completed_responses": "Răspunsuri completate",
|
||||
"concat": "Concat +",
|
||||
"conditional_logic": "Logică condițională",
|
||||
"confirm_default_language": "Confirmați limba implicită",
|
||||
"confirm_survey_changes": "Confirmă modificările sondajului",
|
||||
"connect_formbricks_and_launch_surveys": "Conectează Formbricks și lansează sondaje pe site-ul sau în aplicația ta.",
|
||||
"contact_fields": "Câmpuri de contact",
|
||||
"contains": "Conține",
|
||||
"continue_to_settings": "Continuă către Setări",
|
||||
@@ -1452,7 +1449,6 @@
|
||||
"custom_hostname": "Gazdă personalizată",
|
||||
"customize_survey_logo": "Personalizează logo-ul chestionarului",
|
||||
"darken_or_lighten_background_of_your_choice": "Întunecați sau luminați fundalul după preferințe.",
|
||||
"days_before_showing_this_survey_again": "sau mai multe zile să treacă între ultima afișare a sondajului și afișarea acestui sondaj.",
|
||||
"default_language": "Limba implicită",
|
||||
"delete_anyways": "Șterge oricum",
|
||||
"delete_block": "Șterge blocul",
|
||||
@@ -1558,7 +1554,6 @@
|
||||
"hide_progress_bar": "Ascunde bara de progres",
|
||||
"hide_question_settings": "Ascunde setările întrebării",
|
||||
"hostname": "Nume gazdă",
|
||||
"if_you_need_more_please": "Dacă aveți nevoie de mai mult, vă rugăm",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuă să afișezi de fiecare dată când este declanșat, până când se trimite un răspuns sau un răspuns parțial.",
|
||||
"ignore_global_waiting_time": "Ignoră perioada de răcire",
|
||||
"ignore_global_waiting_time_description": "Acest sondaj poate fi afișat ori de câte ori condițiile sale sunt îndeplinite, chiar dacă un alt sondaj a fost afișat recent.",
|
||||
@@ -1598,7 +1593,7 @@
|
||||
"last_name": "Nume de familie",
|
||||
"let_people_upload_up_to_25_files_at_the_same_time": "Permiteți utilizatorilor să încarce până la 25 de fișiere simultan.",
|
||||
"limit_the_maximum_file_size": "Limitați dimensiunea maximă a fișierului pentru încărcări.",
|
||||
"limit_upload_file_size_to": "Limitați dimensiunea fișierului încărcat la",
|
||||
"limit_upload_file_size_to_mb": "Limitează dimensiunea fișierului încărcat la <fileSizeInput /> MB",
|
||||
"link_survey_description": "Partajați un link către o pagină de chestionar sau încorporați-l într-o pagină web sau email.",
|
||||
"list": "Listă",
|
||||
"load_segment": "Încarcă segment",
|
||||
@@ -1613,7 +1608,8 @@
|
||||
"matrix_all_fields": "Toate câmpurile",
|
||||
"matrix_rows": "Rânduri",
|
||||
"max_file_size": "Dimensiune maximă fișier",
|
||||
"max_file_size_limit_is": "Limita maximă pentru dimensiunea fișierului este",
|
||||
"max_file_size_limit_is_mb": "Limita maximă a dimensiunii fișierului este {{maxSize}} MB.",
|
||||
"max_file_size_limit_is_mb_upgrade": "Limita maximă a dimensiunii fișierului este {{maxSize}} MB. Dacă ai nevoie de mai mult, te rugăm să <upgradeLink>îți actualizezi planul</upgradeLink>.",
|
||||
"missing_first": "Lipsă întâi",
|
||||
"move_question_to_block": "Mută întrebarea în bloc",
|
||||
"multiply": "Multiplicare",
|
||||
@@ -1727,8 +1723,6 @@
|
||||
"save_and_close": "Salvează & Închide",
|
||||
"scale": "Scală",
|
||||
"search_for_images": "Căutare de imagini",
|
||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "secunde după declanșare sondajul va fi închis dacă nu există niciun răspuns",
|
||||
"seconds_before_showing_the_survey": "secunde înainte de afișarea sondajului",
|
||||
"select_field": "Selectează câmpul",
|
||||
"select_or_type_value": "Selectați sau introduceți valoarea",
|
||||
"select_ordering": "Selectează ordonarea",
|
||||
@@ -1736,7 +1730,7 @@
|
||||
"select_type": "Selectați tipul",
|
||||
"send_survey_to_audience_who_match": "Trimiteți sondajul către publicul care se potrivește...",
|
||||
"send_your_respondents_to_a_page_of_your_choice": "Trimiteți respondenții către o pagină la alegerea dumneavoastră",
|
||||
"set_the_global_placement_in_the_look_feel_settings": "Setați amplasarea globală în setările Aspect & Stil.",
|
||||
"set_global_placement_in_look_feel_settings_hint": "Pentru a menține plasarea consistentă pe toate sondajele, poți <lookFeelLink>seta plasarea globală în setările Aspect și Experiență.</lookFeelLink>",
|
||||
"settings_saved_successfully": "Setările au fost salvate cu succes.",
|
||||
"seven_points": "7 puncte",
|
||||
"show_block_settings": "Afișează setările blocului",
|
||||
@@ -1746,7 +1740,7 @@
|
||||
"show_multiple_times": "Afișează de mai multe ori",
|
||||
"show_only_once": "Afișează doar o dată",
|
||||
"show_question_settings": "Afișează setările întrebării",
|
||||
"show_survey_maximum_of": "Afișează sondajul de maxim",
|
||||
"show_survey_maximum_of_n_times": "Afișează sondajul maximum de <displayLimitInput /> ori.",
|
||||
"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",
|
||||
"shrink_preview": "Restrânge previzualizarea",
|
||||
@@ -1782,8 +1776,6 @@
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "Această acțiune va elimina toate traducerile din acest sondaj.",
|
||||
"this_will_remove_the_language_and_all_its_translations": "Aceasta va elimina această limbă și toate traducerile ei din acest chestionar. Această acțiune nu poate fi anulată.",
|
||||
"three_points": "3 puncte",
|
||||
"times": "ori",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Pentru a menține amplasarea consecventă pentru toate sondajele, puteți",
|
||||
"translated": "Tradus",
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "Declanșați sondajul atunci când una dintre acțiuni este realizată...",
|
||||
"try_lollipop_or_mountain": "Încercați „lollipop” sau „mountain”...",
|
||||
@@ -1860,8 +1852,9 @@
|
||||
"visibility_and_recontact": "Vizibilitate și recontactare",
|
||||
"visibility_and_recontact_description": "Controlează când poate apărea acest sondaj și cât de des poate reapărea.",
|
||||
"visible": "Vizibil",
|
||||
"wait": "Așteptați",
|
||||
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Așteptați câteva secunde după declanșare înainte de a afișa sondajul",
|
||||
"wait_n_days_before_showing_this_survey_again": "Așteaptă <daysInput /> sau mai multe zile să treacă între ultimul sondaj afișat și afișarea acestui sondaj.",
|
||||
"wait_n_seconds_before_showing_the_survey": "Așteaptă <delayInput /> secunde înainte de a afișa sondajul.",
|
||||
"waiting_time_across_surveys": "Perioadă de răcire (între sondaje)",
|
||||
"waiting_time_across_surveys_description": "Pentru a preveni oboseala cauzată de sondaje, alege cum interacționează acest sondaj cu perioada de răcire la nivel de workspace.",
|
||||
"welcome_message": "Mesaj de bun venit",
|
||||
@@ -1925,10 +1918,8 @@
|
||||
"search_by_survey_name": "Căutare după nume chestionar",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
|
||||
"custom_single_use_id_placeholder": "CUSTOM-ID",
|
||||
"custom_single_use_id_required": "Enter a custom single-use ID.",
|
||||
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
|
||||
"custom_single_use_id_description": "Dacă nu criptați ID-ul de unică folosință, orice valoare pentru “suid=...” funcționează pentru un singur răspuns.",
|
||||
"custom_single_use_id_title": "Puteți seta orice valoare ca ID unic în URL",
|
||||
"custom_start_point": "Punct de start personalizat",
|
||||
"data_prefilling": "Precompletare date",
|
||||
"description": "Răspunsurile provenite de la aceste linkuri vor fi anonime",
|
||||
@@ -2219,6 +2210,7 @@
|
||||
"custom_scripts_warning": "Scripturile se execută cu acces complet la browser. Adaugă doar scripturi din surse de încredere.",
|
||||
"delete_workspace": "Șterge proiectul",
|
||||
"delete_workspace_confirmation": "Sigur vrei să ștergi {projectName}? Această acțiune nu poate fi anulată.",
|
||||
"delete_workspace_confirmation_name": "Vă rugăm să introduceți {projectName} în câmpul următor pentru a confirma ștergerea definitivă a acestui proiect:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Șterge {projectName} incl. toate sondajele, răspunsurile, persoanele, acțiunile și atributele.",
|
||||
"delete_workspace_settings_description": "Șterge proiectul cu toate sondajele, răspunsurile, persoanele, acțiunile și atributele. Aceasta nu poate fi anulată.",
|
||||
"error_saving_workspace_information": "Eroare la salvarea informațiilor despre proiect",
|
||||
@@ -2471,7 +2463,7 @@
|
||||
"add_another_member": "Adaugă încă un membru",
|
||||
"continue": "Continuă",
|
||||
"failed_to_invite": "Nu s-a reușit invitarea",
|
||||
"invitation_sent_to": "Invitație trimisă către",
|
||||
"invitation_sent_to_email": "Invitația a fost trimisă la {{email}}!",
|
||||
"invite_your_organization_members": "Invitați membrii organizației voastre",
|
||||
"life_s_no_fun_alone": "Viața nu este distractivă singur.",
|
||||
"skip": "Omite",
|
||||
|
||||
+26
-34
@@ -241,10 +241,12 @@
|
||||
"failed_to_load_organizations": "Не удалось загрузить организации",
|
||||
"failed_to_load_workspaces": "Не удалось загрузить рабочие пространства",
|
||||
"field_placeholder": "Заполнитель {{field}}",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Фильтр",
|
||||
"finish": "Завершить",
|
||||
"first_name": "Имя",
|
||||
"follow_these": "Выполните следующие действия",
|
||||
"formbricks_version": "Версия Formbricks",
|
||||
"full_name": "Полное имя",
|
||||
"gathering_responses": "Сбор ответов",
|
||||
@@ -358,7 +360,6 @@
|
||||
"placeholder": "Заполнитель",
|
||||
"please_select_at_least_one_survey": "Пожалуйста, выберите хотя бы один опрос",
|
||||
"please_select_at_least_one_trigger": "Пожалуйста, выберите хотя бы один триггер",
|
||||
"please_upgrade_your_plan": "Пожалуйста, обновите ваш тарифный план",
|
||||
"powered_by_formbricks": "Работает на Formbricks",
|
||||
"preview": "Предпросмотр",
|
||||
"privacy": "Политика конфиденциальности",
|
||||
@@ -499,7 +500,7 @@
|
||||
"you_are_downgraded_to_the_community_edition": "Ваша версия понижена до Community Edition.",
|
||||
"you_are_not_authorized_to_perform_this_action": "У вас нет прав для выполнения этого действия.",
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Вы достигли лимита в {projectLimit} рабочих пространств.",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Вы достигли месячного лимита ответов:",
|
||||
"you_have_reached_your_monthly_response_limit_of_count": "Вы достигли месячного лимита ответов: {{count}}.",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Ваша версия будет понижена до Community Edition {date}.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
},
|
||||
@@ -774,6 +775,7 @@
|
||||
"create_survey_warning": "Чтобы настроить эту интеграцию, необходимо создать опрос",
|
||||
"delete_integration": "Удалить интеграцию",
|
||||
"delete_integration_confirmation": "Вы уверены, что хотите удалить эту интеграцию?",
|
||||
"follow_these_docs_to_configure_it": "Следуйте этой <docsLink>документации</docsLink> для настройки.",
|
||||
"google_sheet_integration_description": "Мгновенно заполняйте таблицы данными опросов",
|
||||
"google_sheets": {
|
||||
"connect_with_google_sheets": "Подключиться к Google Sheets",
|
||||
@@ -858,7 +860,6 @@
|
||||
"slack_reconnect_button_description": "<b>Обратите внимание:</b> Недавно мы обновили интеграцию со Slack, чтобы поддерживать также приватные каналы. Пожалуйста, переподключите ваш рабочий пространство Slack."
|
||||
},
|
||||
"slack_integration_description": "Мгновенно подключайте рабочее пространство Slack к Formbricks",
|
||||
"to_configure_it": "чтобы настроить это.",
|
||||
"webhook_integration_description": "Запускайте Webhook по действиям в ваших опросах",
|
||||
"webhooks": {
|
||||
"add_webhook": "Добавить webhook",
|
||||
@@ -917,9 +918,7 @@
|
||||
"ex_fully_activated_recurring_users": "Напр. полностью активированные постоянные пользователи",
|
||||
"ex_power_users": "Напр. продвинутые пользователи",
|
||||
"filters_reset_successfully": "Фильтры успешно сброшены",
|
||||
"here": "здесь",
|
||||
"hide_filters": "Скрыть фильтры",
|
||||
"identifying_users": "идентификация пользователей",
|
||||
"invalid_segment": "Некорректный сегмент",
|
||||
"invalid_segment_filters": "Некорректные фильтры. Пожалуйста, проверьте фильтры и попробуйте снова.",
|
||||
"load_segment": "Загрузить сегмент",
|
||||
@@ -974,21 +973,20 @@
|
||||
"segment_id": "ID сегмента",
|
||||
"segment_saved_successfully": "Сегмент успешно сохранён",
|
||||
"segment_updated_successfully": "Сегмент успешно обновлён!",
|
||||
"segment_used_in_other_surveys_make_changes_here": "Этот сегмент используется в других опросах. Внесите изменения <segmentsLink>здесь</segmentsLink>.",
|
||||
"segments_help_you_target_users_with_same_characteristics_easily": "Сегменты помогают легко находить пользователей с одинаковыми характеристиками",
|
||||
"target_audience": "Целевая аудитория",
|
||||
"this_action_resets_all_filters_in_this_survey": "Это действие сбросит все фильтры в этом опросе.",
|
||||
"this_segment_is_used_in_other_surveys": "Этот сегмент используется в других опросах. Внесите изменения",
|
||||
"title_is_required": "Требуется указать название.",
|
||||
"unknown_filter_type": "Неизвестный тип фильтра",
|
||||
"unlock_segments_description": "Организуйте контакты по сегментам для таргетирования определённых групп пользователей",
|
||||
"unlock_segments_title": "Откройте сегменты с более высоким тарифом",
|
||||
"user_targeting_is_currently_only_available_when": "Таргетинг пользователей сейчас доступен только когда",
|
||||
"user_targeting_only_available_when_identifying_users": "Таргетинг пользователей доступен только при <docsLink>идентификации пользователей</docsLink> с помощью Formbricks SDK.",
|
||||
"value_cannot_be_empty": "Значение не может быть пустым.",
|
||||
"value_must_be_a_number": "Значение должно быть числом.",
|
||||
"value_must_be_positive": "Значение должно быть положительным числом.",
|
||||
"view_filters": "Просмотреть фильтры",
|
||||
"where": "Где",
|
||||
"with_the_formbricks_sdk": "с помощью Formbricks SDK"
|
||||
"where": "Где"
|
||||
},
|
||||
"settings": {
|
||||
"api_keys": {
|
||||
@@ -1067,8 +1065,8 @@
|
||||
"unlimited_workspaces": "Неограниченное количество рабочих пространств",
|
||||
"upgrade": "Обновить",
|
||||
"upgrade_now": "Обновить сейчас",
|
||||
"usage_count_of_limit_used": "{{current}} / {{limit}} <muted>использовано</muted>",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "использовано",
|
||||
"yearly": "Годовой",
|
||||
"yearly_checkout_unavailable": "Годовая подписка пока недоступна. Сначала добавь способ оплаты в месячном тарифе или обратись в поддержку.",
|
||||
"your_plan": "Ваш тариф"
|
||||
@@ -1129,7 +1127,7 @@
|
||||
"no_credit_card_no_sales_call_just_test_it": "Без кредитной карты. Без звонков от отдела продаж. Просто попробуйте :)",
|
||||
"on_request": "По запросу",
|
||||
"organization_roles": "Роли в организации (администратор, редактор, разработчик и др.)",
|
||||
"questions_please_reach_out_to": "Вопросы? Свяжитесь с",
|
||||
"questions_please_reach_out_to_email": "Есть вопросы? Напишите нам: <contactLink>hola@formbricks.com</contactLink>",
|
||||
"recheck_license": "Проверить лицензию ещё раз",
|
||||
"recheck_license_failed": "Не удалось проверить лицензию. Сервер лицензий может быть недоступен.",
|
||||
"recheck_license_instance_mismatch": "Эта лицензия привязана к другому экземпляру Formbricks. Обратитесь в службу поддержки Formbricks для отключения предыдущей привязки.",
|
||||
@@ -1355,13 +1353,12 @@
|
||||
"address_line_2": "Адрес, строка 2",
|
||||
"adjust_survey_closed_message": "Изменить сообщение «Опрос закрыт»",
|
||||
"adjust_survey_closed_message_description": "Измените сообщение, которое видят посетители, когда опрос закрыт.",
|
||||
"adjust_the_theme_in_the": "Настройте тему в",
|
||||
"adjust_theme_in_look_and_feel_settings": "Настройте тему в разделе <lookFeelLink>Внешний вид</lookFeelLink>.",
|
||||
"all_are_true": "все условия выполняются",
|
||||
"all_other_answers_will_continue_to": "Все остальные ответы будут продолжать",
|
||||
"all_other_answers_will_continue_to_fallback": "Все остальные ответы будут продолжать <fallbackSelect />",
|
||||
"allow_multi_select": "Разрешить множественный выбор",
|
||||
"allow_multiple_files": "Разрешить несколько файлов",
|
||||
"allow_users_to_select_more_than_one_image": "Разрешить пользователям выбирать более одного изображения",
|
||||
"and_launch_surveys_in_your_website_or_app": "и запускать опросы на вашем сайте или в приложении.",
|
||||
"animation": "Анимация",
|
||||
"any_is_true": "выполняется хотя бы одно условие",
|
||||
"app_survey_description": "Встраивайте опрос в ваше веб-приложение или сайт для сбора ответов.",
|
||||
@@ -1373,10 +1370,10 @@
|
||||
"auto_save_disabled": "Автосохранение отключено",
|
||||
"auto_save_disabled_tooltip": "Ваш опрос автоматически сохраняется только в режиме черновика. Это гарантирует, что публичные опросы не будут случайно обновлены.",
|
||||
"auto_save_on": "Автосохранение включено",
|
||||
"automatically_close_survey_after": "Автоматически закрыть опрос через",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "Автоматически закрывать опрос через <autoCloseInput /> секунд после срабатывания триггера, если нет ответа.",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Автоматически закрывать опрос после определённого количества ответов.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Автоматически закрывать опрос, если пользователь не ответил за определённое количество секунд.",
|
||||
"automatically_mark_the_survey_as_complete_after": "Автоматически отмечать опрос как завершённый через",
|
||||
"automatically_mark_complete_after_n_responses": "Автоматически отмечать опрос как завершённый после <autoCompleteInput /> полученных ответов.",
|
||||
"back_button_label": "Метка кнопки «Назад»",
|
||||
"background_styling": "Оформление фона",
|
||||
"block_duplicated": "Блокировать дубликаты.",
|
||||
@@ -1434,11 +1431,11 @@
|
||||
"columns": "Столбцы",
|
||||
"company": "Компания",
|
||||
"company_logo": "Логотип компании",
|
||||
"completed_responses": "завершённых ответов.",
|
||||
"concat": "Конкатенировать +",
|
||||
"conditional_logic": "Условная логика",
|
||||
"confirm_default_language": "Подтвердить язык по умолчанию",
|
||||
"confirm_survey_changes": "Подтвердить изменения в опросе",
|
||||
"connect_formbricks_and_launch_surveys": "Подключите Formbricks и запускайте опросы на вашем сайте или в приложении.",
|
||||
"contact_fields": "Поля контакта",
|
||||
"contains": "Содержит",
|
||||
"continue_to_settings": "Перейти к настройкам",
|
||||
@@ -1452,7 +1449,6 @@
|
||||
"custom_hostname": "Пользовательский хостнейм",
|
||||
"customize_survey_logo": "Настроить логотип опроса",
|
||||
"darken_or_lighten_background_of_your_choice": "Затемните или осветлите выбранный фон.",
|
||||
"days_before_showing_this_survey_again": "или больше дней должно пройти между последним показом опроса и показом этого опроса.",
|
||||
"default_language": "Язык по умолчанию",
|
||||
"delete_anyways": "Удалить в любом случае",
|
||||
"delete_block": "Удалить блок",
|
||||
@@ -1558,7 +1554,6 @@
|
||||
"hide_progress_bar": "Скрыть индикатор прогресса",
|
||||
"hide_question_settings": "Скрыть настройки вопроса",
|
||||
"hostname": "Имя хоста",
|
||||
"if_you_need_more_please": "Если вам нужно больше, пожалуйста",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Продолжай показывать при каждом срабатывании триггера, пока не будет отправлен ответ или частичный ответ.",
|
||||
"ignore_global_waiting_time": "Игнорировать период ожидания",
|
||||
"ignore_global_waiting_time_description": "Этот опрос может отображаться при выполнении условий, даже если недавно уже был показан другой опрос.",
|
||||
@@ -1598,7 +1593,7 @@
|
||||
"last_name": "Фамилия",
|
||||
"let_people_upload_up_to_25_files_at_the_same_time": "Разрешить загружать до 25 файлов одновременно.",
|
||||
"limit_the_maximum_file_size": "Ограничьте максимальный размер загружаемых файлов.",
|
||||
"limit_upload_file_size_to": "Ограничить размер загружаемого файла до",
|
||||
"limit_upload_file_size_to_mb": "Ограничить размер загружаемого файла до <fileSizeInput /> МБ",
|
||||
"link_survey_description": "Поделитесь ссылкой на страницу опроса или вставьте её на веб-страницу или в электронное письмо.",
|
||||
"list": "Список",
|
||||
"load_segment": "Загрузить сегмент",
|
||||
@@ -1613,7 +1608,8 @@
|
||||
"matrix_all_fields": "Все поля",
|
||||
"matrix_rows": "Строки",
|
||||
"max_file_size": "Максимальный размер файла",
|
||||
"max_file_size_limit_is": "Ограничение максимального размера файла",
|
||||
"max_file_size_limit_is_mb": "Максимальный размер файла — {{maxSize}} МБ.",
|
||||
"max_file_size_limit_is_mb_upgrade": "Максимальный размер файла — {{maxSize}} МБ. Если вам нужно больше, <upgradeLink>обновите свой тарифный план</upgradeLink>.",
|
||||
"missing_first": "Сначала отсутствующие",
|
||||
"move_question_to_block": "Переместить вопрос в блок",
|
||||
"multiply": "Умножить *",
|
||||
@@ -1727,8 +1723,6 @@
|
||||
"save_and_close": "Сохранить и закрыть",
|
||||
"scale": "Шкала",
|
||||
"search_for_images": "Поиск изображений",
|
||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "секунд после запуска — опрос будет закрыт, если не будет ответа",
|
||||
"seconds_before_showing_the_survey": "секунд до показа опроса.",
|
||||
"select_field": "Выберите поле",
|
||||
"select_or_type_value": "Выберите или введите значение",
|
||||
"select_ordering": "Выберите порядок",
|
||||
@@ -1736,7 +1730,7 @@
|
||||
"select_type": "Выберите тип",
|
||||
"send_survey_to_audience_who_match": "Отправить опрос аудитории, которая соответствует...",
|
||||
"send_your_respondents_to_a_page_of_your_choice": "Отправьте ваших респондентов на выбранную вами страницу.",
|
||||
"set_the_global_placement_in_the_look_feel_settings": "Установите глобальное размещение в настройках внешнего вида.",
|
||||
"set_global_placement_in_look_feel_settings_hint": "Чтобы сохранить единое размещение для всех опросов, вы можете <lookFeelLink>установить глобальное размещение в настройках внешнего вида.</lookFeelLink>",
|
||||
"settings_saved_successfully": "Настройки успешно сохранены.",
|
||||
"seven_points": "7 баллов",
|
||||
"show_block_settings": "Показать настройки блока",
|
||||
@@ -1746,7 +1740,7 @@
|
||||
"show_multiple_times": "Показать ограниченное количество раз",
|
||||
"show_only_once": "Показать только один раз",
|
||||
"show_question_settings": "Показать настройки вопроса",
|
||||
"show_survey_maximum_of": "Показать опрос максимум",
|
||||
"show_survey_maximum_of_n_times": "Показывать опрос максимум <displayLimitInput /> раз.",
|
||||
"show_survey_to_users": "Показать опрос % пользователей",
|
||||
"show_to_x_percentage_of_targeted_users": "Показать {percentage}% целевых пользователей",
|
||||
"shrink_preview": "Свернуть предпросмотр",
|
||||
@@ -1782,8 +1776,6 @@
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "Это действие удалит все переводы из этого опроса.",
|
||||
"this_will_remove_the_language_and_all_its_translations": "Это удалит данный язык и все его переводы из этого опроса. Это действие нельзя отменить.",
|
||||
"three_points": "3 балла",
|
||||
"times": "раз",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Чтобы сохранить единое расположение во всех опросах, вы можете",
|
||||
"translated": "Переведено",
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "Запустить опрос при выполнении одного из действий...",
|
||||
"try_lollipop_or_mountain": "Попробуйте «lollipop» или «mountain»...",
|
||||
@@ -1860,8 +1852,9 @@
|
||||
"visibility_and_recontact": "Видимость и повторный контакт",
|
||||
"visibility_and_recontact_description": "Управляйте, когда этот опрос может появляться и как часто он может повторяться.",
|
||||
"visible": "Видимый",
|
||||
"wait": "Ожидание",
|
||||
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Подождите несколько секунд после срабатывания триггера перед показом опроса",
|
||||
"wait_n_days_before_showing_this_survey_again": "Подождите <daysInput /> или более дней между последним показом опроса и показом этого опроса.",
|
||||
"wait_n_seconds_before_showing_the_survey": "Подождите <delayInput /> секунд перед показом опроса.",
|
||||
"waiting_time_across_surveys": "Период ожидания (между опросами)",
|
||||
"waiting_time_across_surveys_description": "Чтобы избежать усталости от опросов, выберите, как этот опрос взаимодействует с общим периодом ожидания в рабочем пространстве.",
|
||||
"welcome_message": "Приветственное сообщение",
|
||||
@@ -1925,10 +1918,8 @@
|
||||
"search_by_survey_name": "Поиск по названию опроса",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
|
||||
"custom_single_use_id_placeholder": "CUSTOM-ID",
|
||||
"custom_single_use_id_required": "Enter a custom single-use ID.",
|
||||
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
|
||||
"custom_single_use_id_description": "Если вы не шифруете одноразовый идентификатор, любое значение для “suid=...” подойдет для одного ответа.",
|
||||
"custom_single_use_id_title": "Вы можете задать любое значение в качестве одноразового ID в URL.",
|
||||
"custom_start_point": "Пользовательская точка старта",
|
||||
"data_prefilling": "Предзаполнение данных",
|
||||
"description": "Ответы, полученные по этим ссылкам, будут анонимными",
|
||||
@@ -2219,6 +2210,7 @@
|
||||
"custom_scripts_warning": "Скрипты выполняются с полным доступом к браузеру. Добавляйте только скрипты из доверенных источников.",
|
||||
"delete_workspace": "Удалить рабочий проект",
|
||||
"delete_workspace_confirmation": "Вы уверены, что хотите удалить {projectName}? Это действие необратимо.",
|
||||
"delete_workspace_confirmation_name": "Пожалуйста, введите {projectName} в поле ниже для подтверждения окончательного удаления этого рабочего проекта:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Удалить {projectName} вместе со всеми опросами, ответами, пользователями, действиями и атрибутами.",
|
||||
"delete_workspace_settings_description": "Удалить рабочий проект со всеми опросами, ответами, пользователями, действиями и атрибутами. Это действие необратимо.",
|
||||
"error_saving_workspace_information": "Ошибка при сохранении информации о рабочем проекте",
|
||||
@@ -2471,7 +2463,7 @@
|
||||
"add_another_member": "Добавить ещё одного участника",
|
||||
"continue": "Продолжить",
|
||||
"failed_to_invite": "Не удалось пригласить",
|
||||
"invitation_sent_to": "Приглашение отправлено на",
|
||||
"invitation_sent_to_email": "Приглашение отправлено на {{email}}!",
|
||||
"invite_your_organization_members": "Пригласите участников вашей организации",
|
||||
"life_s_no_fun_alone": "Жизнь неинтересна в одиночку.",
|
||||
"skip": "Пропустить",
|
||||
|
||||
+26
-34
@@ -241,10 +241,12 @@
|
||||
"failed_to_load_organizations": "Misslyckades att ladda organisationer",
|
||||
"failed_to_load_workspaces": "Det gick inte att ladda arbetsytor",
|
||||
"field_placeholder": "Platshållare för {{field}}",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filter",
|
||||
"finish": "Slutför",
|
||||
"first_name": "Förnamn",
|
||||
"follow_these": "Följ dessa",
|
||||
"formbricks_version": "Formbricks-version",
|
||||
"full_name": "Fullständigt namn",
|
||||
"gathering_responses": "Samlar in svar",
|
||||
@@ -358,7 +360,6 @@
|
||||
"placeholder": "Platshållare",
|
||||
"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_upgrade_your_plan": "Vänligen uppgradera din plan",
|
||||
"powered_by_formbricks": "Drivs av Formbricks",
|
||||
"preview": "Förhandsgranska",
|
||||
"privacy": "Integritetspolicy",
|
||||
@@ -499,7 +500,7 @@
|
||||
"you_are_downgraded_to_the_community_edition": "Du har nedgraderats till Community Edition.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Du har inte behörighet att utföra denna åtgärd.",
|
||||
"you_have_reached_your_limit_of_workspace_limit": "Du har nått din gräns på {projectLimit} arbetsytor.",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Du har nått din månatliga svarsgräns på",
|
||||
"you_have_reached_your_monthly_response_limit_of_count": "Du har nått din månatliga svarsgräns på {{count}}.",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "Du kommer att nedgraderas till Community Edition den {date}.",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
},
|
||||
@@ -774,6 +775,7 @@
|
||||
"create_survey_warning": "Du måste skapa en enkät för att kunna konfigurera denna integration",
|
||||
"delete_integration": "Ta bort integration",
|
||||
"delete_integration_confirmation": "Är du säker på att du vill ta bort denna integration?",
|
||||
"follow_these_docs_to_configure_it": "Följ denna <docsLink>dokumentation</docsLink> för att konfigurera den.",
|
||||
"google_sheet_integration_description": "Fyll direkt dina kalkylblad med enkätdata",
|
||||
"google_sheets": {
|
||||
"connect_with_google_sheets": "Anslut med Google Kalkylark",
|
||||
@@ -858,7 +860,6 @@
|
||||
"slack_reconnect_button_description": "<b>Obs:</b> Vi ändrade nyligen vår Slack-integration för att även stödja privata kanaler. Vänligen återanslut din Slack-arbetsyta."
|
||||
},
|
||||
"slack_integration_description": "Anslut direkt din Slack-arbetsyta med Formbricks",
|
||||
"to_configure_it": "för att konfigurera den.",
|
||||
"webhook_integration_description": "Utlös webhooks baserat på åtgärder i dina enkäter",
|
||||
"webhooks": {
|
||||
"add_webhook": "Lägg till webhook",
|
||||
@@ -917,9 +918,7 @@
|
||||
"ex_fully_activated_recurring_users": "T.ex. Fullt aktiverade återkommande användare",
|
||||
"ex_power_users": "T.ex. Superanvändare",
|
||||
"filters_reset_successfully": "Filter återställda",
|
||||
"here": "här",
|
||||
"hide_filters": "Dölj filter",
|
||||
"identifying_users": "identifiera användare",
|
||||
"invalid_segment": "Ogiltigt segment",
|
||||
"invalid_segment_filters": "Ogiltiga filter. Vänligen kontrollera filtren och försök igen.",
|
||||
"load_segment": "Ladda segment",
|
||||
@@ -974,21 +973,20 @@
|
||||
"segment_id": "Segment-ID",
|
||||
"segment_saved_successfully": "Segment sparat",
|
||||
"segment_updated_successfully": "Segment uppdaterat!",
|
||||
"segment_used_in_other_surveys_make_changes_here": "Detta segment används i andra undersökningar. Gör ändringar <segmentsLink>här</segmentsLink>.",
|
||||
"segments_help_you_target_users_with_same_characteristics_easily": "Segment hjälper dig att enkelt rikta in dig på användare med samma egenskaper",
|
||||
"target_audience": "Målgrupp",
|
||||
"this_action_resets_all_filters_in_this_survey": "Denna åtgärd återställer alla filter i denna enkät.",
|
||||
"this_segment_is_used_in_other_surveys": "Detta segment används i andra enkäter. Gör ändringar",
|
||||
"title_is_required": "Titel krävs.",
|
||||
"unknown_filter_type": "Okänd filtertyp",
|
||||
"unlock_segments_description": "Organisera kontakter i segment för att rikta in dig på specifika användargrupper",
|
||||
"unlock_segments_title": "Lås upp segment med en högre plan",
|
||||
"user_targeting_is_currently_only_available_when": "Användarinriktning är för närvarande endast tillgänglig när",
|
||||
"user_targeting_only_available_when_identifying_users": "Användarinriktning är för närvarande endast tillgänglig när du <docsLink>identifierar användare</docsLink> med Formbricks SDK.",
|
||||
"value_cannot_be_empty": "Värdet kan inte vara tomt.",
|
||||
"value_must_be_a_number": "Värdet måste vara ett nummer.",
|
||||
"value_must_be_positive": "Värdet måste vara ett positivt nummer.",
|
||||
"view_filters": "Visa filter",
|
||||
"where": "Där",
|
||||
"with_the_formbricks_sdk": "med Formbricks SDK"
|
||||
"where": "Där"
|
||||
},
|
||||
"settings": {
|
||||
"api_keys": {
|
||||
@@ -1067,8 +1065,8 @@
|
||||
"unlimited_workspaces": "Obegränsat antal arbetsytor",
|
||||
"upgrade": "Uppgradera",
|
||||
"upgrade_now": "Uppgradera nu",
|
||||
"usage_count_of_limit_used": "{{current}} / {{limit}} <muted>använt</muted>",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "använt",
|
||||
"yearly": "Årligen",
|
||||
"yearly_checkout_unavailable": "Årlig betalning är inte tillgänglig ännu. Lägg till en betalningsmetod på en månatlig plan först eller kontakta support.",
|
||||
"your_plan": "Din plan"
|
||||
@@ -1129,7 +1127,7 @@
|
||||
"no_credit_card_no_sales_call_just_test_it": "Inget kreditkort. Inget säljsamtal. Testa bara :)",
|
||||
"on_request": "På begäran",
|
||||
"organization_roles": "Organisationsroller (Admin, Redaktör, Utvecklare, etc.)",
|
||||
"questions_please_reach_out_to": "Frågor? Kontakta",
|
||||
"questions_please_reach_out_to_email": "Frågor? Kontakta oss gärna på <contactLink>hola@formbricks.com</contactLink>",
|
||||
"recheck_license": "Kontrollera licensen igen",
|
||||
"recheck_license_failed": "Licenskontrollen misslyckades. Licensservern kan vara otillgänglig.",
|
||||
"recheck_license_instance_mismatch": "Den här licensen är kopplad till en annan Formbricks-instans. Be Formbricks support att koppla bort den tidigare bindningen.",
|
||||
@@ -1355,13 +1353,12 @@
|
||||
"address_line_2": "Adressrad 2",
|
||||
"adjust_survey_closed_message": "Justera meddelande för 'Enkät stängd'",
|
||||
"adjust_survey_closed_message_description": "Ändra meddelandet besökare ser när enkäten är stängd.",
|
||||
"adjust_the_theme_in_the": "Justera temat i",
|
||||
"adjust_theme_in_look_and_feel_settings": "Justera temat i inställningarna för <lookFeelLink>Utseende & Känsla</lookFeelLink>.",
|
||||
"all_are_true": "alla är sanna",
|
||||
"all_other_answers_will_continue_to": "Alla andra svar fortsätter till",
|
||||
"all_other_answers_will_continue_to_fallback": "Alla andra svar kommer att fortsätta att <fallbackSelect />",
|
||||
"allow_multi_select": "Tillåt flerval",
|
||||
"allow_multiple_files": "Tillåt flera filer",
|
||||
"allow_users_to_select_more_than_one_image": "Tillåt användare att välja mer än en bild",
|
||||
"and_launch_surveys_in_your_website_or_app": "och starta enkäter på din webbplats eller i din app.",
|
||||
"animation": "Animering",
|
||||
"any_is_true": "någon är sann",
|
||||
"app_survey_description": "Bädda in en enkät i din webbapp eller webbplats för att samla in svar.",
|
||||
@@ -1373,10 +1370,10 @@
|
||||
"auto_save_disabled": "Automatisk sparning inaktiverad",
|
||||
"auto_save_disabled_tooltip": "Din enkät sparas endast automatiskt när den är ett utkast. Detta säkerställer att publika enkäter inte uppdateras oavsiktligt.",
|
||||
"auto_save_on": "Automatisk sparning på",
|
||||
"automatically_close_survey_after": "Stäng enkäten automatiskt efter",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "Stäng undersökningen automatiskt efter <autoCloseInput /> sekunder efter utlösning om inget svar ges.",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Stäng enkäten automatiskt efter ett visst antal svar.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Stäng enkäten automatiskt om användaren inte svarar efter ett visst antal sekunder.",
|
||||
"automatically_mark_the_survey_as_complete_after": "Markera enkäten automatiskt som slutförd efter",
|
||||
"automatically_mark_complete_after_n_responses": "Markera undersökningen automatiskt som slutförd efter <autoCompleteInput /> fullständiga svar.",
|
||||
"back_button_label": "\"Tillbaka\"-knappens etikett",
|
||||
"background_styling": "Bakgrundsstil",
|
||||
"block_duplicated": "Block duplicerat.",
|
||||
@@ -1434,11 +1431,11 @@
|
||||
"columns": "Kolumner",
|
||||
"company": "Företag",
|
||||
"company_logo": "Företagslogotyp",
|
||||
"completed_responses": "slutförda svar.",
|
||||
"concat": "Sammanfoga +",
|
||||
"conditional_logic": "Villkorlig logik",
|
||||
"confirm_default_language": "Bekräfta standardspråk",
|
||||
"confirm_survey_changes": "Bekräfta enkätändringar",
|
||||
"connect_formbricks_and_launch_surveys": "Anslut Formbricks och lansera undersökningar på din webbplats eller i din app.",
|
||||
"contact_fields": "Kontaktfält",
|
||||
"contains": "Innehåller",
|
||||
"continue_to_settings": "Fortsätt till inställningar",
|
||||
@@ -1452,7 +1449,6 @@
|
||||
"custom_hostname": "Anpassat värdnamn",
|
||||
"customize_survey_logo": "Anpassa undersökningens logotyp",
|
||||
"darken_or_lighten_background_of_your_choice": "Gör bakgrunden mörkare eller ljusare efter eget val.",
|
||||
"days_before_showing_this_survey_again": "eller fler dagar måste gå mellan den senaste visade enkäten och att visa denna enkät.",
|
||||
"default_language": "Standardspråk",
|
||||
"delete_anyways": "Ta bort ändå",
|
||||
"delete_block": "Ta bort block",
|
||||
@@ -1558,7 +1554,6 @@
|
||||
"hide_progress_bar": "Dölj framstegsindikator",
|
||||
"hide_question_settings": "Dölj frågeinställningar",
|
||||
"hostname": "Värdnamn",
|
||||
"if_you_need_more_please": "Om du behöver mer, vänligen",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Fortsätt visa varje gång det utlöses tills ett svar eller ett delsvar skickas in.",
|
||||
"ignore_global_waiting_time": "Ignorera väntetid",
|
||||
"ignore_global_waiting_time_description": "Denna enkät kan visas när dess villkor är uppfyllda, även om en annan enkät nyligen visats.",
|
||||
@@ -1598,7 +1593,7 @@
|
||||
"last_name": "Efternamn",
|
||||
"let_people_upload_up_to_25_files_at_the_same_time": "Låt personer ladda upp upp till 25 filer samtidigt.",
|
||||
"limit_the_maximum_file_size": "Begränsa den maximala filstorleken för uppladdningar.",
|
||||
"limit_upload_file_size_to": "Begränsa uppladdad filstorlek till",
|
||||
"limit_upload_file_size_to_mb": "Begränsa uppladdad filstorlek till <fileSizeInput /> MB",
|
||||
"link_survey_description": "Dela en länk till en enkätsida eller bädda in den på en webbsida eller i e-post.",
|
||||
"list": "Lista",
|
||||
"load_segment": "Ladda segment",
|
||||
@@ -1613,7 +1608,8 @@
|
||||
"matrix_all_fields": "Alla fält",
|
||||
"matrix_rows": "Rader",
|
||||
"max_file_size": "Max filstorlek",
|
||||
"max_file_size_limit_is": "Maximal filstorleksgräns är",
|
||||
"max_file_size_limit_is_mb": "Maximal filstorlek är {{maxSize}} MB.",
|
||||
"max_file_size_limit_is_mb_upgrade": "Maximal filstorlek är {{maxSize}} MB. Om du behöver mer, vänligen <upgradeLink>uppgradera din plan</upgradeLink>.",
|
||||
"missing_first": "Saknade först",
|
||||
"move_question_to_block": "Flytta fråga till block",
|
||||
"multiply": "Multiplicera *",
|
||||
@@ -1727,8 +1723,6 @@
|
||||
"save_and_close": "Spara och stäng",
|
||||
"scale": "Skala",
|
||||
"search_for_images": "Sök efter bilder",
|
||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "sekunder efter utlösning stängs enkäten om inget svar",
|
||||
"seconds_before_showing_the_survey": "sekunder innan enkäten visas.",
|
||||
"select_field": "Välj fält",
|
||||
"select_or_type_value": "Välj eller skriv värde",
|
||||
"select_ordering": "Välj ordning",
|
||||
@@ -1736,7 +1730,7 @@
|
||||
"select_type": "Välj typ",
|
||||
"send_survey_to_audience_who_match": "Skicka enkät till målgrupp som matchar...",
|
||||
"send_your_respondents_to_a_page_of_your_choice": "Skicka dina respondenter till en sida du väljer.",
|
||||
"set_the_global_placement_in_the_look_feel_settings": "Ställ in den globala placeringen i Utseende och känsla-inställningarna.",
|
||||
"set_global_placement_in_look_feel_settings_hint": "För att hålla placeringen konsekvent över alla undersökningar kan du <lookFeelLink>ställa in den globala placeringen i inställningarna för Utseende & Känsla.</lookFeelLink>",
|
||||
"settings_saved_successfully": "Inställningar sparade.",
|
||||
"seven_points": "7 poäng",
|
||||
"show_block_settings": "Visa blockinställningar",
|
||||
@@ -1746,7 +1740,7 @@
|
||||
"show_multiple_times": "Visa ett begränsat antal gånger",
|
||||
"show_only_once": "Visa endast en gång",
|
||||
"show_question_settings": "Visa frågeinställningar",
|
||||
"show_survey_maximum_of": "Visa enkät maximalt",
|
||||
"show_survey_maximum_of_n_times": "Visa undersökningen maximalt <displayLimitInput /> gånger.",
|
||||
"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",
|
||||
"shrink_preview": "Minimera förhandsgranskning",
|
||||
@@ -1782,8 +1776,6 @@
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "Denna åtgärd kommer att ta bort alla översättningar från denna enkät.",
|
||||
"this_will_remove_the_language_and_all_its_translations": "Detta tar bort språket och alla dess översättningar från denna enkät. Denna åtgärd kan inte ångras.",
|
||||
"three_points": "3 poäng",
|
||||
"times": "gånger",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "För att hålla placeringen konsekvent över alla enkäter kan du",
|
||||
"translated": "Översatt",
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "Utlös enkät när en av åtgärderna aktiveras...",
|
||||
"try_lollipop_or_mountain": "Prova 'lollipop' eller 'mountain'...",
|
||||
@@ -1860,8 +1852,9 @@
|
||||
"visibility_and_recontact": "Synlighet och återkontakt",
|
||||
"visibility_and_recontact_description": "Kontrollera när denna enkät kan visas och hur ofta den kan visas igen.",
|
||||
"visible": "Synlig",
|
||||
"wait": "Vänta",
|
||||
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Vänta några sekunder efter utlösningen innan enkäten visas",
|
||||
"wait_n_days_before_showing_this_survey_again": "Vänta <daysInput /> eller fler dagar mellan den senast visade undersökningen och att visa denna undersökning.",
|
||||
"wait_n_seconds_before_showing_the_survey": "Vänta <delayInput /> sekunder innan undersökningen visas.",
|
||||
"waiting_time_across_surveys": "Väntetid (mellan enkäter)",
|
||||
"waiting_time_across_surveys_description": "För att undvika enkättrötthet, välj hur denna enkät ska förhålla sig till arbetsytans gemensamma väntetid.",
|
||||
"welcome_message": "Välkomstmeddelande",
|
||||
@@ -1925,10 +1918,8 @@
|
||||
"search_by_survey_name": "Sök efter enkätnamn",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
|
||||
"custom_single_use_id_placeholder": "CUSTOM-ID",
|
||||
"custom_single_use_id_required": "Enter a custom single-use ID.",
|
||||
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
|
||||
"custom_single_use_id_description": "Om du inte krypterar engångs-ID fungerar vilket värde som helst för “suid=...” för ett svar.",
|
||||
"custom_single_use_id_title": "Du kan ange vilket värde som helst som engångs-ID i URL:en.",
|
||||
"custom_start_point": "Anpassad startpunkt",
|
||||
"data_prefilling": "Dataförfyllning",
|
||||
"description": "Svar från dessa länkar kommer att vara anonyma",
|
||||
@@ -2219,6 +2210,7 @@
|
||||
"custom_scripts_warning": "Skript körs med full åtkomst till webbläsaren. Lägg endast till skript från betrodda källor.",
|
||||
"delete_workspace": "Ta bort arbetsyta",
|
||||
"delete_workspace_confirmation": "Är du säker på att du vill ta bort {projectName}? Denna åtgärd kan inte ångras.",
|
||||
"delete_workspace_confirmation_name": "Vänligen ange {projectName} i följande fält för att bekräfta den definitiva borttagningen av denna arbetsyta:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Ta bort {projectName} inkl. alla enkäter, svar, personer, åtgärder och attribut.",
|
||||
"delete_workspace_settings_description": "Ta bort arbetsyta med alla enkäter, svar, personer, åtgärder och attribut. Detta kan inte ångras.",
|
||||
"error_saving_workspace_information": "Fel vid sparande av arbetsytans information",
|
||||
@@ -2471,7 +2463,7 @@
|
||||
"add_another_member": "Lägg till en annan medlem",
|
||||
"continue": "Fortsätt",
|
||||
"failed_to_invite": "Misslyckades med att bjuda in",
|
||||
"invitation_sent_to": "Inbjudan skickad till",
|
||||
"invitation_sent_to_email": "Inbjudan skickad till {{email}}!",
|
||||
"invite_your_organization_members": "Bjud in dina organisationsmedlemmar",
|
||||
"life_s_no_fun_alone": "Livet är inte roligt ensam.",
|
||||
"skip": "Hoppa över",
|
||||
|
||||
+26
-34
@@ -241,10 +241,12 @@
|
||||
"failed_to_load_organizations": "Organizasyonlar yüklenemedi",
|
||||
"failed_to_load_workspaces": "Çalışma alanları yüklenemedi",
|
||||
"field_placeholder": "{{field}} Yer Tutucu",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "Filtre",
|
||||
"finish": "Bitir",
|
||||
"first_name": "Ad",
|
||||
"follow_these": "Şunları izleyin",
|
||||
"formbricks_version": "Formbricks Sürümü",
|
||||
"full_name": "Tam ad",
|
||||
"gathering_responses": "Yanıtlar toplanıyor",
|
||||
@@ -358,7 +360,6 @@
|
||||
"placeholder": "Yer tutucu",
|
||||
"please_select_at_least_one_survey": "Lütfen en az bir survey seçin",
|
||||
"please_select_at_least_one_trigger": "Lütfen en az bir tetikleyici seçin",
|
||||
"please_upgrade_your_plan": "Lütfen planınızı yükseltin",
|
||||
"powered_by_formbricks": "Formbricks tarafından desteklenmektedir",
|
||||
"preview": "Önizleme",
|
||||
"privacy": "Gizlilik Politikası",
|
||||
@@ -499,7 +500,7 @@
|
||||
"you_are_downgraded_to_the_community_edition": "Topluluk Sürümüne düşürüldünüz.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Bu işlemi gerçekleştirme yetkiniz yok.",
|
||||
"you_have_reached_your_limit_of_workspace_limit": "{projectLimit} çalışma alanı sınırınıza ulaştınız.",
|
||||
"you_have_reached_your_monthly_response_limit_of": "Aylık yanıt sınırınıza ulaştınız:",
|
||||
"you_have_reached_your_monthly_response_limit_of_count": "Aylık {{count}} yanıt limitinize ulaştınız.",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "{date} tarihinde Topluluk Sürümüne düşürüleceksiniz.",
|
||||
"your_license_has_expired_please_renew": "Kurumsal lisansınızın süresi doldu. Kurumsal özellikleri kullanmaya devam etmek için lütfen yenileyin."
|
||||
},
|
||||
@@ -774,6 +775,7 @@
|
||||
"create_survey_warning": "Bu entegrasyonu kurabilmek için bir survey oluşturmanız gerekiyor",
|
||||
"delete_integration": "Entegrasyonu Sil",
|
||||
"delete_integration_confirmation": "Bu entegrasyonu silmek istediğinizden emin misiniz?",
|
||||
"follow_these_docs_to_configure_it": "Yapılandırmak için şu <docsLink>dokümanlara</docsLink> göz atın.",
|
||||
"google_sheet_integration_description": "Elektronik tablonuzu anında survey verileriyle doldurun",
|
||||
"google_sheets": {
|
||||
"connect_with_google_sheets": "Google Sheets ile bağlan",
|
||||
@@ -858,7 +860,6 @@
|
||||
"slack_reconnect_button_description": "<b>Not:</b> Slack entegrasyonumuzu özel kanalları da destekleyecek şekilde güncelledik. Lütfen Slack çalışma alanınızı yeniden bağlayın."
|
||||
},
|
||||
"slack_integration_description": "Slack çalışma alanınızı Formbricks ile anında bağlayın",
|
||||
"to_configure_it": "yapılandırmak için.",
|
||||
"webhook_integration_description": "Survey'lerinizdeki eylemlere göre Webhook'ları tetikleyin",
|
||||
"webhooks": {
|
||||
"add_webhook": "Webhook Ekle",
|
||||
@@ -917,9 +918,7 @@
|
||||
"ex_fully_activated_recurring_users": "Ör. Tam aktif düzenli kullanıcılar",
|
||||
"ex_power_users": "Ör. Güçlü Kullanıcılar",
|
||||
"filters_reset_successfully": "Filters başarıyla sıfırlandı",
|
||||
"here": "buraya",
|
||||
"hide_filters": "Filtreleri gizle",
|
||||
"identifying_users": "kullanıcıları tanımlama",
|
||||
"invalid_segment": "Geçersiz segment",
|
||||
"invalid_segment_filters": "Geçersiz filtreler. Lütfen filtreleri kontrol edip tekrar deneyin.",
|
||||
"load_segment": "Segment Yükle",
|
||||
@@ -974,21 +973,20 @@
|
||||
"segment_id": "Segment Kimliği",
|
||||
"segment_saved_successfully": "Segment başarıyla kaydedildi",
|
||||
"segment_updated_successfully": "Segment başarıyla güncellendi",
|
||||
"segment_used_in_other_surveys_make_changes_here": "Bu segment diğer anketlerde kullanılıyor. Değişiklikleri <segmentsLink>buradan</segmentsLink> yapabilirsiniz.",
|
||||
"segments_help_you_target_users_with_same_characteristics_easily": "Segmentler aynı özelliklere sahip kullanıcıları kolayca hedeflemenize yardımcı olur",
|
||||
"target_audience": "Hedef Kitle",
|
||||
"this_action_resets_all_filters_in_this_survey": "Bu işlem bu survey'deki tüm filtreleri sıfırlar.",
|
||||
"this_segment_is_used_in_other_surveys": "Bu segment diğer survey'lerde kullanılıyor. Değişiklikleri yapın",
|
||||
"title_is_required": "Başlık zorunludur.",
|
||||
"unknown_filter_type": "Bilinmeyen filtre türü",
|
||||
"unlock_segments_description": "Belirli kullanıcı gruplarını hedeflemek için kişileri segmentlere ayırın",
|
||||
"unlock_segments_title": "Daha yüksek bir planla segmentleri açın",
|
||||
"user_targeting_is_currently_only_available_when": "Kullanıcı hedefleme şu anda yalnızca şu durumda kullanılabilir:",
|
||||
"user_targeting_only_available_when_identifying_users": "Kullanıcı hedefleme şu anda yalnızca Formbricks SDK ile <docsLink>kullanıcıları tanımlarken</docsLink> kullanılabilir.",
|
||||
"value_cannot_be_empty": "Değer boş olamaz.",
|
||||
"value_must_be_a_number": "Değer bir sayı olmalıdır.",
|
||||
"value_must_be_positive": "Değer pozitif bir sayı olmalıdır.",
|
||||
"view_filters": "Filtreleri görüntüle",
|
||||
"where": "Koşul",
|
||||
"with_the_formbricks_sdk": "Formbricks SDK ile"
|
||||
"where": "Koşul"
|
||||
},
|
||||
"settings": {
|
||||
"api_keys": {
|
||||
@@ -1067,8 +1065,8 @@
|
||||
"unlimited_workspaces": "Sınırsız Çalışma Alanı",
|
||||
"upgrade": "Yükselt",
|
||||
"upgrade_now": "Şimdi yükselt",
|
||||
"usage_count_of_limit_used": "{{current}} / {{limit}} <muted>kullanıldı</muted>",
|
||||
"usage_cycle": "Kullanım döngüsü",
|
||||
"used": "kullanıldı",
|
||||
"yearly": "Yıllık",
|
||||
"yearly_checkout_unavailable": "Yıllık ödeme henüz mevcut değil. Önce aylık bir plana ödeme yöntemi ekleyin veya destek ekibiyle iletişime geçin.",
|
||||
"your_plan": "Planınız"
|
||||
@@ -1129,7 +1127,7 @@
|
||||
"no_credit_card_no_sales_call_just_test_it": "Kredi kartı yok. Satış araması yok. Sadece test edin :)",
|
||||
"on_request": "Talep üzerine",
|
||||
"organization_roles": "Kuruluş Rolleri (Yönetici, Editör, Geliştirici vb.)",
|
||||
"questions_please_reach_out_to": "Sorularınız mı var? Lütfen şu adrese ulaşın:",
|
||||
"questions_please_reach_out_to_email": "Sorularınız mı var? Lütfen <contactLink>hola@formbricks.com</contactLink> adresinden bize ulaşın",
|
||||
"recheck_license": "Lisansı yeniden kontrol et",
|
||||
"recheck_license_failed": "Lisans kontrolü başarısız oldu. Lisans sunucusuna ulaşılamıyor olabilir.",
|
||||
"recheck_license_instance_mismatch": "Bu lisans farklı bir Formbricks örneğine bağlı. Önceki bağlantıyı kaldırması için Formbricks desteğiyle iletişime geçin.",
|
||||
@@ -1355,13 +1353,12 @@
|
||||
"address_line_2": "Adres Satırı 2",
|
||||
"adjust_survey_closed_message": "\"Survey Kapatıldı\" mesajını düzenle",
|
||||
"adjust_survey_closed_message_description": "Survey kapatıldığında ziyaretçilerin gördüğü mesajı değiştirin.",
|
||||
"adjust_the_theme_in_the": "Temayı şurada düzenleyin:",
|
||||
"adjust_theme_in_look_and_feel_settings": "Temayı <lookFeelLink>Görünüm ve His</lookFeelLink> Ayarlarından düzenleyin.",
|
||||
"all_are_true": "tümü doğru",
|
||||
"all_other_answers_will_continue_to": "Diğer tüm yanıtlar devam edecek:",
|
||||
"all_other_answers_will_continue_to_fallback": "Diğer tüm yanıtlar <fallbackSelect /> olmaya devam edecek",
|
||||
"allow_multi_select": "Çoklu seçime izin ver",
|
||||
"allow_multiple_files": "Birden fazla dosyaya izin ver",
|
||||
"allow_users_to_select_more_than_one_image": "Kullanıcıların birden fazla görsel seçmesine izin ver",
|
||||
"and_launch_surveys_in_your_website_or_app": "ve web sitenizde veya uygulamanızda survey başlatın.",
|
||||
"animation": "Animasyon",
|
||||
"any_is_true": "herhangi biri doğru",
|
||||
"app_survey_description": "Yanıt toplamak için web uygulamanıza veya sitenize bir survey yerleştirin.",
|
||||
@@ -1373,10 +1370,10 @@
|
||||
"auto_save_disabled": "Otomatik kayıt devre dışı",
|
||||
"auto_save_disabled_tooltip": "Survey'iniz yalnızca taslak durumundayken otomatik kaydedilir. Bu, yayınlanmış survey'lerin yanlışlıkla güncellenmesini önler.",
|
||||
"auto_save_on": "Otomatik kayıt açık",
|
||||
"automatically_close_survey_after": "Survey'i otomatik kapat, şu süre sonra:",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "Yanıt verilmezse anketi tetiklendikten sonra <autoCloseInput /> saniye sonra otomatik olarak kapat.",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Belirli sayıda yanıt sonrasında survey'i otomatik olarak kapatın.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Kullanıcı belirli bir süre yanıt vermezse survey'i otomatik olarak kapatın.",
|
||||
"automatically_mark_the_survey_as_complete_after": "Survey'i otomatik olarak tamamlanmış olarak işaretle, şu süre sonra:",
|
||||
"automatically_mark_complete_after_n_responses": "<autoCompleteInput /> tamamlanmış yanıttan sonra anketi otomatik olarak tamamlandı işaretle.",
|
||||
"back_button_label": "\"Geri\" Düğme Etiketi",
|
||||
"background_styling": "Arka plan stili",
|
||||
"block_duplicated": "Blok kopyalandı.",
|
||||
@@ -1434,11 +1431,11 @@
|
||||
"columns": "Sütunlar",
|
||||
"company": "Şirket",
|
||||
"company_logo": "Şirket logosu",
|
||||
"completed_responses": "tamamlanmış yanıt.",
|
||||
"concat": "Birleştir +",
|
||||
"conditional_logic": "Koşullu Mantık",
|
||||
"confirm_default_language": "Varsayılan dili onayla",
|
||||
"confirm_survey_changes": "Survey Değişikliklerini Onayla",
|
||||
"connect_formbricks_and_launch_surveys": "Formbricks'i bağlayın ve web sitenizde veya uygulamanızda anketler başlatın.",
|
||||
"contact_fields": "İletişim Alanları",
|
||||
"contains": "İçerir",
|
||||
"continue_to_settings": "Ayarlara devam et",
|
||||
@@ -1452,7 +1449,6 @@
|
||||
"custom_hostname": "Özel ana bilgisayar adı",
|
||||
"customize_survey_logo": "Survey logosunu özelleştirin",
|
||||
"darken_or_lighten_background_of_your_choice": "Seçtiğiniz arka planı koyulaştırın veya açın.",
|
||||
"days_before_showing_this_survey_again": "veya daha fazla gün geçmesi gerekir, son gösterilen survey ile bu survey'in gösterilmesi arasında.",
|
||||
"default_language": "Varsayılan dil",
|
||||
"delete_anyways": "Yine de sil",
|
||||
"delete_block": "Bloğu sil",
|
||||
@@ -1558,7 +1554,6 @@
|
||||
"hide_progress_bar": "İlerleme çubuğunu gizle",
|
||||
"hide_question_settings": "Soru ayarlarını gizle",
|
||||
"hostname": "Ana bilgisayar adı",
|
||||
"if_you_need_more_please": "Daha fazlasına ihtiyacınız varsa lütfen",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Bir yanıt veya kısmi yanıt gönderilene kadar her tetiklendiğinde göstermeye devam et.",
|
||||
"ignore_global_waiting_time": "Bekleme Süresini Yoksay",
|
||||
"ignore_global_waiting_time_description": "Bu survey, yakın zamanda başka bir survey gösterilmiş olsa bile koşulları sağlandığında gösterilebilir.",
|
||||
@@ -1598,7 +1593,7 @@
|
||||
"last_name": "Soyad",
|
||||
"let_people_upload_up_to_25_files_at_the_same_time": "Kullanıcıların aynı anda 25 dosyaya kadar yüklemesine izin verin.",
|
||||
"limit_the_maximum_file_size": "Yüklemeler için maksimum dosya boyutunu sınırlayın.",
|
||||
"limit_upload_file_size_to": "Yükleme dosya boyutunu sınırla:",
|
||||
"limit_upload_file_size_to_mb": "Yükleme dosya boyutunu <fileSizeInput /> MB ile sınırla",
|
||||
"link_survey_description": "Bir survey sayfasına bağlantı paylaşın veya bir web sayfasına ya da email'e gömün.",
|
||||
"list": "Liste",
|
||||
"load_segment": "Segment yükle",
|
||||
@@ -1613,7 +1608,8 @@
|
||||
"matrix_all_fields": "Tüm alanlar",
|
||||
"matrix_rows": "Satırlar",
|
||||
"max_file_size": "Maksimum dosya boyutu",
|
||||
"max_file_size_limit_is": "Maksimum dosya boyutu sınırı:",
|
||||
"max_file_size_limit_is_mb": "Maksimum dosya boyutu limiti {{maxSize}} MB.",
|
||||
"max_file_size_limit_is_mb_upgrade": "Maksimum dosya boyutu limiti {{maxSize}} MB. Daha fazlasına ihtiyacınız varsa lütfen <upgradeLink>planınızı yükseltin</upgradeLink>.",
|
||||
"missing_first": "Eksikler önce",
|
||||
"move_question_to_block": "Soruyu bloğa taşı",
|
||||
"multiply": "Çarp *",
|
||||
@@ -1727,8 +1723,6 @@
|
||||
"save_and_close": "Kaydet ve Kapat",
|
||||
"scale": "Ölçek",
|
||||
"search_for_images": "Görsel ara",
|
||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "saniye sonra yanıt gelmezse survey kapatılacak",
|
||||
"seconds_before_showing_the_survey": "saniye bekledikten sonra survey gösterilecek.",
|
||||
"select_field": "Alan seçin",
|
||||
"select_or_type_value": "Değer seçin veya yazın",
|
||||
"select_ordering": "Sıralama seçin",
|
||||
@@ -1736,7 +1730,7 @@
|
||||
"select_type": "Tür seçin",
|
||||
"send_survey_to_audience_who_match": "Eşleşen kitleye survey gönder...",
|
||||
"send_your_respondents_to_a_page_of_your_choice": "Yanıtlayıcılarınızı istediğiniz bir sayfaya yönlendirin.",
|
||||
"set_the_global_placement_in_the_look_feel_settings": "Genel yerleşimi Görünüm ve His ayarlarında belirleyin.",
|
||||
"set_global_placement_in_look_feel_settings_hint": "Yerleşimi tüm anketlerde tutarlı tutmak için <lookFeelLink>Görünüm ve His ayarlarından global yerleşimi belirleyebilirsiniz.</lookFeelLink>",
|
||||
"settings_saved_successfully": "Settings başarıyla kaydedildi",
|
||||
"seven_points": "7 puan",
|
||||
"show_block_settings": "Blok ayarlarını göster",
|
||||
@@ -1746,7 +1740,7 @@
|
||||
"show_multiple_times": "Sınırlı sayıda göster",
|
||||
"show_only_once": "Yalnızca bir kez göster",
|
||||
"show_question_settings": "Soru ayarlarını göster",
|
||||
"show_survey_maximum_of": "Survey'i en fazla göster",
|
||||
"show_survey_maximum_of_n_times": "Anketi maksimum <displayLimitInput /> kez göster.",
|
||||
"show_survey_to_users": "Kullanıcıların %'sine survey göster",
|
||||
"show_to_x_percentage_of_targeted_users": "Hedeflenen kullanıcıların %{percentage}'ine göster",
|
||||
"shrink_preview": "Önizlemeyi Küçült",
|
||||
@@ -1782,8 +1776,6 @@
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "Bu işlem bu survey'deki tüm çevirileri kaldıracak.",
|
||||
"this_will_remove_the_language_and_all_its_translations": "Bu işlem, bu dili ve bu anketteki tüm çevirilerini kaldıracak. Bu işlem geri alınamaz.",
|
||||
"three_points": "3 puan",
|
||||
"times": "kez",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Tüm survey'lerde yerleşimi tutarlı tutmak için",
|
||||
"translated": "Çevrildi",
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "Eylemlerden biri tetiklendiğinde survey'i başlat...",
|
||||
"try_lollipop_or_mountain": "\"lollipop\" veya \"mountain\" deneyin...",
|
||||
@@ -1860,8 +1852,9 @@
|
||||
"visibility_and_recontact": "Görünürlük ve Yeniden İletişim",
|
||||
"visibility_and_recontact_description": "Bu survey'in ne zaman görünebileceğini ve ne sıklıkta tekrar gösterilebileceğini kontrol edin.",
|
||||
"visible": "Görünür",
|
||||
"wait": "Bekle",
|
||||
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "Survey'i göstermeden önce tetikleyiciden sonra birkaç saniye bekleyin",
|
||||
"wait_n_days_before_showing_this_survey_again": "Son gösterilen anket ile bu anketin gösterilmesi arasında <daysInput /> veya daha fazla gün bekle.",
|
||||
"wait_n_seconds_before_showing_the_survey": "Anketi göstermeden önce <delayInput /> saniye bekle.",
|
||||
"waiting_time_across_surveys": "Bekleme Süresi (survey'ler arası)",
|
||||
"waiting_time_across_surveys_description": "Survey yorgunluğunu önlemek için bu survey'in çalışma alanı genelindeki Bekleme Süresiyle nasıl etkileşeceğini seçin.",
|
||||
"welcome_message": "Karşılama mesajı",
|
||||
@@ -1925,10 +1918,8 @@
|
||||
"search_by_survey_name": "Survey adına göre ara",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
|
||||
"custom_single_use_id_placeholder": "CUSTOM-ID",
|
||||
"custom_single_use_id_required": "Enter a custom single-use ID.",
|
||||
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
|
||||
"custom_single_use_id_description": "Tek kullanımlık ID'leri şifrelemezseniz, \"suid=...\" için herhangi bir değer bir yanıt için geçerli olur.",
|
||||
"custom_single_use_id_title": "URL'de herhangi bir değeri tek kullanımlık ID olarak ayarlayabilirsiniz.",
|
||||
"custom_start_point": "Özel başlangıç noktası",
|
||||
"data_prefilling": "Veri ön doldurma",
|
||||
"description": "Bu bağlantılardan gelen yanıtlar anonim olacaktır",
|
||||
@@ -2219,6 +2210,7 @@
|
||||
"custom_scripts_warning": "Betikler tam tarayıcı erişimiyle çalışır. Yalnızca güvenilir kaynaklardan betik ekleyin.",
|
||||
"delete_workspace": "Çalışma Alanını Sil",
|
||||
"delete_workspace_confirmation": "{projectName} öğesini silmek istediğinizden emin misiniz? Bu işlem geri alınamaz.",
|
||||
"delete_workspace_confirmation_name": "Bu çalışma alanının kesin olarak silinmesini onaylamak için lütfen aşağıdaki alana {projectName} yazın:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "Tüm survey, yanıt, kişi, eylem ve nitelikleri dahil {projectName} öğesini silin.",
|
||||
"delete_workspace_settings_description": "Tüm survey, yanıt, kişi, eylem ve nitelikleriyle birlikte çalışma alanını silin. Bu işlem geri alınamaz.",
|
||||
"error_saving_workspace_information": "Çalışma alanı bilgileri kaydedilirken hata oluştu",
|
||||
@@ -2471,7 +2463,7 @@
|
||||
"add_another_member": "Başka bir üye ekle",
|
||||
"continue": "Devam",
|
||||
"failed_to_invite": "Davet gönderilemedi",
|
||||
"invitation_sent_to": "Davet gönderildi:",
|
||||
"invitation_sent_to_email": "{{email}} adresine davetiye gönderildi!",
|
||||
"invite_your_organization_members": "Organizasyon üyelerinizi davet edin",
|
||||
"life_s_no_fun_alone": "Hayat yalnız eğlenceli değil.",
|
||||
"skip": "Atla",
|
||||
|
||||
@@ -241,10 +241,12 @@
|
||||
"failed_to_load_organizations": "加载组织失败",
|
||||
"failed_to_load_workspaces": "加载工作区失败",
|
||||
"field_placeholder": "{{field}} 占位符",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "筛选",
|
||||
"finish": "完成",
|
||||
"first_name": "名字",
|
||||
"follow_these": "遵循 这些",
|
||||
"formbricks_version": "Formbricks 版本",
|
||||
"full_name": "全名",
|
||||
"gathering_responses": "收集反馈",
|
||||
@@ -358,7 +360,6 @@
|
||||
"placeholder": "占位符",
|
||||
"please_select_at_least_one_survey": "请选择至少 一个调查",
|
||||
"please_select_at_least_one_trigger": "请选择至少 一个触发条件",
|
||||
"please_upgrade_your_plan": "请升级您的计划",
|
||||
"powered_by_formbricks": "由 Formbricks 提供支持",
|
||||
"preview": "预览",
|
||||
"privacy": "隐私政策",
|
||||
@@ -499,7 +500,7 @@
|
||||
"you_are_downgraded_to_the_community_edition": "您已降级到社区版。",
|
||||
"you_are_not_authorized_to_perform_this_action": "您无权执行此操作。",
|
||||
"you_have_reached_your_limit_of_workspace_limit": "您已达到 {projectLimit} 个工作区的上限。",
|
||||
"you_have_reached_your_monthly_response_limit_of": "您 已经 达到 每月 的 响应 限制",
|
||||
"you_have_reached_your_monthly_response_limit_of_count": "您已达到每月响应限制 {{count}} 次。",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "您将在 {date} 降级到社区版。",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
},
|
||||
@@ -774,6 +775,7 @@
|
||||
"create_survey_warning": "您 必须 创建 一个 调查 才能 设置 这个 集成",
|
||||
"delete_integration": "删除 Integration",
|
||||
"delete_integration_confirmation": "您 确定 要 删除 此 集成 吗?",
|
||||
"follow_these_docs_to_configure_it": "请参照<docsLink>文档</docsLink>进行配置。",
|
||||
"google_sheet_integration_description": "即时用调查数据 填充 您的 spreadsheets",
|
||||
"google_sheets": {
|
||||
"connect_with_google_sheets": "连接 Google Sheets",
|
||||
@@ -858,7 +860,6 @@
|
||||
"slack_reconnect_button_description": "<b>注意:</b> 我们最近更改了我们的 Slack 集成以支持私人频道。 请重新连接您的 Slack 工作区。"
|
||||
},
|
||||
"slack_integration_description": "立即将 您的 Slack 工作区 与 Formbricks 连接",
|
||||
"to_configure_it": "配置 它。",
|
||||
"webhook_integration_description": "根据您调查中的操作触发 Webhooks",
|
||||
"webhooks": {
|
||||
"add_webhook": "添加 Webhook",
|
||||
@@ -917,9 +918,7 @@
|
||||
"ex_fully_activated_recurring_users": "例如 完全 激活 的 定期 用户",
|
||||
"ex_power_users": "例如 高级 用户",
|
||||
"filters_reset_successfully": "筛选器 重置 成功",
|
||||
"here": "这里",
|
||||
"hide_filters": "隐藏 过滤器",
|
||||
"identifying_users": "识别 用户",
|
||||
"invalid_segment": "无效 细分",
|
||||
"invalid_segment_filters": "无效 的 筛选条件 。请 检查 筛选条件 后 再试 ",
|
||||
"load_segment": "载入 段落",
|
||||
@@ -974,21 +973,20 @@
|
||||
"segment_id": "细分 ID",
|
||||
"segment_saved_successfully": "片段 保存 成功",
|
||||
"segment_updated_successfully": "片段 更新 成功",
|
||||
"segment_used_in_other_surveys_make_changes_here": "此细分在其他调查中使用。请在<segmentsLink>这里</segmentsLink>进行修改。",
|
||||
"segments_help_you_target_users_with_same_characteristics_easily": "细分 帮助 您 轻松 定位 具有 相同 特征 的 用户",
|
||||
"target_audience": "目标 受众",
|
||||
"this_action_resets_all_filters_in_this_survey": "此操作将重置此问卷中的所有筛选器。",
|
||||
"this_segment_is_used_in_other_surveys": "该 段落 已 被 用于 其他 问卷 调查。进行 修改",
|
||||
"title_is_required": "标题 是 必需的",
|
||||
"unknown_filter_type": "未知 过滤器 类型",
|
||||
"unlock_segments_description": "将 联系人 组织成 段,以 目标 具体 用户 群体",
|
||||
"unlock_segments_title": "通过 更 高级 划解锁 细分",
|
||||
"user_targeting_is_currently_only_available_when": "目标用户 功能 当前 仅 限于 当",
|
||||
"user_targeting_only_available_when_identifying_users": "用户定向目前仅在使用 Formbricks SDK <docsLink>识别用户</docsLink>时可用。",
|
||||
"value_cannot_be_empty": "值 不能为空。",
|
||||
"value_must_be_a_number": "值 必须 是 一个 数字。",
|
||||
"value_must_be_positive": "值必须是正数。",
|
||||
"view_filters": "查看 筛选条件",
|
||||
"where": "位置",
|
||||
"with_the_formbricks_sdk": "与 Formbricks SDK"
|
||||
"where": "位置"
|
||||
},
|
||||
"settings": {
|
||||
"api_keys": {
|
||||
@@ -1067,8 +1065,8 @@
|
||||
"unlimited_workspaces": "无限工作区",
|
||||
"upgrade": "升级",
|
||||
"upgrade_now": "立即升级",
|
||||
"usage_count_of_limit_used": "{{current}} / {{limit}} <muted>已使用</muted>",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "已用",
|
||||
"yearly": "按年付费",
|
||||
"yearly_checkout_unavailable": "年度结算暂不可用。请先在月度套餐中添加付款方式,或联系客服。",
|
||||
"your_plan": "你的套餐"
|
||||
@@ -1129,7 +1127,7 @@
|
||||
"no_credit_card_no_sales_call_just_test_it": "无需信用卡 。无需销售电话 。只需测试一下 :)",
|
||||
"on_request": "按请求",
|
||||
"organization_roles": "组织角色(管理员,编辑,开发者等)",
|
||||
"questions_please_reach_out_to": "问题 ? 请 联系",
|
||||
"questions_please_reach_out_to_email": "有疑问?请联系 <contactLink>hola@formbricks.com</contactLink>",
|
||||
"recheck_license": "重新检查许可证",
|
||||
"recheck_license_failed": "许可证检查失败。许可证服务器可能无法访问。",
|
||||
"recheck_license_instance_mismatch": "此许可证已绑定到另一个 Formbricks 实例。请联系 Formbricks 支持团队解除先前的绑定。",
|
||||
@@ -1355,13 +1353,12 @@
|
||||
"address_line_2": "地址 第2行",
|
||||
"adjust_survey_closed_message": "调整 \"调查 关闭\" 消息",
|
||||
"adjust_survey_closed_message_description": "更改 访客 看到 调查 关闭 时 的 消息。",
|
||||
"adjust_the_theme_in_the": "调整主题在",
|
||||
"adjust_theme_in_look_and_feel_settings": "在<lookFeelLink>外观与感觉</lookFeelLink>设置中调整主题。",
|
||||
"all_are_true": "全部为真",
|
||||
"all_other_answers_will_continue_to": "所有其他答案将继续",
|
||||
"all_other_answers_will_continue_to_fallback": "所有其他答案将继续<fallbackSelect />",
|
||||
"allow_multi_select": "允许 多选",
|
||||
"allow_multiple_files": "允许 多 个 文件",
|
||||
"allow_users_to_select_more_than_one_image": "允许 用户 选择 多于 一个 图片",
|
||||
"and_launch_surveys_in_your_website_or_app": "并 在 你 的 网站 或 应用 中 启动 问卷 。",
|
||||
"animation": "动画",
|
||||
"any_is_true": "任一为真",
|
||||
"app_survey_description": "在 你的 网络 应用 或 网站 中 嵌入 问卷 收集 反馈 。",
|
||||
@@ -1373,10 +1370,10 @@
|
||||
"auto_save_disabled": "自动保存已禁用",
|
||||
"auto_save_disabled_tooltip": "您的调查仅在草稿状态时自动保存。这确保公开的调查不会被意外更新。",
|
||||
"auto_save_on": "自动保存已启用",
|
||||
"automatically_close_survey_after": "自动 关闭 调查 后",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "如果触发后无响应,则在 <autoCloseInput /> 秒后自动关闭调查。",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "自动 关闭 调查 在 达到 一定数量 的 回应 后",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "用户未在一定秒数内应答时 自动关闭 问卷",
|
||||
"automatically_mark_the_survey_as_complete_after": "自动 标记 调查 为 完成 在",
|
||||
"automatically_mark_complete_after_n_responses": "在收到 <autoCompleteInput /> 次完整响应后自动将调查标记为完成。",
|
||||
"back_button_label": "\"返回\" 按钮标签",
|
||||
"background_styling": "背景样式",
|
||||
"block_duplicated": "区块已复制。",
|
||||
@@ -1434,11 +1431,11 @@
|
||||
"columns": "列",
|
||||
"company": "公司",
|
||||
"company_logo": "公司 徽标",
|
||||
"completed_responses": "完成反馈。",
|
||||
"concat": "拼接 +",
|
||||
"conditional_logic": "条件逻辑",
|
||||
"confirm_default_language": "确认 默认 语言",
|
||||
"confirm_survey_changes": "确认调查变更",
|
||||
"connect_formbricks_and_launch_surveys": "连接 Formbricks 并在您的网站或应用中启动调查。",
|
||||
"contact_fields": "联络字段",
|
||||
"contains": "包含",
|
||||
"continue_to_settings": "继续 到 设置",
|
||||
@@ -1452,7 +1449,6 @@
|
||||
"custom_hostname": "自 定 义 主 机 名",
|
||||
"customize_survey_logo": "自定义调查 logo",
|
||||
"darken_or_lighten_background_of_your_choice": "根据 您 的 选择 暗化 或 亮化 背景。",
|
||||
"days_before_showing_this_survey_again": "距离上次显示问卷后需间隔不少于指定天数,才能再次显示此问卷。",
|
||||
"default_language": "默认语言",
|
||||
"delete_anyways": "仍然删除",
|
||||
"delete_block": "删除区块",
|
||||
@@ -1558,7 +1554,6 @@
|
||||
"hide_progress_bar": "隐藏 进度 条",
|
||||
"hide_question_settings": "隐藏问题设置",
|
||||
"hostname": "主 机 名",
|
||||
"if_you_need_more_please": "如果您需要更多,请",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "持续在触发时显示,直到提交响应或部分响应。",
|
||||
"ignore_global_waiting_time": "忽略冷却期",
|
||||
"ignore_global_waiting_time_description": "只要满足条件,此调查即可显示,即使最近刚显示过其他调查。",
|
||||
@@ -1598,7 +1593,7 @@
|
||||
"last_name": "姓",
|
||||
"let_people_upload_up_to_25_files_at_the_same_time": "允许 人们 同时 上传 最多 25 个 文件",
|
||||
"limit_the_maximum_file_size": "限制上传文件的最大大小。",
|
||||
"limit_upload_file_size_to": "将上传文件大小限制为",
|
||||
"limit_upload_file_size_to_mb": "限制上传文件大小为 <fileSizeInput /> MB",
|
||||
"link_survey_description": "分享 问卷 页面 链接 或 将其 嵌入 网页 或 电子邮件 中。",
|
||||
"list": "列表",
|
||||
"load_segment": "载入 段落",
|
||||
@@ -1613,7 +1608,8 @@
|
||||
"matrix_all_fields": "所有字段",
|
||||
"matrix_rows": "行",
|
||||
"max_file_size": "最大文件大小",
|
||||
"max_file_size_limit_is": "最大文件大小限制为",
|
||||
"max_file_size_limit_is_mb": "最大文件大小限制为 {{maxSize}} MB。",
|
||||
"max_file_size_limit_is_mb_upgrade": "最大文件大小限制为 {{maxSize}} MB。如需更大容量,请<upgradeLink>升级您的套餐</upgradeLink>。",
|
||||
"missing_first": "缺失优先",
|
||||
"move_question_to_block": "将问题移动到区块",
|
||||
"multiply": "乘 *",
|
||||
@@ -1727,8 +1723,6 @@
|
||||
"save_and_close": "保存 和 关闭",
|
||||
"scale": "规模",
|
||||
"search_for_images": "搜索 图片",
|
||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "触发后 如果 没有 应答 将 在 几秒 后 关闭 调查",
|
||||
"seconds_before_showing_the_survey": "显示问卷前 几秒",
|
||||
"select_field": "选择字段",
|
||||
"select_or_type_value": "选择 或 输入 值",
|
||||
"select_ordering": "选择排序",
|
||||
@@ -1736,7 +1730,7 @@
|
||||
"select_type": "选择 类型",
|
||||
"send_survey_to_audience_who_match": "发送 调查 给 符合 要求 的 受众",
|
||||
"send_your_respondents_to_a_page_of_your_choice": "将 你 的 受访者 发送到 你 选择 的 页面。",
|
||||
"set_the_global_placement_in_the_look_feel_settings": "在外观 设置中 设置 全局 放置。",
|
||||
"set_global_placement_in_look_feel_settings_hint": "为保持所有调查的位置一致,您可以<lookFeelLink>在外观与感觉设置中设置全局位置。</lookFeelLink>",
|
||||
"settings_saved_successfully": "设置 保存 成功",
|
||||
"seven_points": "7 分",
|
||||
"show_block_settings": "显示区块设置",
|
||||
@@ -1746,7 +1740,7 @@
|
||||
"show_multiple_times": "显示有限次数",
|
||||
"show_only_once": "仅 显示 一次",
|
||||
"show_question_settings": "显示问题设置",
|
||||
"show_survey_maximum_of": "显示 调查 最大 一次",
|
||||
"show_survey_maximum_of_n_times": "最多显示调查 <displayLimitInput /> 次。",
|
||||
"show_survey_to_users": "显示 问卷 给 % 的 用户",
|
||||
"show_to_x_percentage_of_targeted_users": "显示 给 {percentage}% 的 目标 用户",
|
||||
"shrink_preview": "收起预览",
|
||||
@@ -1782,8 +1776,6 @@
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "此操作将删除该调查中的所有翻译。",
|
||||
"this_will_remove_the_language_and_all_its_translations": "这将从此调查问卷中删除该语言及其所有翻译。此操作无法撤销。",
|
||||
"three_points": "3 分",
|
||||
"times": "次数",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "为了 保持 所有 调查 的 放置 一致,您 可以",
|
||||
"translated": "已翻译",
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "当 其中 一个 动作 被 触发 时 启动 调查…",
|
||||
"try_lollipop_or_mountain": "尝试 'lollipop' 或 'mountain' ...",
|
||||
@@ -1860,8 +1852,9 @@
|
||||
"visibility_and_recontact": "可见性与重新联系",
|
||||
"visibility_and_recontact_description": "控制此调查何时可以显示以及可以重新显示的频率。",
|
||||
"visible": "可见",
|
||||
"wait": "等待",
|
||||
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "触发后等待几秒再显示问卷",
|
||||
"wait_n_days_before_showing_this_survey_again": "在上次显示调查后等待 <daysInput /> 天或更长时间再显示此调查。",
|
||||
"wait_n_seconds_before_showing_the_survey": "在显示调查前等待 <delayInput /> 秒。",
|
||||
"waiting_time_across_surveys": "冷却期(跨问卷)",
|
||||
"waiting_time_across_surveys_description": "为防止问卷疲劳,请选择此问卷与工作区冷却期的交互方式。",
|
||||
"welcome_message": "欢迎 信息",
|
||||
@@ -1925,10 +1918,8 @@
|
||||
"search_by_survey_name": "按 调查 名称 搜索",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
|
||||
"custom_single_use_id_placeholder": "CUSTOM-ID",
|
||||
"custom_single_use_id_required": "Enter a custom single-use ID.",
|
||||
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
|
||||
"custom_single_use_id_description": "如果您未加密一次性 ID,任何 “suid=...” 的值都可用于一次答复。",
|
||||
"custom_single_use_id_title": "您 可以 在 URL 中 设置 任意 值 作为 一次性 ID。",
|
||||
"custom_start_point": "自定义 起点",
|
||||
"data_prefilling": "数据 预填充",
|
||||
"description": "来自 这些 link 的 响应 将是 匿名 的",
|
||||
@@ -2219,6 +2210,7 @@
|
||||
"custom_scripts_warning": "脚本将以完整浏览器权限执行。请仅添加来自可信来源的脚本。",
|
||||
"delete_workspace": "删除工作区",
|
||||
"delete_workspace_confirmation": "您确定要删除 {projectName} 吗?此操作无法撤销。",
|
||||
"delete_workspace_confirmation_name": "请在下列字段中输入 {projectName} 以确认永久删除此工作区:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "删除 {projectName},包括所有调查、回应、人员、动作和属性。",
|
||||
"delete_workspace_settings_description": "删除工作区及其所有调查、回应、人员、动作和属性。此操作无法撤销。",
|
||||
"error_saving_workspace_information": "保存工作区信息时出错",
|
||||
@@ -2471,7 +2463,7 @@
|
||||
"add_another_member": "添加另一个成员",
|
||||
"continue": "继续",
|
||||
"failed_to_invite": "邀请失败",
|
||||
"invitation_sent_to": "邀请已发送至",
|
||||
"invitation_sent_to_email": "邀请已发送至 {{email}}!",
|
||||
"invite_your_organization_members": "邀请 您 的 组织 成员",
|
||||
"life_s_no_fun_alone": "独 自 一 人 生 活 不 有 趣 。",
|
||||
"skip": "跳过",
|
||||
|
||||
@@ -241,10 +241,12 @@
|
||||
"failed_to_load_organizations": "無法載入組織",
|
||||
"failed_to_load_workspaces": "載入工作區失敗",
|
||||
"field_placeholder": "{{field}} 預設文字",
|
||||
"file_size_must_be_less_than_5_mb": "File size must be less than 5 MB.",
|
||||
"file_storage_not_set_up": "File storage not set up",
|
||||
"file_upload_service_unavailable": "File upload service unavailable",
|
||||
"filter": "篩選",
|
||||
"finish": "完成",
|
||||
"first_name": "名字",
|
||||
"follow_these": "按照這些步驟",
|
||||
"formbricks_version": "Formbricks 版本",
|
||||
"full_name": "全名",
|
||||
"gathering_responses": "收集回應中",
|
||||
@@ -358,7 +360,6 @@
|
||||
"placeholder": "提示文字",
|
||||
"please_select_at_least_one_survey": "請選擇至少一個問卷",
|
||||
"please_select_at_least_one_trigger": "請選擇至少一個觸發器",
|
||||
"please_upgrade_your_plan": "請升級您的方案",
|
||||
"powered_by_formbricks": "由 Formbricks 提供技術支援",
|
||||
"preview": "預覽",
|
||||
"privacy": "隱私權政策",
|
||||
@@ -499,7 +500,7 @@
|
||||
"you_are_downgraded_to_the_community_edition": "您已降級至社群版。",
|
||||
"you_are_not_authorized_to_perform_this_action": "您沒有執行此操作的權限。",
|
||||
"you_have_reached_your_limit_of_workspace_limit": "您已達到 {projectLimit} 個工作區的上限。",
|
||||
"you_have_reached_your_monthly_response_limit_of": "您已達到每月回應上限:",
|
||||
"you_have_reached_your_monthly_response_limit_of_count": "您已達到每月回應上限 {{count}} 次。",
|
||||
"you_will_be_downgraded_to_the_community_edition_on_date": "您將於 '{'date'}' 降級至社群版。",
|
||||
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
|
||||
},
|
||||
@@ -774,6 +775,7 @@
|
||||
"create_survey_warning": "您必須建立問卷才能設定此整合",
|
||||
"delete_integration": "刪除整合",
|
||||
"delete_integration_confirmation": "您確定要刪除此整合嗎?",
|
||||
"follow_these_docs_to_configure_it": "請參考這些<docsLink>文件</docsLink>來進行設定。",
|
||||
"google_sheet_integration_description": "使用問卷資料立即填入您的試算表",
|
||||
"google_sheets": {
|
||||
"connect_with_google_sheets": "連線 Google 試算表",
|
||||
@@ -858,7 +860,6 @@
|
||||
"slack_reconnect_button_description": "<b>注意:</b>我們最近變更了我們的 Slack 整合以支援私人頻道。請重新連線您的 Slack 工作區。"
|
||||
},
|
||||
"slack_integration_description": "將您的 Slack 工作區與 Formbricks 立即連線",
|
||||
"to_configure_it": "進行設定。",
|
||||
"webhook_integration_description": "根據您問卷中的操作觸發 Webhook",
|
||||
"webhooks": {
|
||||
"add_webhook": "新增 Webhook",
|
||||
@@ -917,9 +918,7 @@
|
||||
"ex_fully_activated_recurring_users": "例如:完全啟用的定期使用者",
|
||||
"ex_power_users": "例如:進階使用者",
|
||||
"filters_reset_successfully": "篩選器已成功重設",
|
||||
"here": "這裡",
|
||||
"hide_filters": "隱藏篩選器",
|
||||
"identifying_users": "識別使用者",
|
||||
"invalid_segment": "無效區隔",
|
||||
"invalid_segment_filters": "無效的篩選器。請檢查篩選器並再試一次。",
|
||||
"load_segment": "載入區隔",
|
||||
@@ -974,21 +973,20 @@
|
||||
"segment_id": "區隔 ID",
|
||||
"segment_saved_successfully": "區隔已成功儲存",
|
||||
"segment_updated_successfully": "區隔已成功更新!",
|
||||
"segment_used_in_other_surveys_make_changes_here": "此區隔已用於其他問卷。請<segmentsLink>在此</segmentsLink>進行變更。",
|
||||
"segments_help_you_target_users_with_same_characteristics_easily": "區隔可協助您輕鬆針對具有相同特徵的使用者",
|
||||
"target_audience": "目標受眾",
|
||||
"this_action_resets_all_filters_in_this_survey": "此操作會重設此問卷中的所有篩選器。",
|
||||
"this_segment_is_used_in_other_surveys": "此區隔在其他問卷中使用。請謹慎變更",
|
||||
"title_is_required": "標題為必填項。",
|
||||
"unknown_filter_type": "未知的篩選器類型",
|
||||
"unlock_segments_description": "將聯絡人整理到區隔中,以鎖定特定的使用者群組",
|
||||
"unlock_segments_title": "使用更高等級的方案解鎖區隔",
|
||||
"user_targeting_is_currently_only_available_when": "使用者目標設定目前僅在以下情況下可用:",
|
||||
"user_targeting_only_available_when_identifying_users": "使用者定向功能目前僅在透過 Formbricks SDK <docsLink>識別使用者</docsLink>時可用。",
|
||||
"value_cannot_be_empty": "值不能為空。",
|
||||
"value_must_be_a_number": "值必須是數字。",
|
||||
"value_must_be_positive": "值必須是正數。",
|
||||
"view_filters": "檢視篩選器",
|
||||
"where": "何處",
|
||||
"with_the_formbricks_sdk": "使用 Formbricks SDK"
|
||||
"where": "何處"
|
||||
},
|
||||
"settings": {
|
||||
"api_keys": {
|
||||
@@ -1067,8 +1065,8 @@
|
||||
"unlimited_workspaces": "無限工作區",
|
||||
"upgrade": "升級",
|
||||
"upgrade_now": "立即升級",
|
||||
"usage_count_of_limit_used": "{{current}} / {{limit}} <muted>已使用</muted>",
|
||||
"usage_cycle": "Usage cycle",
|
||||
"used": "已使用",
|
||||
"yearly": "年繳",
|
||||
"yearly_checkout_unavailable": "年度結帳尚未開放。請先在月繳方案中新增付款方式,或聯絡客服。",
|
||||
"your_plan": "您的方案"
|
||||
@@ -1129,7 +1127,7 @@
|
||||
"no_credit_card_no_sales_call_just_test_it": "無需信用卡。無需銷售電話。只需測試一下 :)",
|
||||
"on_request": "依要求",
|
||||
"organization_roles": "組織角色(管理員、編輯者、開發人員等)",
|
||||
"questions_please_reach_out_to": "有任何問題?請聯絡",
|
||||
"questions_please_reach_out_to_email": "有任何問題嗎?請聯繫 <contactLink>hola@formbricks.com</contactLink>",
|
||||
"recheck_license": "重新檢查授權",
|
||||
"recheck_license_failed": "授權檢查失敗。授權伺服器可能無法連線。",
|
||||
"recheck_license_instance_mismatch": "此授權已綁定至不同的 Formbricks 執行個體。請聯繫 Formbricks 支援以解除先前的綁定。",
|
||||
@@ -1355,13 +1353,12 @@
|
||||
"address_line_2": "地址 2",
|
||||
"adjust_survey_closed_message": "調整「問卷已關閉」訊息",
|
||||
"adjust_survey_closed_message_description": "變更訪客在問卷關閉時看到的訊息。",
|
||||
"adjust_the_theme_in_the": "在",
|
||||
"adjust_theme_in_look_and_feel_settings": "在<lookFeelLink>外觀與感覺</lookFeelLink>設定中調整主題。",
|
||||
"all_are_true": "全部為真",
|
||||
"all_other_answers_will_continue_to": "所有其他答案將繼續",
|
||||
"all_other_answers_will_continue_to_fallback": "所有其他答案將繼續<fallbackSelect />",
|
||||
"allow_multi_select": "允許多重選取",
|
||||
"allow_multiple_files": "允許上傳多個檔案",
|
||||
"allow_users_to_select_more_than_one_image": "允許使用者選取多張圖片",
|
||||
"and_launch_surveys_in_your_website_or_app": "並在您的網站或應用程式中啟動問卷。",
|
||||
"animation": "動畫",
|
||||
"any_is_true": "任一為真",
|
||||
"app_survey_description": "將問卷嵌入您的 Web 應用程式或網站中以收集回應。",
|
||||
@@ -1373,10 +1370,10 @@
|
||||
"auto_save_disabled": "自動儲存已停用",
|
||||
"auto_save_disabled_tooltip": "您的問卷僅在草稿狀態時自動儲存。這確保公開的問卷不會被意外更新。",
|
||||
"auto_save_on": "自動儲存已啟用",
|
||||
"automatically_close_survey_after": "在指定時間自動關閉問卷",
|
||||
"automatically_close_survey_after_n_seconds_if_no_response": "如果沒有回應,將在觸發後 <autoCloseInput /> 秒自動關閉問卷。",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "在收到一定數量的回覆後自動關閉問卷。",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "如果用戶在特定秒數後未回應,則自動關閉問卷。",
|
||||
"automatically_mark_the_survey_as_complete_after": "在指定時間後自動將問卷標記為完成",
|
||||
"automatically_mark_complete_after_n_responses": "在收到 <autoCompleteInput /> 份完整回應後自動標記問卷為已完成。",
|
||||
"back_button_label": "「返回」按鈕標籤",
|
||||
"background_styling": "背景樣式",
|
||||
"block_duplicated": "區塊已複製。",
|
||||
@@ -1434,11 +1431,11 @@
|
||||
"columns": "欄位",
|
||||
"company": "公司",
|
||||
"company_logo": "公司標誌",
|
||||
"completed_responses": "完成 回應",
|
||||
"concat": "串連 +",
|
||||
"conditional_logic": "條件邏輯",
|
||||
"confirm_default_language": "確認預設語言",
|
||||
"confirm_survey_changes": "確認問卷變更",
|
||||
"connect_formbricks_and_launch_surveys": "連接 Formbricks 並在您的網站或應用程式中啟動問卷。",
|
||||
"contact_fields": "聯絡人欄位",
|
||||
"contains": "包含",
|
||||
"continue_to_settings": "繼續設定",
|
||||
@@ -1452,7 +1449,6 @@
|
||||
"custom_hostname": "自訂主機名稱",
|
||||
"customize_survey_logo": "自訂問卷標誌",
|
||||
"darken_or_lighten_background_of_your_choice": "變暗或變亮您選擇的背景。",
|
||||
"days_before_showing_this_survey_again": "距離上次顯示問卷後,需間隔指定天數才能再次顯示此問卷。",
|
||||
"default_language": "預設語言",
|
||||
"delete_anyways": "仍要刪除",
|
||||
"delete_block": "刪除區塊",
|
||||
@@ -1558,7 +1554,6 @@
|
||||
"hide_progress_bar": "隱藏進度列",
|
||||
"hide_question_settings": "隱藏問題設定",
|
||||
"hostname": "主機名稱",
|
||||
"if_you_need_more_please": "如果您需要更多,請",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "持續顯示,直到使用者提交回應或部分回應為止。",
|
||||
"ignore_global_waiting_time": "忽略冷卻期",
|
||||
"ignore_global_waiting_time_description": "此問卷在符合條件時即可顯示,即使最近已顯示過其他問卷。",
|
||||
@@ -1598,7 +1593,7 @@
|
||||
"last_name": "姓氏",
|
||||
"let_people_upload_up_to_25_files_at_the_same_time": "允許使用者同時上傳最多 25 個檔案。",
|
||||
"limit_the_maximum_file_size": "限制上傳檔案的最大大小。",
|
||||
"limit_upload_file_size_to": "將上傳檔案大小限制為",
|
||||
"limit_upload_file_size_to_mb": "限制上傳檔案大小為 <fileSizeInput /> MB",
|
||||
"link_survey_description": "分享問卷頁面的連結或將其嵌入網頁或電子郵件中。",
|
||||
"list": "清單",
|
||||
"load_segment": "載入區隔",
|
||||
@@ -1613,7 +1608,8 @@
|
||||
"matrix_all_fields": "所有欄位",
|
||||
"matrix_rows": "列",
|
||||
"max_file_size": "最大檔案大小",
|
||||
"max_file_size_limit_is": "最大檔案大小限制為",
|
||||
"max_file_size_limit_is_mb": "檔案大小上限為 {{maxSize}} MB。",
|
||||
"max_file_size_limit_is_mb_upgrade": "檔案大小上限為 {{maxSize}} MB。如果您需要更大的容量,請<upgradeLink>升級您的方案</upgradeLink>。",
|
||||
"missing_first": "缺少的優先",
|
||||
"move_question_to_block": "將問題移至區塊",
|
||||
"multiply": "乘 *",
|
||||
@@ -1727,8 +1723,6 @@
|
||||
"save_and_close": "儲存並關閉",
|
||||
"scale": "比例",
|
||||
"search_for_images": "搜尋圖片",
|
||||
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "如果沒有回應,則在觸發後幾秒關閉問卷",
|
||||
"seconds_before_showing_the_survey": "秒後顯示問卷。",
|
||||
"select_field": "選擇欄位",
|
||||
"select_or_type_value": "選取或輸入值",
|
||||
"select_ordering": "選取排序",
|
||||
@@ -1736,7 +1730,7 @@
|
||||
"select_type": "選取類型",
|
||||
"send_survey_to_audience_who_match": "將問卷發送給符合以下條件的受眾:",
|
||||
"send_your_respondents_to_a_page_of_your_choice": "將您的回應者傳送到您選擇的頁面。",
|
||||
"set_the_global_placement_in_the_look_feel_settings": "在「外觀與風格」設定中設定整體位置。",
|
||||
"set_global_placement_in_look_feel_settings_hint": "為了讓所有問卷的位置保持一致,您可以<lookFeelLink>在外觀與感覺設定中設定全域位置。</lookFeelLink>",
|
||||
"settings_saved_successfully": "設定已成功儲存",
|
||||
"seven_points": "7 分",
|
||||
"show_block_settings": "顯示區塊設定",
|
||||
@@ -1746,7 +1740,7 @@
|
||||
"show_multiple_times": "顯示有限次數",
|
||||
"show_only_once": "僅顯示一次",
|
||||
"show_question_settings": "顯示問題設定",
|
||||
"show_survey_maximum_of": "最多顯示問卷",
|
||||
"show_survey_maximum_of_n_times": "最多顯示問卷 <displayLimitInput /> 次。",
|
||||
"show_survey_to_users": "將問卷顯示給 % 的使用者",
|
||||
"show_to_x_percentage_of_targeted_users": "顯示給 '{'percentage'}'% 的目標使用者",
|
||||
"shrink_preview": "收合預覽",
|
||||
@@ -1782,8 +1776,6 @@
|
||||
"this_action_will_remove_all_the_translations_from_this_survey": "此操作將從此問卷中移除所有翻譯。",
|
||||
"this_will_remove_the_language_and_all_its_translations": "這將會從此問卷中移除該語言及其所有翻譯。此操作無法復原。",
|
||||
"three_points": "3 分",
|
||||
"times": "次",
|
||||
"to_keep_the_placement_over_all_surveys_consistent_you_can": "若要保持所有問卷的位置一致,您可以",
|
||||
"translated": "已翻譯",
|
||||
"trigger_survey_when_one_of_the_actions_is_fired": "當觸發其中一個操作時,觸發問卷...",
|
||||
"try_lollipop_or_mountain": "嘗試「棒棒糖」或「山峰」...",
|
||||
@@ -1860,8 +1852,9 @@
|
||||
"visibility_and_recontact": "可見性與重新聯絡",
|
||||
"visibility_and_recontact_description": "控制此問卷何時可以顯示以及可以重新顯示的頻率。",
|
||||
"visible": "可見",
|
||||
"wait": "等待",
|
||||
"wait_a_few_seconds_after_the_trigger_before_showing_the_survey": "在觸發後等待幾秒鐘再顯示問卷",
|
||||
"wait_n_days_before_showing_this_survey_again": "在上次顯示問卷後等待 <daysInput /> 天或更久,才再次顯示此問卷。",
|
||||
"wait_n_seconds_before_showing_the_survey": "等待 <delayInput /> 秒後才顯示問卷。",
|
||||
"waiting_time_across_surveys": "冷卻期(跨問卷)",
|
||||
"waiting_time_across_surveys_description": "為避免問卷疲勞,請選擇此問卷如何與工作區的冷卻期互動。",
|
||||
"welcome_message": "歡迎訊息",
|
||||
@@ -1925,10 +1918,8 @@
|
||||
"search_by_survey_name": "依問卷名稱搜尋",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
"custom_single_use_id_description": "Create a readable single-use ID and copy a signed link for it.",
|
||||
"custom_single_use_id_placeholder": "CUSTOM-ID",
|
||||
"custom_single_use_id_required": "Enter a custom single-use ID.",
|
||||
"custom_single_use_id_title": "Use a custom single-use ID in the URL.",
|
||||
"custom_single_use_id_description": "如果您未加密一次性 ID,任何 “suid=...” 的值都可用於一次回應。",
|
||||
"custom_single_use_id_title": "您可以在 URL 中設置任何值 作為 一次性使用 ID",
|
||||
"custom_start_point": "自訂 開始 點",
|
||||
"data_prefilling": "資料預先填寫",
|
||||
"description": "從 這些 連結 獲得 的 回應 將是 匿名 的",
|
||||
@@ -2219,6 +2210,7 @@
|
||||
"custom_scripts_warning": "腳本將以完整瀏覽器權限執行。請僅加入來自可信來源的腳本。",
|
||||
"delete_workspace": "刪除工作區",
|
||||
"delete_workspace_confirmation": "您確定要刪除 {projectName} 嗎?此操作無法復原。",
|
||||
"delete_workspace_confirmation_name": "請在下列欄位中輸入 {projectName} 以確認永久刪除此工作區:",
|
||||
"delete_workspace_name_includes_surveys_responses_people_and_more": "刪除 {projectName}(包含所有問卷、回應、人員、操作和屬性)。",
|
||||
"delete_workspace_settings_description": "刪除工作區及其所有問卷、回應、人員、操作和屬性。此操作無法復原。",
|
||||
"error_saving_workspace_information": "儲存工作區資訊時發生錯誤",
|
||||
@@ -2471,7 +2463,7 @@
|
||||
"add_another_member": "新增另一位成員",
|
||||
"continue": "繼續",
|
||||
"failed_to_invite": "無法邀請",
|
||||
"invitation_sent_to": "已發送邀請至",
|
||||
"invitation_sent_to_email": "邀請已發送至 {{email}}!",
|
||||
"invite_your_organization_members": "邀請您的組織成員",
|
||||
"life_s_no_fun_alone": "孤單一人生活不好玩。",
|
||||
"skip": "跳過",
|
||||
|
||||
@@ -43,12 +43,9 @@ export const ShareSurveyLink = ({
|
||||
const previewUrl = new URL(surveyUrl);
|
||||
|
||||
if (survey.singleUse?.enabled) {
|
||||
const singleUseLinkParams = await refreshSingleUseId();
|
||||
if (singleUseLinkParams) {
|
||||
previewUrl.searchParams.set("suId", singleUseLinkParams.suId);
|
||||
if (singleUseLinkParams.suToken) {
|
||||
previewUrl.searchParams.set("suToken", singleUseLinkParams.suToken);
|
||||
}
|
||||
const newId = await refreshSingleUseId();
|
||||
if (newId) {
|
||||
previewUrl.searchParams.set("suId", newId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trans } from "react-i18next";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { BillingSlider } from "./billing-slider";
|
||||
|
||||
@@ -13,8 +13,6 @@ interface UsageCardProps {
|
||||
}
|
||||
|
||||
export const UsageCard = ({ metric, currentCount, limit, isUnlimited, unlimitedLabel }: UsageCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isUnlimited) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -31,8 +29,11 @@ export const UsageCard = ({ metric, currentCount, limit, isUnlimited, unlimitedL
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-slate-700">{metric}</p>
|
||||
<p className="text-sm text-slate-600">
|
||||
{currentCount.toLocaleString()} / {limit.toLocaleString()}{" "}
|
||||
<span className="text-slate-400">{t("environments.settings.billing.used")}</span>
|
||||
<Trans
|
||||
i18nKey="environments.settings.billing.usage_count_of_limit_used"
|
||||
values={{ current: currentCount.toLocaleString(), limit: limit.toLocaleString() }}
|
||||
components={{ muted: <span className="text-slate-400" /> }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<BillingSlider value={currentCount} max={limit} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ReactNode } from "react";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
@@ -52,7 +52,7 @@ export const ContactsPageLayout = async ({
|
||||
text: IS_FORMBRICKS_CLOUD ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
href: IS_FORMBRICKS_CLOUD
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
: ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
import * as crypto from "@/lib/crypto";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { generateSurveySingleUseLinkParams } from "@/lib/utils/single-use-surveys";
|
||||
import { generateSurveySingleUseId } from "@/lib/utils/single-use-surveys";
|
||||
import { getSurvey } from "@/modules/survey/lib/survey";
|
||||
import * as contactSurveyLink from "./contact-survey-link";
|
||||
|
||||
@@ -41,7 +41,7 @@ vi.mock("@/modules/survey/lib/survey", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/single-use-surveys", () => ({
|
||||
generateSurveySingleUseLinkParams: vi.fn(),
|
||||
generateSurveySingleUseId: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Contact Survey Link", () => {
|
||||
@@ -51,7 +51,7 @@ describe("Contact Survey Link", () => {
|
||||
const mockEncryptedContactId = "encrypted-contact-id";
|
||||
const mockEncryptedSurveyId = "encrypted-survey-id";
|
||||
const mockedGetSurvey = vi.mocked(getSurvey);
|
||||
const mockedGenerateSurveySingleUseLinkParams = vi.mocked(generateSurveySingleUseLinkParams);
|
||||
const mockedGenerateSurveySingleUseId = vi.mocked(generateSurveySingleUseId);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -78,10 +78,7 @@ describe("Contact Survey Link", () => {
|
||||
id: mockSurveyId,
|
||||
singleUse: { enabled: false, isEncrypted: false },
|
||||
} as TSurvey);
|
||||
mockedGenerateSurveySingleUseLinkParams.mockReturnValue({
|
||||
suId: "single-use-id",
|
||||
suToken: "signed-token",
|
||||
});
|
||||
mockedGenerateSurveySingleUseId.mockReturnValue("single-use-id");
|
||||
});
|
||||
|
||||
describe("getContactSurveyLink", () => {
|
||||
@@ -108,7 +105,7 @@ describe("Contact Survey Link", () => {
|
||||
data: `${getPublicDomain()}/c/${mockToken}`,
|
||||
});
|
||||
|
||||
expect(mockedGenerateSurveySingleUseLinkParams).not.toHaveBeenCalled();
|
||||
expect(mockedGenerateSurveySingleUseId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("adds expiration to the token when expirationDays is provided", async () => {
|
||||
@@ -147,17 +144,14 @@ describe("Contact Survey Link", () => {
|
||||
id: mockSurveyId,
|
||||
singleUse: { enabled: true, isEncrypted: false },
|
||||
} as TSurvey);
|
||||
mockedGenerateSurveySingleUseLinkParams.mockReturnValue({
|
||||
suId: "suId-unencrypted",
|
||||
suToken: "signed-token",
|
||||
});
|
||||
mockedGenerateSurveySingleUseId.mockReturnValue("suId-unencrypted");
|
||||
|
||||
const result = await contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId);
|
||||
|
||||
expect(mockedGenerateSurveySingleUseLinkParams).toHaveBeenCalledWith(mockSurveyId, false);
|
||||
expect(mockedGenerateSurveySingleUseId).toHaveBeenCalledWith(false);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: `${getPublicDomain()}/c/${mockToken}?suId=suId-unencrypted&suToken=signed-token`,
|
||||
data: `${getPublicDomain()}/c/${mockToken}?suId=suId-unencrypted`,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -166,11 +160,11 @@ describe("Contact Survey Link", () => {
|
||||
id: mockSurveyId,
|
||||
singleUse: { enabled: true, isEncrypted: true },
|
||||
} as TSurvey);
|
||||
mockedGenerateSurveySingleUseLinkParams.mockReturnValue({ suId: "suId-encrypted" });
|
||||
mockedGenerateSurveySingleUseId.mockReturnValue("suId-encrypted");
|
||||
|
||||
const result = await contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId);
|
||||
|
||||
expect(mockedGenerateSurveySingleUseLinkParams).toHaveBeenCalledWith(mockSurveyId, true);
|
||||
expect(mockedGenerateSurveySingleUseId).toHaveBeenCalledWith(true);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: `${getPublicDomain()}/c/${mockToken}?suId=suId-encrypted`,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { generateSurveySingleUseLinkParams } from "@/lib/utils/single-use-surveys";
|
||||
import { generateSurveySingleUseId } from "@/lib/utils/single-use-surveys";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { getSurvey } from "@/modules/survey/lib/survey";
|
||||
|
||||
@@ -36,10 +36,10 @@ export const getContactSurveyLink = async (
|
||||
const encryptedContactId = symmetricEncrypt(contactId, ENCRYPTION_KEY);
|
||||
const encryptedSurveyId = symmetricEncrypt(surveyId, ENCRYPTION_KEY);
|
||||
|
||||
let singleUseLinkParams: { suId: string; suToken?: string } | undefined;
|
||||
let singleUseId: string | undefined;
|
||||
|
||||
if (isSingleUseEnabled) {
|
||||
singleUseLinkParams = generateSurveySingleUseLinkParams(surveyId, isSingleUseEncrypted ?? false);
|
||||
singleUseId = generateSurveySingleUseId(isSingleUseEncrypted ?? false);
|
||||
}
|
||||
|
||||
// Create JWT payload with encrypted IDs
|
||||
@@ -62,17 +62,9 @@ export const getContactSurveyLink = async (
|
||||
const token = jwt.sign(payload, ENCRYPTION_KEY, tokenOptions);
|
||||
|
||||
// Return the personalized URL
|
||||
const surveyUrl = `${getPublicDomain()}/c/${token}`;
|
||||
if (!singleUseLinkParams) {
|
||||
return ok(surveyUrl);
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams({ suId: singleUseLinkParams.suId });
|
||||
if (singleUseLinkParams.suToken) {
|
||||
searchParams.set("suToken", singleUseLinkParams.suToken);
|
||||
}
|
||||
|
||||
return ok(`${surveyUrl}?${searchParams.toString()}`);
|
||||
return singleUseId
|
||||
? ok(`${getPublicDomain()}/c/${token}?suId=${singleUseId}`)
|
||||
: ok(`${getPublicDomain()}/c/${token}`);
|
||||
};
|
||||
|
||||
// Validates and decrypts a contact survey JWT token
|
||||
|
||||
@@ -6,7 +6,7 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import type {
|
||||
TBaseFilter,
|
||||
@@ -355,13 +355,18 @@ export function TargetingCard({
|
||||
{isSegmentUsedInOtherSurveys ? (
|
||||
<p className="mt-1 flex items-center text-xs text-slate-500">
|
||||
<AlertCircle className="mr-1 inline h-3 w-3" />
|
||||
{t("environments.segments.this_segment_is_used_in_other_surveys")}
|
||||
<Link
|
||||
className="ml-1 underline"
|
||||
href={`/environments/${environmentId}/segments`}
|
||||
target="_blank">
|
||||
{t("environments.segments.here")}
|
||||
</Link>
|
||||
<Trans
|
||||
i18nKey="environments.segments.segment_used_in_other_surveys_make_changes_here"
|
||||
components={{
|
||||
segmentsLink: (
|
||||
<Link
|
||||
className="ml-1 underline"
|
||||
href={`/environments/${environmentId}/segments`}
|
||||
target="_blank"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -430,14 +435,19 @@ export function TargetingCard({
|
||||
<Alert className="flex items-center rounded-none bg-slate-50">
|
||||
<AlertDescription className="ml-2">
|
||||
<span className="mr-1 text-slate-600">
|
||||
{t("environments.segments.user_targeting_is_currently_only_available_when")}{" "}
|
||||
<Link
|
||||
href="https://formbricks.com//docs/app-surveys/user-identification"
|
||||
target="blank"
|
||||
className="underline">
|
||||
{t("environments.segments.identifying_users")}
|
||||
</Link>{" "}
|
||||
{t("environments.segments.with_the_formbricks_sdk")}.
|
||||
<Trans
|
||||
i18nKey="environments.segments.user_targeting_only_available_when_identifying_users"
|
||||
components={{
|
||||
docsLink: (
|
||||
<Link
|
||||
href="https://formbricks.com/docs/app-surveys/user-identification"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -29,6 +29,7 @@ interface QuotasCardProps {
|
||||
isFormbricksCloud?: boolean;
|
||||
quotas: TSurveyQuota[];
|
||||
hasResponses: boolean;
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
const AddQuotaButton = ({
|
||||
@@ -67,6 +68,7 @@ export const QuotasCard = ({
|
||||
isFormbricksCloud,
|
||||
quotas,
|
||||
hasResponses,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: QuotasCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -177,7 +179,7 @@ export const QuotasCard = ({
|
||||
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
href: isFormbricksCloud
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
: enterpriseLicenseRequestFormUrl,
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { TeamsTable } from "@/modules/ee/teams/team-list/components/teams-table";
|
||||
import { getProjectsByOrganizationId } from "@/modules/ee/teams/team-list/lib/project";
|
||||
@@ -41,7 +41,7 @@ export const TeamsView = async ({
|
||||
text: IS_FORMBRICKS_CLOUD ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
href: IS_FORMBRICKS_CLOUD
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/docs/self-hosting/license#30-day-trial-license-request",
|
||||
: ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
|
||||
+5
-2
@@ -18,6 +18,7 @@ import {
|
||||
updateOrganizationEmailLogoUrlAction,
|
||||
} from "@/modules/ee/whitelabel/email-customization/actions";
|
||||
import { handleFileUpload } from "@/modules/storage/file-upload";
|
||||
import { showFileUploadErrorToast } from "@/modules/storage/file-upload-error";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Uploader } from "@/modules/ui/components/file-input/components/uploader";
|
||||
@@ -36,6 +37,7 @@ interface EmailCustomizationSettingsProps {
|
||||
user: TUser | null;
|
||||
fbLogoUrl: string;
|
||||
isStorageConfigured: boolean;
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
export const EmailCustomizationSettings = ({
|
||||
@@ -47,6 +49,7 @@ export const EmailCustomizationSettings = ({
|
||||
user,
|
||||
fbLogoUrl,
|
||||
isStorageConfigured,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: EmailCustomizationSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -136,7 +139,7 @@ export const EmailCustomizationSettings = ({
|
||||
const { url, error } = await handleFileUpload(logoFile, environmentId, allowedFileExtensions);
|
||||
|
||||
if (error) {
|
||||
toast.error(error);
|
||||
showFileUploadErrorToast(error, t);
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
@@ -184,7 +187,7 @@ export const EmailCustomizationSettings = ({
|
||||
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
href: isFormbricksCloud
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
: enterpriseLicenseRequestFormUrl,
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
|
||||
+2
-1
@@ -14,6 +14,7 @@ import {
|
||||
updateOrganizationFaviconUrlAction,
|
||||
} from "@/modules/ee/whitelabel/favicon-customization/actions";
|
||||
import { handleFileUpload } from "@/modules/storage/file-upload";
|
||||
import { showFileUploadErrorToast } from "@/modules/storage/file-upload-error";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { FileInput } from "@/modules/ui/components/file-input";
|
||||
@@ -58,7 +59,7 @@ export const FaviconCustomizationSettings = ({
|
||||
try {
|
||||
const uploadResult = await handleFileUpload(file, environmentId, allowedFileExtensions);
|
||||
if (uploadResult.error) {
|
||||
toast.error(uploadResult.error);
|
||||
showFileUploadErrorToast(uploadResult.error, t);
|
||||
return;
|
||||
}
|
||||
setFaviconUrl(uploadResult.url);
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { Project } from "@prisma/client";
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { EditBranding } from "@/modules/ee/whitelabel/remove-branding/components/edit-branding";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
@@ -26,7 +26,7 @@ export const BrandingSettingsCard = async ({
|
||||
text: IS_FORMBRICKS_CLOUD ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
href: IS_FORMBRICKS_CLOUD
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
: ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
|
||||
@@ -357,20 +357,13 @@ export const sendLinkSurveyToVerifiedEmail = async (data: TLinkSurveyEmailData):
|
||||
const email = data.email;
|
||||
const surveyName = data.surveyName;
|
||||
const singleUseId = data.suId;
|
||||
const singleUseToken = data.suToken;
|
||||
// Resolve relative storage URLs to absolute URLs for email rendering
|
||||
const logoUrl = data.logoUrl ? resolveStorageUrl(data.logoUrl) : "";
|
||||
const token = createTokenForLinkSurvey(surveyId, email);
|
||||
const t = await getTranslate(data.locale);
|
||||
const getSurveyLink = (): string => {
|
||||
if (singleUseId) {
|
||||
const surveyLink = new URL(`${getPublicDomain()}/s/${surveyId}`);
|
||||
surveyLink.searchParams.set("verify", token);
|
||||
surveyLink.searchParams.set("suId", singleUseId);
|
||||
if (singleUseToken) {
|
||||
surveyLink.searchParams.set("suToken", singleUseToken);
|
||||
}
|
||||
return surveyLink.toString();
|
||||
return `${getPublicDomain()}/s/${surveyId}?verify=${encodeURIComponent(token)}&suId=${singleUseId}`;
|
||||
}
|
||||
return `${getPublicDomain()}/s/${surveyId}?verify=${encodeURIComponent(token)}`;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
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 {
|
||||
createPinnedDispatcher,
|
||||
validateAndResolveWebhookUrl,
|
||||
validateWebhookUrl,
|
||||
} from "@/lib/utils/validate-webhook-url";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
|
||||
import { testEndpoint } from "./webhook";
|
||||
@@ -32,6 +36,12 @@ vi.mock("@/lib/crypto", () => ({
|
||||
|
||||
vi.mock("@/lib/utils/validate-webhook-url", () => ({
|
||||
validateWebhookUrl: vi.fn(async () => undefined),
|
||||
validateAndResolveWebhookUrl: vi.fn(async () => ({ ip: "93.184.216.34", family: 4 })),
|
||||
createPinnedDispatcher: vi.fn(() => ({
|
||||
__pinned: true,
|
||||
close: vi.fn(async () => undefined),
|
||||
destroy: vi.fn(async () => undefined),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lingodotdev/server", () => ({
|
||||
@@ -52,6 +62,12 @@ describe("testEndpoint", () => {
|
||||
constantsMock.dangerouslyAllow = false;
|
||||
vi.mocked(generateStandardWebhookSignature).mockReturnValue("signed-payload");
|
||||
vi.mocked(validateWebhookUrl).mockResolvedValue(undefined);
|
||||
vi.mocked(validateAndResolveWebhookUrl).mockResolvedValue({ ip: "93.184.216.34", family: 4 });
|
||||
vi.mocked(createPinnedDispatcher).mockReturnValue({
|
||||
__pinned: true,
|
||||
close: vi.fn(async () => undefined),
|
||||
destroy: vi.fn(async () => undefined),
|
||||
} as never);
|
||||
vi.mocked(getTranslate).mockResolvedValue((key: string) => key);
|
||||
vi.mocked(isDiscordWebhook).mockReturnValue(false);
|
||||
});
|
||||
@@ -80,7 +96,7 @@ describe("testEndpoint", () => {
|
||||
new InvalidInputError(messageKey)
|
||||
);
|
||||
|
||||
expect(validateWebhookUrl).toHaveBeenCalledWith("https://example.com/webhook");
|
||||
expect(validateAndResolveWebhookUrl).toHaveBeenCalledWith("https://example.com/webhook");
|
||||
expect(generateStandardWebhookSignature).toHaveBeenCalled();
|
||||
expect(getTranslate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -12,7 +12,11 @@ import {
|
||||
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "@/lib/constants";
|
||||
import { generateStandardWebhookSignature, generateWebhookSecret } from "@/lib/crypto";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
import {
|
||||
createPinnedDispatcher,
|
||||
validateAndResolveWebhookUrl,
|
||||
validateWebhookUrl,
|
||||
} from "@/lib/utils/validate-webhook-url";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
|
||||
import { TWebhookInput } from "../types/webhooks";
|
||||
@@ -163,7 +167,7 @@ export const getWebhooks = async (environmentId: string): Promise<Webhook[]> =>
|
||||
};
|
||||
|
||||
export const testEndpoint = async (url: string, secret?: string): Promise<boolean> => {
|
||||
await validateWebhookUrl(url);
|
||||
const address = await validateAndResolveWebhookUrl(url);
|
||||
|
||||
if (isDiscordWebhook(url)) {
|
||||
throw new UnknownError("Discord webhooks are currently not supported.");
|
||||
@@ -171,6 +175,10 @@ export const testEndpoint = async (url: string, secret?: string): Promise<boolea
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
// Hoisted out of the try so the finally can close it on every path.
|
||||
// Pin TCP connect to the validated IP — closes DNS-rebinding TOCTOU between
|
||||
// validation and fetch (undici otherwise resolves the hostname a second time).
|
||||
const dispatcher = address ? createPinnedDispatcher(address) : undefined;
|
||||
|
||||
try {
|
||||
const webhookMessageId = uuidv7();
|
||||
@@ -203,7 +211,8 @@ export const testEndpoint = async (url: string, secret?: string): Promise<boolea
|
||||
headers: requestHeaders,
|
||||
signal: controller.signal,
|
||||
redirect: redirectMode,
|
||||
});
|
||||
dispatcher,
|
||||
} as RequestInit & { dispatcher?: ReturnType<typeof createPinnedDispatcher> });
|
||||
|
||||
const statusCode = response.status;
|
||||
|
||||
@@ -236,5 +245,9 @@ export const testEndpoint = async (url: string, secret?: string): Promise<boolea
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
// destroy() — not close() — force-kills sockets. close() drains gracefully and
|
||||
// would deadlock if the endpoint accepted TCP but never responded (controller.abort()
|
||||
// above cancels fetch, but destroy is the belt-and-suspenders cleanup).
|
||||
await dispatcher?.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
+3
@@ -39,6 +39,7 @@ interface OrganizationActionsProps {
|
||||
isStorageConfigured: boolean;
|
||||
isTeamAdmin: boolean;
|
||||
userAdminTeamIds?: string[];
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
export const OrganizationActions = ({
|
||||
@@ -56,6 +57,7 @@ export const OrganizationActions = ({
|
||||
isStorageConfigured,
|
||||
isTeamAdmin,
|
||||
userAdminTeamIds,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: OrganizationActionsProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
@@ -174,6 +176,7 @@ export const OrganizationActions = ({
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
isTeamAdmin={isTeamAdmin}
|
||||
userAdminTeamIds={userAdminTeamIds}
|
||||
enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl}
|
||||
/>
|
||||
|
||||
<Dialog open={isLeaveOrganizationModalOpen} onOpenChange={setIsLeaveOrganizationModalOpen}>
|
||||
|
||||
+3
-1
@@ -29,6 +29,7 @@ interface IndividualInviteTabProps {
|
||||
environmentId: string;
|
||||
membershipRole?: TOrganizationRole;
|
||||
showTeamAdminRestrictions: boolean;
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
export const IndividualInviteTab = ({
|
||||
@@ -40,6 +41,7 @@ export const IndividualInviteTab = ({
|
||||
environmentId,
|
||||
membershipRole,
|
||||
showTeamAdminRestrictions,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: IndividualInviteTabProps) => {
|
||||
const ZFormSchema = z.object({
|
||||
name: ZUserName,
|
||||
@@ -191,7 +193,7 @@ export const IndividualInviteTab = ({
|
||||
href={
|
||||
isFormbricksCloud
|
||||
? `/environments/${environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license"
|
||||
: enterpriseLicenseRequestFormUrl
|
||||
}>
|
||||
{t("common.upgrade_plan")}
|
||||
</Link>
|
||||
|
||||
+3
@@ -30,6 +30,7 @@ interface InviteMemberModalProps {
|
||||
isOwnerOrManager: boolean;
|
||||
isTeamAdmin: boolean;
|
||||
userAdminTeamIds?: string[];
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
export const InviteMemberModal = ({
|
||||
@@ -45,6 +46,7 @@ export const InviteMemberModal = ({
|
||||
isOwnerOrManager,
|
||||
isTeamAdmin,
|
||||
userAdminTeamIds,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: InviteMemberModalProps) => {
|
||||
const [type, setType] = useState<"individual" | "bulk">("individual");
|
||||
|
||||
@@ -68,6 +70,7 @@ export const InviteMemberModal = ({
|
||||
teams={filteredTeams}
|
||||
membershipRole={membershipRole}
|
||||
showTeamAdminRestrictions={showTeamAdminRestrictions}
|
||||
enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl}
|
||||
/>
|
||||
),
|
||||
bulk: (
|
||||
|
||||
@@ -2,7 +2,12 @@ import { Suspense } from "react";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import { INVITE_DISABLED, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||
import {
|
||||
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
INVITE_DISABLED,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_STORAGE_CONFIGURED,
|
||||
} from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getTeamsWhereUserIsAdmin } from "@/modules/ee/teams/lib/roles";
|
||||
@@ -70,6 +75,7 @@ export const MembersView = async ({
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
|
||||
environmentId={environmentId}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
teams={teams}
|
||||
|
||||
@@ -1,44 +1,35 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { getProject, getUserProjects } from "@/lib/project/service";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { isExpectedError } from "@formbricks/types/errors";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { deleteProject } from "@/modules/projects/settings/lib/project";
|
||||
import { deleteProjectWithConfirmation, getProjectIdForLogging } from "./lib/delete-project";
|
||||
|
||||
const ZProjectDeleteAction = z.object({
|
||||
projectId: ZId,
|
||||
});
|
||||
const logProjectDeletionError = (userId: string, projectId: string, error: unknown) => {
|
||||
logger.error({ error, userId, projectId }, "Workspace deletion failed");
|
||||
};
|
||||
|
||||
export const deleteProjectAction = authenticatedActionClient.inputSchema(ZProjectDeleteAction).action(
|
||||
const shouldLogProjectDeletionError = (error: unknown) => {
|
||||
return !(error instanceof Error && isExpectedError(error));
|
||||
};
|
||||
|
||||
export const deleteProjectAction = authenticatedActionClient.inputSchema(z.unknown()).action(
|
||||
withAuditLogging("deleted", "project", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromProjectId(parsedInput.projectId);
|
||||
const projectIdForLogging = getProjectIdForLogging(parsedInput);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const availableProjects = (await getUserProjects(ctx.user.id, organizationId)) ?? null;
|
||||
|
||||
if (!!availableProjects && availableProjects?.length <= 1) {
|
||||
throw new Error("You can't delete the last project in the environment.");
|
||||
try {
|
||||
return await deleteProjectWithConfirmation({
|
||||
input: parsedInput,
|
||||
userId: ctx.user.id,
|
||||
auditLoggingCtx: ctx.auditLoggingCtx,
|
||||
});
|
||||
} catch (error) {
|
||||
if (shouldLogProjectDeletionError(error)) {
|
||||
logProjectDeletionError(ctx.user.id, projectIdForLogging, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = parsedInput.projectId;
|
||||
ctx.auditLoggingCtx.oldObject = await getProject(parsedInput.projectId);
|
||||
|
||||
// delete project
|
||||
return await deleteProject(parsedInput.projectId);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -4,14 +4,17 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { truncate } from "@/lib/utils/strings";
|
||||
import { deleteProjectAction } from "@/modules/projects/settings/general/actions";
|
||||
import { hasMatchingWorkspaceDeleteConfirmation } from "@/modules/projects/settings/general/lib/delete-project-confirmation";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
|
||||
interface DeleteProjectRenderProps {
|
||||
isDeleteDisabled: boolean;
|
||||
@@ -30,30 +33,55 @@ export const DeleteProjectRender = ({
|
||||
const router = useRouter();
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const handleDeleteProject = async () => {
|
||||
setIsDeleting(true);
|
||||
const deleteProjectResponse = await deleteProjectAction({ projectId: currentProject.id });
|
||||
if (deleteProjectResponse?.data) {
|
||||
if (organizationProjects.length === 1) {
|
||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
} else if (organizationProjects.length > 1) {
|
||||
// prevents changing of organization when deleting project
|
||||
const remainingProjects = organizationProjects.filter((project) => project.id !== currentProject.id);
|
||||
const productionEnvironment = remainingProjects[0].environments.find(
|
||||
(environment) => environment.type === "production"
|
||||
);
|
||||
if (productionEnvironment) {
|
||||
localStorage.setItem(FORMBRICKS_ENVIRONMENT_ID_LS, productionEnvironment.id);
|
||||
}
|
||||
}
|
||||
toast.success(t("environments.workspace.general.workspace_deleted_successfully"));
|
||||
router.push("/");
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(deleteProjectResponse);
|
||||
toast.error(errorMessage);
|
||||
setIsDeleteDialogOpen(false);
|
||||
const [confirmationName, setConfirmationName] = useState("");
|
||||
const hasValidConfirmation = hasMatchingWorkspaceDeleteConfirmation(confirmationName, currentProject.name);
|
||||
|
||||
const handleDeleteDialogOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
setConfirmationName("");
|
||||
}
|
||||
setIsDeleteDialogOpen(open);
|
||||
};
|
||||
|
||||
const handleDeleteProject = async () => {
|
||||
if (!hasValidConfirmation) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
const deleteProjectResponse = await deleteProjectAction({
|
||||
projectId: currentProject.id,
|
||||
confirmationName,
|
||||
});
|
||||
|
||||
if (deleteProjectResponse?.data) {
|
||||
if (organizationProjects.length === 1) {
|
||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
} else if (organizationProjects.length > 1) {
|
||||
// prevents changing of organization when deleting project
|
||||
const remainingProject = organizationProjects.find((project) => project.id !== currentProject.id);
|
||||
const productionEnvironment = remainingProject?.environments.find(
|
||||
(environment) => environment.type === "production"
|
||||
);
|
||||
if (productionEnvironment) {
|
||||
localStorage.setItem(FORMBRICKS_ENVIRONMENT_ID_LS, productionEnvironment.id);
|
||||
}
|
||||
}
|
||||
toast.success(t("environments.workspace.general.workspace_deleted_successfully"));
|
||||
router.push("/");
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(deleteProjectResponse);
|
||||
logger.error({ errorMessage, projectId: currentProject.id }, "Workspace deletion action failed");
|
||||
toast.error(errorMessage);
|
||||
handleDeleteDialogOpenChange(false);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ error, projectId: currentProject.id }, "Workspace deletion failed");
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
setIsDeleting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -91,13 +119,36 @@ export const DeleteProjectRender = ({
|
||||
<DeleteDialog
|
||||
deleteWhat={t("environments.settings.domain.workspace")}
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setIsDeleteDialogOpen}
|
||||
setOpen={handleDeleteDialogOpenChange}
|
||||
onDelete={handleDeleteProject}
|
||||
text={t("environments.workspace.general.delete_workspace_confirmation", {
|
||||
projectName: truncate(currentProject.name, 30),
|
||||
})}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
disabled={!hasValidConfirmation}>
|
||||
<div className="py-5">
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
await handleDeleteProject();
|
||||
}}>
|
||||
<label htmlFor="deleteProjectConfirmation">
|
||||
{t("environments.workspace.general.delete_workspace_confirmation_name", {
|
||||
projectName: currentProject.name,
|
||||
})}
|
||||
</label>
|
||||
<Input
|
||||
value={confirmationName}
|
||||
onChange={(e) => setConfirmationName(e.target.value)}
|
||||
placeholder={currentProject.name}
|
||||
className="mt-2"
|
||||
type="text"
|
||||
id="deleteProjectConfirmation"
|
||||
name="deleteProjectConfirmation"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</DeleteDialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { hasMatchingWorkspaceDeleteConfirmation } from "./delete-project-confirmation";
|
||||
|
||||
describe("workspace delete confirmation", () => {
|
||||
test("accepts an exact workspace name match", () => {
|
||||
expect(hasMatchingWorkspaceDeleteConfirmation("Acme Workspace", "Acme Workspace")).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts different casing", () => {
|
||||
expect(hasMatchingWorkspaceDeleteConfirmation("acme workspace", "Acme Workspace")).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts leading and trailing whitespace", () => {
|
||||
expect(hasMatchingWorkspaceDeleteConfirmation(" Acme Workspace ", "Acme Workspace")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects an empty confirmation", () => {
|
||||
expect(hasMatchingWorkspaceDeleteConfirmation("", "Acme Workspace")).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects mismatched confirmations", () => {
|
||||
expect(hasMatchingWorkspaceDeleteConfirmation("Other Workspace", "Acme Workspace")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
export const WORKSPACE_DELETE_CONFIRMATION_ERROR = "Workspace name confirmation does not match";
|
||||
|
||||
const normalizeWorkspaceNameConfirmation = (value: string) => value.trim().toLowerCase();
|
||||
|
||||
export const hasMatchingWorkspaceDeleteConfirmation = (
|
||||
confirmationName: string,
|
||||
workspaceName: string
|
||||
): boolean => {
|
||||
return (
|
||||
normalizeWorkspaceNameConfirmation(confirmationName) === normalizeWorkspaceNameConfirmation(workspaceName)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
AuthorizationError,
|
||||
InvalidInputError,
|
||||
OperationNotAllowedError,
|
||||
ResourceNotFoundError,
|
||||
} from "@formbricks/types/errors";
|
||||
import {
|
||||
DELETE_PROJECT_CONFIRMATION_REQUIRED_ERROR,
|
||||
deleteProjectWithConfirmation,
|
||||
getProjectIdForLogging,
|
||||
} from "./delete-project";
|
||||
import { WORKSPACE_DELETE_CONFIRMATION_ERROR } from "./delete-project-confirmation";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
checkAuthorizationUpdated: vi.fn(),
|
||||
deleteProject: vi.fn(),
|
||||
getProject: vi.fn(),
|
||||
getUserProjects: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/project/service", () => ({
|
||||
getProject: mocks.getProject,
|
||||
getUserProjects: mocks.getUserProjects,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
|
||||
checkAuthorizationUpdated: mocks.checkAuthorizationUpdated,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/projects/settings/lib/project", () => ({
|
||||
deleteProject: mocks.deleteProject,
|
||||
}));
|
||||
|
||||
const baseProject = {
|
||||
id: "cmproject000000000000000000",
|
||||
name: "Acme Workspace",
|
||||
organizationId: "cmorg00000000000000000000",
|
||||
};
|
||||
|
||||
const userId = "cmuser00000000000000000000";
|
||||
|
||||
const callDeleteProjectWithConfirmation = (input = {}) =>
|
||||
deleteProjectWithConfirmation({
|
||||
input: {
|
||||
projectId: baseProject.id,
|
||||
confirmationName: baseProject.name,
|
||||
...input,
|
||||
},
|
||||
userId,
|
||||
auditLoggingCtx: {},
|
||||
});
|
||||
|
||||
describe("deleteProjectWithConfirmation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.checkAuthorizationUpdated.mockResolvedValue(undefined);
|
||||
mocks.getProject.mockResolvedValue(baseProject);
|
||||
mocks.getUserProjects.mockResolvedValue([baseProject, { ...baseProject, id: "cmproject2" }]);
|
||||
mocks.deleteProject.mockResolvedValue(baseProject);
|
||||
});
|
||||
|
||||
test("deletes a workspace when the confirmation name matches", async () => {
|
||||
const auditLoggingCtx = {};
|
||||
|
||||
const result = await deleteProjectWithConfirmation({
|
||||
input: {
|
||||
projectId: baseProject.id,
|
||||
confirmationName: "acme workspace",
|
||||
},
|
||||
userId,
|
||||
auditLoggingCtx,
|
||||
});
|
||||
|
||||
expect(mocks.checkAuthorizationUpdated).toHaveBeenCalledWith({
|
||||
userId,
|
||||
organizationId: baseProject.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mocks.getUserProjects).toHaveBeenCalledWith(userId, baseProject.organizationId);
|
||||
expect(mocks.deleteProject).toHaveBeenCalledWith(baseProject.id);
|
||||
expect(auditLoggingCtx).toMatchObject({
|
||||
organizationId: baseProject.organizationId,
|
||||
projectId: baseProject.id,
|
||||
oldObject: baseProject,
|
||||
});
|
||||
expect(result).toEqual(baseProject);
|
||||
});
|
||||
|
||||
test("rejects invalid input before any project lookup", async () => {
|
||||
await expect(
|
||||
deleteProjectWithConfirmation({
|
||||
input: {},
|
||||
userId,
|
||||
auditLoggingCtx: {},
|
||||
})
|
||||
).rejects.toThrow(InvalidInputError);
|
||||
await expect(
|
||||
deleteProjectWithConfirmation({
|
||||
input: {},
|
||||
userId,
|
||||
auditLoggingCtx: {},
|
||||
})
|
||||
).rejects.toThrow(DELETE_PROJECT_CONFIRMATION_REQUIRED_ERROR);
|
||||
|
||||
expect(mocks.getProject).not.toHaveBeenCalled();
|
||||
expect(mocks.deleteProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not delete when the confirmation name does not match", async () => {
|
||||
const deleteAttempt = callDeleteProjectWithConfirmation({ confirmationName: "Other Workspace" });
|
||||
|
||||
await expect(deleteAttempt).rejects.toThrow(InvalidInputError);
|
||||
await expect(deleteAttempt).rejects.toThrow(WORKSPACE_DELETE_CONFIRMATION_ERROR);
|
||||
|
||||
expect(mocks.checkAuthorizationUpdated).not.toHaveBeenCalled();
|
||||
expect(mocks.getUserProjects).not.toHaveBeenCalled();
|
||||
expect(mocks.deleteProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not delete when the workspace cannot be found", async () => {
|
||||
mocks.getProject.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(callDeleteProjectWithConfirmation()).rejects.toThrow(ResourceNotFoundError);
|
||||
|
||||
expect(mocks.checkAuthorizationUpdated).not.toHaveBeenCalled();
|
||||
expect(mocks.deleteProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not delete when authorization fails", async () => {
|
||||
mocks.checkAuthorizationUpdated.mockRejectedValueOnce(new AuthorizationError("Not authorized"));
|
||||
|
||||
await expect(callDeleteProjectWithConfirmation()).rejects.toThrow(AuthorizationError);
|
||||
|
||||
expect(mocks.getUserProjects).not.toHaveBeenCalled();
|
||||
expect(mocks.deleteProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not delete the last available workspace", async () => {
|
||||
mocks.getUserProjects.mockResolvedValueOnce([baseProject]);
|
||||
|
||||
await expect(callDeleteProjectWithConfirmation()).rejects.toThrow(OperationNotAllowedError);
|
||||
|
||||
expect(mocks.deleteProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rethrows downstream delete failures", async () => {
|
||||
const error = new Error("delete failed");
|
||||
mocks.deleteProject.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(callDeleteProjectWithConfirmation()).rejects.toThrow(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getProjectIdForLogging", () => {
|
||||
test("returns the project id when present", () => {
|
||||
expect(getProjectIdForLogging({ projectId: baseProject.id })).toBe(baseProject.id);
|
||||
});
|
||||
|
||||
test("returns unknown when the project id is missing or invalid", () => {
|
||||
expect(getProjectIdForLogging({})).toBe("unknown");
|
||||
expect(getProjectIdForLogging({ projectId: 123 })).toBe("unknown");
|
||||
expect(getProjectIdForLogging(null)).toBe("unknown");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getProject, getUserProjects } from "@/lib/project/service";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { deleteProject } from "@/modules/projects/settings/lib/project";
|
||||
import {
|
||||
WORKSPACE_DELETE_CONFIRMATION_ERROR,
|
||||
hasMatchingWorkspaceDeleteConfirmation,
|
||||
} from "./delete-project-confirmation";
|
||||
|
||||
const ZProjectDeleteAction = z.object({
|
||||
projectId: ZId,
|
||||
confirmationName: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export const DELETE_PROJECT_CONFIRMATION_REQUIRED_ERROR =
|
||||
"Workspace name confirmation is required to delete this workspace.";
|
||||
|
||||
export const parseProjectDeleteActionInput = (input: unknown) => {
|
||||
const parsedInput = ZProjectDeleteAction.safeParse(input);
|
||||
|
||||
if (!parsedInput.success) {
|
||||
throw new InvalidInputError(DELETE_PROJECT_CONFIRMATION_REQUIRED_ERROR);
|
||||
}
|
||||
|
||||
return parsedInput.data;
|
||||
};
|
||||
|
||||
export const getProjectIdForLogging = (input: unknown) => {
|
||||
if (typeof input !== "object" || input === null || !("projectId" in input)) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
const projectId = input.projectId;
|
||||
|
||||
return typeof projectId === "string" ? projectId : "unknown";
|
||||
};
|
||||
|
||||
const assertMatchingWorkspaceDeleteConfirmation = (confirmationName: string, workspaceName: string) => {
|
||||
if (!hasMatchingWorkspaceDeleteConfirmation(confirmationName, workspaceName)) {
|
||||
throw new InvalidInputError(WORKSPACE_DELETE_CONFIRMATION_ERROR);
|
||||
}
|
||||
};
|
||||
|
||||
interface DeleteProjectWithConfirmationParams {
|
||||
input: unknown;
|
||||
userId: string;
|
||||
auditLoggingCtx: {
|
||||
organizationId?: string;
|
||||
projectId?: string;
|
||||
oldObject?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export const deleteProjectWithConfirmation = async ({
|
||||
input,
|
||||
userId,
|
||||
auditLoggingCtx,
|
||||
}: DeleteProjectWithConfirmationParams) => {
|
||||
const { confirmationName, projectId } = parseProjectDeleteActionInput(input);
|
||||
const project = await getProject(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new ResourceNotFoundError("project", projectId);
|
||||
}
|
||||
|
||||
assertMatchingWorkspaceDeleteConfirmation(confirmationName, project.name);
|
||||
|
||||
const organizationId = project.organizationId;
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const availableProjects = await getUserProjects(userId, organizationId);
|
||||
|
||||
if (availableProjects.length <= 1) {
|
||||
throw new OperationNotAllowedError("You can't delete the last project in the environment.");
|
||||
}
|
||||
|
||||
auditLoggingCtx.organizationId = organizationId;
|
||||
auditLoggingCtx.projectId = projectId;
|
||||
auditLoggingCtx.oldObject = project;
|
||||
|
||||
return await deleteProject(projectId);
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { updateProjectAction } from "@/modules/projects/settings/actions";
|
||||
import { handleFileUpload } from "@/modules/storage/file-upload";
|
||||
import { showFileUploadErrorToast } from "@/modules/storage/file-upload-error";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -39,7 +40,7 @@ export const EditLogo = ({ project, environmentId, isReadOnly, isStorageConfigur
|
||||
try {
|
||||
const uploadResult = await handleFileUpload(file, environmentId);
|
||||
if (uploadResult.error) {
|
||||
toast.error(uploadResult.error);
|
||||
showFileUploadErrorToast(uploadResult.error, t);
|
||||
return;
|
||||
}
|
||||
setLogoUrl(uploadResult.url);
|
||||
|
||||
+2
-2
@@ -44,12 +44,12 @@ export const InviteMembers = ({ IS_SMTP_CONFIGURED, organizationId }: InviteMemb
|
||||
organizationId,
|
||||
});
|
||||
if (inviteResponse?.data) {
|
||||
toast.success(`${t("setup.invite.invitation_sent_to")} ${member.email}!`);
|
||||
toast.success(t("setup.invite.invitation_sent_to_email", { email: member.email }));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(inviteResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error(`${t("setup.invite.failed_to_invite")} ${member.email}.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { type TFunction } from "i18next";
|
||||
import toast from "react-hot-toast";
|
||||
import { FileUploadError } from "@/modules/storage/file-upload";
|
||||
import { showStorageNotConfiguredToast } from "@/modules/ui/components/storage-not-configured-toast/lib/utils";
|
||||
|
||||
export const getFileUploadErrorMessage = (error: FileUploadError, t: TFunction): string => {
|
||||
switch (error) {
|
||||
case FileUploadError.NO_FILE:
|
||||
return t("common.no_files_uploaded");
|
||||
case FileUploadError.INVALID_FILE_TYPE:
|
||||
return t("common.invalid_file_type");
|
||||
case FileUploadError.FILE_SIZE_EXCEEDED:
|
||||
return t("common.file_size_must_be_less_than_5_mb");
|
||||
case FileUploadError.INVALID_FILE_NAME:
|
||||
return t("common.invalid_file_name");
|
||||
case FileUploadError.STORAGE_NOT_CONFIGURED:
|
||||
return t("common.storage_not_configured");
|
||||
case FileUploadError.STORAGE_UPLOAD_FAILED:
|
||||
return t("common.file_upload_service_unavailable");
|
||||
case FileUploadError.UPLOAD_FAILED:
|
||||
default:
|
||||
return t("common.upload_failed");
|
||||
}
|
||||
};
|
||||
|
||||
export const showFileUploadErrorToast = (error: FileUploadError, t: TFunction): void => {
|
||||
if (error === FileUploadError.STORAGE_NOT_CONFIGURED) {
|
||||
showStorageNotConfiguredToast("notConfigured");
|
||||
return;
|
||||
}
|
||||
|
||||
if (error === FileUploadError.STORAGE_UPLOAD_FAILED) {
|
||||
showStorageNotConfiguredToast("uploadUnavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(getFileUploadErrorMessage(error, t));
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { STORAGE_ERROR_CODES } from "@formbricks/types/storage";
|
||||
import * as fileUploadModule from "./file-upload";
|
||||
|
||||
// Mock global fetch
|
||||
@@ -67,7 +68,41 @@ describe("fileUpload", () => {
|
||||
});
|
||||
|
||||
const result = await fileUploadModule.handleFileUpload(file, "test-env");
|
||||
expect(result.error).toBe("Upload failed. Please try again.");
|
||||
expect(result.error).toBe(fileUploadModule.FileUploadError.UPLOAD_FAILED);
|
||||
expect(result.url).toBe("");
|
||||
});
|
||||
|
||||
test("should return STORAGE_NOT_CONFIGURED when signing API returns a storage configuration error", async () => {
|
||||
const file = createMockFile("test.jpg", "image/jpeg", 1000);
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({
|
||||
code: "internal_server_error",
|
||||
message: "File storage is not configured correctly. Please check your file upload settings.",
|
||||
details: { storage_error_code: STORAGE_ERROR_CODES.S3_CREDENTIALS_ERROR },
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await fileUploadModule.handleFileUpload(file, "test-env");
|
||||
|
||||
expect(result.error).toBe(fileUploadModule.FileUploadError.STORAGE_NOT_CONFIGURED);
|
||||
expect(result.url).toBe("");
|
||||
});
|
||||
|
||||
test("should return INVALID_FILE_NAME when signing API rejects the file name", async () => {
|
||||
const file = createMockFile("----.jpg", "image/jpeg", 1000);
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: async () => ({ details: { fileName: "Invalid file name" } }),
|
||||
});
|
||||
|
||||
const result = await fileUploadModule.handleFileUpload(file, "test-env");
|
||||
|
||||
expect(result.error).toBe(fileUploadModule.FileUploadError.INVALID_FILE_NAME);
|
||||
expect(result.url).toBe("");
|
||||
});
|
||||
|
||||
@@ -129,7 +164,7 @@ describe("fileUpload", () => {
|
||||
}, 0);
|
||||
|
||||
const result = await fileUploadModule.handleFileUpload(file, "test-env");
|
||||
expect(result.error).toBe("Upload failed. Please try again.");
|
||||
expect(result.error).toBe(fileUploadModule.FileUploadError.UPLOAD_FAILED);
|
||||
expect(result.url).toBe("");
|
||||
});
|
||||
|
||||
@@ -161,7 +196,34 @@ describe("fileUpload", () => {
|
||||
}, 0);
|
||||
|
||||
const result = await fileUploadModule.handleFileUpload(file, "test-env");
|
||||
expect(result.error).toBe("Upload failed. Please try again.");
|
||||
expect(result.error).toBe(fileUploadModule.FileUploadError.STORAGE_UPLOAD_FAILED);
|
||||
expect(result.url).toBe("");
|
||||
});
|
||||
|
||||
test("should return STORAGE_UPLOAD_FAILED when storage upload request throws", async () => {
|
||||
const file = createMockFile("test.jpg", "image/jpeg", 1000);
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
signedUrl: "https://s3.example.com/upload",
|
||||
fileUrl: "/storage/test-env/public/file.jpg",
|
||||
presignedFields: {
|
||||
key: "value",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
mockFetch.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
setTimeout(() => {
|
||||
mockFileReader.onload();
|
||||
}, 0);
|
||||
|
||||
const result = await fileUploadModule.handleFileUpload(file, "test-env");
|
||||
expect(result.error).toBe(fileUploadModule.FileUploadError.STORAGE_UPLOAD_FAILED);
|
||||
expect(result.url).toBe("");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,45 @@
|
||||
import { STORAGE_CONFIGURATION_ERROR_CODES, type TStorageApiErrorDetails } from "@formbricks/types/storage";
|
||||
|
||||
export enum FileUploadError {
|
||||
NO_FILE = "No file provided or invalid file type. Expected a File or Blob.",
|
||||
INVALID_FILE_TYPE = "Please upload an image file.",
|
||||
FILE_SIZE_EXCEEDED = "File size must be less than 5 MB.",
|
||||
UPLOAD_FAILED = "Upload failed. Please try again.",
|
||||
INVALID_FILE_NAME = "Invalid file name. Please rename your file and try again.",
|
||||
NO_FILE = "no_file",
|
||||
INVALID_FILE_TYPE = "invalid_file_type",
|
||||
FILE_SIZE_EXCEEDED = "file_size_exceeded",
|
||||
UPLOAD_FAILED = "upload_failed",
|
||||
INVALID_FILE_NAME = "invalid_file_name",
|
||||
STORAGE_NOT_CONFIGURED = "storage_not_configured",
|
||||
STORAGE_UPLOAD_FAILED = "storage_upload_failed",
|
||||
}
|
||||
|
||||
type UploadApiErrorResponse = {
|
||||
details?: TStorageApiErrorDetails;
|
||||
};
|
||||
|
||||
const parseUploadApiError = async (response: Response): Promise<UploadApiErrorResponse | undefined> => {
|
||||
try {
|
||||
return (await response.json()) as UploadApiErrorResponse;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const getFileUploadErrorFromResponse = async (response: Response): Promise<FileUploadError> => {
|
||||
const json = await parseUploadApiError(response);
|
||||
|
||||
if (response.status === 400 && json?.details?.fileName) {
|
||||
return FileUploadError.INVALID_FILE_NAME;
|
||||
}
|
||||
|
||||
if (
|
||||
response.status >= 500 &&
|
||||
json?.details?.storage_error_code &&
|
||||
STORAGE_CONFIGURATION_ERROR_CODES.has(json.details.storage_error_code)
|
||||
) {
|
||||
return FileUploadError.STORAGE_NOT_CONFIGURED;
|
||||
}
|
||||
|
||||
return FileUploadError.UPLOAD_FAILED;
|
||||
};
|
||||
|
||||
export const toBase64 = (file: File) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -61,18 +95,8 @@ export const handleFileUpload = async (
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 400) {
|
||||
const json = (await response.json()) as { details?: { fileName?: string } };
|
||||
if (json.details?.fileName) {
|
||||
return {
|
||||
error: FileUploadError.INVALID_FILE_NAME,
|
||||
url: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
error: FileUploadError.UPLOAD_FAILED,
|
||||
error: await getFileUploadErrorFromResponse(response),
|
||||
url: "",
|
||||
};
|
||||
}
|
||||
@@ -107,14 +131,24 @@ export const handleFileUpload = async (
|
||||
};
|
||||
}
|
||||
|
||||
const uploadResponse = await fetch(signedUrl, {
|
||||
method: "POST",
|
||||
body: formDataForS3,
|
||||
});
|
||||
let uploadResponse: Response;
|
||||
|
||||
try {
|
||||
uploadResponse = await fetch(signedUrl, {
|
||||
method: "POST",
|
||||
body: formDataForS3,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error in uploading file: ", err);
|
||||
return {
|
||||
error: FileUploadError.STORAGE_UPLOAD_FAILED,
|
||||
url: "",
|
||||
};
|
||||
}
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
return {
|
||||
error: FileUploadError.UPLOAD_FAILED,
|
||||
error: FileUploadError.STORAGE_UPLOAD_FAILED,
|
||||
url: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,7 +98,9 @@ describe("storage utils", () => {
|
||||
);
|
||||
const spyISE = vi
|
||||
.spyOn(responseMod.responses, "internalServerErrorResponse")
|
||||
.mockImplementation((_msg: string, _public?: boolean) => new Response(null, { status: 500 }));
|
||||
.mockImplementation((msg: string, _public?: boolean, details = {}) =>
|
||||
Response.json({ code: "internal_server_error", message: msg, details }, { status: 500 })
|
||||
);
|
||||
|
||||
const { getErrorResponseFromStorageError } = await import("@/modules/storage/utils");
|
||||
|
||||
@@ -120,8 +122,16 @@ describe("storage utils", () => {
|
||||
// S3 related and Unknown -> 500
|
||||
const r500a = getErrorResponseFromStorageError({ code: StorageErrorCode.S3ClientError });
|
||||
expect(r500a.status).toBe(500);
|
||||
await expect(r500a.json()).resolves.toMatchObject({
|
||||
message: "File storage is not configured correctly. Please check your file upload settings.",
|
||||
details: { storage_error_code: StorageErrorCode.S3ClientError },
|
||||
});
|
||||
const r500b = getErrorResponseFromStorageError({ code: StorageErrorCode.S3CredentialsError });
|
||||
expect(r500b.status).toBe(500);
|
||||
await expect(r500b.json()).resolves.toMatchObject({
|
||||
message: "File storage is not configured correctly. Please check your file upload settings.",
|
||||
details: { storage_error_code: StorageErrorCode.S3CredentialsError },
|
||||
});
|
||||
const r500c = getErrorResponseFromStorageError({ code: StorageErrorCode.Unknown });
|
||||
expect(r500c.status).toBe(500);
|
||||
|
||||
|
||||
@@ -121,9 +121,13 @@ export const getErrorResponseFromStorageError = (
|
||||
case StorageErrorCode.InvalidInput:
|
||||
return responses.badRequestResponse("Invalid input", details, true);
|
||||
case StorageErrorCode.S3ClientError:
|
||||
return responses.internalServerErrorResponse("Internal server error", true);
|
||||
case StorageErrorCode.S3CredentialsError:
|
||||
return responses.internalServerErrorResponse("Internal server error", true);
|
||||
return responses.internalServerErrorResponse(
|
||||
"File storage is not configured correctly. Please check your file upload settings.",
|
||||
true,
|
||||
{ storage_error_code: error.code },
|
||||
"private, no-store"
|
||||
);
|
||||
case StorageErrorCode.Unknown:
|
||||
return responses.internalServerErrorResponse("Internal server error", true);
|
||||
default: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user