Compare commits

..

27 Commits

Author SHA1 Message Date
Dhruwang 87cc477547 fix(security): prevent SSRF via redirect following in webhook delivery
Webhook fetch calls followed HTTP redirects by default, allowing an
attacker to bypass URL validation by returning a redirect to an
internal endpoint (e.g. cloud metadata service). Setting redirect to
manual prevents the fetch from following redirects automatically.
2026-04-27 10:47:52 +05:30
Tiago 4128731c5f fix: password hash visibility improvement (#7814)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-24 18:16:59 +00:00
Matti Nannt ef96426ca0 chore(security): dependency audit — reduce attack surface & resolve all vulnerabilities (#7801)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 20:32:32 +02:00
Dhruwang Jariwala ce1dbe8b00 fix: apply plan changes immediately for non-standard plans (#7807) 2026-04-24 07:38:33 +00:00
Dhruwang Jariwala 444f043140 fix: prevent Airtable integration crash when token expires (#7811)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 08:36:10 +00:00
Dhruwang Jariwala 2d32c0d671 feat: add iframe preview to website embed tab (#7791)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 06:36:55 +00:00
Dhruwang Jariwala 8dc70a5e30 fix: prevent survey widget CSS from polluting host page styles (#7805)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 12:24:44 +00:00
Aryan Ghugare 3e4e55fbf1 fix: prevent bypass of single-use survey restriction via v1 API (#7735)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-22 09:48:18 +00:00
Tiago fcfedd6e15 feat: replace minio with rustfs (#7742) 2026-04-22 08:07:38 +00:00
Tiago 6c4342690f fix: harden legacy SSO relinking (#7755) 2026-04-22 07:35:23 +00:00
arasucar b8c361fcf3 refactor: use context instead of prop drilling in survey analysis components (#6223) (#7754)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-22 06:00:13 +00:00
Matti Nannt 8771a0ec91 fix: lodash vulnerability (#7800) 2026-04-22 05:57:08 +00:00
Bhagya Amarasinghe fc33c52133 fix: patch protobufjs transitive vulnerabilities (#7790) 2026-04-21 09:59:12 +00:00
Balázs Úr 75cf9293b1 fix: Hungarian translation (#7752) 2026-04-21 08:41:53 +00:00
Serhat e489c6a346 feat: Add Turkish (tr) translations (#7645)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-20 12:51:25 +00:00
Johannes cefc2bdf60 fix: show oversized upload error when mime type is missing (#7757)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-04-20 07:00:41 +00:00
dependabot[bot] 78473bf3d0 chore(deps): bump the npm_and_yarn group across 12 directories with 4 updates (#7680)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2026-04-20 06:59:52 +00:00
Johannes 15403c6a92 fix: add accessible dialog title to project limit modal (#7769)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-20 06:45:21 +00:00
Johannes 35b98863a4 feat: auto-fill safe attribute key from label (#7771)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-20 06:44:10 +00:00
Anshuman Pandey 65f5968fb1 fix: fixes sentry ref issue (#7776) 2026-04-20 06:29:44 +00:00
Bhagya Amarasinghe 2dfea4d72f fix: prevent split offline responses on restore (#7767) 2026-04-20 06:05:13 +00:00
Dhruwang Jariwala ff77118932 fix: response tag UI issues in response modal (#7765) 2026-04-17 11:59:59 +00:00
Johannes 79a773432a feat: extend auto-progress to single-select question types (#7725) 2026-04-17 10:17:00 +00:00
Niels Kaspers d53869f1df fix: fix duplicate block and misleading subheader in trial conversion template (#7560)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-17 10:01:54 +00:00
Balázs Úr fc9ddb2b0d fix: mark Identify Customer Goals survey as translatable (#7566)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-17 09:53:15 +00:00
Bhagya Amarasinghe 6fcb6863bd feat: migrate survey overview to v3 APIs (#7741) 2026-04-17 09:45:12 +00:00
Johannes b1cee91ad9 fix: redirect active project and organization selections (#7724)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-17 09:33:12 +00:00
254 changed files with 16533 additions and 6154 deletions
+4 -4
View File
@@ -20,12 +20,12 @@ runs:
using: "composite"
steps:
- name: Checkout repo
uses: actions/checkout@v3
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./.github/actions/dangerous-git-checkout
- name: Cache Build
uses: actions/cache@v3
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
id: cache-build
env:
cache-name: prod-build
@@ -43,7 +43,7 @@ runs:
shell: bash
- name: Setup Node.js 20.x
uses: actions/setup-node@v3
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20.x
if: steps.cache-build.outputs.cache-hit != 'true'
@@ -53,7 +53,7 @@ runs:
if: steps.cache-build.outputs.cache-hit != 'true'
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
if: steps.cache-build.outputs.cache-hit != 'true'
shell: bash
@@ -4,7 +4,7 @@ runs:
using: "composite"
steps:
- name: Checkout repo
uses: actions/checkout@v3
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 2
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: Run Chromatic
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
+37 -48
View File
@@ -57,7 +57,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 22.x
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
@@ -65,7 +65,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
shell: bash
- name: create .env
@@ -85,65 +85,48 @@ jobs:
echo "S3_REGION=us-east-1" >> .env
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
echo "S3_ACCESS_KEY=devminio" >> .env
echo "S3_SECRET_KEY=devminio123" >> .env
echo "S3_ACCESS_KEY=devrustfs-service" >> .env
echo "S3_SECRET_KEY=devrustfs-service123" >> .env
echo "S3_FORCE_PATH_STYLE=1" >> .env
shell: bash
- name: Install MinIO client (mc)
run: |
set -euo pipefail
MC_VERSION="RELEASE.2025-08-13T08-35-41Z"
MC_BASE="https://dl.min.io/client/mc/release/linux-amd64/archive"
MC_BIN="mc.${MC_VERSION}"
MC_SUM="${MC_BIN}.sha256sum"
curl -fsSL "${MC_BASE}/${MC_BIN}" -o "${MC_BIN}"
curl -fsSL "${MC_BASE}/${MC_SUM}" -o "${MC_SUM}"
sha256sum -c "${MC_SUM}"
chmod +x "${MC_BIN}"
sudo mv "${MC_BIN}" /usr/local/bin/mc
- name: Start MinIO Server
- name: Start RustFS Server
run: |
set -euo pipefail
# Start MinIO server in background
# Start RustFS server in background
docker run -d \
--name minio-server \
--name rustfs-server \
-p 9000:9000 \
-p 9001:9001 \
-e MINIO_ROOT_USER=devminio \
-e MINIO_ROOT_PASSWORD=devminio123 \
minio/minio:RELEASE.2025-09-07T16-13-09Z \
server /data --console-address :9001
-e RUSTFS_ACCESS_KEY=devrustfs \
-e RUSTFS_SECRET_KEY=devrustfs123 \
-e RUSTFS_ADDRESS=:9000 \
-e RUSTFS_CONSOLE_ENABLE=true \
-e RUSTFS_CONSOLE_ADDRESS=:9001 \
rustfs/rustfs:1.0.0-alpha.93 \
/data
echo "MinIO server started"
echo "RustFS server started"
- name: Wait for MinIO and create S3 bucket
- name: Bootstrap RustFS bucket and browser upload CORS
run: |
set -euo pipefail
echo "Waiting for MinIO to be ready..."
ready=0
for i in {1..60}; do
if curl -fsS http://localhost:9000/minio/health/live >/dev/null; then
echo "MinIO is up after ${i} seconds"
ready=1
break
fi
sleep 1
done
if [ "$ready" -ne 1 ]; then
echo "::error::MinIO did not become ready within 60 seconds"
exit 1
fi
mc alias set local http://localhost:9000 devminio devminio123
mc mb --ignore-existing local/formbricks-e2e
docker run --rm \
--network host \
--entrypoint /bin/sh \
-e RUSTFS_ENDPOINT_URL=http://127.0.0.1:9000 \
-e RUSTFS_ADMIN_USER=devrustfs \
-e RUSTFS_ADMIN_PASSWORD=devrustfs123 \
-e RUSTFS_SERVICE_USER=devrustfs-service \
-e RUSTFS_SERVICE_PASSWORD=devrustfs-service123 \
-e RUSTFS_BUCKET_NAME=formbricks-e2e \
-e RUSTFS_POLICY_NAME=formbricks-e2e-policy \
-e RUSTFS_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 \
-v "$PWD/docker/rustfs-init.sh:/tmp/rustfs-init.sh:ro" \
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 \
/tmp/rustfs-init.sh
- name: Build App
run: |
@@ -242,8 +225,14 @@ jobs:
if: failure()
with:
name: app-logs
if-no-files-found: ignore
path: app.log
- name: Output App Logs
if: failure()
run: cat app.log
run: |
if [ -f app.log ]; then
cat app.log
else
echo "app.log not found because the Run App step did not execute or failed before log creation."
fi
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20.x
@@ -29,7 +29,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Setup Node.js 22.x
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
@@ -33,7 +33,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20.x
@@ -30,7 +30,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
+3 -2
View File
@@ -2,6 +2,7 @@ name: Translation Validation
permissions:
contents: read
pull-requests: read
on:
pull_request:
@@ -39,7 +40,7 @@ jobs:
- name: Setup Node.js 22.x
if: steps.changes.outputs.translations == 'true'
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
@@ -49,7 +50,7 @@ jobs:
- name: Install dependencies
if: steps.changes.outputs.translations == 'true'
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: Validate translation keys
if: steps.changes.outputs.translations == 'true'
+9 -9
View File
@@ -12,18 +12,18 @@
},
"devDependencies": {
"@chromatic-com/storybook": "^5.0.1",
"@storybook/addon-a11y": "10.2.17",
"@storybook/addon-links": "10.2.17",
"@storybook/addon-onboarding": "10.2.17",
"@storybook/react-vite": "10.2.17",
"@typescript-eslint/eslint-plugin": "8.57.0",
"@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",
"@tailwindcss/vite": "4.2.1",
"@typescript-eslint/eslint-plugin": "8.57.0",
"@typescript-eslint/parser": "8.57.0",
"@vitejs/plugin-react": "5.1.4",
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.2.17",
"storybook": "10.2.17",
"vite": "7.3.1",
"@storybook/addon-docs": "10.2.17"
"eslint-plugin-storybook": "10.3.5",
"storybook": "10.3.5",
"vite": "7.3.2"
}
}
@@ -1,4 +1,4 @@
import { Prisma } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
@@ -32,7 +32,7 @@ describe("getTeamsByOrganizationId", () => {
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
new PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
});
@@ -1,6 +1,6 @@
"use server";
import { Prisma } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
@@ -27,7 +27,7 @@ export const getTeamsByOrganizationId = reactCache(
name: team.name,
}));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error instanceof PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
@@ -409,16 +409,22 @@ export const MainNavigation = ({
: `/environments/${environment.id}/surveys/`;
const handleProjectChange = (projectId: string) => {
if (projectId === project.id) return;
const targetPath =
projectId === project.id ? `/environments/${environment.id}/surveys` : `/workspaces/${projectId}/`;
startTransition(() => {
router.push(`/workspaces/${projectId}/`);
setIsWorkspaceDropdownOpen(false);
router.push(targetPath);
});
};
const handleOrganizationChange = (organizationId: string) => {
if (organizationId === organization.id) return;
const targetPath =
organizationId === organization.id
? `/environments/${environment.id}/settings/general`
: `/organizations/${organizationId}/`;
startTransition(() => {
router.push(`/organizations/${organizationId}/`);
setIsOrganizationDropdownOpen(false);
router.push(targetPath);
});
};
@@ -114,8 +114,12 @@ export const OrganizationBreadcrumb = ({
}
const handleOrganizationChange = (organizationId: string) => {
if (organizationId === currentOrganizationId) return;
startTransition(() => {
setIsOrganizationDropdownOpen(false);
if (organizationId === currentOrganizationId && currentEnvironmentId) {
router.push(`/environments/${currentEnvironmentId}/settings/general`);
return;
}
router.push(`/organizations/${organizationId}/`);
});
};
@@ -152,9 +152,13 @@ export const ProjectBreadcrumb = ({
}
const handleProjectChange = (projectId: string) => {
if (projectId === currentProjectId) return;
const targetPath =
projectId === currentProjectId
? `/environments/${currentEnvironmentId}/surveys`
: `/workspaces/${projectId}/`;
startTransition(() => {
router.push(`/workspaces/${projectId}/`);
setIsProjectDropdownOpen(false);
router.push(targetPath);
});
};
@@ -3,25 +3,22 @@
import { InboxIcon, PresentationIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { revalidateSurveyIdPath } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
interface SurveyAnalysisNavigationProps {
environmentId: string;
survey: TSurvey;
activeId: string;
}
export const SurveyAnalysisNavigation = ({
environmentId,
survey,
activeId,
}: SurveyAnalysisNavigationProps) => {
export const SurveyAnalysisNavigation = ({ activeId }: SurveyAnalysisNavigationProps) => {
const pathname = usePathname();
const { t } = useTranslation();
const { environment } = useEnvironment();
const { survey } = useSurvey();
const url = `/environments/${environmentId}/surveys/${survey.id}`;
const url = `/environments/${environment.id}/surveys/${survey.id}`;
const navigation = [
{
@@ -31,7 +28,7 @@ export const SurveyAnalysisNavigation = ({
href: `${url}/summary?referer=true`,
current: pathname?.includes("/summary"),
onClick: () => {
revalidateSurveyIdPath(environmentId, survey.id);
revalidateSurveyIdPath(environment.id, survey.id);
},
},
{
@@ -41,7 +38,7 @@ export const SurveyAnalysisNavigation = ({
href: `${url}/responses?referer=true`,
current: pathname?.includes("/responses"),
onClick: () => {
revalidateSurveyIdPath(environmentId, survey.id);
revalidateSurveyIdPath(environment.id, survey.id);
},
},
];
@@ -1,5 +1,4 @@
import { TFunction } from "i18next";
import { capitalize } from "lodash";
import {
AirplayIcon,
ArrowUpFromDotIcon,
@@ -9,6 +8,7 @@ import {
SmartphoneIcon,
} from "lucide-react";
import { TResponseMeta } from "@formbricks/types/responses";
import { capitalize } from "@/lib/utils/object";
export const getAddressFieldLabel = (field: string, t: TFunction) => {
switch (field) {
@@ -64,8 +64,6 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
pageTitle={survey.name}
cta={
<SurveyAnalysisCTA
environment={environment}
survey={survey}
isReadOnly={isReadOnly}
user={user}
publicDomain={publicDomain}
@@ -76,7 +74,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
isStorageConfigured={IS_STORAGE_CONFIGURED}
/>
}>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" />
<SurveyAnalysisNavigation activeId="responses" />
</PageHeader>
<ResponsePage
environment={environment}
@@ -4,16 +4,13 @@ import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { Confetti } from "@/modules/ui/components/confetti";
interface SummaryMetadataProps {
environment: TEnvironment;
survey: TSurvey;
}
export const SuccessMessage = ({ environment, survey }: SummaryMetadataProps) => {
export const SuccessMessage = () => {
const { environment } = useEnvironment();
const { survey } = useSurvey();
const { t } = useTranslation();
const searchParams = useSearchParams();
const [confetti, setConfetti] = useState(false);
@@ -5,14 +5,13 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
@@ -23,8 +22,6 @@ import { IconBar } from "@/modules/ui/components/iconbar";
import { resetSurveyAction } from "../actions";
interface SurveyAnalysisCTAProps {
survey: TSurvey;
environment: TEnvironment;
isReadOnly: boolean;
user: TUser;
publicDomain: string;
@@ -41,8 +38,6 @@ interface ModalState {
}
export const SurveyAnalysisCTA = ({
survey,
environment,
isReadOnly,
user,
publicDomain,
@@ -64,7 +59,8 @@ export const SurveyAnalysisCTA = ({
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const { project } = useEnvironment();
const { environment, project } = useEnvironment();
const { survey } = useSurvey();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -183,7 +179,7 @@ export const SurveyAnalysisCTA = ({
return (
<div className="hidden justify-end gap-x-1.5 sm:flex">
{!isReadOnly && (appSetupCompleted || survey.type === "link") && survey.status !== "draft" && (
<SurveyStatusDropdown environment={environment} survey={survey} />
<SurveyStatusDropdown />
)}
<IconBar actions={iconActions} />
@@ -215,7 +211,7 @@ export const SurveyAnalysisCTA = ({
projectCustomScripts={project.customHeadScripts}
/>
)}
<SuccessMessage environment={environment} survey={survey} />
<SuccessMessage />
{responseCount > 0 && (
<EditPublicSurveyAlertDialog
@@ -16,13 +16,19 @@ export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => {
const [embedModeEnabled, setEmbedModeEnabled] = useState(false);
const { t } = useTranslation();
const iframeCode = `<div style="position: relative; height:80dvh; overflow:auto;">
<iframe
src="${surveyUrl}${embedModeEnabled ? "?embed=true" : ""}"
const separator = surveyUrl.includes("?") ? "&" : "?";
const iframeSrc = embedModeEnabled ? `${surveyUrl}${separator}embed=true` : surveyUrl;
const iframeCode = `<div style="position: relative; height:80dvh; overflow:auto;">
<iframe
src="${iframeSrc}"
frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;">
</iframe>
</div>`;
const previewSrc = `${iframeSrc}${iframeSrc.includes("?") ? "&" : "?"}preview=true`;
return (
<>
<CodeBlock language="html" noMargin>
@@ -48,6 +54,15 @@ export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => {
{t("common.copy_code")}
<CopyIcon />
</Button>
<p className="text-base font-medium text-slate-800">{t("common.preview")}</p>
<div className="relative h-[500px] w-full overflow-hidden rounded-lg border border-slate-300">
<iframe
title={t("common.preview")}
src={previewSrc}
className="absolute inset-0 h-full w-full border-0"
/>
</div>
</>
);
};
@@ -66,8 +66,6 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
pageTitle={survey.name}
cta={
<SurveyAnalysisCTA
environment={environment}
survey={survey}
isReadOnly={isReadOnly}
user={user}
publicDomain={publicDomain}
@@ -78,7 +76,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
isStorageConfigured={IS_STORAGE_CONFIGURED}
/>
}>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="summary" />
<SurveyAnalysisNavigation activeId="summary" />
</PageHeader>
<SummaryPage
environment={environment}
@@ -3,8 +3,9 @@
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateSurveyAction } from "@/modules/survey/editor/actions";
import {
@@ -16,17 +17,9 @@ import {
} from "@/modules/ui/components/select";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
interface SurveyStatusDropdownProps {
environment: TEnvironment;
updateLocalSurveyStatus?: (status: TSurvey["status"]) => void;
survey: TSurvey;
}
export const SurveyStatusDropdown = ({
environment,
updateLocalSurveyStatus,
survey,
}: SurveyStatusDropdownProps) => {
export const SurveyStatusDropdown = () => {
const { environment } = useEnvironment();
const { survey } = useSurvey();
const { t } = useTranslation();
const router = useRouter();
@@ -46,10 +39,6 @@ export const SurveyStatusDropdown = ({
toast.success(toastMessage);
}
if (updateLocalSurveyStatus) {
updateLocalSurveyStatus(resultingStatus);
}
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse);
@@ -0,0 +1,8 @@
import { type ReactNode } from "react";
import { SurveysQueryClientProvider } from "./query-client-provider";
const SurveysLayout = ({ children }: { children: ReactNode }) => {
return <SurveysQueryClientProvider>{children}</SurveysQueryClientProvider>;
};
export default SurveysLayout;
@@ -0,0 +1,10 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { type ReactNode, useState } from "react";
export const SurveysQueryClientProvider = ({ children }: { children: ReactNode }) => {
const [queryClient] = useState(() => new QueryClient());
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
@@ -18,6 +18,7 @@ interface AirtableWrapperProps {
isEnabled: boolean;
webAppUrl: string;
locale: TUserLocale;
showReconnectButton?: boolean;
}
export const AirtableWrapper = ({
@@ -28,6 +29,7 @@ export const AirtableWrapper = ({
isEnabled,
webAppUrl,
locale,
showReconnectButton = false,
}: AirtableWrapperProps) => {
const [isConnected, setIsConnected] = useState(
airtableIntegration ? airtableIntegration.config?.key : false
@@ -49,6 +51,8 @@ export const AirtableWrapper = ({
setIsConnected={setIsConnected}
surveys={surveys}
locale={locale}
showReconnectButton={showReconnectButton}
handleAirtableAuthorization={handleAirtableAuthorization}
/>
) : (
<ConnectIntegration
@@ -1,6 +1,6 @@
"use client";
import { Trash2Icon } from "lucide-react";
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -12,9 +12,11 @@ import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AddIntegrationModal";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { IntegrationModalInputs } from "../lib/types";
interface ManageIntegrationProps {
@@ -24,10 +26,20 @@ interface ManageIntegrationProps {
surveys: TSurvey[];
airtableArray: TIntegrationItem[];
locale: TUserLocale;
showReconnectButton: boolean;
handleAirtableAuthorization: () => Promise<void>;
}
export const ManageIntegration = (props: ManageIntegrationProps) => {
const { airtableIntegration, environmentId, setIsConnected, surveys, airtableArray } = props;
export const ManageIntegration = ({
airtableIntegration,
environmentId,
setIsConnected,
surveys,
airtableArray,
showReconnectButton,
handleAirtableAuthorization,
locale,
}: ManageIntegrationProps) => {
const { t } = useTranslation();
const tableHeaders = [
@@ -73,15 +85,34 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
: { isEditMode: false as const };
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
<div className="flex w-full justify-end gap-x-6">
<div className="flex items-center">
{showReconnectButton && (
<Alert variant="warning" size="small" className="mb-4 w-full">
<AlertDescription>{t("environments.integrations.reconnect_button_description")}</AlertDescription>
<AlertButton onClick={handleAirtableAuthorization}>
{t("environments.integrations.reconnect_button")}
</AlertButton>
</Alert>
)}
<div className="flex w-full justify-end space-x-2">
<div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="cursor-pointer text-slate-500">
<span className="text-slate-500">
{t("environments.integrations.connected_with_email", {
email: airtableIntegration.config.email,
})}
</span>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleAirtableAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("environments.integrations.reconnect_button")}
</Button>
</TooltipTrigger>
<TooltipContent>{t("environments.integrations.reconnect_button_tooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
onClick={() => {
setDefaultValues(null);
@@ -122,9 +153,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.tableName}</div>
<div className="col-span-2 text-center">{data.elements}</div>
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), props.locale)}
</div>
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
</button>
))}
</div>
@@ -1,4 +1,5 @@
import { redirect } from "next/navigation";
import { logger } from "@formbricks/logger";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
@@ -31,8 +32,14 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
);
let airtableArray: TIntegrationItem[] = [];
let isTokenValid = true;
if (airtableIntegration?.config.key) {
airtableArray = await getAirtableTables(params.environmentId);
try {
airtableArray = await getAirtableTables(params.environmentId);
} catch (error) {
logger.error(error, "Failed to load Airtable bases — token may be expired or revoked");
isTokenValid = false;
}
}
if (isReadOnly) {
return redirect("./");
@@ -51,6 +58,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
surveys={surveys}
webAppUrl={WEBAPP_URL}
locale={locale ?? DEFAULT_LOCALE}
showReconnectButton={!isTokenValid}
/>
</div>
</PageContentWrapper>
@@ -94,7 +94,7 @@ export const POST = async (request: Request) => {
// Fetch with timeout of 5 seconds to prevent hanging
const fetchWithTimeout = (url: string, options: RequestInit, timeout: number = 5000): Promise<Response> => {
return Promise.race([
fetch(url, options),
fetch(url, { ...options, redirect: "manual" }),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeout)),
]);
};
@@ -185,4 +185,20 @@ describe("auth route audit logging", () => {
})
);
});
test("does not log a completed sign-in for the intermediate SSO recovery verification step", async () => {
const authOptions = await getWrappedAuthOptions("req-sso-recovery");
const user = {
id: "user_4",
email: "user4@example.com",
authFlowPurpose: "sso_recovery",
};
const account = { provider: "token" };
await expect(authOptions.callbacks.signIn({ user, account })).resolves.toBe(true);
await authOptions.events.signIn({ user, account, isNewUser: false });
expect(mocks.baseEventSignIn).not.toHaveBeenCalled();
expect(mocks.queueAuditEventBackground).not.toHaveBeenCalled();
});
});
@@ -26,6 +26,12 @@ const getAuthMethod = (account: Account | null) => {
return "unknown";
};
const isSsoRecoveryVerificationFlow = (account: Account | null, user: User | AdapterUser) =>
account?.provider === "token" &&
"authFlowPurpose" in user &&
typeof user.authFlowPurpose === "string" &&
user.authFlowPurpose === "sso_recovery";
const handler = async (req: Request, ctx: any) => {
const eventId = req.headers.get("x-request-id") ?? undefined;
@@ -117,6 +123,10 @@ const handler = async (req: Request, ctx: any) => {
events: {
...baseAuthOptions.events,
async signIn({ user, account, isNewUser }: any) {
if (isSsoRecoveryVerificationFlow(account, user)) {
return;
}
try {
await baseAuthOptions.events?.signIn?.({ user, account, isNewUser });
} catch (err) {
@@ -0,0 +1,67 @@
import { getServerSession } from "next-auth";
import { NextResponse } from "next/server";
import { logger } from "@formbricks/logger";
import { verifySsoRelinkIntent } from "@/lib/jwt";
import { deleteSessionBySessionToken } from "@/modules/auth/lib/auth-session-repository";
import { authOptions } from "@/modules/auth/lib/authOptions";
import {
NEXT_AUTH_SESSION_COOKIE_NAMES,
getSessionTokenFromCookieHeader,
} from "@/modules/auth/lib/session-cookie";
import { completeSsoRecovery, getSsoRecoveryFailureRedirectUrl } from "@/modules/ee/sso/lib/sso-recovery";
const clearSessionCookies = (response: NextResponse) => {
for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) {
response.cookies.set({
name: cookieName,
value: "",
expires: new Date(0),
path: "/",
secure: cookieName.startsWith("__Secure-"),
});
}
};
const buildFailedRecoveryResponse = async (request: Request, callbackUrl?: string) => {
const response = NextResponse.redirect(getSsoRecoveryFailureRedirectUrl(callbackUrl));
clearSessionCookies(response);
const sessionToken = getSessionTokenFromCookieHeader(request.headers.get("cookie"));
if (!sessionToken) {
return response;
}
try {
await deleteSessionBySessionToken(sessionToken);
} catch (error) {
logger.error(error, "Failed to delete SSO recovery session after recovery completion error");
}
return response;
};
export const GET = async (request: Request) => {
const url = new URL(request.url);
const intentToken = url.searchParams.get("intent");
if (!intentToken) {
return NextResponse.redirect(getSsoRecoveryFailureRedirectUrl());
}
try {
const session = await getServerSession(authOptions);
const callbackUrl = await completeSsoRecovery({
intentToken,
sessionUserId: session?.user.id,
});
return NextResponse.redirect(callbackUrl);
} catch {
try {
const intent = verifySsoRelinkIntent(intentToken);
return await buildFailedRecoveryResponse(request, intent.callbackUrl);
} catch {
return await buildFailedRecoveryResponse(request);
}
}
};
@@ -0,0 +1,98 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { getResponseIdByDisplayId } from "./response";
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn((inputs: [unknown, unknown][]) =>
inputs.map((input: [unknown, unknown]) => input[0])
),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
display: {
findFirst: vi.fn(),
},
},
}));
describe("getResponseIdByDisplayId", () => {
const environmentId = "env1234567890123456789012";
const displayId = "display1234567890123456789";
beforeEach(() => {
vi.clearAllMocks();
});
test("returns the linked responseId when a response exists", async () => {
vi.mocked(prisma.display.findFirst).mockResolvedValue({
response: {
id: "response123456789012345678",
},
} as any);
const result = await getResponseIdByDisplayId(environmentId, displayId);
expect(validateInputs).toHaveBeenCalledWith(
[environmentId, expect.any(Object)],
[displayId, expect.any(Object)]
);
expect(prisma.display.findFirst).toHaveBeenCalledWith({
where: {
id: displayId,
survey: {
environmentId,
},
},
select: {
response: {
select: {
id: true,
},
},
},
});
expect(result).toEqual({ responseId: "response123456789012345678" });
});
test("returns null when the display exists but has no response", async () => {
vi.mocked(prisma.display.findFirst).mockResolvedValue({
response: null,
} as any);
await expect(getResponseIdByDisplayId(environmentId, displayId)).resolves.toEqual({
responseId: null,
});
});
test("throws ResourceNotFoundError when the display does not exist in the environment", async () => {
vi.mocked(prisma.display.findFirst).mockResolvedValue(null);
await expect(getResponseIdByDisplayId(environmentId, displayId)).rejects.toThrow(
new ResourceNotFoundError("Display", displayId)
);
});
test("throws ValidationError when input validation fails", async () => {
const validationError = new ValidationError("Validation failed");
vi.mocked(validateInputs).mockImplementation(() => {
throw validationError;
});
await expect(getResponseIdByDisplayId(environmentId, displayId)).rejects.toThrow(ValidationError);
expect(prisma.display.findFirst).not.toHaveBeenCalled();
});
test("throws DatabaseError on Prisma request errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "test",
});
vi.mocked(prisma.display.findFirst).mockRejectedValue(prismaError);
await expect(getResponseIdByDisplayId(environmentId, displayId)).rejects.toThrow(DatabaseError);
});
});
@@ -0,0 +1,44 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
export const getResponseIdByDisplayId = async (
environmentId: string,
displayId: string
): Promise<{ responseId: string | null }> => {
validateInputs([environmentId, ZId], [displayId, ZId]);
try {
const display = await prisma.display.findFirst({
where: {
id: displayId,
survey: {
environmentId,
},
},
select: {
response: {
select: {
id: true,
},
},
},
});
if (!display) {
throw new ResourceNotFoundError("Display", displayId);
}
return {
responseId: display.response?.id ?? null,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
@@ -0,0 +1,70 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getResponseIdByDisplayId } from "./lib/response";
import { GET } from "./route";
vi.mock("@/app/lib/api/with-api-logging", async () => {
return {
withV1ApiWrapper:
({ handler }: { handler: any }) =>
async (req: NextRequest, props: any) => {
const result = await handler({ req, props });
return result.response;
},
};
});
vi.mock("./lib/response", () => ({
getResponseIdByDisplayId: vi.fn(),
}));
describe("GET /api/v1/client/[environmentId]/displays/[displayId]/response", () => {
const req = new NextRequest("http://localhost/api/v1/client/env/displays/display/response");
const props = {
params: Promise.resolve({
environmentId: "env1234567890123456789012",
displayId: "display1234567890123456789",
}),
};
beforeEach(() => {
vi.clearAllMocks();
});
test("returns the responseId when a linked response exists", async () => {
vi.mocked(getResponseIdByDisplayId).mockResolvedValue({ responseId: "response123456789012345678" });
const response = await GET(req, props);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
data: {
responseId: "response123456789012345678",
},
});
});
test("returns null when the display exists without a response", async () => {
vi.mocked(getResponseIdByDisplayId).mockResolvedValue({ responseId: null });
const response = await GET(req, props);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
data: {
responseId: null,
},
});
});
test("returns 404 when the display is missing for the environment", async () => {
vi.mocked(getResponseIdByDisplayId).mockRejectedValue(
new ResourceNotFoundError("Display", "display1234567890123456789")
);
const response = await GET(req, props);
expect(response.status).toBe(404);
});
});
@@ -0,0 +1,40 @@
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getResponseIdByDisplayId } from "./lib/response";
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
export const GET = withV1ApiWrapper({
handler: async ({
req,
props,
}: THandlerParams<{ params: Promise<{ environmentId: string; displayId: string }> }>) => {
const params = await props.params;
try {
const response = await getResponseIdByDisplayId(params.environmentId, params.displayId);
return {
response: responses.successResponse(response, true),
};
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Display", params.displayId, true),
};
}
logger.error(
{ error, url: req.url, environmentId: params.environmentId, displayId: params.displayId },
"Error in GET /api/v1/client/[environmentId]/displays/[displayId]/response"
);
return {
response: responses.internalServerErrorResponse("Something went wrong. Please try again."),
};
}
},
});
@@ -10,6 +10,8 @@ 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";
@@ -127,6 +129,114 @@ export const POST = withV1ApiWrapper({
};
}
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
),
};
}
}
if (!validateFileUploads(responseInputData.data, survey.questions)) {
return {
response: responses.badRequestResponse("Invalid file upload response"),
@@ -5,7 +5,7 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { fetchAirtableAuthToken } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration } from "@/lib/integration/service";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
@@ -78,12 +78,16 @@ export const GET = withV1ApiWrapper({
}
const email = await getEmail(key.access_token);
// Preserve existing integration data (survey-to-table mappings) when re-authorizing
const existingIntegration = await getIntegrationByType(environmentId, "airtable");
const existingData = existingIntegration?.config?.data ?? [];
const airtableIntegrationInput = {
type: "airtable" as "airtable",
environment: environmentId,
config: {
key,
data: [],
data: existingData,
email,
},
};
@@ -1,8 +1,7 @@
import * as z from "zod";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getTables } from "@/lib/airtable/service";
import { getAirtableToken, getTables } from "@/lib/airtable/service";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { getIntegrationByType } from "@/lib/integration/service";
@@ -36,7 +35,7 @@ export const GET = withV1ApiWrapper({
};
}
const integration = (await getIntegrationByType(environmentId, "airtable")) as TIntegrationAirtable;
const integration = await getIntegrationByType(environmentId, "airtable");
if (!integration) {
return {
@@ -44,7 +43,12 @@ export const GET = withV1ApiWrapper({
};
}
const tables = await getTables(integration.config.key, baseId.data);
// Use getAirtableToken to ensure the access token is refreshed if expired
const freshAccessToken = await getAirtableToken(environmentId);
const tables = await getTables(
{ ...integration.config.key, access_token: freshAccessToken },
baseId.data
);
return {
response: responses.successResponse(tables),
};
@@ -4,6 +4,7 @@ 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";
@@ -176,6 +177,7 @@ const handleSessionAuthentication = async () => {
const user = await prisma.user.findUnique({
where: { id: sessionUser.id },
select: publicUserSelect,
});
return Response.json(user);
@@ -1,47 +1,19 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { deleteSurvey } from "./surveys";
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
const { mockDeleteSharedSurvey } = vi.hoisted(() => ({
mockDeleteSharedSurvey: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
delete: vi.fn(),
},
segment: {
delete: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
vi.mock("@/modules/survey/lib/surveys", () => ({
deleteSurvey: mockDeleteSharedSurvey,
}));
const surveyId = "clq5n7p1q0000m7z0h5p6g3r2";
const environmentId = "clq5n7p1q0000m7z0h5p6g3r3";
const segmentId = "clq5n7p1q0000m7z0h5p6g3r4";
const actionClassId1 = "clq5n7p1q0000m7z0h5p6g3r5";
const actionClassId2 = "clq5n7p1q0000m7z0h5p6g3r6";
const mockDeletedSurveyAppPrivateSegment = {
id: surveyId,
environmentId,
type: "app",
segment: { id: segmentId, isPrivate: true },
triggers: [{ actionClass: { id: actionClassId1 } }, { actionClass: { id: actionClassId2 } }],
};
const mockDeletedSurveyLink = {
id: surveyId,
environmentId,
environmentId: "clq5n7p1q0000m7z0h5p6g3r3",
type: "link",
segment: null,
triggers: [],
@@ -56,66 +28,20 @@ describe("deleteSurvey", () => {
vi.clearAllMocks();
});
test("should delete a link survey without a segment and revalidate caches", async () => {
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyLink as any);
test("delegates survey deletion to the shared service", async () => {
mockDeleteSharedSurvey.mockResolvedValue(mockDeletedSurveyLink);
const deletedSurvey = await deleteSurvey(surveyId);
expect(validateInputs).toHaveBeenCalledWith([surveyId, expect.any(Object)]);
expect(prisma.survey.delete).toHaveBeenCalledWith({
where: { id: surveyId },
include: {
segment: true,
triggers: { include: { actionClass: true } },
},
});
expect(prisma.segment.delete).not.toHaveBeenCalled();
expect(mockDeleteSharedSurvey).toHaveBeenCalledWith(surveyId);
expect(deletedSurvey).toEqual(mockDeletedSurveyLink);
});
test("should handle PrismaClientKnownRequestError during survey deletion", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
code: "P2025",
clientVersion: "4.0.0",
});
vi.mocked(prisma.survey.delete).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
expect(prisma.segment.delete).not.toHaveBeenCalled();
});
test("should handle PrismaClientKnownRequestError during segment deletion", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Foreign key constraint failed", {
code: "P2003",
clientVersion: "4.0.0",
});
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyAppPrivateSegment as any);
vi.mocked(prisma.segment.delete).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
expect(prisma.segment.delete).toHaveBeenCalledWith({ where: { id: segmentId } });
});
test("should handle generic errors during deletion", async () => {
test("rethrows shared delete service errors", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(prisma.survey.delete).mockRejectedValue(genericError);
mockDeleteSharedSurvey.mockRejectedValue(genericError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(genericError);
expect(logger.error).not.toHaveBeenCalled();
expect(prisma.segment.delete).not.toHaveBeenCalled();
});
test("should throw validation error for invalid surveyId", async () => {
const invalidSurveyId = "invalid-id";
const validationError = new Error("Validation failed");
vi.mocked(validateInputs).mockImplementation(() => {
throw validationError;
});
await expect(deleteSurvey(invalidSurveyId)).rejects.toThrow(validationError);
expect(prisma.survey.delete).not.toHaveBeenCalled();
expect(mockDeleteSharedSurvey).toHaveBeenCalledWith(surveyId);
});
});
@@ -1,43 +1,3 @@
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { deleteSurvey as deleteSharedSurvey } from "@/modules/survey/lib/surveys";
export const deleteSurvey = async (surveyId: string) => {
validateInputs([surveyId, z.cuid2()]);
try {
const deletedSurvey = await prisma.survey.delete({
where: {
id: surveyId,
},
include: {
segment: true,
triggers: {
include: {
actionClass: true,
},
},
},
});
if (deletedSurvey.type === "app" && deletedSurvey.segment?.isPrivate) {
await prisma.segment.delete({
where: {
id: deletedSurvey.segment.id,
},
});
}
return deletedSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error({ error, surveyId }, "Error deleting survey");
throw new DatabaseError(error.message);
}
throw error;
}
};
export const deleteSurvey = async (surveyId: string) => deleteSharedSurvey(surveyId);
@@ -1,5 +1,6 @@
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
import { handleErrorResponse } from "@/app/api/v1/auth";
import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/surveys";
@@ -70,6 +71,12 @@ export const GET = withV1ApiWrapper({
response: responses.successResponse(resolveStorageUrlsInObject(result.survey)),
};
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Survey", params.surveyId),
};
}
return {
response: handleErrorResponse(error),
};
+132
View File
@@ -9,6 +9,22 @@ const { mockAuthenticateRequest, mockGetServerSession } = vi.hoisted(() => ({
mockGetServerSession: vi.fn(),
}));
const { mockQueueAuditEvent, mockBuildAuditLogBaseObject } = vi.hoisted(() => ({
mockQueueAuditEvent: vi.fn().mockImplementation(async () => undefined),
mockBuildAuditLogBaseObject: vi.fn((action: string, targetType: string, apiUrl: string) => ({
action,
targetType,
userId: "unknown",
targetId: "unknown",
organizationId: "unknown",
status: "failure",
oldObject: undefined,
newObject: undefined,
userType: "api",
apiUrl,
})),
}));
vi.mock("next-auth", () => ({
getServerSession: mockGetServerSession,
}));
@@ -25,6 +41,14 @@ vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEvent: mockQueueAuditEvent,
}));
vi.mock("@/app/lib/api/with-api-logging", () => ({
buildAuditLogBaseObject: mockBuildAuditLogBaseObject,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
@@ -45,6 +69,114 @@ describe("withV3ApiWrapper", () => {
vi.clearAllMocks();
});
test("passes an audit log to the handler and queues success after the response", async () => {
const { queueAuditEvent } = await import("@/modules/ee/audit-logs/lib/handler");
mockGetServerSession.mockResolvedValue({
user: { id: "user_1", name: "Test", email: "t@example.com" },
expires: "2026-01-01",
});
const handler = vi.fn(async ({ auditLog }) => {
expect(auditLog).toEqual(
expect.objectContaining({
action: "deleted",
targetType: "survey",
userId: "user_1",
userType: "user",
status: "failure",
})
);
if (auditLog) {
auditLog.targetId = "survey_1";
auditLog.organizationId = "org_1";
auditLog.oldObject = { id: "survey_1" };
}
return Response.json({ ok: true });
});
const wrapped = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
handler,
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys/survey_1", {
method: "DELETE",
headers: { "x-request-id": "req-audit" },
}),
{} as never
);
expect(response.status).toBe(200);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "survey_1",
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "success",
oldObject: { id: "survey_1" },
})
);
});
test("queues a failure audit log when the handler returns a non-ok response", async () => {
const { queueAuditEvent } = await import("@/modules/ee/audit-logs/lib/handler");
mockAuthenticateRequest.mockResolvedValue({
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
environmentPermissions: [],
});
const wrapped = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
handler: async ({ auditLog }) => {
if (auditLog) {
auditLog.targetId = "survey_2";
}
return new Response("forbidden", { status: 403 });
},
});
const response = await wrapped(
new NextRequest("http://localhost/api/v3/surveys/survey_2", {
method: "DELETE",
headers: {
"x-request-id": "req-failure-audit",
"x-api-key": "fbk_test",
},
}),
{} as never
);
expect(response.status).toBe(403);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "survey_2",
organizationId: "org_1",
userId: "key_1",
userType: "api",
status: "failure",
eventId: "req-failure-audit",
})
);
});
test("uses session auth first in both mode and injects request id into plain responses", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
mockGetServerSession.mockResolvedValue({
+76 -2
View File
@@ -4,10 +4,13 @@ import { z } from "zod";
import { logger } from "@formbricks/logger";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { authenticateRequest } from "@/app/api/v1/auth";
import { buildAuditLogBaseObject } from "@/app/lib/api/with-api-logging";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import type { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditTarget } from "@/modules/ee/audit-logs/types/audit-log";
import {
type InvalidParam,
problemBadRequest,
@@ -15,7 +18,7 @@ import {
problemTooManyRequests,
problemUnauthorized,
} from "./response";
import type { TV3Authentication } from "./types";
import type { TV3AuditLog, TV3Authentication } from "./types";
type TV3Schema = z.ZodTypeAny;
type MaybePromise<T> = T | Promise<T>;
@@ -38,6 +41,7 @@ export type TV3HandlerParams<TParsedInput = Record<string, never>, TProps = unkn
req: NextRequest;
props: TProps;
authentication: TV3Authentication;
auditLog?: TV3AuditLog;
parsedInput: TParsedInput;
requestId: string;
instance: string;
@@ -48,6 +52,8 @@ export type TWithV3ApiWrapperParams<S extends TV3Schemas | undefined, TProps = u
schemas?: S;
rateLimit?: boolean;
customRateLimitConfig?: TRateLimitConfig;
action?: TAuditAction;
targetType?: TAuditTarget;
handler: (params: TV3HandlerParams<TV3ParsedInput<S>, TProps>) => MaybePromise<Response>;
};
@@ -293,10 +299,61 @@ async function applyV3RateLimitOrRespond(params: {
return null;
}
function buildV3AuditLog(
authentication: TV3Authentication,
action?: TAuditAction,
targetType?: TAuditTarget,
apiUrl?: string
): TV3AuditLog | undefined {
if (!authentication || !action || !targetType || !apiUrl) {
return undefined;
}
const auditLog = buildAuditLogBaseObject(action, targetType, apiUrl);
if ("user" in authentication && authentication.user?.id) {
auditLog.userId = authentication.user.id;
auditLog.userType = "user";
} else if ("apiKeyId" in authentication) {
auditLog.userId = authentication.apiKeyId;
auditLog.userType = "api";
auditLog.organizationId = authentication.organizationId;
}
return auditLog;
}
async function queueV3AuditLog(
auditLog: TV3AuditLog | undefined,
requestId: string,
log: ReturnType<typeof logger.withContext>
): Promise<void> {
if (!auditLog) {
return;
}
try {
await queueAuditEvent({
...auditLog,
...(auditLog.status === "failure" ? { eventId: auditLog.eventId ?? requestId } : {}),
});
} catch (error) {
log.error({ error }, "Failed to queue V3 audit event");
}
}
export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unknown>(
params: TWithV3ApiWrapperParams<S, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const { auth = "both", schemas, rateLimit = true, customRateLimitConfig, handler } = params;
const {
auth = "both",
schemas,
rateLimit = true,
customRateLimitConfig,
handler,
action,
targetType,
} = params;
return async (req: NextRequest, props: TProps): Promise<Response> => {
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
@@ -306,6 +363,7 @@ export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unkn
method: req.method,
path: instance,
});
let auditLog: TV3AuditLog | undefined;
try {
const authResult = await authenticateV3RequestOrRespond(req, auth, requestId, instance);
@@ -331,17 +389,33 @@ export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unkn
return rateLimitResponse;
}
auditLog = buildV3AuditLog(authResult.authentication, action, targetType, req.url);
const response = await handler({
req,
props,
authentication: authResult.authentication,
auditLog,
parsedInput: parsedInputResult.parsedInput,
requestId,
instance,
});
if (auditLog) {
if (response.ok) {
auditLog.status = "success";
} else {
auditLog.eventId = requestId;
}
}
await queueV3AuditLog(auditLog, requestId, log);
return ensureRequestIdHeader(response, requestId);
} catch (error) {
if (auditLog) {
auditLog.eventId = requestId;
await queueV3AuditLog(auditLog, requestId, log);
}
log.error({ error, statusCode: 500 }, "V3 API unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
+25
View File
@@ -7,6 +7,7 @@ import {
problemTooManyRequests,
problemUnauthorized,
successListResponse,
successResponse,
} from "./response";
describe("v3 problem responses", () => {
@@ -93,3 +94,27 @@ describe("successListResponse", () => {
expect(res.headers.get("Cache-Control")).toBe("private, max-age=0");
});
});
describe("successResponse", () => {
test("wraps the payload in a data envelope", async () => {
const res = successResponse({ id: "survey_1" }, { requestId: "req-success" });
expect(res.status).toBe(200);
expect(res.headers.get("X-Request-Id")).toBe("req-success");
expect(res.headers.get("Cache-Control")).toContain("no-store");
expect(await res.json()).toEqual({
data: { id: "survey_1" },
});
});
test("allows custom status and cache headers", async () => {
const res = successResponse(
{ ok: true },
{
cache: "private, max-age=60",
status: 202,
}
);
expect(res.status).toBe(202);
expect(res.headers.get("Cache-Control")).toBe("private, max-age=60");
});
});
+24
View File
@@ -147,3 +147,27 @@ export function successListResponse<T, TMeta extends Record<string, unknown>>(
}
return Response.json({ data, meta }, { status: 200, headers });
}
export function successResponse<T>(
data: T,
options?: { requestId?: string; cache?: string; status?: number }
): Response {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
};
if (options?.requestId) {
headers["X-Request-Id"] = options.requestId;
}
return Response.json(
{
data,
},
{
status: options?.status ?? 200,
headers,
}
);
}
+2
View File
@@ -1,4 +1,6 @@
import type { Session } from "next-auth";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import type { TApiAuditLog } from "@/app/lib/api/with-api-logging";
export type TV3Authentication = TAuthenticationApiKey | Session | null;
export type TV3AuditLog = TApiAuditLog;
@@ -0,0 +1,321 @@
import { ApiKeyPermission, EnvironmentType } from "@prisma/client";
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
import { DELETE } from "./route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
}));
const { mockQueueAuditEvent, mockBuildAuditLogBaseObject } = vi.hoisted(() => ({
mockQueueAuditEvent: vi.fn().mockImplementation(async () => undefined),
mockBuildAuditLogBaseObject: vi.fn((action: string, targetType: string, apiUrl: string) => ({
action,
targetType,
userId: "unknown",
targetId: "unknown",
organizationId: "unknown",
status: "failure",
oldObject: undefined,
newObject: undefined,
userType: "api",
apiUrl,
})),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/app/api/v1/auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/app/api/v1/auth")>();
return { ...actual, authenticateRequest: mockAuthenticateRequest };
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return { ...actual, AUDIT_LOG_ENABLED: false };
});
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
vi.mock("@/modules/survey/lib/surveys", () => ({
deleteSurvey: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEvent: mockQueueAuditEvent,
}));
vi.mock("@/app/lib/api/with-api-logging", () => ({
buildAuditLogBaseObject: mockBuildAuditLogBaseObject,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
const queueAuditEvent = vi.mocked((await import("@/modules/ee/audit-logs/lib/handler")).queueAuditEvent);
const surveyId = "clxx1234567890123456789012";
const environmentId = "clzz9876543210987654321098";
function createRequest(url: string, requestId?: string, extraHeaders?: Record<string, string>): NextRequest {
const headers: Record<string, string> = { ...extraHeaders };
if (requestId) {
headers["x-request-id"] = requestId;
}
return new NextRequest(url, {
method: "DELETE",
headers,
});
}
const apiKeyAuth = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: {
accessControl: { read: true, write: true },
},
environmentPermissions: [
{
environmentId,
environmentType: EnvironmentType.development,
projectId: "proj_1",
projectName: "P",
permission: ApiKeyPermission.write,
},
],
};
describe("DELETE /api/v3/surveys/[surveyId]", () => {
beforeEach(() => {
vi.resetAllMocks();
getServerSession.mockResolvedValue({
user: { id: "user_1", name: "User", email: "u@example.com" },
expires: "2026-01-01",
} as any);
mockAuthenticateRequest.mockResolvedValue(null);
vi.mocked(getSurvey).mockResolvedValue({
id: surveyId,
name: "Delete me",
environmentId,
type: "link",
status: "draft",
createdAt: new Date("2026-04-15T10:00:00.000Z"),
updatedAt: new Date("2026-04-15T10:00:00.000Z"),
responseCount: 0,
creator: { name: "User" },
singleUse: null,
} as any);
vi.mocked(deleteSurvey).mockResolvedValue({
id: surveyId,
environmentId,
type: "link",
segment: null,
triggers: [],
} as any);
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
environmentId,
projectId: "proj_1",
organizationId: "org_1",
});
});
afterEach(() => {
vi.clearAllMocks();
});
test("returns 401 when no session and no API key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(401);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 200 with session auth and deletes the survey", async () => {
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-delete"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
environmentId,
"readWrite",
"req-delete",
`/api/v3/surveys/${surveyId}`
);
expect(deleteSurvey).toHaveBeenCalledWith(surveyId);
expect(await res.json()).toEqual({
data: {
id: surveyId,
},
});
});
test("returns 200 with x-api-key when the key can delete in the survey workspace", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
const res = await DELETE(
createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-api-key", {
"x-api-key": "fbk_test",
}),
{
params: Promise.resolve({ surveyId }),
} as never
);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ apiKeyId: "key_1" }),
environmentId,
"readWrite",
"req-api-key",
`/api/v3/surveys/${surveyId}`
);
});
test("returns 400 when surveyId is invalid", async () => {
const res = await DELETE(createRequest("http://localhost/api/v3/surveys/not-a-cuid"), {
params: Promise.resolve({ surveyId: "not-a-cuid" }),
} as never);
expect(res.status).toBe(400);
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
});
test("returns 403 when the survey does not exist", async () => {
vi.mocked(getSurvey).mockResolvedValueOnce(null);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
});
test("returns 403 when the user lacks readWrite workspace access", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce(
new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req-forbidden",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
)
);
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-forbidden"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
expect(deleteSurvey).not.toHaveBeenCalled();
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: "unknown",
organizationId: "unknown",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: undefined,
})
);
});
test("returns 500 when survey deletion fails", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new DatabaseError("db down"));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-db"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
test("returns 403 when the survey is deleted after authorization succeeds", async () => {
vi.mocked(deleteSurvey).mockRejectedValueOnce(new ResourceNotFoundError("Survey", surveyId));
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-race"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "failure",
oldObject: expect.objectContaining({
id: surveyId,
environmentId,
}),
})
);
});
test("queues an audit log with target, actor, organization, and old object", async () => {
await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-audit"), {
params: Promise.resolve({ surveyId }),
} as never);
expect(queueAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "deleted",
targetType: "survey",
targetId: surveyId,
organizationId: "org_1",
userId: "user_1",
userType: "user",
status: "success",
oldObject: expect.objectContaining({
id: surveyId,
environmentId,
}),
})
);
});
});
@@ -0,0 +1,72 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { problemForbidden, problemInternalError, successResponse } from "@/app/api/v3/lib/response";
import { getSurvey } from "@/lib/survey/service";
import { deleteSurvey } from "@/modules/survey/lib/surveys";
export const DELETE = withV3ApiWrapper({
auth: "both",
action: "deleted",
targetType: "survey",
schemas: {
params: z.object({
surveyId: z.cuid2(),
}),
},
handler: async ({ parsedInput, authentication, requestId, instance, auditLog }) => {
const surveyId = parsedInput.params.surveyId;
const log = logger.withContext({ requestId, surveyId });
try {
const survey = await getSurvey(surveyId);
if (!survey) {
log.warn({ statusCode: 403 }, "Survey not found or not accessible");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
const authResult = await requireV3WorkspaceAccess(
authentication,
survey.environmentId,
"readWrite",
requestId,
instance
);
if (authResult instanceof Response) {
return authResult;
}
if (auditLog) {
auditLog.targetId = survey.id;
auditLog.organizationId = authResult.organizationId;
auditLog.oldObject = survey;
}
const deletedSurvey = await deleteSurvey(surveyId);
return successResponse(
{
id: deletedSurvey.id,
},
{ requestId }
);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
log.warn({ errorCode: error.name, statusCode: 403 }, "Survey not found or not accessible");
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
}
if (error instanceof DatabaseError) {
log.error({ error, statusCode: 500 }, "Database error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
log.error({ error, statusCode: 500 }, "V3 survey delete unexpected error");
return problemInternalError(requestId, "An unexpected error occurred.", instance);
}
},
});
+1 -1
View File
@@ -321,11 +321,11 @@ describe("GET /api/v3/surveys", () => {
const res = await GET(req, {} as any);
const body = await res.json();
expect(body.data[0]).not.toHaveProperty("blocks");
expect(body.data[0]).not.toHaveProperty("singleUse");
expect(body.data[0]).not.toHaveProperty("_count");
expect(body.data[0]).not.toHaveProperty("environmentId");
expect(body.data[0].id).toBe("s1");
expect(body.data[0].workspaceId).toBe("env_1");
expect(body.data[0].singleUse).toBeNull();
});
test("returns 403 when getSurveyListPage throws ResourceNotFoundError", async () => {
+2 -2
View File
@@ -1,6 +1,6 @@
import type { TSurvey } from "@/modules/survey/list/types/surveys";
export type TV3SurveyListItem = Omit<TSurvey, "environmentId" | "singleUse"> & {
export type TV3SurveyListItem = Omit<TSurvey, "environmentId"> & {
workspaceId: string;
};
@@ -9,7 +9,7 @@ export type TV3SurveyListItem = Omit<TSurvey, "environmentId" | "singleUse"> & {
* Internally surveys are still scoped by environmentId; externally v3 exposes workspaceId.
*/
export function serializeV3SurveyListItem(survey: TSurvey): TV3SurveyListItem {
const { environmentId, singleUse: _omitSingleUse, ...rest } = survey;
const { environmentId, ...rest } = survey;
return {
...rest,
+5 -5
View File
@@ -1647,14 +1647,14 @@ const identifyCustomerGoals = (t: TFunction): TTemplate => {
elements: [
buildMultipleChoiceElement({
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
headline: "What's your primary goal for using $[projectName]?",
headline: t("templates.identify_customer_goals_question_1_headline"),
required: true,
shuffleOption: "none",
choices: [
"Understand my user base deeply",
"Identify upselling opportunities",
"Build the best possible product",
"Rule the world to make everyone breakfast brussels sprouts.",
t("templates.identify_customer_goals_question_1_choice_1"),
t("templates.identify_customer_goals_question_1_choice_2"),
t("templates.identify_customer_goals_question_1_choice_3"),
t("templates.identify_customer_goals_question_1_choice_4"),
],
}),
],
+1
View File
@@ -19,6 +19,7 @@
"ro-RO",
"ru-RU",
"sv-SE",
"tr-TR",
"zh-Hans-CN",
"zh-Hant-TW"
]
+9 -11
View File
@@ -315,7 +315,6 @@ checksums:
common/other: 79acaa6cd481262bea4e743a422529d2
common/other_filters: 20b09213c131db47eb8b23e72d0c4bea
common/other_placeholder: f3a0fa2eaaf75aa92b290449c928c081
common/others: 39160224ce0e35eb4eb252c997edf4d8
common/overlay_color: 4b72073285d13fff93d094aabffe05ac
common/overview: 30c54e4dc4ce599b87d94be34a8617f5
common/password: 223a61cf906ab9c40d22612c588dff48
@@ -333,7 +332,6 @@ checksums:
common/please_upgrade_your_plan: 03d54a21ecd27723c72a13644837e5ed
common/powered_by_formbricks: 1c3e19894583292bfaf686cac84a4960
common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d
common/preview_survey: 7409e9c118e3e5d5f2a86201c2b354f2
common/privacy: 7459744a63ef8af4e517a09024bd7c08
common/product_manager: dfeadc96e6d3de22a884ee97974b505e
common/production: 226e0ce83b49700bc1b1c08c4c3ed23a
@@ -468,7 +466,6 @@ checksums:
common/workspace_name_placeholder: 8a9e30ab01666af13c44a73b82c37ec1
common/workspaces: 8ba082a84aa35cf851af1cf874b853e2
common/years: eb4f5fdd2b320bf13e200fd6a6c1abff
common/you: db2a4a796b70cc1430d1b21f6ffb6dcb
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
@@ -792,6 +789,9 @@ checksums:
environments/integrations/notion/update_connection_tooltip: 2429919f575e47f5c76e54b4442ba706
environments/integrations/notion_integration_description: 31a73dbe88fe18a078d6dc15f0c303e2
environments/integrations/please_select_a_survey_error: 465aa7048773079c8ffdde8b333b78eb
environments/integrations/reconnect_button: 8992a0f250278c116cb26be448b68ba2
environments/integrations/reconnect_button_description: 01f79dc561ff87b5f2a80bf66e492844
environments/integrations/reconnect_button_tooltip: 5552effda9df8d6778dda1cf42e5d880
environments/integrations/select_at_least_one_question_error: a3513cb02ab0de2a1531893ac0c7e089
environments/integrations/slack/already_connected_another_survey: 4508f9e4a2915e3818ea5f9e2695e000
environments/integrations/slack/channel_name: 1afcd1d0401850ff353f5ae27502b04a
@@ -1239,12 +1239,7 @@ checksums:
environments/settings/teams/you_are_a_member: cf5af638d5371c8fbc337e92519e5150
environments/surveys/all_set_time_to_create_first_survey: 21d3bb74c3b9642b3195d17c17346399
environments/surveys/alphabetical: 5fcfeff9c5fd28714f0a390e0ddaaaee
environments/surveys/copy_survey: de8142b45e7bca61f2dca0069a62b417
environments/surveys/copy_survey_description: 66d0aadf192ad5790fbf3f55f3bb5485
environments/surveys/copy_survey_error: 74cab7d84ea8b669e106d4c326cac005
environments/surveys/copy_survey_link_to_clipboard: 77387e3d3de4be07a2a34963f73cd7e8
environments/surveys/copy_survey_partially_success: a436a5fb7167b95c2308794d35aab070
environments/surveys/copy_survey_success: a829e645fe034b3e712d0b8572a5edc4
environments/surveys/delete_survey_and_responses_warning: 3320c91c1fd27378b7f3d6abc003f2ae
environments/surveys/edit/activate_translations: af127c1bed2b47e2012e3a23e489ecb8
environments/surveys/edit/add: 5196f5cd4ba3a6ac8edef91345e17f66
@@ -1296,7 +1291,7 @@ checksums:
environments/surveys/edit/audience: a4d9fab4214a641e2d358fbb28f010e0
environments/surveys/edit/auto_close_on_inactivity: 093db516799315ccd4242a3675693012
environments/surveys/edit/auto_progress_rating_and_nps: 76b98e95a5b850850baa0ccc3c7fbf7c
environments/surveys/edit/auto_progress_rating_and_nps_description: cbf676789b9f3f47e36bdf35fa58282b
environments/surveys/edit/auto_progress_rating_and_nps_description: 2a992dd8a5b9532f178f9a21881feb9a
environments/surveys/edit/auto_save_disabled: f7411fb0dcfb8f7b19b85f0be54f2231
environments/surveys/edit/auto_save_disabled_tooltip: 77322e1e866b7d29f7641a88bbd3b681
environments/surveys/edit/auto_save_on: 1524d466830b00c5d727c701db404963
@@ -1965,7 +1960,6 @@ checksums:
environments/surveys/summary/downloading_qr_code: 3c46bf636e617848a4fca9b6c5b51dac
environments/surveys/summary/drop_offs: 605ee950f82110132d6c5780926af109
environments/surveys/summary/drop_offs_tooltip: 2a01683380be45f17636365886cf3452
environments/surveys/summary/failed_to_copy_link: 4e891c757c80e770674e8e74d1c08487
environments/surveys/summary/filter_added_successfully: e247f65020cd87454bcec0da6f0fd034
environments/surveys/summary/filter_updated_successfully: 01146bc7e6394e271836be2f1b3a257b
environments/surveys/summary/filtered_responses_csv: aad66a98be6a09cac8bef9e4db4a75cf
@@ -2050,7 +2044,6 @@ checksums:
environments/surveys/summary/youre_not_plugged_in_yet: f19da3cd474b9a3cf28e956fd811fb00
environments/surveys/survey_deleted_successfully: a6b654cc914b344a4475fd2fd4a98cc5
environments/surveys/survey_duplicated_successfully: 91e244f1e7a33640bb4817166a01ff46
environments/surveys/survey_duplication_error: 35994330aed844ce37d8b4f09df24581
environments/surveys/templates/all_channels: 6be67a82fc7326dc2304b23ab3348b87
environments/surveys/templates/all_industries: c7354412fe34585526ff2232aadace41
environments/surveys/templates/all_roles: 6582ccd0a2349c162a7ae1574cdf76be
@@ -2732,6 +2725,11 @@ checksums:
templates/gauge_feature_satisfaction_question_2_headline: 0fcbefbfcf5c21e42de8a36cb2cad854
templates/identify_customer_goals_description: c30d06df9e5c76334e4c3d470ee6e4d8
templates/identify_customer_goals_name: f8123dbfa22e169517a811fae7496595
templates/identify_customer_goals_question_1_choice_1: a6803cfbdbd6208eedf5c691f9e106a5
templates/identify_customer_goals_question_1_choice_2: 7461749517d62030ec2e3915cf1d223b
templates/identify_customer_goals_question_1_choice_3: 725eb3ee0d4f2d229fcf588c21e66a86
templates/identify_customer_goals_question_1_choice_4: 3985521036afaf1cbd2bdc7a4d86d351
templates/identify_customer_goals_question_1_headline: bd9cd414fb723110d7f0a786bbf89d6c
templates/identify_sign_up_barriers_description: 5b2fbee8c425d7a4d0706ec3628cea11
templates/identify_sign_up_barriers_name: 3bbc5352dfa7a9c237bc2c6b21b608dd
templates/identify_sign_up_barriers_question_1_button_label: 080fd22c580f56ffdcea6c3d60448b84
+11 -5
View File
@@ -3,7 +3,6 @@ import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TIntegrationItem } from "@formbricks/types/integration";
import {
TIntegrationAirtable,
TIntegrationAirtableConfigData,
TIntegrationAirtableCredential,
ZIntegrationAirtableBases,
@@ -24,6 +23,11 @@ export const getBases = async (key: string) => {
},
});
if (!req.ok) {
const body = await req.text().catch(() => "");
throw new Error(`Airtable API error fetching bases: ${req.status} ${req.statusText} ${body}`);
}
const res = await req.json();
return ZIntegrationAirtableBases.parse(res);
};
@@ -35,6 +39,11 @@ const tableFetcher = async (key: TIntegrationAirtableCredential, baseId: string)
},
});
if (!req.ok) {
const body = await req.text().catch(() => "");
throw new Error(`Airtable API error fetching tables: ${req.status} ${req.statusText} ${body}`);
}
const res = await req.json();
return res;
@@ -78,10 +87,7 @@ export const fetchAirtableAuthToken = async (formData: Record<string, any>) => {
export const getAirtableToken = async (environmentId: string) => {
try {
const airtableIntegration = (await getIntegrationByType(
environmentId,
"airtable"
)) as TIntegrationAirtable;
const airtableIntegration = await getIntegrationByType(environmentId, "airtable");
const { access_token, expiry_date, refresh_token } = ZIntegrationAirtableCredential.parse(
airtableIntegration?.config.key
+1
View File
@@ -182,6 +182,7 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
"ro-RO",
"ru-RU",
"sv-SE",
"tr-TR",
"zh-Hans-CN",
"zh-Hant-TW",
];
+7
View File
@@ -213,6 +213,13 @@ export const appLanguages = [
native: "Svenska",
},
},
{
code: "tr-TR",
label: {
"en-US": "Turkish",
native: "Türkçe",
},
},
{
code: "zh-Hans-CN",
label: {
+11 -3
View File
@@ -5,7 +5,12 @@ import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TIntegration, TIntegrationInput, ZIntegrationType } from "@formbricks/types/integration";
import {
TIntegration,
TIntegrationByType,
TIntegrationInput,
ZIntegrationType,
} from "@formbricks/types/integration";
import { ITEMS_PER_PAGE } from "../constants";
import { validateInputs } from "../utils/validate";
@@ -94,7 +99,10 @@ export const getIntegration = reactCache(async (integrationId: string): Promise<
});
export const getIntegrationByType = reactCache(
async (environmentId: string, type: TIntegrationInput["type"]): Promise<TIntegration | null> => {
async <T extends TIntegrationInput["type"]>(
environmentId: string,
type: T
): Promise<TIntegrationByType<T> | null> => {
validateInputs([environmentId, ZId], [type, ZIntegrationType]);
try {
@@ -106,7 +114,7 @@ export const getIntegrationByType = reactCache(
},
},
});
return integration ? transformIntegration(integration) : null;
return integration ? (transformIntegration(integration) as TIntegrationByType<T>) : null;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
+78
View File
@@ -6,11 +6,13 @@ import {
createEmailChangeToken,
createEmailToken,
createInviteToken,
createSsoRelinkIntent,
createToken,
createTokenForLinkSurvey,
getEmailFromEmailToken,
verifyEmailChangeToken,
verifyInviteToken,
verifySsoRelinkIntent,
verifyToken,
verifyTokenForLinkSurvey,
} from "./jwt";
@@ -380,6 +382,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
expect(verified).toEqual({
id: mockUser.id, // Returns the decrypted user ID
email: mockUser.email,
purpose: "email_verification",
});
});
@@ -414,6 +417,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
expect(verified).toEqual({
id: mockUser.id, // Returns the raw ID from payload
email: mockUser.email,
purpose: "email_verification",
});
});
@@ -425,6 +429,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
expect(verified).toEqual({
id: mockUser.id, // Returns the decrypted user ID
email: mockUser.email,
purpose: "email_verification",
});
});
@@ -1004,5 +1009,78 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
expect(results.every((result: any) => result.id === mockUser.id)).toBe(true); // Returns decrypted user ID
});
});
describe("SSO recovery support", () => {
test("creates verification tokens that preserve the recovery purpose", async () => {
const token = createToken(mockUser.id, { purpose: "sso_recovery", expiresIn: "15m" });
await expect(verifyToken(token)).resolves.toEqual(
expect.objectContaining({
id: mockUser.id,
email: mockUser.email,
purpose: "sso_recovery",
})
);
});
test("defaults legacy verification tokens to email_verification when purpose is missing", async () => {
const legacyToken = jwt.sign({ id: `encrypted_${mockUser.id}` }, TEST_NEXTAUTH_SECRET);
await expect(verifyToken(legacyToken)).resolves.toEqual(
expect.objectContaining({
id: mockUser.id,
email: mockUser.email,
purpose: "email_verification",
})
);
});
test("round-trips SSO relink intents without losing callback state", () => {
const intent = createSsoRelinkIntent({
userId: mockUser.id,
email: mockUser.email,
provider: "google",
providerAccountId: "provider-123",
callbackUrl: "http://localhost:3000/invite?token=invite-token",
});
expect(verifySsoRelinkIntent(intent)).toEqual({
userId: mockUser.id,
email: mockUser.email,
provider: "google",
providerAccountId: "provider-123",
callbackUrl: "http://localhost:3000/invite?token=invite-token",
});
});
test("rejects expired SSO relink intents", () => {
const expiredIntent = jwt.sign(
{
userId: crypto.symmetricEncrypt(mockUser.id, TEST_ENCRYPTION_KEY),
email: crypto.symmetricEncrypt(mockUser.email, TEST_ENCRYPTION_KEY),
provider: "google",
providerAccountId: crypto.symmetricEncrypt("provider-123", TEST_ENCRYPTION_KEY),
callbackUrl: crypto.symmetricEncrypt("http://localhost:3000", TEST_ENCRYPTION_KEY),
exp: Math.floor(Date.now() / 1000) - 3600,
},
TEST_NEXTAUTH_SECRET
);
expect(() => verifySsoRelinkIntent(expiredIntent)).toThrow();
});
test("rejects tampered SSO relink intents", () => {
const intent = createSsoRelinkIntent({
userId: mockUser.id,
email: mockUser.email,
provider: "google",
providerAccountId: "provider-123",
callbackUrl: "http://localhost:3000",
});
const tamperedIntent = `${intent.slice(0, -1)}x`;
expect(() => verifySsoRelinkIntent(tamperedIntent)).toThrow();
});
});
});
});
+108 -5
View File
@@ -1,4 +1,4 @@
import jwt, { JwtPayload } from "jsonwebtoken";
import jwt, { JwtPayload, SignOptions } from "jsonwebtoken";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ENCRYPTION_KEY, NEXTAUTH_SECRET } from "@/lib/constants";
@@ -13,7 +13,39 @@ const decryptWithFallback = (encryptedText: string, key: string): string => {
}
};
export const createToken = (userId: string, options = {}): string => {
export const VERIFICATION_TOKEN_PURPOSES = ["email_verification", "sso_recovery"] as const;
export type TVerificationTokenPurpose = (typeof VERIFICATION_TOKEN_PURPOSES)[number];
export type TVerifyTokenPayload = JwtPayload & {
id: string;
email: string;
purpose: TVerificationTokenPurpose;
};
type TVerificationTokenOptions = SignOptions & {
purpose?: TVerificationTokenPurpose;
};
type TSsoRelinkIntentPayload = {
callbackUrl: string;
email: string;
provider: string;
providerAccountId: string;
userId: string;
};
const DEFAULT_VERIFICATION_TOKEN_PURPOSE: TVerificationTokenPurpose = "email_verification";
const getVerificationTokenPurpose = (purpose: unknown): TVerificationTokenPurpose => {
if (purpose && VERIFICATION_TOKEN_PURPOSES.includes(purpose as TVerificationTokenPurpose)) {
return purpose as TVerificationTokenPurpose;
}
return DEFAULT_VERIFICATION_TOKEN_PURPOSE;
};
export const createToken = (userId: string, options: TVerificationTokenOptions = {}): string => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
@@ -23,7 +55,9 @@ export const createToken = (userId: string, options = {}): string => {
}
const encryptedUserId = symmetricEncrypt(userId, ENCRYPTION_KEY);
return jwt.sign({ id: encryptedUserId }, NEXTAUTH_SECRET, options);
const { purpose = DEFAULT_VERIFICATION_TOKEN_PURPOSE, ...jwtOptions } = options;
return jwt.sign({ id: encryptedUserId, purpose }, NEXTAUTH_SECRET, jwtOptions);
};
export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
if (!NEXTAUTH_SECRET) {
@@ -224,7 +258,72 @@ const getUserEmailForLegacyVerification = async (
return { userId: decryptedId, userEmail: foundUser.email };
};
export const verifyToken = async (token: string): Promise<JwtPayload> => {
const DEFAULT_SSO_RELINK_INTENT_OPTIONS: SignOptions = {
expiresIn: "15m",
};
export const createSsoRelinkIntent = (
payload: TSsoRelinkIntentPayload,
options: SignOptions = DEFAULT_SSO_RELINK_INTENT_OPTIONS
): string => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
return jwt.sign(
{
userId: symmetricEncrypt(payload.userId, ENCRYPTION_KEY),
email: symmetricEncrypt(payload.email, ENCRYPTION_KEY),
provider: payload.provider,
providerAccountId: symmetricEncrypt(payload.providerAccountId, ENCRYPTION_KEY),
callbackUrl: symmetricEncrypt(payload.callbackUrl, ENCRYPTION_KEY),
},
NEXTAUTH_SECRET,
options
);
};
export const verifySsoRelinkIntent = (token: string): TSsoRelinkIntentPayload => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
userId: string;
email: string;
provider: string;
providerAccountId: string;
callbackUrl: string;
};
if (
!payload?.userId ||
!payload?.email ||
!payload?.provider ||
!payload?.providerAccountId ||
!payload?.callbackUrl
) {
throw new Error("Token is invalid or missing required fields");
}
return {
userId: decryptWithFallback(payload.userId, ENCRYPTION_KEY),
email: decryptWithFallback(payload.email, ENCRYPTION_KEY),
provider: payload.provider,
providerAccountId: decryptWithFallback(payload.providerAccountId, ENCRYPTION_KEY),
callbackUrl: decryptWithFallback(payload.callbackUrl, ENCRYPTION_KEY),
};
};
export const verifyToken = async (token: string): Promise<TVerifyTokenPayload> => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
@@ -263,7 +362,11 @@ export const verifyToken = async (token: string): Promise<JwtPayload> => {
// Get user email if we don't have it yet
userData ??= await getUserEmailForLegacyVerification(token, payload.id);
return { id: userData.userId, email: userData.userEmail };
return {
id: userData.userId,
email: userData.userEmail,
purpose: getVerificationTokenPurpose(payload.purpose),
};
};
export const verifyInviteToken = (token: string): { inviteId: string; email: string } => {
+2 -6
View File
@@ -1,8 +1,4 @@
import {
TIntegrationNotion,
TIntegrationNotionConfig,
TIntegrationNotionDatabase,
} from "@formbricks/types/integration/notion";
import { TIntegrationNotionConfig, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { getIntegrationByType } from "../integration/service";
@@ -29,7 +25,7 @@ const fetchPages = async (config: TIntegrationNotionConfig) => {
export const getNotionDatabases = async (environmentId: string): Promise<TIntegrationNotionDatabase[]> => {
let results: TIntegrationNotionDatabase[] = [];
try {
const notionIntegration = (await getIntegrationByType(environmentId, "notion")) as TIntegrationNotion;
const notionIntegration = await getIntegrationByType(environmentId, "notion");
if (notionIntegration && notionIntegration.config?.key.bot_id) {
results = await fetchPages(notionIntegration.config);
}
+1 -1
View File
@@ -63,7 +63,7 @@ const mapOrganizationBilling = (billing: TOrganizationWithBilling["billing"]): T
stripeCustomerId: billing.stripeCustomerId,
limits: billing.limits,
usageCycleAnchor: billing.usageCycleAnchor,
...(billing.stripe === undefined ? {} : { stripe: billing.stripe }),
...(billing.stripe == null ? {} : { stripe: billing.stripe }),
};
};
+1 -4
View File
@@ -1,6 +1,3 @@
import structuredClonePolyfill from "@ungap/structured-clone";
const structuredCloneExport =
typeof structuredClone === "undefined" ? structuredClonePolyfill : structuredClone;
const structuredCloneExport = globalThis.structuredClone;
export { structuredCloneExport as structuredClone };
+2 -2
View File
@@ -1,7 +1,7 @@
import { Prisma } from "@prisma/client";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import { TIntegration, TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationSlack, TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
import { TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
import { SLACK_MESSAGE_LIMIT } from "../constants";
import { deleteIntegration, getIntegrationByType } from "../integration/service";
import { truncateText } from "../utils/strings";
@@ -58,7 +58,7 @@ export const fetchChannels = async (slackIntegration: TIntegration): Promise<TIn
export const getSlackChannels = async (environmentId: string): Promise<TIntegrationItem[]> => {
let channels: TIntegrationItem[] = [];
try {
const slackIntegration = (await getIntegrationByType(environmentId, "slack")) as TIntegrationSlack;
const slackIntegration = await getIntegrationByType(environmentId, "slack");
if (slackIntegration && slackIntegration.config?.key) {
channels = await fetchChannels(slackIntegration);
}
+11 -7
View File
@@ -190,6 +190,14 @@ const mockWelcomeCard: TSurveyWelcomeCard = {
showResponseCount: false,
};
const mockBlocks = [
{
id: "block1",
name: "Block 1",
elements: [mockQuestion],
},
];
const baseSurveyProperties = {
id: mockId,
name: "Mock Survey",
@@ -201,13 +209,7 @@ const baseSurveyProperties = {
displayLimit: 3,
welcomeCard: mockWelcomeCard,
questions: [],
blocks: [
{
id: "block1",
name: "Block 1",
elements: [mockQuestion],
},
],
blocks: mockBlocks as unknown as SurveyMock["blocks"],
isBackButtonHidden: false,
isAutoProgressingEnabled: false,
isCaptureIpEnabled: false,
@@ -304,6 +306,7 @@ export const createSurveyInput: TSurveyCreateInput = {
displayOption: "respondMultiple",
triggers: [{ actionClass: mockActionClass }],
...baseSurveyProperties,
blocks: mockBlocks,
};
export const updateSurveyInput: TSurvey = {
@@ -326,6 +329,7 @@ export const updateSurveyInput: TSurvey = {
followUps: [],
...baseSurveyProperties,
...commonMockProperties,
blocks: mockBlocks,
slug: null,
customHeadScripts: null,
customHeadScriptsMode: null,
+6 -34
View File
@@ -5,7 +5,8 @@ import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSegment, ZSegmentFilters } from "@formbricks/types/segment";
import { ZSegmentFilters } from "@formbricks/types/segment";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TSurvey, TSurveyCreateInput, ZSurvey, ZSurveyCreateInput } from "@formbricks/types/surveys/types";
import {
getOrganizationByEnvironmentId,
@@ -558,22 +559,7 @@ export const updateSurveyInternal = async (
select: selectSurvey,
});
let surveySegment: TSegment | null = null;
if (prismaSurvey.segment) {
surveySegment = {
...prismaSurvey.segment,
surveys: prismaSurvey.segment.surveys.map((survey) => survey.id),
};
}
const modifiedSurvey: TSurvey = {
...prismaSurvey, // Properties from prismaSurvey
displayPercentage: Number(prismaSurvey.displayPercentage) || null,
segment: surveySegment,
customHeadScriptsMode: prismaSurvey.customHeadScriptsMode,
};
return modifiedSurvey;
return transformPrismaSurvey<TSurvey>(prismaSurvey);
} catch (error) {
logger.error(error, "Error updating survey");
if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -648,8 +634,8 @@ export const createSurvey = async (
}
// Validate and prepare blocks for persistence
if (data.blocks && data.blocks.length > 0) {
data.blocks = validateMediaAndPrepareBlocks(data.blocks);
if (Array.isArray(data.blocks) && data.blocks.length > 0) {
data.blocks = validateMediaAndPrepareBlocks(data.blocks as unknown as TSurveyBlock[]);
}
const survey = await prisma.survey.create({
@@ -773,21 +759,7 @@ export const loadNewSegmentInSurvey = async (surveyId: string, newSegmentId: str
});
}
let surveySegment: TSegment | null = null;
if (prismaSurvey.segment) {
surveySegment = {
...prismaSurvey.segment,
surveys: prismaSurvey.segment.surveys.map((survey) => survey.id),
};
}
const modifiedSurvey = {
...prismaSurvey,
segment: surveySegment,
customHeadScriptsMode: prismaSurvey.customHeadScriptsMode,
};
return modifiedSurvey as TSurvey;
return transformPrismaSurvey<TSurvey>(prismaSurvey);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
+2 -1
View File
@@ -1,5 +1,5 @@
import { type Locale, formatDistance } from "date-fns";
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, tr, zhCN, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user";
import { formatDateForDisplay } from "./utils/datetime";
@@ -17,6 +17,7 @@ const TIME_SINCE_LOCALES: Record<TUserLocale, Locale> = {
"ro-RO": ro,
"ru-RU": ru,
"sv-SE": sv,
"tr-TR": tr,
"zh-Hans-CN": zhCN,
"zh-Hant-TW": zhTW,
};
+20
View File
@@ -0,0 +1,20 @@
import { Prisma } from "@prisma/client";
export const publicUserSelect = {
id: true,
name: true,
email: true,
emailVerified: true,
createdAt: true,
updatedAt: true,
twoFactorEnabled: true,
identityProvider: true,
notificationSettings: true,
locale: true,
lastLoginAt: true,
isActive: true,
} as const satisfies Prisma.UserSelect;
export type TPublicUser = Prisma.UserGetPayload<{
select: typeof publicUserSelect;
}>;
+10 -10
View File
@@ -6,6 +6,7 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TOrganization } from "@formbricks/types/organizations";
import { TUserLocale, TUserUpdateInput } from "@formbricks/types/user";
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { publicUserSelect } from "./public-user";
import { deleteUser, getUser, getUserByEmail, getUsersWithOrganization, updateUser } from "./service";
vi.mock("@formbricks/database", () => ({
@@ -47,11 +48,6 @@ describe("User Service", () => {
locale: "en-US" as TUserLocale,
lastLoginAt: new Date(),
isActive: true,
twoFactorSecret: null,
backupCodes: null,
password: null,
identityProviderAccountId: null,
groupId: null,
};
const mockOrganizations: TOrganization[] = [
@@ -102,8 +98,12 @@ describe("User Service", () => {
expect(result).toEqual(mockPrismaUser);
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: "user1" },
select: expect.any(Object),
select: publicUserSelect,
});
expect(result).not.toHaveProperty("password");
expect(result).not.toHaveProperty("twoFactorSecret");
expect(result).not.toHaveProperty("backupCodes");
expect(result).not.toHaveProperty("identityProviderAccountId");
});
test("should return null when user not found", async () => {
@@ -134,7 +134,7 @@ describe("User Service", () => {
expect(result).toEqual(mockPrismaUser);
expect(prisma.user.findFirst).toHaveBeenCalledWith({
where: { email: "test@example.com" },
select: expect.any(Object),
select: publicUserSelect,
});
});
@@ -176,7 +176,7 @@ describe("User Service", () => {
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user1" },
data: updateData,
select: expect.any(Object),
select: publicUserSelect,
});
});
@@ -204,7 +204,7 @@ describe("User Service", () => {
expect(deleteOrganization).toHaveBeenCalledWith("org1");
expect(prisma.user.delete).toHaveBeenCalledWith({
where: { id: "user1" },
select: expect.any(Object),
select: publicUserSelect,
});
});
@@ -236,7 +236,7 @@ describe("User Service", () => {
},
},
},
select: expect.any(Object),
select: publicUserSelect,
});
});
+7 -21
View File
@@ -10,21 +10,7 @@ import { TUser, TUserLocale, TUserUpdateInput, ZUserUpdateInput } from "@formbri
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { deleteBrevoCustomerByEmail } from "@/modules/auth/lib/brevo";
import { validateInputs } from "../utils/validate";
const responseSelection = {
id: true,
name: true,
email: true,
emailVerified: true,
createdAt: true,
updatedAt: true,
twoFactorEnabled: true,
identityProvider: true,
notificationSettings: true,
locale: true,
lastLoginAt: true,
isActive: true,
};
import { publicUserSelect } from "./public-user";
// function to retrive basic information about a user's user
export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
@@ -35,7 +21,7 @@ export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
where: {
id,
},
select: responseSelection,
select: publicUserSelect,
});
if (!user) {
@@ -59,7 +45,7 @@ export const getUserByEmail = reactCache(async (email: string): Promise<TUser |
where: {
email,
},
select: responseSelection,
select: publicUserSelect,
});
return user;
@@ -82,7 +68,7 @@ export const updateUser = async (personId: string, data: TUserUpdateInput): Prom
id: personId,
},
data: data,
select: responseSelection,
select: publicUserSelect,
});
return updatedUser;
@@ -105,7 +91,7 @@ const deleteUserById = async (id: string): Promise<TUser> => {
where: {
id,
},
select: responseSelection,
select: publicUserSelect,
});
return user;
} catch (error) {
@@ -153,7 +139,7 @@ export const getUsersWithOrganization = async (organizationId: string): Promise<
},
},
},
select: responseSelection,
select: publicUserSelect,
});
return users;
@@ -174,7 +160,7 @@ export const getUserLocale = reactCache(async (id: string): Promise<TUserLocale
where: {
id,
},
select: responseSelection,
select: publicUserSelect,
});
if (!user) {
+27
View File
@@ -0,0 +1,27 @@
export type DebouncedFunction<T extends (...args: any[]) => void> = ((...args: Parameters<T>) => void) & {
cancel: () => void;
};
export const debounce = <T extends (...args: any[]) => void>(
callback: T,
delay: number
): DebouncedFunction<T> => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const debounced = ((...args: Parameters<T>) => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => callback(...args), delay);
}) as DebouncedFunction<T>;
debounced.cancel = () => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
timeoutId = undefined;
}
};
return debounced;
};
+35
View File
@@ -0,0 +1,35 @@
export const capitalize = (value: string): string => {
if (!value) return "";
return `${value.charAt(0).toUpperCase()}${value.slice(1).toLowerCase()}`;
};
export const isDeepEqual = (left: unknown, right: unknown): boolean => {
if (Object.is(left, right)) return true;
if (left instanceof Date && right instanceof Date) {
return left.getTime() === right.getTime();
}
if (typeof left !== "object" || left === null || typeof right !== "object" || right === null) {
return false;
}
if (Array.isArray(left) || Array.isArray(right)) {
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
return left.every((item, index) => isDeepEqual(item, right[index]));
}
const leftRecord = left as Record<string, unknown>;
const rightRecord = right as Record<string, unknown>;
const leftKeys = Object.keys(leftRecord);
const rightKeys = Object.keys(rightRecord);
if (leftKeys.length !== rightKeys.length) return false;
return leftKeys.every(
(key) =>
Object.prototype.hasOwnProperty.call(rightRecord, key) && isDeepEqual(leftRecord[key], rightRecord[key])
);
};
+20 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { isSafeIdentifier } from "./safe-identifier";
import { isSafeIdentifier, toSafeIdentifier } from "./safe-identifier";
describe("safe-identifier", () => {
describe("isSafeIdentifier", () => {
@@ -32,4 +32,23 @@ describe("safe-identifier", () => {
expect(isSafeIdentifier("")).toBe(false);
});
});
describe("toSafeIdentifier", () => {
test("normalizes free-form labels into safe identifiers", () => {
expect(toSafeIdentifier("Date of Birth")).toBe("date_of_birth");
expect(toSafeIdentifier("Customer-ID")).toBe("customer_id");
expect(toSafeIdentifier(" Preferred Language ")).toBe("preferred_language");
expect(toSafeIdentifier("city__name")).toBe("city_name");
});
test("strips invalid leading characters until first lowercase letter", () => {
expect(toSafeIdentifier("123 Date")).toBe("date");
expect(toSafeIdentifier("__name")).toBe("name");
expect(toSafeIdentifier("99")).toBe("");
});
test("keeps already safe identifiers unchanged", () => {
expect(toSafeIdentifier("country_code")).toBe("country_code");
});
});
});
+38
View File
@@ -12,6 +12,44 @@ export const isSafeIdentifier = (value: string): boolean => {
return /^[a-z0-9_]+$/.test(value);
};
/**
* Converts a free-form string to a safe identifier candidate.
* The output only contains lowercase letters, numbers, and underscores.
* It also ensures the identifier starts with a lowercase letter by stripping invalid leading chars.
*/
export const toSafeIdentifier = (value: string): string => {
const normalized = value.trim().toLowerCase();
let safeIdentifier = "";
let shouldInsertUnderscore = false;
for (const char of normalized) {
const isLowercaseLetter = char >= "a" && char <= "z";
const isDigit = char >= "0" && char <= "9";
if (isLowercaseLetter || isDigit) {
if (shouldInsertUnderscore && safeIdentifier.length > 0) {
safeIdentifier += "_";
}
safeIdentifier += char;
shouldInsertUnderscore = false;
continue;
}
if (safeIdentifier.length > 0) {
shouldInsertUnderscore = true;
}
}
for (let i = 0; i < safeIdentifier.length; i++) {
const char = safeIdentifier[i];
if (char >= "a" && char <= "z") {
return safeIdentifier.slice(i);
}
}
return "";
};
/**
* Converts a snake_case string to Title Case for display as a label.
* Example: "job_description" -> "Job Description"
+9 -11
View File
@@ -342,7 +342,6 @@
"other": "Andere",
"other_filters": "Weitere Filter",
"other_placeholder": "Sonstiger Platzhalter",
"others": "Andere",
"overlay_color": "Overlay-Farbe",
"overview": "Überblick",
"password": "Passwort",
@@ -360,7 +359,6 @@
"please_upgrade_your_plan": "Bitte aktualisieren Sie Ihren Plan",
"powered_by_formbricks": "Bereitgestellt von Formbricks",
"preview": "Vorschau",
"preview_survey": "Umfragevorschau",
"privacy": "Datenschutz",
"product_manager": "Produktmanager",
"production": "Produktion",
@@ -495,7 +493,6 @@
"workspace_name_placeholder": "z. B. Formbricks",
"workspaces": "Projekte",
"years": "Jahre",
"you": "Du",
"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.",
@@ -836,6 +833,9 @@
},
"notion_integration_description": "Sende Daten an deine Notion Datenbank",
"please_select_a_survey_error": "Bitte wähle eine Umfrage aus",
"reconnect_button": "Erneut verbinden",
"reconnect_button_description": "Deine Integrationsverbindung ist abgelaufen. Bitte verbinde dich erneut, um weiterhin Antworten zu synchronisieren. Deine bestehenden Links und Daten bleiben erhalten.",
"reconnect_button_tooltip": "Verbinde die Integration erneut, um deinen Zugriff zu aktualisieren. Deine bestehenden Links und Daten bleiben erhalten.",
"select_at_least_one_question_error": "Bitte wähle mindestens eine Frage aus",
"slack": {
"already_connected_another_survey": "Du hast bereits eine andere Umfrage mit diesem Kanal verbunden.",
@@ -1309,12 +1309,7 @@
"surveys": {
"all_set_time_to_create_first_survey": "Alles klar! Zeit, deine erste Umfrage zu erstellen",
"alphabetical": "alphabetisch",
"copy_survey": "Umfrage kopieren",
"copy_survey_description": "Kopiere diese Umfrage in eine andere Umgebung",
"copy_survey_error": "Kopieren der Umfrage fehlgeschlagen",
"copy_survey_link_to_clipboard": "Umfragelink in die Zwischenablage kopieren",
"copy_survey_partially_success": "{success} Umfragen erfolgreich kopiert, {error} fehlgeschlagen.",
"copy_survey_success": "Umfrage erfolgreich kopiert!",
"delete_survey_and_responses_warning": "Bist Du sicher, dass Du diese Umfrage und alle ihre Antworten löschen möchtest?",
"edit": {
"activate_translations": "Übersetzungen aktivieren",
@@ -1367,7 +1362,7 @@
"audience": "Publikum",
"auto_close_on_inactivity": "Automatisches Schließen bei Inaktivität",
"auto_progress_rating_and_nps": "Bewertungs- und NPS-Fragen automatisch fortsetzen",
"auto_progress_rating_and_nps_description": "Fahre automatisch fort, sobald Befragte eine Antwort bei Bewertungs- oder NPS-Fragen auswählen. Dies gilt nur für Blöcke mit einer einzelnen Frage. Bei Pflichtfragen wird die Weiter-Schaltfläche ausgeblendet; bei optionalen Fragen bleibt sie zum Überspringen sichtbar.",
"auto_progress_rating_and_nps_description": "Automatisches Weitergehen bei Einzelfragen-Blöcken. Pflichtfragen blenden Weiter aus, außer wenn \"Sonstiges\" ausgewählt ist.",
"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",
@@ -2066,7 +2061,6 @@
"downloading_qr_code": "QR-Code wird heruntergeladen",
"drop_offs": "Drop-Off Rate",
"drop_offs_tooltip": "So oft wurde die Umfrage gestartet, aber nicht abgeschlossen.",
"failed_to_copy_link": "Kopieren des Links fehlgeschlagen",
"filter_added_successfully": "Filter erfolgreich hinzugefügt",
"filter_updated_successfully": "Filter erfolgreich aktualisiert",
"filtered_responses_csv": "Gefilterte Antworten (CSV)",
@@ -2154,7 +2148,6 @@
},
"survey_deleted_successfully": "Umfrage erfolgreich gelöscht",
"survey_duplicated_successfully": "Umfrage erfolgreich dupliziert",
"survey_duplication_error": "Duplizieren der Umfrage fehlgeschlagen",
"templates": {
"all_channels": "Alle Kanäle",
"all_industries": "Alle Branchen",
@@ -2887,6 +2880,11 @@
"gauge_feature_satisfaction_question_2_headline": "Was könnten wir besser machen?",
"identify_customer_goals_description": "Besser verstehen, ob deine Botschaften die richtigen Erwartungen an dein Produkt schaffen.",
"identify_customer_goals_name": "Kundenziele identifizieren",
"identify_customer_goals_question_1_choice_1": "Meine Nutzerbasis tiefgehend verstehen",
"identify_customer_goals_question_1_choice_2": "Upselling-Möglichkeiten identifizieren",
"identify_customer_goals_question_1_choice_3": "Das bestmögliche Produkt entwickeln",
"identify_customer_goals_question_1_choice_4": "Die Welt beherrschen, um allen Rosenkohl zum Frühstück zu servieren",
"identify_customer_goals_question_1_headline": "Was ist Ihr Hauptziel bei der Nutzung von $[projectName]?",
"identify_sign_up_barriers_description": "Biete einen Rabatt an, um Einblicke in Anmeldebarrieren zu gewinnen.",
"identify_sign_up_barriers_name": "Identifiziere Anmeldebarrieren",
"identify_sign_up_barriers_question_1_button_label": "Erhalte 10% Rabatt",
+9 -11
View File
@@ -342,7 +342,6 @@
"other": "Other",
"other_filters": "Other Filters",
"other_placeholder": "Other Placeholder",
"others": "Others",
"overlay_color": "Overlay color",
"overview": "Overview",
"password": "Password",
@@ -360,7 +359,6 @@
"please_upgrade_your_plan": "Please upgrade your plan",
"powered_by_formbricks": "Powered by Formbricks",
"preview": "Preview",
"preview_survey": "Preview Survey",
"privacy": "Privacy Policy",
"product_manager": "Product Manager",
"production": "Production",
@@ -495,7 +493,6 @@
"workspace_name_placeholder": "e.g. Formbricks",
"workspaces": "Workspaces",
"years": "years",
"you": "You",
"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.",
@@ -836,6 +833,9 @@
},
"notion_integration_description": "Send data to your Notion database",
"please_select_a_survey_error": "Please select a survey",
"reconnect_button": "Reconnect",
"reconnect_button_description": "Your integration connection has expired. Please reconnect to continue syncing responses. Your existing links and data will be preserved.",
"reconnect_button_tooltip": "Reconnect the integration to refresh your access. Your existing links and data will be preserved.",
"select_at_least_one_question_error": "Please select at least one question",
"slack": {
"already_connected_another_survey": "You have already connected another survey to this channel.",
@@ -1309,12 +1309,7 @@
"surveys": {
"all_set_time_to_create_first_survey": "You are all set! Time to create your first survey",
"alphabetical": "Alphabetical",
"copy_survey": "Copy survey",
"copy_survey_description": "Copy this survey to another environment",
"copy_survey_error": "Failed to copy survey",
"copy_survey_link_to_clipboard": "Copy survey link to clipboard",
"copy_survey_partially_success": "{success} surveys copied successfully, {error} failed.",
"copy_survey_success": "Survey copied successfully",
"delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses?",
"edit": {
"activate_translations": "Activate translations",
@@ -1367,7 +1362,7 @@
"audience": "Audience",
"auto_close_on_inactivity": "Auto close on inactivity",
"auto_progress_rating_and_nps": "Auto-progress rating and NPS questions",
"auto_progress_rating_and_nps_description": "Automatically advance when respondents select an answer on rating or NPS questions. This only applies to single-question blocks. Required questions hide the Next button; optional questions still show it for skipping.",
"auto_progress_rating_and_nps_description": "Auto-advance in single-question blocks. Required questions hide Next, except when \"Other\" is selected.",
"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",
@@ -2066,7 +2061,6 @@
"downloading_qr_code": "Downloading QR code",
"drop_offs": "Drop-Offs",
"drop_offs_tooltip": "Number of times the survey has been started but not completed.",
"failed_to_copy_link": "Failed to copy link",
"filter_added_successfully": "Filter added successfully",
"filter_updated_successfully": "Filter updated successfully",
"filtered_responses_csv": "Filtered responses (CSV)",
@@ -2154,7 +2148,6 @@
},
"survey_deleted_successfully": "Survey deleted successfully",
"survey_duplicated_successfully": "Survey duplicated successfully",
"survey_duplication_error": "Failed to duplicate the survey.",
"templates": {
"all_channels": "All channels",
"all_industries": "All industries",
@@ -2887,6 +2880,11 @@
"gauge_feature_satisfaction_question_2_headline": "What is one thing we could do better?",
"identify_customer_goals_description": "Better understand if your messaging creates the right expectations of the value your product provides.",
"identify_customer_goals_name": "Identify Customer Goals",
"identify_customer_goals_question_1_choice_1": "Understand my user base deeply",
"identify_customer_goals_question_1_choice_2": "Identify upselling opportunities",
"identify_customer_goals_question_1_choice_3": "Build the best possible product",
"identify_customer_goals_question_1_choice_4": "Rule the world to make everyone breakfast brussels sprouts",
"identify_customer_goals_question_1_headline": "What is your primary goal for using $[projectName]?",
"identify_sign_up_barriers_description": "Offer a discount to gather insights about sign up barriers.",
"identify_sign_up_barriers_name": "Identify Sign Up Barriers",
"identify_sign_up_barriers_question_1_button_label": "Get 10% discount",
+9 -11
View File
@@ -342,7 +342,6 @@
"other": "Otro",
"other_filters": "Otros Filtros",
"other_placeholder": "Otro marcador de posición",
"others": "Otros",
"overlay_color": "Color de superposición",
"overview": "Resumen",
"password": "Contraseña",
@@ -360,7 +359,6 @@
"please_upgrade_your_plan": "Por favor, actualiza tu plan",
"powered_by_formbricks": "Desarrollado por Formbricks",
"preview": "Vista previa",
"preview_survey": "Vista previa de la encuesta",
"privacy": "Política de privacidad",
"product_manager": "Gestor de producto",
"production": "Producción",
@@ -495,7 +493,6 @@
"workspace_name_placeholder": "p. ej. Formbricks",
"workspaces": "Proyectos",
"years": "años",
"you": "Tú",
"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.",
@@ -836,6 +833,9 @@
},
"notion_integration_description": "Envía datos a tu base de datos de Notion",
"please_select_a_survey_error": "Por favor, selecciona una encuesta",
"reconnect_button": "Reconectar",
"reconnect_button_description": "Tu conexión de integración ha caducado. Por favor, reconecta para seguir sincronizando las respuestas. Tus enlaces y datos existentes se conservarán.",
"reconnect_button_tooltip": "Reconecta la integración para actualizar tu acceso. Tus enlaces y datos existentes se conservarán.",
"select_at_least_one_question_error": "Por favor, selecciona al menos una pregunta",
"slack": {
"already_connected_another_survey": "Ya has conectado otra encuesta a este canal.",
@@ -1309,12 +1309,7 @@
"surveys": {
"all_set_time_to_create_first_survey": "¡Todo listo! Es hora de crear tu primera encuesta",
"alphabetical": "Alfabético",
"copy_survey": "Copiar encuesta",
"copy_survey_description": "Copia esta encuesta a otro entorno",
"copy_survey_error": "Error al copiar la encuesta",
"copy_survey_link_to_clipboard": "Copiar enlace de la encuesta al portapapeles",
"copy_survey_partially_success": "{success} encuestas copiadas correctamente, {error} fallidas.",
"copy_survey_success": "¡Encuesta copiada correctamente!",
"delete_survey_and_responses_warning": "¿Estás seguro de que quieres eliminar esta encuesta y todas sus respuestas?",
"edit": {
"activate_translations": "Activar traducciones",
@@ -1367,7 +1362,7 @@
"audience": "Audiencia",
"auto_close_on_inactivity": "Cierre automático por inactividad",
"auto_progress_rating_and_nps": "Avanzar automáticamente en preguntas de valoración y NPS",
"auto_progress_rating_and_nps_description": "Avanza automáticamente cuando los encuestados seleccionen una respuesta en preguntas de valoración o NPS. Esto solo se aplica a bloques de una sola pregunta. Las preguntas obligatorias ocultan el botón Siguiente; las preguntas opcionales aún lo muestran para omitirlas.",
"auto_progress_rating_and_nps_description": "Avance automático en bloques de una sola pregunta. Las preguntas obligatorias ocultan Siguiente, excepto cuando se selecciona \"Otro\".",
"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",
@@ -2066,7 +2061,6 @@
"downloading_qr_code": "Descargando código QR",
"drop_offs": "Abandonos",
"drop_offs_tooltip": "Número de veces que se ha iniciado la encuesta pero no se ha completado.",
"failed_to_copy_link": "Error al copiar el enlace",
"filter_added_successfully": "Filtro añadido correctamente",
"filter_updated_successfully": "Filtro actualizado correctamente",
"filtered_responses_csv": "Respuestas filtradas (CSV)",
@@ -2154,7 +2148,6 @@
},
"survey_deleted_successfully": "¡Encuesta eliminada correctamente!",
"survey_duplicated_successfully": "Encuesta duplicada correctamente.",
"survey_duplication_error": "Error al duplicar la encuesta.",
"templates": {
"all_channels": "Todos los canales",
"all_industries": "Todas las industrias",
@@ -2887,6 +2880,11 @@
"gauge_feature_satisfaction_question_2_headline": "¿Qué es una cosa que podríamos mejorar?",
"identify_customer_goals_description": "Comprende mejor si tus mensajes crean las expectativas correctas sobre el valor que proporciona tu producto.",
"identify_customer_goals_name": "Identificar objetivos del cliente",
"identify_customer_goals_question_1_choice_1": "Comprender en profundidad a mi base de usuarios",
"identify_customer_goals_question_1_choice_2": "Identificar oportunidades de venta adicional",
"identify_customer_goals_question_1_choice_3": "Construir el mejor producto posible",
"identify_customer_goals_question_1_choice_4": "Conquistar el mundo para que todos desayunen coles de Bruselas",
"identify_customer_goals_question_1_headline": "¿Cuál es tu objetivo principal al usar $[projectName]?",
"identify_sign_up_barriers_description": "Ofrece un descuento para obtener información sobre las barreras de registro.",
"identify_sign_up_barriers_name": "Identificar barreras de registro",
"identify_sign_up_barriers_question_1_button_label": "Obtener 10 % de descuento",
+9 -11
View File
@@ -342,7 +342,6 @@
"other": "Autre",
"other_filters": "Autres filtres",
"other_placeholder": "Autre espace réservé",
"others": "Autres",
"overlay_color": "Couleur de superposition",
"overview": "Aperçu",
"password": "Mot de passe",
@@ -360,7 +359,6 @@
"please_upgrade_your_plan": "Veuillez mettre à niveau votre plan",
"powered_by_formbricks": "Propulsé par Formbricks",
"preview": "Aperçu",
"preview_survey": "Aperçu de l'enquête",
"privacy": "Politique de confidentialité",
"product_manager": "Chef de produit",
"production": "Production",
@@ -495,7 +493,6 @@
"workspace_name_placeholder": "par ex. Formbricks",
"workspaces": "Projets",
"years": "années",
"you": "Vous",
"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.",
@@ -836,6 +833,9 @@
},
"notion_integration_description": "Envoyez des données à votre base de données Notion.",
"please_select_a_survey_error": "Veuillez sélectionner une enquête.",
"reconnect_button": "Reconnecter",
"reconnect_button_description": "Ta connexion à l'intégration a expiré. Reconnecte-toi pour continuer à synchroniser les réponses. Tes liens et données existants seront conservés.",
"reconnect_button_tooltip": "Reconnecte l'intégration pour actualiser ton accès. Tes liens et données existants seront conservés.",
"select_at_least_one_question_error": "Veuillez sélectionner au moins une question.",
"slack": {
"already_connected_another_survey": "Vous avez déjà connecté une autre enquête à ce canal.",
@@ -1309,12 +1309,7 @@
"surveys": {
"all_set_time_to_create_first_survey": "Vous êtes prêt ! Il est temps de créer votre première enquête.",
"alphabetical": "Alphabétique",
"copy_survey": "Copier l'enquête",
"copy_survey_description": "Copier cette enquête dans un autre environnement",
"copy_survey_error": "Échec de la copie du sondage",
"copy_survey_link_to_clipboard": "Copier le lien du sondage dans le presse-papiers",
"copy_survey_partially_success": "{success} enquêtes copiées avec succès, {error} échouées.",
"copy_survey_success": "Enquête copiée avec succès !",
"delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses?",
"edit": {
"activate_translations": "Activer les traductions",
@@ -1367,7 +1362,7 @@
"audience": "Public",
"auto_close_on_inactivity": "Fermeture automatique en cas d'inactivité",
"auto_progress_rating_and_nps": "Progression automatique pour les questions d'évaluation et NPS",
"auto_progress_rating_and_nps_description": "Passe automatiquement à la question suivante lorsque les répondants sélectionnent une réponse aux questions d'évaluation ou NPS. Cela s'applique uniquement aux blocs à question unique. Les questions obligatoires masquent le bouton Suivant ; les questions facultatives l'affichent toujours pour permettre de passer la question.",
"auto_progress_rating_and_nps_description": "Avancement automatique dans les blocs à question unique. Les questions obligatoires masquent le bouton Suivant, sauf lorsque « Autre » est sélection.",
"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",
@@ -2066,7 +2061,6 @@
"downloading_qr_code": "Téléchargement du code QR",
"drop_offs": "Dépôts",
"drop_offs_tooltip": "Nombre de fois que l'enquête a été commencée mais non terminée.",
"failed_to_copy_link": "Échec de la copie du lien",
"filter_added_successfully": "Filtre ajouté avec succès",
"filter_updated_successfully": "Filtre mis à jour avec succès",
"filtered_responses_csv": "Réponses filtrées (CSV)",
@@ -2154,7 +2148,6 @@
},
"survey_deleted_successfully": "Enquête supprimée avec succès !",
"survey_duplicated_successfully": "Enquête dupliquée avec succès.",
"survey_duplication_error": "Échec de la duplication de l'enquête.",
"templates": {
"all_channels": "Tous les canaux",
"all_industries": "Tous les secteurs",
@@ -2887,6 +2880,11 @@
"gauge_feature_satisfaction_question_2_headline": "Quelle est une chose que nous pourrions améliorer ?",
"identify_customer_goals_description": "Mieux comprendre si votre message crée les bonnes attentes quant à la valeur que votre produit apporte.",
"identify_customer_goals_name": "Identifier les objectifs des clients",
"identify_customer_goals_question_1_choice_1": "Comprendre ma base d'utilisateurs en profondeur",
"identify_customer_goals_question_1_choice_2": "Identifier des opportunités de montée en gamme",
"identify_customer_goals_question_1_choice_3": "Créer le meilleur produit possible",
"identify_customer_goals_question_1_choice_4": "Conquérir le monde pour imposer des choux de Bruxelles au petit-déjeuner à tout le monde",
"identify_customer_goals_question_1_headline": "Quel est votre objectif principal pour l'utilisation de $[projectName] ?",
"identify_sign_up_barriers_description": "Offrir une remise pour recueillir des informations sur les obstacles à l'inscription.",
"identify_sign_up_barriers_name": "Identifier les obstacles à l'inscription",
"identify_sign_up_barriers_question_1_button_label": "Obtenez 10 % de réduction",
+69 -71
View File
@@ -63,8 +63,8 @@
"login_with_email": "Bejelentkezés e-mail-címmel",
"lost_access": "Elvesztette a hozzáférést?",
"new_to_formbricks": "Új a Formbicksen?",
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
"oauth_account_not_linked_description": "Ez az SSO-szolgáltató nincs összekapcsolva egy meglévő Formbricks-fiókkal. Jelentkezzen be az eredetileg használt módszerrel. Ha ez e-mail és jelszó páros volt, akkor először végezze el az e-mail-ellenőrzést, ha a rendszer erre kéri.",
"oauth_account_not_linked_title": "Ezt az SSO-bejelentkezést nem sikerült összekapcsolni",
"use_a_backup_code": "Visszaszerzési kód használata"
},
"saml_connection_error": "Valami probléma történt. A további részletekért nézze meg az alkalmazás konzolját.",
@@ -150,8 +150,8 @@
"bottom_right": "Jobbra lent",
"cancel": "Mégse",
"centered_modal": "Középre helyezett kizárólagos",
"change_organization": "Szervezet módosítása",
"change_workspace": "Munkaterület módosítása",
"change_organization": "Szervezet megváltoztatása",
"change_workspace": "Munkaterület megváltoztatása",
"choice_n": "{{n}}. választás",
"choices": "Választási lehetőségek",
"choose_environment": "Környezet kiválasztása",
@@ -173,7 +173,7 @@
"connect": "Kapcsolódás",
"connect_formbricks": "Kapcsolódás a Formbrickshez",
"connected": "Kapcsolódva",
"contact": "Kapcsolat",
"contact": "Partner",
"contacts": "Partnerek",
"continue": "Folytatás",
"copied": "Másolva",
@@ -238,7 +238,7 @@
"failed_to_copy_to_clipboard": "Nem sikerült másolni a vágólapra",
"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}} helyőrző",
"field_placeholder": "{{field}} helykitöltője",
"filter": "Szűrő",
"finish": "Befejezés",
"first_name": "Keresztnév",
@@ -250,7 +250,7 @@
"generate": "Előállítás",
"go_back": "Vissza",
"go_to_dashboard": "Ugrás a vezérlőpultra",
"headline": "Címsor",
"headline": "Főcím",
"hidden": "Rejtett",
"hidden_field": "Rejtett mező",
"hidden_fields": "Rejtett mezők",
@@ -272,7 +272,7 @@
"invite": "Meghívás",
"invite_them": "Meghívó nekik",
"javascript_required": "JavaScript szükséges",
"javascript_required_description": "A Formbricks használatához JavaScript szükséges. Kérjük, engedélyezze a JavaScriptet a böngésző beállításaiban a folytatáshoz.",
"javascript_required_description": "A Formbricks megfelelő működéséhez JavaScript szükséges. Engedélyezze a JavaScriptet a böngésző beállításaiban a folytatáshoz.",
"key": "Kulcs",
"label": "Címke",
"language": "Nyelv",
@@ -326,9 +326,9 @@
"notifications": "Értesítések",
"number": "Szám",
"off": "Ki",
"offline_all_responses_synced": "Az Ön válasza sikeresen mentésre került.",
"offline_syncing_responses": "Az Ön válaszainak szinkronizálása folyamatban…",
"offline_you_are_offline": "Ön offline állapotban van. Az Ön válasza a böngészőjében tárolásra került, és mentésre kerül, amint ismét online lesz.",
"offline_all_responses_synced": "A válasz sikeresen el lett mentve.",
"offline_syncing_responses": "Válaszok szinkronizálása…",
"offline_you_are_offline": "Ön nem érhető el. A válasza a böngészőjében van tárolva, és akkor lesz elmentve, ha újra elérhető lesz.",
"on": "Be",
"only_one_file_allowed": "Csak egy fájl engedélyezett",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Csak tulajdonosok és kezelők hajthatják végre ezt a műveletet.",
@@ -341,8 +341,7 @@
"organization_settings": "Szervezet beállításai",
"other": "Egyéb",
"other_filters": "Egyéb szűrők",
"other_placeholder": "Egyéb helyőrző",
"others": "Mások",
"other_placeholder": "Egyéb helykitöltő",
"overlay_color": "Rávetítés színe",
"overview": "Áttekintés",
"password": "Jelszó",
@@ -360,7 +359,6 @@
"please_upgrade_your_plan": "Váltson magasabb csomagra",
"powered_by_formbricks": "A gépházban: Formbricks",
"preview": "Előnézet",
"preview_survey": "Kérdőív előnézete",
"privacy": "Adatvédelmi irányelvek",
"product_manager": "Termékmenedzser",
"production": "Produktív",
@@ -448,7 +446,7 @@
"team_name": "Csapat neve",
"team_role": "Csapatszerep",
"teams": "Csapatok",
"terms_of_service": "Felhasználási feltételek",
"terms_of_service": "Használati feltételek",
"text": "Szöveg",
"time": "Idő",
"time_to_finish": "Idő a befejezésig",
@@ -495,7 +493,6 @@
"workspace_name_placeholder": "például Formbricks",
"workspaces": "Munkaterületek",
"years": "év",
"you": "Ön",
"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.",
@@ -558,7 +555,7 @@
"verification_email_heading": "Már majdnem kész vagyunk!",
"verification_email_hey": "Helló 👋",
"verification_email_if_expired_request_new_token": "Ha lejárt, kérjen új tokent itt:",
"verification_email_link_valid_for_24_hours": "A hivatkozás 24 órán keresztül érvényes.",
"verification_email_link_valid_for_24_hours": "A hivatkozás 24 óráig érvényes.",
"verification_email_request_new_verification": "Új ellenőrzés kérése",
"verification_email_subject": "Ellenőrizze az e-mail-címét a Formbricks használatához",
"verification_email_survey_name": "Kérdőív neve",
@@ -836,6 +833,9 @@
},
"notion_integration_description": "Adatok küldése a Notion-adatbázisba",
"please_select_a_survey_error": "Válasszon kérdőívet",
"reconnect_button": "Újracsatlakozás",
"reconnect_button_description": "Az integráció kapcsolata lejárt. Kérjük, csatlakozzon újra a válaszok szinkronizálásának folytatásához. A meglévő hivatkozások és adatok megmaradnak.",
"reconnect_button_tooltip": "Csatlakoztassa újra az integrációt a hozzáférés frissítéséhez. A meglévő hivatkozások és adatok megmaradnak.",
"select_at_least_one_question_error": "Válasszon legalább egy kérdést",
"slack": {
"already_connected_another_survey": "Már hozzákapcsolt egy másik kérdőívet ehhez a csatornához.",
@@ -867,16 +867,16 @@
"created_by_third_party": "Harmadik fél által létrehozva",
"discord_webhook_not_supported": "A Discord webhorgok jelenleg nem támogatottak.",
"empty_webhook_message": "A webhorgai itt fognak megjelenni, amint hozzáadja azokat. ⏲️",
"endpoint_bad_gateway_error": "Hibás átjáró (502): Proxy-/átjáróhiba, a szolgáltatás nem érhető el",
"endpoint_gateway_timeout_error": "Átjáró időtúllépés (504): Átjáró időtúllépés, a szolgáltatás nem érhető el",
"endpoint_internal_server_error": "Belső szerverhiba (500): A szolgáltatás váratlan hibába ütközött",
"endpoint_method_not_allowed_error": "A metódus nem engedélyezett (405): A végpont létezik, de nem fogad POST kéréseket",
"endpoint_not_found_error": "Nem található (404): A végpont nem létezik",
"endpoint_bad_gateway_error": "Hibás átjáró (502): proxy- vagy átjáróhiba, a szolgáltatás nem érhető el",
"endpoint_gateway_timeout_error": "Átjáró időtúllépés (504): átjáró időtúllépés, a szolgáltatás nem érhető el",
"endpoint_internal_server_error": "Belső kiszolgálóhiba (500): a szolgáltatás váratlan hibába ütközött",
"endpoint_method_not_allowed_error": "A módszer nem engedélyezett (405): a végpont létezik, de nem fogad POST-kéréseket",
"endpoint_not_found_error": "Nem található (404): a végpont nem létezik",
"endpoint_pinged": "Hurrá! Képesek vagyunk pingelni a webhorgot!",
"endpoint_pinged_error": "Nem lehet pingelni a webhorgot!",
"endpoint_service_unavailable_error": "A szolgáltatás nem érhető el (503): A szolgáltatás átmenetileg nem elérhető",
"endpoint_service_unavailable_error": "A szolgáltatás nem érhető el (503): a szolgáltatás átmenetileg nem érhető el",
"learn_to_verify": "Tudja meg, hogy kell ellenőrizni a webhorog aláírásait",
"no_triggers": "Nincsenek Triggerek",
"no_triggers": "Nincsenek aktiválók",
"please_check_console": "További részletekért nézze meg a konzolt",
"please_enter_a_url": "Adjon meg egy URL-t",
"response_created": "Válasz létrehozva",
@@ -892,7 +892,7 @@
"webhook_created": "Webhorog létrehozva",
"webhook_delete_confirmation": "Biztosan törölni szeretné ezt a webhorgot? Ez le fogja állítani a jövőbeli értesítések küldését.",
"webhook_deleted_successfully": "A webhorog sikeresen törölve",
"webhook_name_placeholder": "Választható: címkézze meg a webhorgot az egyszerű azonosításért",
"webhook_name_placeholder": "Elhagyható: címkézze meg a webhorgot az egyszerű azonosításért",
"webhook_test_failed_due_to": "A webhorog tesztelése sikertelen a következő miatt:",
"webhook_updated_successfully": "A webhorog sikeresen frissítve",
"webhook_url_placeholder": "Illessze be azt az URL-t, amelyen az eseményt aktiválni szeretné"
@@ -1018,9 +1018,9 @@
"plan_change_applied": "A csomag sikeresen frissítve.",
"plan_change_scheduled": "A csomagváltoztatás sikeresen ütemezve.",
"plan_custom": "Egyéni",
"plan_feature_everything_in_hobby": "Minden a Hobbi csomagban",
"plan_feature_everything_in_hobby": "Minden a Hobby csomagban",
"plan_feature_everything_in_pro": "Minden a Pro csomagban",
"plan_hobby": "Hobbi",
"plan_hobby": "Hobby",
"plan_hobby_description": "Magánszemélyeknek és kis csapatoknak, akik most teszik meg a kezdeti lépéseket a Formbricks Cloud szolgáltatással.",
"plan_hobby_feature_responses": "250 válasz/hónap",
"plan_hobby_feature_workspaces": "1 munkaterület",
@@ -1028,11 +1028,11 @@
"plan_pro_description": "Növekvő csapatoknak, akiknek magasabb korlátokra, automatizálásra és dinamikus túllépési lehetőségekre van szükségük.",
"plan_pro_feature_responses": "2000 válasz/hónap (dinamikus túllépés)",
"plan_pro_feature_workspaces": "3 munkaterület",
"plan_scale": "Méretezés",
"plan_scale": "Scale",
"plan_scale_description": "Nagyobb csapatoknak, amelyeknek több kapacitásra, erősebb irányításra és nagyobb válaszmennyiségre van szükségük.",
"plan_scale_feature_responses": "5000 válasz/hónap (dinamikus túllépés)",
"plan_scale_feature_workspaces": "5 munkaterület",
"plan_selection_description": "Hobbi, Pro és Méretezés csomagok összehasonlítása, majd csomagok közötti váltás közvetlenül a Formbricksben.",
"plan_selection_description": "Hobby, Pro és Scale csomagok összehasonlítása, majd csomagok közötti váltás közvetlenül a Formbricksben.",
"plan_selection_title": "Csomag kiválasztása",
"plan_unknown": "Ismeretlen",
"remove_branding": "Márkajel eltávolítása",
@@ -1040,7 +1040,7 @@
"select_plan_header_subtitle": "Nincs szükség hitelkártyára, nincs kötöttség.",
"select_plan_header_title": "Zökkenőmentesen integrált kérdőívek, 100%-ban az Ön márkájához igazítva.",
"status_trialing": "Próbaidőszak",
"stay_on_hobby_plan": "A Hobbi csomagnál szeretnék maradni",
"stay_on_hobby_plan": "A Hobby csomagnál szeretnék maradni",
"stripe_setup_incomplete": "A számlázási beállítás befejezetlen",
"stripe_setup_incomplete_description": "A számlázási beállítás nem fejeződött be sikeresen. Próbálja meg újra aktiválni az előfizetését.",
"subscription": "Előfizetés",
@@ -1145,17 +1145,17 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "A Formbricks teljes erejének feloldása. 30 napig ingyen."
},
"general": {
"ai_data_analysis_disabled_for_organization": "Az MI-alapú adatelemzés és adatgazdagítás ki van kapcsolva ennél a szervezetnél.",
"ai_data_analysis_enabled": "Adatgazdagítás és elemzés (AI)",
"ai_data_analysis_enabled_description": "AI segítségével többet hozhat ki az adataiból, irányítópultokat, diagramokat, jelentéseket és egyebeket állíthat be. Hozzáfér az élményekhez kapcsolódó adatokhoz.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "AI-alapú funkciók kezelése ehhez a szervezethez.",
"ai_data_analysis_disabled_for_organization": "Az MI-adatelemzés le van tiltva ennél a szervezetnél.",
"ai_data_analysis_enabled": "Adatgazdagítás és -elemzés (MI)",
"ai_data_analysis_enabled_description": "Mesterséges intelligencia ahhoz, hogy többet hozzon ki az adataiból. Vezérlőpultok, diagramok, jelentések és még sok más beállítása. Az élményadatokra is kiterjed.",
"ai_enabled": "Formbricks MI",
"ai_enabled_description": "MI-alapú funkciók kezelése ennél a szervezetnél.",
"ai_features_not_enabled_for_organization": "Az MI-funkciók nincsenek engedélyezve ennél a szervezetnél.",
"ai_instance_not_configured": "Az MI példányszinten, környezeti változókkal van konfigurálva. Kérd meg a rendszergazdát, hogy állítsa be az AI_PROVIDER értékét, a szolgáltató hitelesítő adatait és a megfelelő modelllistát, mielőtt engedélyezné az MI-funkciókat.",
"ai_settings_updated_successfully": "AI beállítások sikeresen frissítve",
"ai_smart_tools_disabled_for_organization": "Az MI intelligens funkciói ki vannak kapcsolva ennél a szervezetnél.",
"ai_smart_tools_enabled": "Intelligens funkciók (AI)",
"ai_smart_tools_enabled_description": "AI segítségével kevesebb idő alatt többet érhet el. Soha nem fér hozzá a Formbricks által gyűjtött adatokhoz. Csak például felmérések más nyelvekre történő fordításához használatos.",
"ai_instance_not_configured": "Az MI példányszinten van beállítva környezeti változókon keresztül. Kérje meg az adminisztrátort, hogy állítsa be az AI_PROVIDER, AI_MODEL és a hozzájuk tartozó szolgáltató hitelesítési adatait, mielőtt engedélyezné az MI-funkciókat.",
"ai_settings_updated_successfully": "Az MI-beállítások sikeresen frissítve",
"ai_smart_tools_disabled_for_organization": "Az MI intelligens eszközei le vannak tiltva ennél a szervezetnél.",
"ai_smart_tools_enabled": "Intelligens funkcionalitás (MI)",
"ai_smart_tools_enabled_description": "Mesterséges intelligencia ahhoz, hogy segítsen Önnek többet elérni kevesebb idő alatt. Soha sem érinti a Formbricks segítségével gyűjtött adatokat. Csak például a kérdőívek más nyelvekre történő fordításához kerül felhasználásra.",
"bulk_invite_warning_description": "Az ingyenes csomagban az összes szervezeti tag mindig a „Tulajdonos” szerephez van hozzárendelve.",
"cannot_delete_only_organization": "Ez az egyetlen szervezete, nem lehet törölni. Először hozzon létre egy új szervezetet.",
"cannot_leave_only_organization": "Nem hagyhatja el ezt a szervezetet, mivel ez az egyetlen szervezete. Először hozzon létre egy új szervezetet.",
@@ -1309,12 +1309,7 @@
"surveys": {
"all_set_time_to_create_first_survey": "Mindent beállított! Ideje létrehozni az első kérdőívet",
"alphabetical": "Ábécé-sorrend",
"copy_survey": "Kérdőív másolása",
"copy_survey_description": "A kérdőív másolása egy másik környezetbe",
"copy_survey_error": "Nem sikerült másolni a kérdőívet",
"copy_survey_link_to_clipboard": "Kérdőív hivatkozásának másolása a vágólapra",
"copy_survey_partially_success": "{success} kérdőív sikeresen másolva, {error} sikertelen.",
"copy_survey_success": "A kérdőív sikeresen másolva",
"delete_survey_and_responses_warning": "Biztosan törölni szeretné ezt a kérdőívet és az összes válaszát?",
"edit": {
"activate_translations": "Fordítások aktiválása",
@@ -1366,8 +1361,8 @@
"assign": "= hozzárendelése",
"audience": "Közönség",
"auto_close_on_inactivity": "Automatikus lezárás tétlenségnél",
"auto_progress_rating_and_nps": "Automatikus továbblépés értékelési és NPS kérdéseknél",
"auto_progress_rating_and_nps_description": "Automatikus továbblépés, amikor a válaszadók kiválasztanak egy választ az értékelési vagy NPS kérdéseknél. Ez csak az egykérdéses blokkokra vonatkozik. A kötelező kérdések elrejtik a Tovább gombot; az opcionális kérdések továbbra is megjelenítik azt a kihagyás lehetősége érdekében.",
"auto_progress_rating_and_nps": "Értékelés és valós ügyfél-támogatottsági érték kérdések automatikus feldolgozása",
"auto_progress_rating_and_nps_description": "Automatikus továbblépés az egykérdéses blokkokban. A kötelező kérdések elrejtik a Tovább gombot, kivéve ha az „Egyéb” van kiválasztva.",
"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",
@@ -1413,7 +1408,7 @@
"caution_text": "A változtatások következetlenségekhez vezetnek",
"change_anyway": "Változtatás mindenképp",
"change_background": "Háttér megváltoztatása",
"change_default": "Alapértelmezett módosítása",
"change_default": "Alapértelmezett megváltoztatása",
"change_question_type": "Kérdés típusának megváltoztatása",
"change_survey_type": "A kérdőív típusának megváltoztatása befolyásolja a meglévő hozzáférést",
"change_the_background_to_a_color_image_or_animation": "A háttér megváltoztatása színre, képre vagy animációra.",
@@ -1612,7 +1607,7 @@
"matrix_rows": "Sorok",
"max_file_size": "Legnagyobb fájlméret",
"max_file_size_limit_is": "A legnagyobb fájlméretkorlát",
"missing_first": "Hiányzók először",
"missing_first": "Hiányzik az első",
"move_question_to_block": "Kérdés áthelyezése egy blokkba",
"multiply": "Szorzás *",
"needed_for_self_hosted_cal_com_instance": "Saját üzemeltetésű Cal.com-példányhoz szükséges",
@@ -1620,7 +1615,7 @@
"next_button_label": "A „Következő” gomb címkéje",
"no_hidden_fields_yet_add_first_one_below": "Még nincsenek rejtett mezők. Adja hozzá az elsőt lent.",
"no_images_found_for": "Nem találhatók képek a(z) „{query}” lekérdezéshez",
"no_languages_found_add_first_one_to_get_started": "Nem található felmérési nyelv ebben a munkaterületen. Kérem, adjon hozzá egyet a kezdéshez.",
"no_languages_found_add_first_one_to_get_started": "Nem találhatók kérdőívnyelvek ezen a munkaterületen. Adja hozzá egyet a kezdéshez.",
"no_option_found": "Nem található lehetőség",
"no_recall_items_found": "Nem találhatók visszahívási elemek",
"no_variables_yet_add_first_one_below": "Még nincsenek változók. Adja hozzá az elsőt lent.",
@@ -1631,7 +1626,7 @@
"only_people_who_match_your_targeting_can_be_surveyed": "Csak azok a személyek kérdezhetők meg, akik megfelelnek a célcsoportnak.",
"option_idx": "{choiceIndex}. lehetőség",
"option_used_in_logic_error": "Ez a lehetőség használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",
"optional": "Választható",
"optional": "Elhagyható",
"options": "Beállítások*",
"options_used_in_logic_bulk_error": "A következő lehetőségek használatban vannak a logikában: {questionIndexes}. Először távolítsa el azokat a logikából.",
"override_theme_with_individual_styles_for_this_survey": "A téma felülírása egyéni stílusokkal ennél a kérdőívnél.",
@@ -1647,7 +1642,7 @@
"please_enter_a_valid_url": "Adjon meg egy érvényes URL-t (például https://example.com)",
"please_set_a_survey_trigger": "Állítson be kérdőív-aktiválót",
"please_specify": "Adja meg",
"present_your_survey_in_multiple_languages": "Mutassa be felmérését több nyelven",
"present_your_survey_in_multiple_languages": "A kérdőív bemutatása több nyelven",
"prevent_double_submission": "Kettős beküldés megakadályozása",
"prevent_double_submission_description": "E-mail-címenként csak 1 válasz engedélyezése",
"progress_saved": "Folyamat elmentve",
@@ -1716,8 +1711,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "A válaszkorlátnak meg kell haladnia a kapott válaszok számát ({responseCount}).",
"response_limits_redirections_and_more": "Válaszkorlátok, átirányítások és egyebek.",
"response_options": "Válasz beállításai",
"reverse_order_occasionally": "Sorrend alkalmi megfordítása",
"reverse_order_occasionally_except_last": "Sorrend alkalmi megfordítása az utolsó kivételével",
"reverse_order_occasionally": "Időnként fordított sorrendben",
"reverse_order_occasionally_except_last": "Időnként fordított sorrendben, kivéve az utolsó",
"roundness": "Kerekesség",
"roundness_description": "Annak vezérlése, hogy a sarkok mennyire legyenek lekerekítve.",
"row_used_in_logic_error": "Ez a sor használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",
@@ -1739,7 +1734,7 @@
"seven_points": "7 pont",
"show_block_settings": "Blokkbeállítások megjelenítése",
"show_button": "Gomb megjelenítése",
"show_in_order": "Sorrendben megjelenítés",
"show_in_order": "Megjelenítés sorrendben",
"show_language_switch": "Nyelvválasztó megjelenítése",
"show_multiple_times": "Megjelenítés korlátozott számú alkalommal",
"show_only_once": "Megjelenítés csak egyszer",
@@ -1778,7 +1773,7 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Megjelenítés egyetlen alkalommal, még akkor is, ha nem válaszolnak.",
"then": "Azután",
"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 eltávolítja ezt a nyelvet és az összes fordítását ebből a felmérésből. Ez a művelet nem vonható vissza.",
"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:",
@@ -1798,11 +1793,11 @@
"upper_label": "Felső címke",
"url_filters": "URL szűrők",
"url_not_supported": "Az URL nem támogatott",
"validate_id_duplicate": "A(z) {type} azonosító már létezik a kérdések, rejtett mezők vagy változók között.",
"validate_id_empty": "Kérjük, adjon meg egy {type} azonosítót.",
"validate_id_invalid_chars": "A(z) {type} azonosító nem engedélyezett. Kérjük, csak alfanumerikus karaktereket, kötőjeleket vagy aláhúzásjeleket használjon.",
"validate_id_no_spaces": "A(z) {type} azonosító nem tartalmazhat szóközöket. Kérjük, távolítsa el a szóközöket.",
"validate_id_reserved": "A(z) {type} azonosító \"{field}\" nem engedélyezett. Ez egy fenntartott kulcsszó.",
"validate_id_duplicate": "A {type} azonosítója már létezik a kérdésekben, rejtett mezőkben vagy változókban.",
"validate_id_empty": "Adja meg egy {type} azonosítót.",
"validate_id_invalid_chars": "A {type} azonosítója nem engedélyezett. Használjon csak alfanumerikus karaktereket, kötőjeleket vagy aláhúzásjeleket.",
"validate_id_no_spaces": "A {type} azonosítója nem tartalmazhat szóközöket. Távolítsa el a szóközöket.",
"validate_id_reserved": "A {type} „{field}” azonosítója nem engedélyezett. Ez egy foglalt kulcsszó.",
"validation": {
"add_validation_rule": "Ellenőrzési szabály hozzáadása",
"answer_all_rows": "Válaszoljon az összes sorra",
@@ -2066,7 +2061,6 @@
"downloading_qr_code": "QR-kód letöltése",
"drop_offs": "Megszakítások",
"drop_offs_tooltip": "A kérdőív elkezdési, de be nem fejezési alkalmainak száma.",
"failed_to_copy_link": "Nem sikerült a hivatkozás másolása",
"filter_added_successfully": "A szűrő sikeresen hozzáadva",
"filter_updated_successfully": "A szűrő sikeresen frissítve",
"filtered_responses_csv": "Szűrt válaszok (CSV)",
@@ -2144,7 +2138,7 @@
"this_quarter": "Ez a negyedév",
"this_year": "Ez az év",
"time_to_complete": "Kitöltéshez szükséges idő",
"ttc_survey_tooltip": "A felmérés kitöltésének átlagos ideje.",
"ttc_survey_tooltip": "A kérdőív megválaszolásának átlagos ideje.",
"ttc_tooltip": "A kérdés megválaszolásának átlagos ideje.",
"unknown_question_type": "Ismeretlen kérdéstípus",
"use_personal_links": "Személyes hivatkozások használata",
@@ -2154,7 +2148,6 @@
},
"survey_deleted_successfully": "A kérdőív sikeresen törölve",
"survey_duplicated_successfully": "A kérdőív sikeresen megkettőzve",
"survey_duplication_error": "Nem sikerült megkettőzni a kérdőívet.",
"templates": {
"all_channels": "Összes csatorna",
"all_industries": "Összes iparág",
@@ -2234,7 +2227,7 @@
"languages": {
"add_language": "Nyelv hozzáadása",
"alias": "Álnév",
"alias_tooltip": "Az álnév egy alternatív név a hivatkozás-kérdőívekben és az SDK-ban lévő nyelv azonosításához (választható)",
"alias_tooltip": "Az álnév egy alternatív név a hivatkozás-kérdőívekben és az SDK-ban lévő nyelv azonosításához (elhagyható)",
"cannot_remove_language_warning": "Nem tudja eltávolítani ezt a nyelvet, mert még mindig használatban van ezekben a kérdőívekben:",
"conflict_between_identifier_and_alias": "Ütközés van egy hozzáadott nyelv azonosítója és az álnevei egyike között. Az álnevek és az azonosítók nem lehetnek azonosak.",
"conflict_between_selected_alias_and_another_language": "Ütközés van a kiválasztott álnév és egy másik, ezzel az azonosítóval rendelkező nyelv között. A következetlenségek elkerülése érdekében ezzel az azonosítóval adja hozzá a nyelvet a munkaterületéhez.",
@@ -2312,11 +2305,11 @@
"advanced_styling_field_track_height": "Követés magassága",
"advanced_styling_field_track_height_description": "A folyamatjelző vastagságát vezérli.",
"advanced_styling_field_upper_label_color": "Címke színe",
"advanced_styling_field_upper_label_color_description": "A beviteli mezők feletti kis címkék és a skálacímkék színét állítja be.",
"advanced_styling_field_upper_label_color_description": "Kiszínezi a beviteli mezők fölötti kis címkéket és a méretezés címkéit.",
"advanced_styling_field_upper_label_size": "Címke betűmérete",
"advanced_styling_field_upper_label_size_description": "A beviteli mezők feletti kis címkék és a skálacímkék betűméretét állítja be.",
"advanced_styling_field_upper_label_size_description": "Átméretezi a beviteli mezők fölötti kis címkéket és a méretezés címkéit.",
"advanced_styling_field_upper_label_weight": "Címke betűvastagsága",
"advanced_styling_field_upper_label_weight_description": "A címkék vékonyabbá vagy vastagabbá tételét teszi lehetővé.",
"advanced_styling_field_upper_label_weight_description": "Vékonyabbá vagy vastagabbá teszi a címkéket.",
"advanced_styling_section_buttons": "Gombok",
"advanced_styling_section_headlines": "Címsorok és leírások",
"advanced_styling_section_inputs": "Beviteli mezők",
@@ -2653,7 +2646,7 @@
"csat_question_1_headline": "Mennyire valószínű, hogy ezt a(z) $[projectName] projektet ajánlaná egy ismerősnek vagy kollégának?",
"csat_question_1_lower_label": "Nem valószínű",
"csat_question_1_upper_label": "Nagyon valószínű",
"csat_question_2_choice_1": "Részben elégedett",
"csat_question_2_choice_1": "Valamelyest elégedett",
"csat_question_2_choice_2": "Nagyon elégedett",
"csat_question_2_choice_3": "Sem elégedett, sem elégedetlen",
"csat_question_2_choice_4": "Valamelyest elégedetlen",
@@ -2887,6 +2880,11 @@
"gauge_feature_satisfaction_question_2_headline": "Mi az egyetlen dolog, amelyet jobban csinálhatnánk?",
"identify_customer_goals_description": "Jobban megérteni, hogy az üzenetei a termék által nyújtott érték megfelelő elvárásait keltik-e.",
"identify_customer_goals_name": "Ügyfélcélok azonosítása",
"identify_customer_goals_question_1_choice_1": "A felhasználói bázisom alapos megértése",
"identify_customer_goals_question_1_choice_2": "Felülértékesítési lehetőségek azonosítása",
"identify_customer_goals_question_1_choice_3": "A lehető legjobb termék elkészítése",
"identify_customer_goals_question_1_choice_4": "Világuralom szerezése, hogy mindenki kelbimbót egyen reggelire",
"identify_customer_goals_question_1_headline": "Mi az elsődleges célja a(z) $[projectName] használatával?",
"identify_sign_up_barriers_description": "Kedvezmény felajánlása a regisztrációs akadályokkal kapcsolatos tapasztalatok gyűjtéséhez.",
"identify_sign_up_barriers_name": "Regisztrációs akadályok azonosítása",
"identify_sign_up_barriers_question_1_button_label": "10% kedvezmény",
@@ -2962,13 +2960,13 @@
"improve_trial_conversion_question_2_button_label": "Következő",
"improve_trial_conversion_question_2_headline": "Sajnálattal halljuk. Mi volt a legnagyobb probléma a(z) $[projectName] projekt használatával?",
"improve_trial_conversion_question_3_button_label": "Következő",
"improve_trial_conversion_question_3_headline": "Mit várt a(z) $[projectName] projekttől?",
"improve_trial_conversion_question_3_headline": "Mit vár el a(z) $[projectName] projekttől?",
"improve_trial_conversion_question_4_button_label": "20% kedvezmény",
"improve_trial_conversion_question_4_headline": "Sajnálattal halljuk! 20% kedvezményt kap az első évre.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Boldogan felajánlunk 20% kedvezményt az éves csomagra.</span></p>",
"improve_trial_conversion_question_5_button_label": "Következő",
"improve_trial_conversion_question_5_headline": "Mit szeretne elérni?",
"improve_trial_conversion_question_5_subheader": "Kérjük, ismertesse az alábbiakban:",
"improve_trial_conversion_question_5_subheader": "Írja le az alábbiakban:",
"improve_trial_conversion_question_6_headline": "Hogyan oldja meg a problémáját most?",
"improve_trial_conversion_question_6_subheader": "Nevezzen meg alternatív megoldásokat:",
"integration_setup_survey_description": "Annak kiértékelése, hogy a felhasználók mennyire könnyen tudnak integrációkat hozzáadni a termékéhez. A vakfoltok megtalálása.",
+9 -11
View File
@@ -342,7 +342,6 @@
"other": "その他",
"other_filters": "その他のフィルター",
"other_placeholder": "その他のプレースホルダー",
"others": "その他",
"overlay_color": "オーバーレイの色",
"overview": "概要",
"password": "パスワード",
@@ -360,7 +359,6 @@
"please_upgrade_your_plan": "プランをアップグレードしてください",
"powered_by_formbricks": "Powered by Formbricks",
"preview": "プレビュー",
"preview_survey": "フォームをプレビュー",
"privacy": "プライバシーポリシー",
"product_manager": "プロダクトマネージャー",
"production": "本番",
@@ -495,7 +493,6 @@
"workspace_name_placeholder": "例: Formbricks",
"workspaces": "ワークスペース",
"years": "年",
"you": "あなた",
"you_are_downgraded_to_the_community_edition": "コミュニティ版にダウングレードされました。",
"you_are_not_authorized_to_perform_this_action": "このアクションを実行する権限がありません。",
"you_have_reached_your_limit_of_workspace_limit": "ワークスペースの上限である{projectLimit}件に達しました。",
@@ -836,6 +833,9 @@
},
"notion_integration_description": "回答を直接Notionに送信します",
"please_select_a_survey_error": "フォームを選択してください",
"reconnect_button": "再接続",
"reconnect_button_description": "統合の接続が期限切れになりました。回答の同期を続けるには再接続してください。既存のリンクとデータは保持されます。",
"reconnect_button_tooltip": "統合を再接続してアクセスを更新します。既存のリンクとデータは保持されます。",
"select_at_least_one_question_error": "少なくとも1つの質問を選択してください",
"slack": {
"already_connected_another_survey": "このチャンネルには別のフォームがすでに接続されています。",
@@ -1309,12 +1309,7 @@
"surveys": {
"all_set_time_to_create_first_survey": "すべての準備が整いました!最初のフォームを作成しましょう",
"alphabetical": "アルファベット順",
"copy_survey": "フォームをコピー",
"copy_survey_description": "このフォームを別の環境にコピー",
"copy_survey_error": "フォームのコピーに失敗しました",
"copy_survey_link_to_clipboard": "フォームのリンクをクリップボードにコピー",
"copy_survey_partially_success": "{success} 個のフォームが正常にコピーされ、{error} 個が失敗しました。",
"copy_survey_success": "フォームを正常にコピーしました!",
"delete_survey_and_responses_warning": "本当にこのフォームとすべての回答を削除しますか?",
"edit": {
"activate_translations": "翻訳を有効化",
@@ -1367,7 +1362,7 @@
"audience": "オーディエンス",
"auto_close_on_inactivity": "非アクティブ時に自動閉鎖",
"auto_progress_rating_and_nps": "評価とNPSの質問を自動進行",
"auto_progress_rating_and_nps_description": "評価またはNPSの質問で回答者が選択肢を選んだ際に自動的に次へ進みます。これは単一質問ブロックにのみ適用されます。必須質問では「次へ」ボタンが非表示になり、任意の質問ではスキップ用に引き続き表示されます。",
"auto_progress_rating_and_nps_description": "単一質問ブロックで自動的に次へ進みます。必須質問では「次へ」ボタンが非表示になりますが、「その他」が選択された場合は表示されます。",
"auto_save_disabled": "自動保存が無効",
"auto_save_disabled_tooltip": "アンケートは下書き状態の時のみ自動保存されます。これにより、公開中のアンケートが意図せず更新されることを防ぎます。",
"auto_save_on": "自動保存オン",
@@ -2066,7 +2061,6 @@
"downloading_qr_code": "QRコードをダウンロード中",
"drop_offs": "離脱",
"drop_offs_tooltip": "フォームが開始されたが完了しなかった回数。",
"failed_to_copy_link": "リンクのコピーに失敗しました",
"filter_added_successfully": "フィルターを正常に追加しました",
"filter_updated_successfully": "フィルターを正常に更新しました",
"filtered_responses_csv": "フィルター済み回答 (CSV)",
@@ -2154,7 +2148,6 @@
},
"survey_deleted_successfully": "フォームを正常に削除しました!",
"survey_duplicated_successfully": "フォームを正常に複製しました。",
"survey_duplication_error": "フォームの複製に失敗しました。",
"templates": {
"all_channels": "すべてのチャネル",
"all_industries": "すべての業界",
@@ -2887,6 +2880,11 @@
"gauge_feature_satisfaction_question_2_headline": "私たちがもっとうまくできることは何ですか?",
"identify_customer_goals_description": "あなたのメッセージが製品の価値に対する正しい期待を抱かせているかどうかをよりよく理解する。",
"identify_customer_goals_name": "顧客目標の特定",
"identify_customer_goals_question_1_choice_1": "ユーザーベースを深く理解する",
"identify_customer_goals_question_1_choice_2": "アップセルの機会を特定する",
"identify_customer_goals_question_1_choice_3": "最高の製品を構築する",
"identify_customer_goals_question_1_choice_4": "世界を支配して全員に朝食に芽キャベツを食べさせる",
"identify_customer_goals_question_1_headline": "$[projectName]を使用する主な目的は何ですか?",
"identify_sign_up_barriers_description": "サインアップの障壁に関する洞察を得るために割引を提供する。",
"identify_sign_up_barriers_name": "サインアップの障壁を特定する",
"identify_sign_up_barriers_question_1_button_label": "10%割引を取得",
+9 -11
View File
@@ -342,7 +342,6 @@
"other": "Ander",
"other_filters": "Overige filters",
"other_placeholder": "Andere tijdelijke aanduiding",
"others": "Anderen",
"overlay_color": "Overlaykleur",
"overview": "Overzicht",
"password": "Wachtwoord",
@@ -360,7 +359,6 @@
"please_upgrade_your_plan": "Upgrade je abonnement",
"powered_by_formbricks": "Mogelijk gemaakt door Formbricks",
"preview": "Voorbeeld",
"preview_survey": "Voorbeeld van enquête",
"privacy": "Privacybeleid",
"product_manager": "Productmanager",
"production": "Productie",
@@ -495,7 +493,6 @@
"workspace_name_placeholder": "bijv. Formbricks",
"workspaces": "Werkruimtes",
"years": "jaren",
"you": "Jij",
"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.",
@@ -836,6 +833,9 @@
},
"notion_integration_description": "Verzend gegevens naar uw Notion-database",
"please_select_a_survey_error": "Selecteer een enquête",
"reconnect_button": "Opnieuw verbinden",
"reconnect_button_description": "Je integratieverbinding is verlopen. Maak opnieuw verbinding om door te gaan met het synchroniseren van reacties. Je bestaande links en gegevens blijven behouden.",
"reconnect_button_tooltip": "Verbind de integratie opnieuw om je toegang te vernieuwen. Je bestaande links en gegevens blijven behouden.",
"select_at_least_one_question_error": "Selecteer minimaal één vraag",
"slack": {
"already_connected_another_survey": "U heeft al een andere enquête aan dit kanaal gekoppeld.",
@@ -1309,12 +1309,7 @@
"surveys": {
"all_set_time_to_create_first_survey": "Je bent helemaal klaar! Tijd om uw eerste enquête te maken",
"alphabetical": "Alfabetisch",
"copy_survey": "Kopieer enquête",
"copy_survey_description": "Kopieer deze enquête naar een andere omgeving",
"copy_survey_error": "Het kopiëren van de enquête is mislukt",
"copy_survey_link_to_clipboard": "Kopieer de enquêtelink naar het klembord",
"copy_survey_partially_success": "{success} enquêtes zijn succesvol gekopieerd, {error} is mislukt.",
"copy_survey_success": "Enquête succesvol gekopieerd!",
"delete_survey_and_responses_warning": "Weet u zeker dat u deze enquête en alle antwoorden erop wilt verwijderen?",
"edit": {
"activate_translations": "Vertalingen activeren",
@@ -1367,7 +1362,7 @@
"audience": "Publiek",
"auto_close_on_inactivity": "Automatisch sluiten bij inactiviteit",
"auto_progress_rating_and_nps": "Automatisch doorgaan bij beoordelings- en NPS-vragen",
"auto_progress_rating_and_nps_description": "Ga automatisch verder wanneer respondenten een antwoord selecteren bij beoordelings- of NPS-vragen. Dit geldt alleen voor blokken met één vraag. Bij verplichte vragen wordt de Volgende-knop verborgen; bij optionele vragen blijft deze zichtbaar om de vraag over te slaan.",
"auto_progress_rating_and_nps_description": "Automatisch doorgaan bij blokken met één vraag. Verplichte vragen verbergen Volgende, behalve wanneer \"Anders\" is geselecteerd.",
"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",
@@ -2066,7 +2061,6 @@
"downloading_qr_code": "QR-code downloaden",
"drop_offs": "Drop-offs",
"drop_offs_tooltip": "Aantal keren dat de enquête is gestart maar niet is voltooid.",
"failed_to_copy_link": "Kan de link niet kopiëren",
"filter_added_successfully": "Filter succesvol toegevoegd",
"filter_updated_successfully": "Filter succesvol bijgewerkt",
"filtered_responses_csv": "Gefilterde reacties (CSV)",
@@ -2154,7 +2148,6 @@
},
"survey_deleted_successfully": "Enquête succesvol verwijderd!",
"survey_duplicated_successfully": "Enquête is succesvol gedupliceerd.",
"survey_duplication_error": "Het is niet gelukt de enquête te dupliceren.",
"templates": {
"all_channels": "Alle kanalen",
"all_industries": "Alle industrieën",
@@ -2887,6 +2880,11 @@
"gauge_feature_satisfaction_question_2_headline": "Wat kunnen we beter doen?",
"identify_customer_goals_description": "Begrijp beter of uw boodschap de juiste verwachtingen wekt van de waarde die uw product biedt.",
"identify_customer_goals_name": "Identificeer klantdoelen",
"identify_customer_goals_question_1_choice_1": "Mijn gebruikersgroep grondig begrijpen",
"identify_customer_goals_question_1_choice_2": "Upselling-mogelijkheden identificeren",
"identify_customer_goals_question_1_choice_3": "Het best mogelijke product bouwen",
"identify_customer_goals_question_1_choice_4": "De wereld regeren om iedereen spruitjes als ontbijt te geven",
"identify_customer_goals_question_1_headline": "Wat is je primaire doel voor het gebruik van $[projectName]?",
"identify_sign_up_barriers_description": "Bied een korting aan om inzicht te krijgen in de aanmeldingsbarrières.",
"identify_sign_up_barriers_name": "Identificeer aanmeldingsbarrières",
"identify_sign_up_barriers_question_1_button_label": "Krijg 10% korting",
+9 -11
View File
@@ -342,7 +342,6 @@
"other": "outro",
"other_filters": "Outros Filtros",
"other_placeholder": "Outro espaço reservado",
"others": "Outros",
"overlay_color": "Cor da sobreposição",
"overview": "Visão Geral",
"password": "Senha",
@@ -360,7 +359,6 @@
"please_upgrade_your_plan": "Por favor, atualize seu plano",
"powered_by_formbricks": "Desenvolvido por Formbricks",
"preview": "Prévia",
"preview_survey": "Prévia da Pesquisa",
"privacy": "Política de Privacidade",
"product_manager": "Gerente de Produto",
"production": "Produção",
@@ -495,7 +493,6 @@
"workspace_name_placeholder": "ex: Formbricks",
"workspaces": "Projetos",
"years": "anos",
"you": "Você",
"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.",
@@ -836,6 +833,9 @@
},
"notion_integration_description": "Enviar dados para seu banco de dados do Notion",
"please_select_a_survey_error": "Por favor, escolha uma pesquisa",
"reconnect_button": "Reconectar",
"reconnect_button_description": "Sua conexão de integração expirou. Por favor, reconecte para continuar sincronizando respostas. Seus links e dados existentes serão preservados.",
"reconnect_button_tooltip": "Reconecte a integração para atualizar seu acesso. Seus links e dados existentes serão preservados.",
"select_at_least_one_question_error": "Por favor, selecione pelo menos uma pergunta",
"slack": {
"already_connected_another_survey": "Você já conectou outra pesquisa a este canal.",
@@ -1309,12 +1309,7 @@
"surveys": {
"all_set_time_to_create_first_survey": "Tá tudo pronto! Hora de criar sua primeira pesquisa",
"alphabetical": "alfabético",
"copy_survey": "Copiar pesquisa",
"copy_survey_description": "Copiar essa pesquisa para outro ambiente",
"copy_survey_error": "Falha ao copiar pesquisa",
"copy_survey_link_to_clipboard": "Copiar link da pesquisa para a área de transferência",
"copy_survey_partially_success": "{success} pesquisas copiadas com sucesso, {error} falharam.",
"copy_survey_success": "Pesquisa copiada com sucesso!",
"delete_survey_and_responses_warning": "Você tem certeza de que quer deletar essa pesquisa e todas as suas respostas?",
"edit": {
"activate_translations": "Ativar traduções",
@@ -1367,7 +1362,7 @@
"audience": "Público",
"auto_close_on_inactivity": "Fechar automaticamente por inatividade",
"auto_progress_rating_and_nps": "Avançar automaticamente em perguntas de avaliação e NPS",
"auto_progress_rating_and_nps_description": "Avança automaticamente quando os respondentes selecionam uma resposta em perguntas de avaliação ou NPS. Isso se aplica apenas a blocos com uma única pergunta. Perguntas obrigatórias ocultam o botão Próximo; perguntas opcionais ainda o exibem para permitir pular.",
"auto_progress_rating_and_nps_description": "Avança automaticamente em blocos de pergunta única. Perguntas obrigatórias ocultam o botão Próximo, exceto quando \"Outro\" está selecionado.",
"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",
@@ -2066,7 +2061,6 @@
"downloading_qr_code": "Baixando código QR",
"drop_offs": "Pontos de Entrega",
"drop_offs_tooltip": "Número de vezes que a pesquisa foi iniciada mas não concluída.",
"failed_to_copy_link": "Falha ao copiar link",
"filter_added_successfully": "Filtro adicionado com sucesso",
"filter_updated_successfully": "Filtro atualizado com sucesso",
"filtered_responses_csv": "Respostas filtradas (CSV)",
@@ -2154,7 +2148,6 @@
},
"survey_deleted_successfully": "Pesquisa deletada com sucesso!",
"survey_duplicated_successfully": "Pesquisa duplicada com sucesso.",
"survey_duplication_error": "Falha ao duplicar a pesquisa.",
"templates": {
"all_channels": "Todos os canais",
"all_industries": "Todas as indústrias",
@@ -2887,6 +2880,11 @@
"gauge_feature_satisfaction_question_2_headline": "O que a gente poderia melhorar?",
"identify_customer_goals_description": "Entenda melhor se sua mensagem cria as expectativas certas sobre o valor que seu produto oferece.",
"identify_customer_goals_name": "Identificar Objetivos do Cliente",
"identify_customer_goals_question_1_choice_1": "Entender profundamente minha base de usuários",
"identify_customer_goals_question_1_choice_2": "Identificar oportunidades de upsell",
"identify_customer_goals_question_1_choice_3": "Construir o melhor produto possível",
"identify_customer_goals_question_1_choice_4": "Dominar o mundo para fazer todo mundo tomar couve de bruxelas no café da manhã",
"identify_customer_goals_question_1_headline": "Qual é o seu objetivo principal ao usar $[projectName]?",
"identify_sign_up_barriers_description": "Ofereça um desconto pra entender melhor as barreiras de cadastro.",
"identify_sign_up_barriers_name": "Identificar Barreiras de Cadastro",
"identify_sign_up_barriers_question_1_button_label": "Ganhe 10% de desconto",
+9 -11
View File
@@ -342,7 +342,6 @@
"other": "Outro",
"other_filters": "Outros Filtros",
"other_placeholder": "Outro espaço reservado",
"others": "Outros",
"overlay_color": "Cor da sobreposição",
"overview": "Visão geral",
"password": "Palavra-passe",
@@ -360,7 +359,6 @@
"please_upgrade_your_plan": "Por favor, atualize o seu plano",
"powered_by_formbricks": "Desenvolvido por Formbricks",
"preview": "Pré-visualização",
"preview_survey": "Pré-visualização do inquérito",
"privacy": "Política de Privacidade",
"product_manager": "Gestor de Produto",
"production": "Produção",
@@ -495,7 +493,6 @@
"workspace_name_placeholder": "ex. Formbricks",
"workspaces": "Projetos",
"years": "anos",
"you": "Você",
"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.",
@@ -836,6 +833,9 @@
},
"notion_integration_description": "Enviar dados para a sua base de dados do Notion",
"please_select_a_survey_error": "Por favor, selecione um inquérito",
"reconnect_button": "Voltar a ligar",
"reconnect_button_description": "A ligação da tua integração expirou. Por favor, volta a ligar para continuar a sincronizar as respostas. As tuas ligações e dados existentes serão preservados.",
"reconnect_button_tooltip": "Volta a ligar a integração para atualizar o teu acesso. As tuas ligações e dados existentes serão preservados.",
"select_at_least_one_question_error": "Por favor, selecione pelo menos uma pergunta",
"slack": {
"already_connected_another_survey": "Já ligou outro inquérito a este canal.",
@@ -1309,12 +1309,7 @@
"surveys": {
"all_set_time_to_create_first_survey": "Está tudo pronto! Hora de criar o seu primeiro inquérito",
"alphabetical": "Alfabética",
"copy_survey": "Copiar inquérito",
"copy_survey_description": "Copiar este questionário para outro ambiente",
"copy_survey_error": "Falha ao copiar inquérito",
"copy_survey_link_to_clipboard": "Copiar link do inquérito para a área de transferência",
"copy_survey_partially_success": "{success} inquéritos copiados com sucesso, {error} falharam.",
"copy_survey_success": "Inquérito copiado com sucesso!",
"delete_survey_and_responses_warning": "Tem a certeza de que deseja eliminar este inquérito e todas as suas respostas?",
"edit": {
"activate_translations": "Ativar traduções",
@@ -1367,7 +1362,7 @@
"audience": "Público",
"auto_close_on_inactivity": "Fechar automaticamente por inatividade",
"auto_progress_rating_and_nps": "Avançar automaticamente em perguntas de classificação e NPS",
"auto_progress_rating_and_nps_description": "Avança automaticamente quando os inquiridos selecionam uma resposta em perguntas de classificação ou NPS. Isto aplica-se apenas a blocos com uma única pergunta. Perguntas obrigatórias ocultam o botão Seguinte; perguntas opcionais continuam a mostrá-lo para permitir saltar.",
"auto_progress_rating_and_nps_description": "Avança automaticamente em blocos de pergunta única. Perguntas obrigatórias ocultam o botão Seguinte, exceto quando \"Outro\" está selecionado.",
"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",
@@ -2066,7 +2061,6 @@
"downloading_qr_code": "A transferir código QR",
"drop_offs": "Desistências",
"drop_offs_tooltip": "Número de vezes que o inquérito foi iniciado mas não concluído.",
"failed_to_copy_link": "Falha ao copiar link",
"filter_added_successfully": "Filtro adicionado com sucesso",
"filter_updated_successfully": "Filtro atualizado com sucesso",
"filtered_responses_csv": "Respostas filtradas (CSV)",
@@ -2154,7 +2148,6 @@
},
"survey_deleted_successfully": "Inquérito eliminado com sucesso!",
"survey_duplicated_successfully": "Inquérito duplicado com sucesso.",
"survey_duplication_error": "Falha ao duplicar o inquérito.",
"templates": {
"all_channels": "Todos os canais",
"all_industries": "Todas as indústrias",
@@ -2887,6 +2880,11 @@
"gauge_feature_satisfaction_question_2_headline": "O que é uma coisa que poderíamos fazer melhor?",
"identify_customer_goals_description": "Compreenda melhor se a sua mensagem cria as expectativas certas sobre o valor que o seu produto oferece.",
"identify_customer_goals_name": "Identificar Objetivos do Cliente",
"identify_customer_goals_question_1_choice_1": "Compreender profundamente a minha base de utilizadores",
"identify_customer_goals_question_1_choice_2": "Identificar oportunidades de upselling",
"identify_customer_goals_question_1_choice_3": "Construir o melhor produto possível",
"identify_customer_goals_question_1_choice_4": "Dominar o mundo para fazer couves de Bruxelas ao pequeno-almoço para todos",
"identify_customer_goals_question_1_headline": "Qual é o seu objetivo principal ao usar $[projectName]?",
"identify_sign_up_barriers_description": "Ofereça um desconto para obter informações sobre as barreiras de inscrição.",
"identify_sign_up_barriers_name": "Identificar Barreiras de Inscrição",
"identify_sign_up_barriers_question_1_button_label": "Obtenha 10% de desconto",
+9 -11
View File
@@ -342,7 +342,6 @@
"other": "Altele",
"other_filters": "Alte Filtre",
"other_placeholder": "Alt substituent",
"others": "Altele",
"overlay_color": "Culoare overlay",
"overview": "Prezentare generală",
"password": "Parolă",
@@ -360,7 +359,6 @@
"please_upgrade_your_plan": "Vă rugăm să faceți upgrade la planul dumneavoastră",
"powered_by_formbricks": "Oferit de Formbricks",
"preview": "Previzualizare",
"preview_survey": "Previzualizare Chestionar",
"privacy": "Politica de Confidențialitate",
"product_manager": "Manager de Produs",
"production": "Producție",
@@ -495,7 +493,6 @@
"workspace_name_placeholder": "ex: Formbricks",
"workspaces": "Workspaces",
"years": "ani",
"you": "Tu",
"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.",
@@ -836,6 +833,9 @@
},
"notion_integration_description": "Trimiteți datele în baza de date Notion",
"please_select_a_survey_error": "Vă rugăm să selectați un sondaj",
"reconnect_button": "Reconectează",
"reconnect_button_description": "Conexiunea integrării tale a expirat. Te rugăm să te reconectezi pentru a continua sincronizarea răspunsurilor. Linkurile și datele tale existente vor fi păstrate.",
"reconnect_button_tooltip": "Reconectează integrarea pentru a reîmprospăta accesul. Linkurile și datele tale existente vor fi păstrate.",
"select_at_least_one_question_error": "Vă rugăm să selectați cel puțin o întrebare",
"slack": {
"already_connected_another_survey": "Ați conectat deja un alt chestionar la acest canal.",
@@ -1309,12 +1309,7 @@
"surveys": {
"all_set_time_to_create_first_survey": "Ești gata! Este timpul să creezi primul tău chestionar",
"alphabetical": "Alfabetic",
"copy_survey": "Copiază sondajul",
"copy_survey_description": "Copiază acest sondaj într-un alt mediu",
"copy_survey_error": "Nu s-a putut copia sondajul",
"copy_survey_link_to_clipboard": "Copiază linkul chestionarului în clipboard",
"copy_survey_partially_success": "\"{success} sondaje copiate cu succes, {error} eșuate.\"",
"copy_survey_success": "\"Sondaj copiat cu succes!\"",
"delete_survey_and_responses_warning": "Sigur doriți să ștergeți acest sondaj și toate răspunsurile sale?",
"edit": {
"activate_translations": "Activează traducerile",
@@ -1367,7 +1362,7 @@
"audience": "Public",
"auto_close_on_inactivity": "Închidere automată la inactivitate",
"auto_progress_rating_and_nps": "Avansare automată pentru întrebări de rating și NPS",
"auto_progress_rating_and_nps_description": "Avansează automat când respondenții selectează un răspuns la întrebările de rating sau NPS. Aceasta se aplică doar blocurilor cu o singură întrebare. Întrebările obligatorii ascund butonul Următorul; întrebările opționale îl afișează în continuare pentru a permite omiterea.",
"auto_progress_rating_and_nps_description": "Avansare automată în blocurile cu o singură întrebare. Întrebările obligatorii ascund butonul Următorul, cu excepția cazului în care este selectată opțiunea „Altele“.",
"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ă",
@@ -2066,7 +2061,6 @@
"downloading_qr_code": "Se descarcă codul QR",
"drop_offs": "Renunțări",
"drop_offs_tooltip": "Număr de ori când sondajul a fost început dar nu a fost finalizat.",
"failed_to_copy_link": "Nu s-a putut copia legătura",
"filter_added_successfully": "Filtru adăugat cu succes",
"filter_updated_successfully": "Filtru actualizat cu succes",
"filtered_responses_csv": "Răspunsuri filtrate (CSV)",
@@ -2154,7 +2148,6 @@
},
"survey_deleted_successfully": "\"Sondaj șters cu succes!\"",
"survey_duplicated_successfully": "\"Sondaj duplicat cu succes!\"",
"survey_duplication_error": "Eșec la duplicarea sondajului.",
"templates": {
"all_channels": "Toate canalele",
"all_industries": "Toate industriile",
@@ -2887,6 +2880,11 @@
"gauge_feature_satisfaction_question_2_headline": "Care este acel lucru pe care l-am putea îmbunătăți?",
"identify_customer_goals_description": "Înțelegeți mai bine dacă mesajele voastre creează așteptările corecte privind valoarea pe care o oferă produsul vostru.",
"identify_customer_goals_name": "Identifică Obiectivele Clienților",
"identify_customer_goals_question_1_choice_1": "Să îmi înțeleg în profunzime baza de utilizatori",
"identify_customer_goals_question_1_choice_2": "Să identific oportunități de upselling",
"identify_customer_goals_question_1_choice_3": "Să construiesc cel mai bun produs posibil",
"identify_customer_goals_question_1_choice_4": "Să cuceresc lumea pentru a-i face tuturor la micul dejun varză de Bruxelles",
"identify_customer_goals_question_1_headline": "Care este obiectivul tău principal pentru utilizarea $[projectName]?",
"identify_sign_up_barriers_description": "Oferiți o reducere pentru a obține informații despre barierele de înscriere.",
"identify_sign_up_barriers_name": "Identificați barierele de înscriere",
"identify_sign_up_barriers_question_1_button_label": "Obține reducere de 10%",
+9 -11
View File
@@ -342,7 +342,6 @@
"other": "Другое",
"other_filters": "Другие фильтры",
"other_placeholder": "Другой заполнитель",
"others": "Другие",
"overlay_color": "Цвет наложения",
"overview": "Обзор",
"password": "Пароль",
@@ -360,7 +359,6 @@
"please_upgrade_your_plan": "Пожалуйста, обновите ваш тарифный план",
"powered_by_formbricks": "Работает на Formbricks",
"preview": "Предпросмотр",
"preview_survey": "Предпросмотр опроса",
"privacy": "Политика конфиденциальности",
"product_manager": "Менеджер продукта",
"production": "Продакшн",
@@ -495,7 +493,6 @@
"workspace_name_placeholder": "например, Formbricks",
"workspaces": "Рабочие пространства",
"years": "годы",
"you": "Вы",
"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} рабочих пространств.",
@@ -836,6 +833,9 @@
},
"notion_integration_description": "Отправляйте данные в вашу базу данных Notion",
"please_select_a_survey_error": "Пожалуйста, выберите опрос",
"reconnect_button": "Переподключить",
"reconnect_button_description": "Срок действия подключения интеграции истёк. Пожалуйста, переподключитесь, чтобы продолжить синхронизацию ответов. Ваши существующие ссылки и данные будут сохранены.",
"reconnect_button_tooltip": "Переподключите интеграцию, чтобы обновить доступ. Ваши существующие ссылки и данные будут сохранены.",
"select_at_least_one_question_error": "Пожалуйста, выберите хотя бы один вопрос",
"slack": {
"already_connected_another_survey": "Вы уже подключили другой опрос к этому каналу.",
@@ -1309,12 +1309,7 @@
"surveys": {
"all_set_time_to_create_first_survey": "Всё готово! Пора создать первый опрос",
"alphabetical": "По алфавиту",
"copy_survey": "Копировать опрос",
"copy_survey_description": "Скопируйте этот опрос в другую среду",
"copy_survey_error": "Не удалось скопировать опрос",
"copy_survey_link_to_clipboard": "Скопировать ссылку на опрос в буфер обмена",
"copy_survey_partially_success": "Успешно скопировано опросов: {success}, не удалось: {error}.",
"copy_survey_success": "Опрос успешно скопирован!",
"delete_survey_and_responses_warning": "Вы уверены, что хотите удалить этот опрос и все его ответы?",
"edit": {
"activate_translations": "Активировать переводы",
@@ -1367,7 +1362,7 @@
"audience": "Аудитория",
"auto_close_on_inactivity": "Автоматически закрывать при бездействии",
"auto_progress_rating_and_nps": "Автоматический переход для вопросов с оценкой и NPS",
"auto_progress_rating_and_nps_description": "Автоматически переходить к следующему шагу, когда респонденты выбирают ответ в вопросах с оценкой или NPS. Это применяется только к блокам с одним вопросом. В обязательных вопросах кнопка «Далее» скрыта; в необязательных вопросах она остается видимой для пропуска.",
"auto_progress_rating_and_nps_description": "Автоматический переход в блоках с одним вопросом. Обязательные вопросы скрывают кнопку «Далее», за исключением случаев, когда выбран вариант «Другое».",
"auto_save_disabled": "Автосохранение отключено",
"auto_save_disabled_tooltip": "Ваш опрос автоматически сохраняется только в режиме черновика. Это гарантирует, что публичные опросы не будут случайно обновлены.",
"auto_save_on": "Автосохранение включено",
@@ -2066,7 +2061,6 @@
"downloading_qr_code": "Скачивание QR-кода",
"drop_offs": "Прерывания",
"drop_offs_tooltip": "Количество раз, когда опрос был начат, но не завершён.",
"failed_to_copy_link": "Не удалось скопировать ссылку",
"filter_added_successfully": "Фильтр успешно добавлен",
"filter_updated_successfully": "Фильтр успешно обновлён",
"filtered_responses_csv": "Отфильтрованные ответы (CSV)",
@@ -2154,7 +2148,6 @@
},
"survey_deleted_successfully": "Опрос успешно удалён!",
"survey_duplicated_successfully": "Опрос успешно продублирован.",
"survey_duplication_error": "Не удалось продублировать опрос.",
"templates": {
"all_channels": "Все каналы",
"all_industries": "Все отрасли",
@@ -2887,6 +2880,11 @@
"gauge_feature_satisfaction_question_2_headline": "Что мы могли бы сделать лучше?",
"identify_customer_goals_description": "Лучше понять, создают ли ваши сообщения правильные ожидания относительно ценности вашего продукта.",
"identify_customer_goals_name": "Определение целей клиента",
"identify_customer_goals_question_1_choice_1": "Глубоко понять свою пользовательскую базу",
"identify_customer_goals_question_1_choice_2": "Выявить возможности для допродаж",
"identify_customer_goals_question_1_choice_3": "Создать наилучший продукт",
"identify_customer_goals_question_1_choice_4": "Править миром, чтобы накормить всех брюссельской капустой на завтрак",
"identify_customer_goals_question_1_headline": "Какова ваша основная цель использования $[projectName]?",
"identify_sign_up_barriers_description": "Предложите скидку, чтобы узнать, что мешает регистрации.",
"identify_sign_up_barriers_name": "Определение барьеров регистрации",
"identify_sign_up_barriers_question_1_button_label": "Получить скидку 10%",
+9 -11
View File
@@ -342,7 +342,6 @@
"other": "Annat",
"other_filters": "Andra filter",
"other_placeholder": "Annan platshållare",
"others": "Andra",
"overlay_color": "Overlay-färg",
"overview": "Översikt",
"password": "Lösenord",
@@ -360,7 +359,6 @@
"please_upgrade_your_plan": "Vänligen uppgradera din plan",
"powered_by_formbricks": "Drivs av Formbricks",
"preview": "Förhandsgranska",
"preview_survey": "Förhandsgranska enkät",
"privacy": "Integritetspolicy",
"product_manager": "Produktchef",
"production": "Produktion",
@@ -495,7 +493,6 @@
"workspace_name_placeholder": "t.ex. Formbricks",
"workspaces": "Arbetsytor",
"years": "år",
"you": "Du",
"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.",
@@ -836,6 +833,9 @@
},
"notion_integration_description": "Skicka data till din Notion-databas",
"please_select_a_survey_error": "Vänligen välj en enkät",
"reconnect_button": "Återanslut",
"reconnect_button_description": "Din integrationsanslutning har gått ut. Vänligen återanslut för att fortsätta synkronisera svar. Dina befintliga länkar och data kommer att bevaras.",
"reconnect_button_tooltip": "Återanslut integrationen för att uppdatera din åtkomst. Dina befintliga länkar och data kommer att bevaras.",
"select_at_least_one_question_error": "Vänligen välj minst en fråga",
"slack": {
"already_connected_another_survey": "Du har redan anslutit en annan enkät till denna kanal.",
@@ -1309,12 +1309,7 @@
"surveys": {
"all_set_time_to_create_first_survey": "Allt klart! Dags att skapa din första enkät",
"alphabetical": "Alfabetisk",
"copy_survey": "Kopiera enkät",
"copy_survey_description": "Kopiera denna enkät till en annan miljö",
"copy_survey_error": "Misslyckades med att kopiera enkät",
"copy_survey_link_to_clipboard": "Kopiera enkätlänk till urklipp",
"copy_survey_partially_success": "{success} enkäter kopierade, {error} misslyckades.",
"copy_survey_success": "Enkät kopierad!",
"delete_survey_and_responses_warning": "Är du säker på att du vill ta bort denna enkät och alla dess svar?",
"edit": {
"activate_translations": "Aktivera översättningar",
@@ -1367,7 +1362,7 @@
"audience": "Målgrupp",
"auto_close_on_inactivity": "Stäng automatiskt vid inaktivitet",
"auto_progress_rating_and_nps": "Gå vidare automatiskt vid betygs- och NPS-frågor",
"auto_progress_rating_and_nps_description": "Gå automatiskt vidare när respondenter väljer ett svar på betygs- eller NPS-frågor. Detta gäller endast block med en enda fråga. Obligatoriska frågor döljer Nästa-knappen; valfria frågor visar den fortfarande för att kunna hoppas över.",
"auto_progress_rating_and_nps_description": "Gå automatiskt vidare i block med en enda fråga. Obligatoriska frågor döljer Nästa, utom när \"Annat\" är valt.",
"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å",
@@ -2066,7 +2061,6 @@
"downloading_qr_code": "Laddar ner QR-kod",
"drop_offs": "Avhopp",
"drop_offs_tooltip": "Antal gånger enkäten har startats men inte slutförts.",
"failed_to_copy_link": "Misslyckades med att kopiera länk",
"filter_added_successfully": "Filter tillagt",
"filter_updated_successfully": "Filter uppdaterat",
"filtered_responses_csv": "Filtrerade svar (CSV)",
@@ -2154,7 +2148,6 @@
},
"survey_deleted_successfully": "Enkät borttagen!",
"survey_duplicated_successfully": "Enkät duplicerad.",
"survey_duplication_error": "Misslyckades med att duplicera enkäten.",
"templates": {
"all_channels": "Alla kanaler",
"all_industries": "Alla branscher",
@@ -2887,6 +2880,11 @@
"gauge_feature_satisfaction_question_2_headline": "Vad är en sak vi kunde göra bättre?",
"identify_customer_goals_description": "Förstå bättre om din kommunikation skapar rätt förväntningar på värdet din produkt ger.",
"identify_customer_goals_name": "Identifiera kundmål",
"identify_customer_goals_question_1_choice_1": "Förstå min användarbas på djupet",
"identify_customer_goals_question_1_choice_2": "Identifiera merförsäljningsmöjligheter",
"identify_customer_goals_question_1_choice_3": "Bygga bästa möjliga produkt",
"identify_customer_goals_question_1_choice_4": "Härska över världen för att få alla att äta brysselkål till frukost",
"identify_customer_goals_question_1_headline": "Vad är ditt primära mål med att använda $[projectName]?",
"identify_sign_up_barriers_description": "Erbjud en rabatt för att samla insikter om registreringshinder.",
"identify_sign_up_barriers_name": "Identifiera registreringshinder",
"identify_sign_up_barriers_question_1_button_label": "Få 10% rabatt",
File diff suppressed because it is too large Load Diff
+9 -11
View File
@@ -342,7 +342,6 @@
"other": "其他",
"other_filters": "其他筛选条件",
"other_placeholder": "其他占位符",
"others": "其他",
"overlay_color": "覆盖层颜色",
"overview": "概览",
"password": "密码",
@@ -360,7 +359,6 @@
"please_upgrade_your_plan": "请升级您的计划",
"powered_by_formbricks": "由 Formbricks 提供支持",
"preview": "预览",
"preview_survey": "预览 Survey",
"privacy": "隐私政策",
"product_manager": "产品经理",
"production": "生产环境",
@@ -495,7 +493,6 @@
"workspace_name_placeholder": "例如:Formbricks",
"workspaces": "工作区",
"years": "年",
"you": "你 ",
"you_are_downgraded_to_the_community_edition": "您已降级到社区版。",
"you_are_not_authorized_to_perform_this_action": "您无权执行此操作。",
"you_have_reached_your_limit_of_workspace_limit": "您已达到 {projectLimit} 个工作区的上限。",
@@ -836,6 +833,9 @@
},
"notion_integration_description": "将 数据 发送到 您的 Notion 数据库",
"please_select_a_survey_error": "请选择 一个 调查",
"reconnect_button": "重新连接",
"reconnect_button_description": "你的集成连接已过期。请重新连接以继续同步响应。你现有的链接和数据将被保留。",
"reconnect_button_tooltip": "重新连接集成以刷新你的访问权限。你现有的链接和数据将被保留。",
"select_at_least_one_question_error": "请选择至少 一个问题",
"slack": {
"already_connected_another_survey": "您 已 经 将 另 一 个 调 查 连 接 到 此 频 道 。",
@@ -1309,12 +1309,7 @@
"surveys": {
"all_set_time_to_create_first_survey": "一切准备就绪!是时候创建您的第一个调查了",
"alphabetical": "字母顺序",
"copy_survey": "复制 调查",
"copy_survey_description": "复制 此 调查 到 另 一个 环境",
"copy_survey_error": "复制 调查 失败",
"copy_survey_link_to_clipboard": "复制 survey 链接 到 剪贴板",
"copy_survey_partially_success": "{success} 个调查成功复制,{error} 个失败。",
"copy_survey_success": "调查成功复制!",
"delete_survey_and_responses_warning": "您 确定 要 删除 此 调查 及 所有 回复 吗?",
"edit": {
"activate_translations": "激活翻译",
@@ -1367,7 +1362,7 @@
"audience": "受众",
"auto_close_on_inactivity": "自动关闭 在 无活动时",
"auto_progress_rating_and_nps": "自动推进评分和 NPS 问题",
"auto_progress_rating_and_nps_description": "当受访者在评分或 NPS 问题上选择答案时自动前进。这仅适用于单问题区块。必填问题会隐藏\"下一步\"按钮;可选问题仍会显示该按钮以便跳过。",
"auto_progress_rating_and_nps_description": "在单问题块中自动前进。必填问题会隐藏\"下一步\"按钮,除非选择了\"其他\"选项。",
"auto_save_disabled": "自动保存已禁用",
"auto_save_disabled_tooltip": "您的调查仅在草稿状态时自动保存。这确保公开的调查不会被意外更新。",
"auto_save_on": "自动保存已启用",
@@ -2066,7 +2061,6 @@
"downloading_qr_code": "正在下载二维码",
"drop_offs": "流失",
"drop_offs_tooltip": "调查 被 开始 但 未 完成 的 次数",
"failed_to_copy_link": "复制链接失败",
"filter_added_successfully": "筛选器 添加成功",
"filter_updated_successfully": "筛选器 更新 成功",
"filtered_responses_csv": "过滤 反馈 CSV",
@@ -2154,7 +2148,6 @@
},
"survey_deleted_successfully": "调查 删除 成功",
"survey_duplicated_successfully": "调查成功复制。",
"survey_duplication_error": "无法复制 调查。",
"templates": {
"all_channels": "所有 渠道",
"all_industries": "所有 行业",
@@ -2887,6 +2880,11 @@
"gauge_feature_satisfaction_question_2_headline": "我们 可以 改进 的 一 件事 是 什么?",
"identify_customer_goals_description": "更好 地 了解 您 的 信息 是否 创造了 您 的 产品 所 提供 价值 的 正确 期望。",
"identify_customer_goals_name": "识别 客户 目标",
"identify_customer_goals_question_1_choice_1": "深入了解我的用户群体",
"identify_customer_goals_question_1_choice_2": "识别追加销售机会",
"identify_customer_goals_question_1_choice_3": "打造最优质的产品",
"identify_customer_goals_question_1_choice_4": "统治世界,让每个人早餐都吃抱子甘蓝",
"identify_customer_goals_question_1_headline": "您使用 $[projectName] 的主要目标是什么?",
"identify_sign_up_barriers_description": "提供折扣以收集有关 注册障碍 的见解。",
"identify_sign_up_barriers_name": "识别 注册 障碍",
"identify_sign_up_barriers_question_1_button_label": "获取 10% 折扣",
+9 -11
View File
@@ -342,7 +342,6 @@
"other": "其他",
"other_filters": "其他篩選條件",
"other_placeholder": "其他預設文字",
"others": "其他",
"overlay_color": "覆蓋層顏色",
"overview": "概覽",
"password": "密碼",
@@ -360,7 +359,6 @@
"please_upgrade_your_plan": "請升級您的方案",
"powered_by_formbricks": "由 Formbricks 提供技術支援",
"preview": "預覽",
"preview_survey": "預覽問卷",
"privacy": "隱私權政策",
"product_manager": "產品經理",
"production": "正式環境",
@@ -495,7 +493,6 @@
"workspace_name_placeholder": "例如:Formbricks",
"workspaces": "工作區",
"years": "年",
"you": "您",
"you_are_downgraded_to_the_community_edition": "您已降級至社群版。",
"you_are_not_authorized_to_perform_this_action": "您沒有執行此操作的權限。",
"you_have_reached_your_limit_of_workspace_limit": "您已達到 {projectLimit} 個工作區的上限。",
@@ -836,6 +833,9 @@
},
"notion_integration_description": "將資料傳送至您的 Notion 資料庫",
"please_select_a_survey_error": "請選取問卷",
"reconnect_button": "重新連接",
"reconnect_button_description": "您的整合連線已過期。請重新連接以繼續同步回應。您現有的連結和資料將會保留。",
"reconnect_button_tooltip": "重新連接整合以更新您的存取權限。您現有的連結和資料將會保留。",
"select_at_least_one_question_error": "請選取至少一個問題",
"slack": {
"already_connected_another_survey": "您已將另一個問卷連線到此頻道。",
@@ -1309,12 +1309,7 @@
"surveys": {
"all_set_time_to_create_first_survey": "您已準備就緒!是時候建立您的第一個問卷",
"alphabetical": "依字母順序",
"copy_survey": "複製問卷",
"copy_survey_description": "將此問卷複製到另一個環境",
"copy_survey_error": "無法複製問卷",
"copy_survey_link_to_clipboard": "將問卷連結複製到剪貼簿",
"copy_survey_partially_success": "{success} 個問卷已成功複製,{error} 個失敗。",
"copy_survey_success": "問卷已成功複製!",
"delete_survey_and_responses_warning": "您確定要刪除此問卷及其所有回應嗎?",
"edit": {
"activate_translations": "啟用翻譯",
@@ -1367,7 +1362,7 @@
"audience": "受眾",
"auto_close_on_inactivity": "非活動時自動關閉",
"auto_progress_rating_and_nps": "自動前進評分與 NPS 問題",
"auto_progress_rating_and_nps_description": "當受訪者在評分或 NPS 問題中選擇答案時自動前進。此設定僅適用於單一問題區塊。必填問題會隱藏「下一步」按鈕;選填問題仍會顯示該按鈕以便跳過。",
"auto_progress_rating_and_nps_description": "在單一問題區塊中自動前進。必填問題會隱藏「下一步」按鈕,除非選擇了「其他」選項。",
"auto_save_disabled": "自動儲存已停用",
"auto_save_disabled_tooltip": "您的問卷僅在草稿狀態時自動儲存。這確保公開的問卷不會被意外更新。",
"auto_save_on": "自動儲存已啟用",
@@ -2066,7 +2061,6 @@
"downloading_qr_code": "正在下載 QR code",
"drop_offs": "放棄",
"drop_offs_tooltip": "問卷已開始但未完成的次數。",
"failed_to_copy_link": "無法複製連結",
"filter_added_successfully": "篩選器已成功新增",
"filter_updated_successfully": "篩選器已成功更新",
"filtered_responses_csv": "篩選回應 (CSV)",
@@ -2154,7 +2148,6 @@
},
"survey_deleted_successfully": "問卷已成功刪除!",
"survey_duplicated_successfully": "問卷已成功複製。",
"survey_duplication_error": "無法複製問卷。",
"templates": {
"all_channels": "所有管道",
"all_industries": "所有產業",
@@ -2887,6 +2880,11 @@
"gauge_feature_satisfaction_question_2_headline": "我們可以做哪一件事來改進?",
"identify_customer_goals_description": "更瞭解您的訊息傳遞是否符合您的產品所提供價值的正確期望。",
"identify_customer_goals_name": "識別客戶目標",
"identify_customer_goals_question_1_choice_1": "深入了解我的使用者群",
"identify_customer_goals_question_1_choice_2": "找出向上銷售的機會",
"identify_customer_goals_question_1_choice_3": "打造最優秀的產品",
"identify_customer_goals_question_1_choice_4": "統治世界,讓每個人都吃早餐球芽甘藍",
"identify_customer_goals_question_1_headline": "您使用 $[projectName] 的主要目標是什麼?",
"identify_sign_up_barriers_description": "提供折扣以收集有關註冊障礙的洞察。",
"identify_sign_up_barriers_name": "識別註冊障礙",
"identify_sign_up_barriers_question_1_button_label": "獲得 10% 折扣",
@@ -6,7 +6,6 @@ import { useTranslation } from "react-i18next";
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
import { TResponse } from "@formbricks/types/responses";
import { TUserLocale } from "@formbricks/types/user";
import { Button } from "@/modules/ui/components/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface InfoIconButtonProps {
@@ -26,9 +25,11 @@ const InfoIconButton = ({
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" aria-label={ariaLabel}>
<button
className="flex h-4 w-4 items-center justify-center rounded text-slate-500 hover:text-slate-700"
aria-label={ariaLabel}>
<Icon className="h-4 w-4" />
</Button>
</button>
</TooltipTrigger>
<TooltipContent avoidCollisions align="start" side="bottom" className={maxWidth}>
{tooltipContent}
@@ -0,0 +1,38 @@
import { describe, expect, test } from "vitest";
import { V3ApiError, getV3ApiErrorMessage, parseV3ApiError } from "@/modules/api/lib/v3-client";
describe("parseV3ApiError", () => {
test("parses RFC 9457 error responses into a typed V3ApiError", async () => {
const response = new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
code: "forbidden",
requestId: "req_1",
invalid_params: [{ name: "surveyId", reason: "Invalid id" }],
}),
{
status: 403,
headers: {
"Content-Type": "application/problem+json",
"X-Request-Id": "req_1",
},
}
);
const error = await parseV3ApiError(response);
expect(error).toBeInstanceOf(V3ApiError);
expect(error.status).toBe(403);
expect(error.detail).toBe("You are not authorized to access this resource");
expect(error.code).toBe("forbidden");
expect(error.requestId).toBe("req_1");
expect(error.invalid_params).toEqual([{ name: "surveyId", reason: "Invalid id" }]);
});
test("falls back to a provided fallback message", () => {
expect(getV3ApiErrorMessage(new Error("boom"), "fallback")).toBe("boom");
expect(getV3ApiErrorMessage("bad", "fallback")).toBe("fallback");
});
});
+74
View File
@@ -0,0 +1,74 @@
export type TV3InvalidParam = {
name: string;
reason: string;
};
type TV3ProblemBody = {
status?: number;
detail?: string;
code?: string;
requestId?: string;
invalid_params?: TV3InvalidParam[];
};
export class V3ApiError extends Error {
status: number;
code?: string;
requestId?: string;
invalid_params?: TV3InvalidParam[];
constructor({
status,
detail,
code,
requestId,
invalid_params,
}: {
status: number;
detail: string;
code?: string;
requestId?: string;
invalid_params?: TV3InvalidParam[];
}) {
super(detail);
this.name = "V3ApiError";
this.status = status;
this.code = code;
this.requestId = requestId;
this.invalid_params = invalid_params;
}
get detail(): string {
return this.message;
}
}
export function getV3ApiErrorMessage(error: unknown, fallbackMessage: string): string {
if (error instanceof V3ApiError) {
return error.detail;
}
if (error instanceof Error && error.message) {
return error.message;
}
return fallbackMessage;
}
export async function parseV3ApiError(response: Response): Promise<V3ApiError> {
let problemBody: TV3ProblemBody | undefined;
try {
problemBody = (await response.json()) as TV3ProblemBody;
} catch {
problemBody = undefined;
}
return new V3ApiError({
status: problemBody?.status ?? response.status,
detail: problemBody?.detail ?? response.statusText ?? "An unexpected error occurred.",
code: problemBody?.code,
requestId: problemBody?.requestId ?? response.headers.get("X-Request-Id") ?? undefined,
invalid_params: problemBody?.invalid_params,
});
}
+3 -3
View File
@@ -20,7 +20,7 @@ import { ApiErrorDetails } from "@/modules/api/v2/types/api-error";
* @returns Validation error map keyed by element ID, or null if validation passes
*/
export const validateResponseData = (
blocks: TSurveyBlock[] | undefined | null,
blocks: unknown[] | undefined | null,
responseData: TResponseData,
languageCode: string = "en",
questions?: TSurveyQuestion[] | undefined | null
@@ -28,8 +28,8 @@ export const validateResponseData = (
// Use blocks if available, otherwise transform questions to blocks
let blocksToUse: TSurveyBlock[] = [];
if (blocks && blocks.length > 0) {
blocksToUse = blocks;
if (Array.isArray(blocks) && blocks.length > 0) {
blocksToUse = blocks as TSurveyBlock[];
} else if (questions && questions.length > 0) {
// Transform legacy questions format to blocks for validation
blocksToUse = transformQuestionsToBlocks(questions, []);
+18 -10
View File
@@ -1,22 +1,32 @@
import { TResponseData } from "@formbricks/types/responses";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurveyQuestionChoice } from "@formbricks/types/surveys/types";
import { MAX_OTHER_OPTION_LENGTH } from "@/lib/constants";
import { getLocalizedValue } from "@/lib/i18n/utils";
type TQuestionWithOtherOptionValidation = {
id: string;
type: string;
choices?: unknown[];
};
/**
* Helper function to check if a string value is a valid "other" option
* @returns BadRequestResponse if the value exceeds the limit, undefined otherwise
*/
export const validateOtherOptionLength = (
value: string,
choices: TSurveyQuestionChoice[],
choices: unknown[],
questionId: string,
language?: string
): string | undefined => {
// Check if this is an "other" option (not in predefined choices)
const matchingChoice = choices.find(
(choice) => getLocalizedValue(choice.label, language ?? "default") === value
(choice) =>
typeof choice === "object" &&
choice !== null &&
"label" in choice &&
typeof choice.label === "object" &&
choice.label !== null &&
getLocalizedValue(choice.label as Record<string, string>, language ?? "default") === value
);
// If this is an "other" option with value that's too long, reject the response
@@ -31,7 +41,7 @@ export const validateOtherOptionLengthForMultipleChoice = ({
responseLanguage,
}: {
responseData?: TResponseData;
surveyQuestions: TSurveyElement[];
surveyQuestions: TQuestionWithOtherOptionValidation[];
responseLanguage?: string;
}): string | undefined => {
if (!responseData) return undefined;
@@ -39,11 +49,9 @@ export const validateOtherOptionLengthForMultipleChoice = ({
const question = surveyQuestions.find((q) => q.id === questionId);
if (!question) continue;
const isMultiChoice =
question.type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
question.type === TSurveyElementTypeEnum.MultipleChoiceSingle;
const isMultiChoice = question.type === "multipleChoiceMulti" || question.type === "multipleChoiceSingle";
if (!isMultiChoice) continue;
if (!isMultiChoice || !question.choices) continue;
const error = validateAnswer(answer, question.choices, questionId, responseLanguage);
if (error) return error;
@@ -54,7 +62,7 @@ export const validateOtherOptionLengthForMultipleChoice = ({
function validateAnswer(
answer: unknown,
choices: TSurveyQuestionChoice[],
choices: unknown[],
questionId: string,
language?: string
): string | undefined {
@@ -13,6 +13,10 @@ const mockUser = {
createdAt: new Date(),
updatedAt: new Date(),
isActive: true,
password: "$2b$12$hashedPassword",
twoFactorSecret: "encrypted-2fa-secret",
backupCodes: "encrypted-backup-codes",
identityProviderAccountId: "provider-account-id",
role: "admin",
memberships: [{ organizationId: "org456", role: "admin" }],
teamUsers: [{ team: { name: "Test Team", id: "team123", projectTeams: [{ projectId: "proj789" }] } }],
@@ -60,6 +64,10 @@ describe("Users Lib", () => {
updatedAt: expect.any(Date),
},
]);
expect(result.data.data[0]).not.toHaveProperty("password");
expect(result.data.data[0]).not.toHaveProperty("twoFactorSecret");
expect(result.data.data[0]).not.toHaveProperty("backupCodes");
expect(result.data.data[0]).not.toHaveProperty("identityProviderAccountId");
}
});
@@ -84,6 +92,10 @@ describe("Users Lib", () => {
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.id).toBe(mockUser.id);
expect(result.data).not.toHaveProperty("password");
expect(result.data).not.toHaveProperty("twoFactorSecret");
expect(result.data).not.toHaveProperty("backupCodes");
expect(result.data).not.toHaveProperty("identityProviderAccountId");
}
});
@@ -148,6 +160,10 @@ describe("Users Lib", () => {
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.name).toBe("Updated User");
expect(result.data).not.toHaveProperty("password");
expect(result.data).not.toHaveProperty("twoFactorSecret");
expect(result.data).not.toHaveProperty("backupCodes");
expect(result.data).not.toHaveProperty("identityProviderAccountId");
}
});
@@ -1,5 +1,6 @@
import "server-only";
import { Prisma, PrismaClient } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
@@ -35,3 +36,22 @@ export const deleteSessionsByUserId = async (
return handleDatabaseError(error);
}
};
export const deleteSessionBySessionToken = async (
sessionToken: string,
tx?: Prisma.TransactionClient
): Promise<number> => {
validateInputs([sessionToken, z.string().min(1)]);
try {
const result = await getDbClient(tx).session.deleteMany({
where: {
sessionToken,
},
});
return result.count;
} catch (error) {
return handleDatabaseError(error);
}
};
@@ -3,8 +3,11 @@ import { Provider } from "next-auth/providers/index";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
import { verifyToken } from "@/lib/jwt";
import { capturePostHogEvent } from "@/lib/posthog";
import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
// Import mocked rate limiting functions
import { updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { authOptions } from "./authOptions";
@@ -133,6 +136,10 @@ vi.mock("@/lib/posthog", () => ({
capturePostHogEvent: vi.fn(),
}));
vi.mock("@/modules/auth/lib/brevo", () => ({
createBrevoCustomer: vi.fn(),
}));
// Helper to get the provider by id from authOptions.providers.
function getProviderById(id: string): Provider {
const provider = authOptions.providers.find((p) => p.options.id === id);
@@ -315,6 +322,105 @@ describe("authOptions", () => {
);
});
test("allows verified users through the token provider when the token purpose is sso_recovery", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
vi.mocked(verifyToken).mockResolvedValue({
id: mockUser.id,
email: mockUser.email,
purpose: "sso_recovery",
} as any);
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
...mockUser,
emailVerified: new Date(),
} as any);
const result = await tokenProvider.options.authorize({ token: "recovery-token" }, {});
expect(result).toEqual(
expect.objectContaining({
id: mockUser.id,
email: mockUser.email,
authFlowPurpose: "sso_recovery",
})
);
});
test("defers verification side effects for unverified users when the token purpose is sso_recovery", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
vi.mocked(verifyToken).mockResolvedValue({
id: mockUser.id,
email: mockUser.email,
purpose: "sso_recovery",
} as any);
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
...mockUser,
emailVerified: null,
} as any);
const result = await tokenProvider.options.authorize({ token: "recovery-token" }, {});
expect(result).toEqual(
expect.objectContaining({
id: mockUser.id,
email: mockUser.email,
emailVerified: null,
authFlowPurpose: "sso_recovery",
})
);
expect(updateUser).not.toHaveBeenCalled();
});
test("verifies unverified users during the standard email verification flow", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
vi.mocked(verifyToken).mockResolvedValue({
id: mockUser.id,
email: mockUser.email,
purpose: "email_verification",
} as any);
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
...mockUser,
emailVerified: null,
} as any);
vi.mocked(updateUser).mockResolvedValue({
...mockUser,
emailVerified: new Date("2026-04-16T00:00:00.000Z"),
} as any);
const result = await tokenProvider.options.authorize({ token: "verify-token" }, {});
expect(updateUser).toHaveBeenCalledWith(mockUser.id, { emailVerified: expect.any(Date) });
expect(createBrevoCustomer).toHaveBeenCalledWith({ id: mockUser.id, email: mockUser.email });
expect(result).toEqual(
expect.objectContaining({
id: mockUser.id,
email: mockUser.email,
authFlowPurpose: "email_verification",
emailVerified: new Date("2026-04-16T00:00:00.000Z"),
})
);
});
test("rejects inactive users even when the verification token is otherwise valid", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
vi.mocked(verifyToken).mockResolvedValue({
id: mockUser.id,
email: mockUser.email,
purpose: "email_verification",
} as any);
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
...mockUser,
emailVerified: null,
isActive: false,
} as any);
await expect(tokenProvider.options.authorize({ token: "inactive-token" }, {})).rejects.toThrow(
"Your account is currently inactive. Please contact the organization admin."
);
expect(updateUser).not.toHaveBeenCalled();
expect(createBrevoCustomer).not.toHaveBeenCalled();
});
describe("Rate Limiting", () => {
test("should apply rate limiting before token verification", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
@@ -432,6 +538,51 @@ describe("authOptions", () => {
expect(capturePostHogEvent).not.toHaveBeenCalled();
}
});
test("should not record a completed sign-in while the recovery token is only proving inbox ownership", async () => {
const user = {
...mockUser,
emailVerified: new Date(),
authFlowPurpose: "sso_recovery",
};
const account = { provider: "token" } as any;
if (authOptions.callbacks?.signIn) {
const result = await authOptions.callbacks.signIn({ user, account } as any);
expect(result).toBe(true);
expect(updateUserLastLoginAt).not.toHaveBeenCalled();
expect(capturePostHogEvent).not.toHaveBeenCalled();
}
});
test("should allow an unverified recovery session through until SSO completion finishes the reclaim", async () => {
const user = {
...mockUser,
emailVerified: null,
authFlowPurpose: "sso_recovery",
};
const account = { provider: "token" } as any;
if (authOptions.callbacks?.signIn) {
const result = await authOptions.callbacks.signIn({ user, account } as any);
expect(result).toBe(true);
expect(updateUserLastLoginAt).not.toHaveBeenCalled();
expect(capturePostHogEvent).not.toHaveBeenCalled();
}
});
test("should finalize successful sign-in when no provider information is available", async () => {
const user = { ...mockUser, emailVerified: new Date() };
if (authOptions.callbacks?.signIn) {
const result = await authOptions.callbacks.signIn({ user, account: undefined } as any);
expect(result).toBe(true);
expect(updateUserLastLoginAt).toHaveBeenCalledWith(user.email);
}
});
});
});
@@ -489,6 +640,57 @@ describe("authOptions", () => {
expect(mockUpdateUserLastLoginAt).not.toHaveBeenCalled();
expect(mockCapturePostHogEvent).not.toHaveBeenCalled();
});
test("should finalize successful sign-in after a successful enterprise SSO callback", async () => {
vi.resetModules();
const mockHandleSsoCallback = vi.fn().mockResolvedValueOnce(true);
const mockUpdateUserLastLoginAt = vi.fn();
const mockCapturePostHogEvent = vi.fn();
vi.doMock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
EMAIL_VERIFICATION_DISABLED: false,
SESSION_MAX_AGE: 86400,
NEXTAUTH_SECRET: "test-secret",
WEBAPP_URL: "http://localhost:3000",
ENCRYPTION_KEY: "12345678901234567890123456789012",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: false,
AUDIT_LOG_GET_USER_IP: false,
ENTERPRISE_LICENSE_KEY: "test-enterprise-license",
SENTRY_DSN: undefined,
BREVO_API_KEY: undefined,
RATE_LIMITING_DISABLED: false,
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
POSTHOG_KEY: "phc_test_key",
};
});
vi.doMock("@/modules/ee/sso/lib/providers", () => ({
getSSOProviders: vi.fn(() => []),
}));
vi.doMock("@/modules/ee/sso/lib/sso-handlers", () => ({
handleSsoCallback: mockHandleSsoCallback,
}));
vi.doMock("@/modules/auth/lib/user", () => ({
updateUser: vi.fn(),
updateUserLastLoginAt: mockUpdateUserLastLoginAt,
}));
vi.doMock("@/lib/posthog", () => ({
capturePostHogEvent: mockCapturePostHogEvent,
}));
const { authOptions: enterpriseAuthOptions } = await import("./authOptions");
const user = { ...mockUser, emailVerified: new Date() };
const account = { provider: "google", type: "oauth", providerAccountId: "provider-123" } as any;
await expect(enterpriseAuthOptions.callbacks?.signIn?.({ user, account } as any)).resolves.toBe(true);
expect(mockHandleSsoCallback).toHaveBeenCalled();
expect(mockUpdateUserLastLoginAt).toHaveBeenCalledWith(user.email);
});
});
describe("Two-Factor Authentication (TOTP)", () => {
+46 -41
View File
@@ -10,16 +10,15 @@ import {
EMAIL_VERIFICATION_DISABLED,
ENCRYPTION_KEY,
ENTERPRISE_LICENSE_KEY,
POSTHOG_KEY,
SESSION_MAX_AGE,
WEBAPP_URL,
} from "@/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { verifyToken } from "@/lib/jwt";
import { capturePostHogEvent } from "@/lib/posthog";
import { getValidatedCallbackUrl } from "@/lib/utils/url";
import { getAuthCallbackUrlFromCookies } from "@/modules/auth/lib/callback-url";
import { updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
import { finalizeSuccessfulSignIn } from "@/modules/auth/lib/sign-in-tracking";
import { updateUser } from "@/modules/auth/lib/user";
import {
logAuthAttempt,
logAuthEvent,
@@ -267,12 +266,13 @@ export const authOptions: NextAuthOptions = {
throw new Error("Token not found");
}
const { id } = await verifyToken(credentials?.token);
user = await prisma.user.findUnique({
const { id, purpose } = await verifyToken(credentials?.token);
const foundUser = await prisma.user.findUnique({
where: {
id: id,
},
});
user = foundUser ? { ...foundUser, authFlowPurpose: purpose } : null;
} catch (e) {
logger.error(e, "Error in CredentialsProvider authorize");
@@ -291,7 +291,10 @@ export const authOptions: NextAuthOptions = {
throw new Error("Either a user does not match the provided token or the token is invalid");
}
if (user.emailVerified) {
const authFlowPurpose = user.authFlowPurpose ?? "email_verification";
const isSsoRecovery = authFlowPurpose === "sso_recovery";
if (user.emailVerified && !isSsoRecovery) {
logEmailVerificationAttempt(false, "email_already_verified", user.id, user.email);
throw new Error("Email already verified");
}
@@ -301,14 +304,20 @@ export const authOptions: NextAuthOptions = {
throw new Error("Your account is currently inactive. Please contact the organization admin.");
}
user = await updateUser(user.id, { emailVerified: new Date() });
if (!user.emailVerified && !isSsoRecovery) {
const updatedUser = await updateUser(user.id, { emailVerified: new Date() });
user = {
...updatedUser,
authFlowPurpose,
};
logEmailVerificationAttempt(true, undefined, user.id, user.email, {
emailVerifiedAt: user.emailVerified,
});
logEmailVerificationAttempt(true, undefined, user.id, user.email, {
emailVerifiedAt: user.emailVerified,
});
// send new user to brevo after email verification
createBrevoCustomer({ id: user.id, email: user.email });
// send new user to brevo after email verification
createBrevoCustomer({ id: user.id, email: user.email });
}
return user;
},
@@ -339,37 +348,27 @@ export const authOptions: NextAuthOptions = {
const userEmail = user.email ?? "";
const userId = user.id as string;
// Capture sign-in event for PostHog (query BEFORE updating lastLoginAt)
const captureSignIn = async (provider: string) => {
if (!POSTHOG_KEY) return;
try {
const [membershipCount, userData] = await Promise.all([
prisma.membership.count({ where: { userId } }),
prisma.user.findUnique({ where: { id: userId }, select: { lastLoginAt: true } }),
]);
const isFirstLoginToday =
userData?.lastLoginAt?.toISOString().slice(0, 10) !== new Date().toISOString().slice(0, 10);
capturePostHogEvent(userId, "user_signed_in", {
auth_provider: provider,
organization_count: membershipCount,
is_first_login_today: isFirstLoginToday,
});
} catch (error) {
logger.warn({ error }, "Failed to capture PostHog sign-in event");
}
};
const authFlowPurpose =
"authFlowPurpose" in user && typeof user.authFlowPurpose === "string"
? user.authFlowPurpose
: undefined;
if (account?.provider === "credentials" || account?.provider === "token") {
if (account.provider === "token" && authFlowPurpose === "sso_recovery") {
return true;
}
// check if user's email is verified or not
if ("emailVerified" in user && !user.emailVerified && !EMAIL_VERIFICATION_DISABLED) {
logger.error("Email Verification is Pending");
throw new Error("Email Verification is Pending");
}
void captureSignIn(account.provider);
await updateUserLastLoginAt(userEmail);
await finalizeSuccessfulSignIn({
userId,
email: userEmail,
provider: account.provider,
});
return true;
}
if (ENTERPRISE_LICENSE_KEY && account) {
@@ -379,14 +378,20 @@ export const authOptions: NextAuthOptions = {
callbackUrl,
});
if (result) {
void captureSignIn(account.provider);
await updateUserLastLoginAt(userEmail);
if (result === true) {
await finalizeSuccessfulSignIn({
userId,
email: userEmail,
provider: account.provider,
});
}
return result;
}
void captureSignIn(account?.provider ?? "unknown");
await updateUserLastLoginAt(userEmail);
await finalizeSuccessfulSignIn({
userId,
email: userEmail,
provider: account?.provider ?? "unknown",
});
return true;
},
},
+2 -13
View File
@@ -1,9 +1,5 @@
import { prisma } from "@formbricks/database";
const NEXT_AUTH_SESSION_COOKIE_NAMES = [
"__Secure-next-auth.session-token",
"next-auth.session-token",
] as const;
import { getSessionTokenFromCookieStore } from "./session-cookie";
type TCookieStore = {
get: (name: string) => { value: string } | undefined;
@@ -14,14 +10,7 @@ type TRequestWithCookies = {
};
export const getSessionTokenFromRequest = (request: TRequestWithCookies): string | null => {
for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) {
const cookie = request.cookies.get(cookieName);
if (cookie?.value) {
return cookie.value;
}
}
return null;
return getSessionTokenFromCookieStore(request.cookies);
};
export const getProxySession = async (request: TRequestWithCookies) => {
@@ -0,0 +1,49 @@
export const NEXT_AUTH_SESSION_COOKIE_NAMES = [
"__Secure-next-auth.session-token",
"next-auth.session-token",
] as const;
type TCookieStore = {
get: (name: string) => { value: string } | undefined;
};
const getCookieValueFromHeader = (cookieHeader: string, cookieName: string): string | null => {
const cookies = cookieHeader.split(";").map((cookie) => cookie.trim());
for (const cookie of cookies) {
if (!cookie.startsWith(`${cookieName}=`)) {
continue;
}
const cookieValue = cookie.slice(cookieName.length + 1);
return cookieValue.length > 0 ? decodeURIComponent(cookieValue) : null;
}
return null;
};
export const getSessionTokenFromCookieStore = (cookieStore: TCookieStore): string | null => {
for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) {
const cookie = cookieStore.get(cookieName);
if (cookie?.value) {
return cookie.value;
}
}
return null;
};
export const getSessionTokenFromCookieHeader = (cookieHeader: string | null): string | null => {
if (!cookieHeader) {
return null;
}
for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) {
const cookieValue = getCookieValueFromHeader(cookieHeader, cookieName);
if (cookieValue) {
return cookieValue;
}
}
return null;
};

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