mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-06 11:20:56 -05:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e85933c64e |
@@ -106,13 +106,6 @@ PASSWORD_RESET_DISABLED=1
|
||||
# Organization Invite. Disable the ability for invited users to create an account.
|
||||
# INVITE_DISABLED=1
|
||||
|
||||
###########################################
|
||||
# Account deletion reauthentication #
|
||||
###########################################
|
||||
|
||||
# Danger: disables fresh SSO reauthentication for passwordless account deletion. Keep unset unless you accept the risk.
|
||||
# DISABLE_ACCOUNT_DELETION_SSO_REAUTH=1
|
||||
|
||||
|
||||
##########
|
||||
# Other #
|
||||
@@ -139,9 +132,6 @@ GITHUB_SECRET=
|
||||
# Configure Google Login
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
# Google only returns the auth_time proof after Auth Platform Security Bundle "Session age claims" is enabled.
|
||||
# Keep this unset until that setting is active for the OAuth app.
|
||||
# GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED=1
|
||||
|
||||
# Configure Azure Active Directory Login
|
||||
AZUREAD_CLIENT_ID=
|
||||
|
||||
@@ -20,12 +20,12 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Cache Build
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
uses: actions/cache@v3
|
||||
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@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@v3
|
||||
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 --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||
run: pnpm install --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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 2
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: Run Chromatic
|
||||
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
|
||||
|
||||
+48
-37
@@ -57,7 +57,7 @@ jobs:
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Setup Node.js 22.x
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
|
||||
with:
|
||||
node-version: 22.x
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
shell: bash
|
||||
|
||||
- name: create .env
|
||||
@@ -85,48 +85,65 @@ 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=devrustfs-service" >> .env
|
||||
echo "S3_SECRET_KEY=devrustfs-service123" >> .env
|
||||
echo "S3_ACCESS_KEY=devminio" >> .env
|
||||
echo "S3_SECRET_KEY=devminio123" >> .env
|
||||
echo "S3_FORCE_PATH_STYLE=1" >> .env
|
||||
shell: bash
|
||||
|
||||
- name: Start RustFS Server
|
||||
- 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
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Start RustFS server in background
|
||||
# Start MinIO server in background
|
||||
docker run -d \
|
||||
--name rustfs-server \
|
||||
--name minio-server \
|
||||
-p 9000:9000 \
|
||||
-p 9001: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
|
||||
-e MINIO_ROOT_USER=devminio \
|
||||
-e MINIO_ROOT_PASSWORD=devminio123 \
|
||||
minio/minio:RELEASE.2025-09-07T16-13-09Z \
|
||||
server /data --console-address :9001
|
||||
|
||||
echo "RustFS server started"
|
||||
echo "MinIO server started"
|
||||
|
||||
- name: Bootstrap RustFS bucket and browser upload CORS
|
||||
- name: Wait for MinIO and create S3 bucket
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
- name: Build App
|
||||
run: |
|
||||
@@ -225,14 +242,8 @@ jobs:
|
||||
if: failure()
|
||||
with:
|
||||
name: app-logs
|
||||
if-no-files-found: ignore
|
||||
path: app.log
|
||||
|
||||
- name: Output App Logs
|
||||
if: failure()
|
||||
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
|
||||
run: cat app.log
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||
with:
|
||||
node-version: 20.x
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: create .env
|
||||
run: cp .env.example .env
|
||||
|
||||
@@ -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@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||
with:
|
||||
node-version: 22.x
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: create .env
|
||||
run: cp .env.example .env
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
|
||||
with:
|
||||
node-version: 20.x
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: create .env
|
||||
run: cp .env.example .env
|
||||
|
||||
@@ -2,7 +2,6 @@ name: Translation Validation
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -40,7 +39,7 @@ jobs:
|
||||
|
||||
- name: Setup Node.js 22.x
|
||||
if: steps.changes.outputs.translations == 'true'
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||
with:
|
||||
node-version: 22.x
|
||||
|
||||
@@ -50,7 +49,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.changes.outputs.translations == 'true'
|
||||
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: Validate translation keys
|
||||
if: steps.changes.outputs.translations == 'true'
|
||||
|
||||
+12
-12
@@ -11,19 +11,19 @@
|
||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "5.0.2",
|
||||
"@storybook/addon-a11y": "10.3.5",
|
||||
"@storybook/addon-docs": "10.3.5",
|
||||
"@storybook/addon-links": "10.3.5",
|
||||
"@storybook/addon-onboarding": "10.3.5",
|
||||
"@storybook/react-vite": "10.3.5",
|
||||
"@tailwindcss/vite": "4.2.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.2",
|
||||
"@typescript-eslint/parser": "8.57.2",
|
||||
"@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",
|
||||
"@tailwindcss/vite": "4.2.1",
|
||||
"@typescript-eslint/parser": "8.57.0",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"eslint-plugin-react-refresh": "0.4.26",
|
||||
"eslint-plugin-storybook": "10.3.5",
|
||||
"storybook": "10.3.5",
|
||||
"vite": "7.3.2"
|
||||
"eslint-plugin-storybook": "10.2.17",
|
||||
"storybook": "10.2.17",
|
||||
"vite": "7.3.2",
|
||||
"@storybook/addon-docs": "10.2.17"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { Prisma } from "@prisma/client";
|
||||
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 PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
|
||||
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
|
||||
);
|
||||
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { Prisma } from "@prisma/client";
|
||||
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 PrismaClientKnownRequestError) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -6,9 +6,11 @@ import {
|
||||
TUserUpdateInput,
|
||||
ZUserPersonalInfoUpdateInput,
|
||||
} from "@formbricks/types/user";
|
||||
import { getIsEmailUnique } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
|
||||
import {
|
||||
getIsEmailUnique,
|
||||
verifyUserPassword,
|
||||
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
|
||||
import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||
import { verifyUserPassword } from "@/lib/user/password";
|
||||
import { getUser, updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
|
||||
+8
-47
@@ -1,68 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import type { Session } from "next-auth";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountModal";
|
||||
import {
|
||||
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
|
||||
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
|
||||
} from "@/modules/account/constants";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface DeleteAccountProps {
|
||||
session: Session | null;
|
||||
IS_FORMBRICKS_CLOUD: boolean;
|
||||
user: TUser;
|
||||
organizationsWithSingleOwner: TOrganization[];
|
||||
accountDeletionError?: string | string[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
requiresPasswordConfirmation: boolean;
|
||||
}
|
||||
|
||||
export const DeleteAccount = ({
|
||||
session,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
user,
|
||||
organizationsWithSingleOwner,
|
||||
accountDeletionError,
|
||||
isMultiOrgEnabled,
|
||||
requiresPasswordConfirmation,
|
||||
}: Readonly<DeleteAccountProps>) => {
|
||||
}: {
|
||||
session: Session | null;
|
||||
IS_FORMBRICKS_CLOUD: boolean;
|
||||
user: TUser;
|
||||
organizationsWithSingleOwner: TOrganization[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
}) => {
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const isDeleteDisabled = !isMultiOrgEnabled && organizationsWithSingleOwner.length > 0;
|
||||
const { t } = useTranslation();
|
||||
const accountDeletionErrorCode = Array.isArray(accountDeletionError)
|
||||
? accountDeletionError[0]
|
||||
: accountDeletionError;
|
||||
const hasShownAccountDeletionError = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountDeletionErrorCode || hasShownAccountDeletionError.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasShownAccountDeletionError.current = true;
|
||||
|
||||
if (accountDeletionErrorCode === ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE) {
|
||||
toast.error(t("environments.settings.profile.google_sso_account_deletion_requires_setup"), {
|
||||
id: "account-deletion-sso-reauth-error",
|
||||
});
|
||||
} else {
|
||||
toast.error(t("environments.settings.profile.sso_reauthentication_failed"), {
|
||||
id: "account-deletion-sso-reauth-error",
|
||||
});
|
||||
}
|
||||
|
||||
const url = new URL(globalThis.location.href);
|
||||
url.searchParams.delete(ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM);
|
||||
globalThis.history.replaceState(null, "", url.toString());
|
||||
}, [accountDeletionErrorCode, t]);
|
||||
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
@@ -70,7 +32,6 @@ export const DeleteAccount = ({
|
||||
return (
|
||||
<div>
|
||||
<DeleteAccountModal
|
||||
requiresPasswordConfirmation={requiresPasswordConfirmation}
|
||||
open={isModalOpen}
|
||||
setOpen={setModalOpen}
|
||||
user={user}
|
||||
|
||||
+87
-1
@@ -1,6 +1,12 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { getIsEmailUnique } from "./user";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib/utils";
|
||||
import { getIsEmailUnique, verifyUserPassword } from "./user";
|
||||
|
||||
vi.mock("@/modules/auth/lib/utils", () => ({
|
||||
verifyPassword: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
@@ -11,12 +17,92 @@ vi.mock("@formbricks/database", () => ({
|
||||
}));
|
||||
|
||||
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
|
||||
const mockVerifyPasswordUtil = vi.mocked(mockVerifyPasswordImported);
|
||||
|
||||
describe("User Library Tests", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("verifyUserPassword", () => {
|
||||
const userId = "test-user-id";
|
||||
const password = "test-password";
|
||||
|
||||
test("should return true for correct password", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: "hashed-password",
|
||||
identityProvider: "email",
|
||||
} as any);
|
||||
mockVerifyPasswordUtil.mockResolvedValue(true);
|
||||
|
||||
const result = await verifyUserPassword(userId, password);
|
||||
expect(result).toBe(true);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
|
||||
});
|
||||
|
||||
test("should return false for incorrect password", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: "hashed-password",
|
||||
identityProvider: "email",
|
||||
} as any);
|
||||
mockVerifyPasswordUtil.mockResolvedValue(false);
|
||||
|
||||
const result = await verifyUserPassword(userId, password);
|
||||
expect(result).toBe(false);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError if user not found", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(ResourceNotFoundError);
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(`user with ID ${userId} not found`);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError if identityProvider is not email", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: "hashed-password",
|
||||
identityProvider: "google", // Not 'email'
|
||||
} as any);
|
||||
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError if password is not set for email provider", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: null, // Password not set
|
||||
identityProvider: "email",
|
||||
} as any);
|
||||
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIsEmailUnique", () => {
|
||||
const email = "test@example.com";
|
||||
|
||||
|
||||
+37
@@ -1,5 +1,42 @@
|
||||
import { User } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { verifyPassword } from "@/modules/auth/lib/utils";
|
||||
|
||||
export const getUserById = reactCache(
|
||||
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
password: true,
|
||||
identityProvider: true,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
throw new ResourceNotFoundError("user", userId);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
);
|
||||
|
||||
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
|
||||
const user = await getUserById(userId);
|
||||
|
||||
if (user.identityProvider !== "email" || !user.password) {
|
||||
throw new InvalidInputError("Password is not set for this user");
|
||||
}
|
||||
|
||||
const isCorrectPassword = await verifyPassword(password, user.password);
|
||||
|
||||
if (!isCorrectPassword) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getIsEmailUnique = reactCache(async (email: string): Promise<boolean> => {
|
||||
const user = await prisma.user.findUnique({
|
||||
|
||||
@@ -5,7 +5,6 @@ import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABL
|
||||
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { requiresPasswordConfirmationForAccountDeletion } from "@/modules/account/lib/account-deletion-auth";
|
||||
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
@@ -16,14 +15,10 @@ import { SettingsCard } from "../../components/SettingsCard";
|
||||
import { DeleteAccount } from "./components/DeleteAccount";
|
||||
import { EditProfileDetailsForm } from "./components/EditProfileDetailsForm";
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ environmentId: string }>;
|
||||
searchParams: Promise<{ accountDeletionError?: string | string[] }>;
|
||||
}) => {
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const isTwoFactorAuthEnabled = await getIsTwoFactorAuthEnabled();
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
const params = await props.params;
|
||||
const searchParams = await props.searchParams;
|
||||
const t = await getTranslate();
|
||||
const { environmentId } = params;
|
||||
|
||||
@@ -38,7 +33,6 @@ const Page = async (props: {
|
||||
}
|
||||
|
||||
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
|
||||
const requiresPasswordConfirmation = requiresPasswordConfirmationForAccountDeletion(user);
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
@@ -96,8 +90,6 @@ const Page = async (props: {
|
||||
user={user}
|
||||
organizationsWithSingleOwner={organizationsWithSingleOwner}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
accountDeletionError={searchParams.accountDeletionError}
|
||||
requiresPasswordConfirmation={requiresPasswordConfirmation}
|
||||
/>
|
||||
</SettingsCard>
|
||||
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
|
||||
|
||||
+11
-8
@@ -3,22 +3,25 @@
|
||||
import { InboxIcon, PresentationIcon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
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 = ({ activeId }: SurveyAnalysisNavigationProps) => {
|
||||
export const SurveyAnalysisNavigation = ({
|
||||
environmentId,
|
||||
survey,
|
||||
activeId,
|
||||
}: SurveyAnalysisNavigationProps) => {
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslation();
|
||||
const { environment } = useEnvironment();
|
||||
const { survey } = useSurvey();
|
||||
|
||||
const url = `/environments/${environment.id}/surveys/${survey.id}`;
|
||||
const url = `/environments/${environmentId}/surveys/${survey.id}`;
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
@@ -28,7 +31,7 @@ export const SurveyAnalysisNavigation = ({ activeId }: SurveyAnalysisNavigationP
|
||||
href: `${url}/summary?referer=true`,
|
||||
current: pathname?.includes("/summary"),
|
||||
onClick: () => {
|
||||
revalidateSurveyIdPath(environment.id, survey.id);
|
||||
revalidateSurveyIdPath(environmentId, survey.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -38,7 +41,7 @@ export const SurveyAnalysisNavigation = ({ activeId }: SurveyAnalysisNavigationP
|
||||
href: `${url}/responses?referer=true`,
|
||||
current: pathname?.includes("/responses"),
|
||||
onClick: () => {
|
||||
revalidateSurveyIdPath(environment.id, survey.id);
|
||||
revalidateSurveyIdPath(environmentId, survey.id);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
+15
-41
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
import React, { createContext, useCallback, useContext, useState } from "react";
|
||||
import {
|
||||
ElementOption,
|
||||
ElementOptions,
|
||||
@@ -30,7 +30,7 @@ interface SelectedFilterOptions {
|
||||
|
||||
export interface DateRange {
|
||||
from: Date | undefined;
|
||||
to?: Date;
|
||||
to?: Date | undefined;
|
||||
}
|
||||
|
||||
interface FilterDateContextProps {
|
||||
@@ -41,8 +41,6 @@ interface FilterDateContextProps {
|
||||
dateRange: DateRange;
|
||||
setDateRange: React.Dispatch<React.SetStateAction<DateRange>>;
|
||||
resetState: () => void;
|
||||
refreshAnalysisData: () => Promise<void>;
|
||||
registerAnalysisRefreshHandler: (handler: () => Promise<void>) => () => void;
|
||||
}
|
||||
|
||||
const ResponseFilterContext = createContext<FilterDateContextProps | undefined>(undefined);
|
||||
@@ -63,7 +61,6 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
|
||||
from: undefined,
|
||||
to: getTodayDate(),
|
||||
});
|
||||
const refreshHandlerRef = useRef<(() => Promise<void>) | null>(null);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setDateRange({
|
||||
@@ -76,43 +73,20 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
|
||||
});
|
||||
}, []);
|
||||
|
||||
const refreshAnalysisData = useCallback(async () => {
|
||||
await refreshHandlerRef.current?.();
|
||||
}, []);
|
||||
|
||||
const registerAnalysisRefreshHandler = useCallback((handler: () => Promise<void>) => {
|
||||
refreshHandlerRef.current = handler;
|
||||
|
||||
return () => {
|
||||
if (refreshHandlerRef.current === handler) {
|
||||
refreshHandlerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
setSelectedFilter,
|
||||
selectedFilter,
|
||||
selectedOptions,
|
||||
setSelectedOptions,
|
||||
dateRange,
|
||||
setDateRange,
|
||||
resetState,
|
||||
refreshAnalysisData,
|
||||
registerAnalysisRefreshHandler,
|
||||
}),
|
||||
[
|
||||
dateRange,
|
||||
refreshAnalysisData,
|
||||
registerAnalysisRefreshHandler,
|
||||
resetState,
|
||||
selectedFilter,
|
||||
selectedOptions,
|
||||
]
|
||||
return (
|
||||
<ResponseFilterContext.Provider
|
||||
value={{
|
||||
setSelectedFilter,
|
||||
selectedFilter,
|
||||
selectedOptions,
|
||||
setSelectedOptions,
|
||||
dateRange,
|
||||
setDateRange,
|
||||
resetState,
|
||||
}}>
|
||||
{children}
|
||||
</ResponseFilterContext.Provider>
|
||||
);
|
||||
|
||||
return <ResponseFilterContext.Provider value={contextValue}>{children}</ResponseFilterContext.Provider>;
|
||||
};
|
||||
|
||||
const useResponseFilter = () => {
|
||||
|
||||
+2
-35
@@ -2,8 +2,6 @@
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponseWithQuotas } from "@formbricks/types/responses";
|
||||
@@ -15,7 +13,6 @@ import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surv
|
||||
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
|
||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
|
||||
interface ResponsePageProps {
|
||||
@@ -49,8 +46,8 @@ export const ResponsePage = ({
|
||||
const [page, setPage] = useState<number | null>(null);
|
||||
const [hasMore, setHasMore] = useState<boolean>(initialResponses.length >= responsesPerPage);
|
||||
const [isFetchingFirstPage, setIsFetchingFirstPage] = useState<boolean>(false);
|
||||
const { selectedFilter, dateRange, resetState, registerAnalysisRefreshHandler } = useResponseFilter();
|
||||
const { t } = useTranslation();
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
|
||||
const filters = useMemo(
|
||||
() => getFormattedFilters(survey, selectedFilter, dateRange),
|
||||
|
||||
@@ -89,34 +86,6 @@ export const ResponsePage = ({
|
||||
setResponses((prev) => prev.map((r) => (r.id === responseId ? updatedResponse : r)));
|
||||
};
|
||||
|
||||
const refetchResponses = useCallback(async () => {
|
||||
setIsFetchingFirstPage(true);
|
||||
|
||||
try {
|
||||
const getResponsesActionResponse = await getResponsesAction({
|
||||
surveyId,
|
||||
limit: responsesPerPage,
|
||||
offset: 0,
|
||||
filterCriteria: filters,
|
||||
});
|
||||
|
||||
if (getResponsesActionResponse?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(getResponsesActionResponse) ?? t("common.something_went_wrong"));
|
||||
}
|
||||
|
||||
const freshResponses = getResponsesActionResponse?.data ?? [];
|
||||
setResponses(freshResponses);
|
||||
setPage(1);
|
||||
setHasMore(freshResponses.length >= responsesPerPage);
|
||||
} finally {
|
||||
setIsFetchingFirstPage(false);
|
||||
}
|
||||
}, [filters, responsesPerPage, surveyId]);
|
||||
|
||||
useEffect(() => {
|
||||
return registerAnalysisRefreshHandler(refetchResponses);
|
||||
}, [refetchResponses, registerAnalysisRefreshHandler]);
|
||||
|
||||
const surveyMemoized = useMemo(() => {
|
||||
return replaceHeadlineRecall(survey, "default");
|
||||
}, [survey]);
|
||||
@@ -165,8 +134,6 @@ export const ResponsePage = ({
|
||||
}
|
||||
};
|
||||
fetchFilteredResponses();
|
||||
// page is intentionally omitted to avoid refetching after the initial page setup.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]);
|
||||
|
||||
return (
|
||||
|
||||
+1
-1
@@ -1,4 +1,5 @@
|
||||
import { TFunction } from "i18next";
|
||||
import { capitalize } from "lodash";
|
||||
import {
|
||||
AirplayIcon,
|
||||
ArrowUpFromDotIcon,
|
||||
@@ -8,7 +9,6 @@ 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) {
|
||||
|
||||
+3
-1
@@ -64,6 +64,8 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
|
||||
pageTitle={survey.name}
|
||||
cta={
|
||||
<SurveyAnalysisCTA
|
||||
environment={environment}
|
||||
survey={survey}
|
||||
isReadOnly={isReadOnly}
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
@@ -74,7 +76,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
|
||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||
/>
|
||||
}>
|
||||
<SurveyAnalysisNavigation activeId="responses" />
|
||||
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" />
|
||||
</PageHeader>
|
||||
<ResponsePage
|
||||
environment={environment}
|
||||
|
||||
+8
-5
@@ -4,13 +4,16 @@ import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { Confetti } from "@/modules/ui/components/confetti";
|
||||
|
||||
export const SuccessMessage = () => {
|
||||
const { environment } = useEnvironment();
|
||||
const { survey } = useSurvey();
|
||||
interface SummaryMetadataProps {
|
||||
environment: TEnvironment;
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
export const SuccessMessage = ({ environment, survey }: SummaryMetadataProps) => {
|
||||
const { t } = useTranslation();
|
||||
const searchParams = useSearchParams();
|
||||
const [confetti, setConfetti] = useState(false);
|
||||
|
||||
+19
-35
@@ -71,7 +71,7 @@ export const SummaryPage = ({
|
||||
const [tab, setTab] = useState<"dropOffs" | "quotas" | "impressions" | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
|
||||
|
||||
const { selectedFilter, dateRange, resetState, registerAnalysisRefreshHandler } = useResponseFilter();
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
|
||||
const [displays, setDisplays] = useState<TDisplayWithContact[]>([]);
|
||||
const [isDisplaysLoading, setIsDisplaysLoading] = useState(false);
|
||||
@@ -111,7 +111,7 @@ export const SummaryPage = ({
|
||||
} finally {
|
||||
setIsDisplaysLoading(false);
|
||||
}
|
||||
}, [fetchDisplays]);
|
||||
}, [fetchDisplays, t]);
|
||||
|
||||
const handleLoadMoreDisplays = useCallback(async () => {
|
||||
try {
|
||||
@@ -131,39 +131,13 @@ export const SummaryPage = ({
|
||||
}
|
||||
}, [tab, loadInitialDisplays]);
|
||||
|
||||
const fetchSummary = useCallback(async () => {
|
||||
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||
const updatedSurveySummary = await getSurveySummaryAction({
|
||||
surveyId,
|
||||
filterCriteria: currentFilters,
|
||||
});
|
||||
|
||||
if (updatedSurveySummary?.serverError) {
|
||||
throw new Error(getFormattedErrorMessage(updatedSurveySummary));
|
||||
}
|
||||
|
||||
setSurveySummary(updatedSurveySummary?.data ?? defaultSurveySummary);
|
||||
}, [dateRange, selectedFilter, survey, surveyId]);
|
||||
|
||||
const refreshSummary = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await Promise.all([fetchSummary(), tab === "impressions" ? loadInitialDisplays() : Promise.resolve()]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [fetchSummary, loadInitialDisplays, tab]);
|
||||
|
||||
useEffect(() => {
|
||||
return registerAnalysisRefreshHandler(refreshSummary);
|
||||
}, [refreshSummary, registerAnalysisRefreshHandler]);
|
||||
|
||||
// Only fetch data when filters change or when there's no initial data
|
||||
useEffect(() => {
|
||||
// If we have initial data and no filters are applied, don't fetch
|
||||
const hasNoFilters =
|
||||
(!selectedFilter || Object.keys(selectedFilter).length === 0 || selectedFilter.filter?.length === 0) &&
|
||||
(!selectedFilter ||
|
||||
Object.keys(selectedFilter).length === 0 ||
|
||||
(selectedFilter.filter && selectedFilter.filter.length === 0)) &&
|
||||
(!dateRange || (!dateRange.from && !dateRange.to));
|
||||
|
||||
if (initialSurveySummary && hasNoFilters) {
|
||||
@@ -171,11 +145,21 @@ export const SummaryPage = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchFilteredSummary = async () => {
|
||||
const fetchSummary = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await fetchSummary();
|
||||
// Recalculate filters inside the effect to ensure we have the latest values
|
||||
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||
let updatedSurveySummary;
|
||||
|
||||
updatedSurveySummary = await getSurveySummaryAction({
|
||||
surveyId,
|
||||
filterCriteria: currentFilters,
|
||||
});
|
||||
|
||||
const surveySummary = updatedSurveySummary?.data ?? defaultSurveySummary;
|
||||
setSurveySummary(surveySummary);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
@@ -183,8 +167,8 @@ export const SummaryPage = ({
|
||||
}
|
||||
};
|
||||
|
||||
fetchFilteredSummary();
|
||||
}, [selectedFilter, dateRange, initialSurveySummary, fetchSummary]);
|
||||
fetchSummary();
|
||||
}, [selectedFilter, dateRange, survey, surveyId, initialSurveySummary]);
|
||||
|
||||
const surveyMemoized = useMemo(() => {
|
||||
return replaceHeadlineRecall(survey, "default");
|
||||
|
||||
+11
-29
@@ -1,18 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { BellRing, Eye, ListRestart, RefreshCcwIcon, SquarePenIcon } from "lucide-react";
|
||||
import { BellRing, Eye, ListRestart, SquarePenIcon } from "lucide-react";
|
||||
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 { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-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,6 +23,8 @@ import { IconBar } from "@/modules/ui/components/iconbar";
|
||||
import { resetSurveyAction } from "../actions";
|
||||
|
||||
interface SurveyAnalysisCTAProps {
|
||||
survey: TSurvey;
|
||||
environment: TEnvironment;
|
||||
isReadOnly: boolean;
|
||||
user: TUser;
|
||||
publicDomain: string;
|
||||
@@ -39,6 +41,8 @@ interface ModalState {
|
||||
}
|
||||
|
||||
export const SurveyAnalysisCTA = ({
|
||||
survey,
|
||||
environment,
|
||||
isReadOnly,
|
||||
user,
|
||||
publicDomain,
|
||||
@@ -59,12 +63,9 @@ export const SurveyAnalysisCTA = ({
|
||||
});
|
||||
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const { environment, project } = useEnvironment();
|
||||
const { survey } = useSurvey();
|
||||
const { project } = useEnvironment();
|
||||
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
|
||||
const { refreshAnalysisData } = useResponseFilter();
|
||||
|
||||
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
||||
|
||||
@@ -76,7 +77,7 @@ export const SurveyAnalysisCTA = ({
|
||||
}, [searchParams]);
|
||||
|
||||
const handleShareModalToggle = (open: boolean) => {
|
||||
const params = new URLSearchParams(globalThis.location.search);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const currentShareParam = params.get("share") === "true";
|
||||
|
||||
if (open && !currentShareParam) {
|
||||
@@ -146,25 +147,6 @@ export const SurveyAnalysisCTA = ({
|
||||
};
|
||||
|
||||
const iconActions = [
|
||||
{
|
||||
icon: RefreshCcwIcon,
|
||||
tooltip: t("common.refresh"),
|
||||
onClick: async () => {
|
||||
if (isRefreshing) return;
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await refreshAnalysisData();
|
||||
toast.success(t("common.data_refreshed_successfully"));
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : t("common.something_went_wrong");
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
},
|
||||
disabled: isRefreshing,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
icon: BellRing,
|
||||
tooltip: t("environments.surveys.summary.configure_alerts"),
|
||||
@@ -201,7 +183,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 />
|
||||
<SurveyStatusDropdown environment={environment} survey={survey} />
|
||||
)}
|
||||
|
||||
<IconBar actions={iconActions} />
|
||||
@@ -233,7 +215,7 @@ export const SurveyAnalysisCTA = ({
|
||||
projectCustomScripts={project.customHeadScripts}
|
||||
/>
|
||||
)}
|
||||
<SuccessMessage />
|
||||
<SuccessMessage environment={environment} survey={survey} />
|
||||
|
||||
{responseCount > 0 && (
|
||||
<EditPublicSurveyAlertDialog
|
||||
|
||||
+5
-69
@@ -2,7 +2,7 @@
|
||||
|
||||
import DOMPurify from "dompurify";
|
||||
import { CopyIcon, SendIcon } from "lucide-react";
|
||||
import { type SyntheticEvent, useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
@@ -21,7 +21,6 @@ interface EmailTabProps {
|
||||
export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
||||
const [activeTab, setActiveTab] = useState("preview");
|
||||
const [emailHtmlPreview, setEmailHtmlPreview] = useState<string>("");
|
||||
const [previewFrameHeight, setPreviewFrameHeight] = useState(560);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const emailHtml = useMemo(() => {
|
||||
@@ -32,40 +31,6 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
||||
.replaceAll("?preview=true", "");
|
||||
}, [emailHtmlPreview]);
|
||||
|
||||
const sanitizedEmailHtml = useMemo(() => {
|
||||
if (!emailHtmlPreview) return "";
|
||||
return DOMPurify.sanitize(emailHtmlPreview, { ADD_ATTR: ["bgcolor", "target"] });
|
||||
}, [emailHtmlPreview]);
|
||||
|
||||
const emailPreviewDocument = useMemo(() => {
|
||||
if (!sanitizedEmailHtml) return "";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="only light" />
|
||||
<meta name="supported-color-schemes" content="light" />
|
||||
<base target="_blank" />
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: only light;
|
||||
supported-color-schemes: light;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #ffffff;
|
||||
color-scheme: only light;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>${sanitizedEmailHtml}</body>
|
||||
</html>`;
|
||||
}, [sanitizedEmailHtml]);
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: "preview",
|
||||
@@ -86,25 +51,6 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
||||
getData();
|
||||
}, [surveyId]);
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewFrameHeight(560);
|
||||
}, [emailPreviewDocument]);
|
||||
|
||||
const handlePreviewFrameLoad = (event: SyntheticEvent<HTMLIFrameElement>) => {
|
||||
const { contentDocument } = event.currentTarget;
|
||||
if (!contentDocument) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextHeight = Math.max(
|
||||
contentDocument.body.scrollHeight,
|
||||
contentDocument.documentElement.scrollHeight,
|
||||
560
|
||||
);
|
||||
|
||||
setPreviewFrameHeight(nextHeight);
|
||||
};
|
||||
|
||||
const sendPreviewEmail = async () => {
|
||||
try {
|
||||
const val = await sendEmbedSurveyPreviewEmailAction({ surveyId });
|
||||
@@ -127,9 +73,7 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
||||
if (activeTab === "preview") {
|
||||
return (
|
||||
<div className="space-y-4 pb-4">
|
||||
<div
|
||||
className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4"
|
||||
data-testid="survey-email-preview-shell">
|
||||
<div className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4">
|
||||
<div className="mb-6 flex gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-red-500" />
|
||||
<div className="h-3 w-3 rounded-full bg-amber-500" />
|
||||
@@ -143,17 +87,9 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
||||
{t("environments.surveys.share.send_email.email_subject_label")} :{" "}
|
||||
{t("environments.surveys.share.send_email.formbricks_email_survey_preview")}
|
||||
</div>
|
||||
<div data-testid="survey-email-preview-content">
|
||||
{emailPreviewDocument ? (
|
||||
<iframe
|
||||
className="mt-2 w-full rounded-md border-0 bg-white"
|
||||
data-testid="survey-email-preview-frame"
|
||||
onLoad={handlePreviewFrameLoad}
|
||||
sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin"
|
||||
srcDoc={emailPreviewDocument}
|
||||
style={{ height: `${previewFrameHeight}px` }}
|
||||
title={t("environments.surveys.share.send_email.email_preview_tab")}
|
||||
/>
|
||||
<div className="p-2">
|
||||
{emailHtml ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(emailHtml) }} />
|
||||
) : (
|
||||
<LoadingSpinner />
|
||||
)}
|
||||
|
||||
+3
-18
@@ -16,19 +16,13 @@ export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => {
|
||||
const [embedModeEnabled, setEmbedModeEnabled] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
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}"
|
||||
const iframeCode = `<div style="position: relative; height:80dvh; overflow:auto;">
|
||||
<iframe
|
||||
src="${surveyUrl}${embedModeEnabled ? "?embed=true" : ""}"
|
||||
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>
|
||||
@@ -54,15 +48,6 @@ 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { extractEmailBodyFragment } from "./emailTemplateFragment";
|
||||
|
||||
describe("extractEmailBodyFragment", () => {
|
||||
test("returns the body contents for rendered email documents", () => {
|
||||
const html = `
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html>
|
||||
<head>
|
||||
<style>.foo { color: red; }</style>
|
||||
</head>
|
||||
<body class="email-body">
|
||||
<table>
|
||||
<tr>
|
||||
<td>Preview content</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
expect(extractEmailBodyFragment(html)).toBe(
|
||||
"<table>\n <tr>\n <td>Preview content</td>\n </tr>\n </table>"
|
||||
);
|
||||
});
|
||||
|
||||
test("removes document-level tags from rendered survey email markup", () => {
|
||||
const fragment = extractEmailBodyFragment(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>.foo { color: red; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<table>
|
||||
<tr>
|
||||
<td>Which fruits do you like</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
expect(fragment).toBe(
|
||||
"<table>\n <tr>\n <td>Which fruits do you like</td>\n </tr>\n </table>"
|
||||
);
|
||||
expect(fragment).not.toMatch(/<!DOCTYPE|<html|<head|<body/i);
|
||||
});
|
||||
|
||||
test("falls back to the original markup when no body tag exists", () => {
|
||||
expect(extractEmailBodyFragment("<div>Preview content</div>")).toBe("<div>Preview content</div>");
|
||||
});
|
||||
|
||||
test("removes React server markers from rendered fragments", () => {
|
||||
expect(extractEmailBodyFragment("<body><!--$--><div>Preview content</div><!--/$--></body>")).toBe(
|
||||
"<div>Preview content</div>"
|
||||
);
|
||||
});
|
||||
});
|
||||
+4
-2
@@ -5,7 +5,6 @@ import { getSurvey } from "@/lib/survey/service";
|
||||
import { getStyling } from "@/lib/utils/styling";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
|
||||
import { extractEmailBodyFragment } from "./emailTemplateFragment";
|
||||
|
||||
export const getEmailTemplateHtml = async (surveyId: string, locale: string) => {
|
||||
const t = await getTranslate();
|
||||
@@ -21,6 +20,9 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
|
||||
const styling = getStyling(project, survey);
|
||||
const surveyUrl = getPublicDomain() + "/s/" + survey.id;
|
||||
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
|
||||
const doctype =
|
||||
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
|
||||
const htmlCleaned = html.toString().replace(doctype, "");
|
||||
|
||||
return extractEmailBodyFragment(html.toString());
|
||||
return htmlCleaned;
|
||||
};
|
||||
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
const EMAIL_DOCTYPE_PATTERN = /<!DOCTYPE[^>]*>/i;
|
||||
const EMAIL_BODY_PATTERN = /<body\b[^>]*>([\s\S]*?)<\/body>/i;
|
||||
const EMAIL_REACT_SERVER_MARKER_PATTERN = /<!--\/?\$-->/g;
|
||||
|
||||
export const extractEmailBodyFragment = (html: string): string => {
|
||||
const htmlWithoutDoctype = html.replace(EMAIL_DOCTYPE_PATTERN, "").trim();
|
||||
const bodyMatch = EMAIL_BODY_PATTERN.exec(htmlWithoutDoctype);
|
||||
const fragment = bodyMatch?.[1].trim() ?? htmlWithoutDoctype;
|
||||
|
||||
return fragment.replaceAll(EMAIL_REACT_SERVER_MARKER_PATTERN, "").trim();
|
||||
};
|
||||
+3
-1
@@ -66,6 +66,8 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
pageTitle={survey.name}
|
||||
cta={
|
||||
<SurveyAnalysisCTA
|
||||
environment={environment}
|
||||
survey={survey}
|
||||
isReadOnly={isReadOnly}
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
@@ -76,7 +78,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||
/>
|
||||
}>
|
||||
<SurveyAnalysisNavigation activeId="summary" />
|
||||
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="summary" />
|
||||
</PageHeader>
|
||||
<SummaryPage
|
||||
environment={environment}
|
||||
|
||||
+16
-5
@@ -3,9 +3,8 @@
|
||||
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 {
|
||||
@@ -17,9 +16,17 @@ import {
|
||||
} from "@/modules/ui/components/select";
|
||||
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
|
||||
|
||||
export const SurveyStatusDropdown = () => {
|
||||
const { environment } = useEnvironment();
|
||||
const { survey } = useSurvey();
|
||||
interface SurveyStatusDropdownProps {
|
||||
environment: TEnvironment;
|
||||
updateLocalSurveyStatus?: (status: TSurvey["status"]) => void;
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
export const SurveyStatusDropdown = ({
|
||||
environment,
|
||||
updateLocalSurveyStatus,
|
||||
survey,
|
||||
}: SurveyStatusDropdownProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -39,6 +46,10 @@ export const SurveyStatusDropdown = () => {
|
||||
toast.success(toastMessage);
|
||||
}
|
||||
|
||||
if (updateLocalSurveyStatus) {
|
||||
updateLocalSurveyStatus(resultingStatus);
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse);
|
||||
|
||||
-4
@@ -18,7 +18,6 @@ interface AirtableWrapperProps {
|
||||
isEnabled: boolean;
|
||||
webAppUrl: string;
|
||||
locale: TUserLocale;
|
||||
showReconnectButton?: boolean;
|
||||
}
|
||||
|
||||
export const AirtableWrapper = ({
|
||||
@@ -29,7 +28,6 @@ export const AirtableWrapper = ({
|
||||
isEnabled,
|
||||
webAppUrl,
|
||||
locale,
|
||||
showReconnectButton = false,
|
||||
}: AirtableWrapperProps) => {
|
||||
const [isConnected, setIsConnected] = useState(
|
||||
airtableIntegration ? airtableIntegration.config?.key : false
|
||||
@@ -51,8 +49,6 @@ export const AirtableWrapper = ({
|
||||
setIsConnected={setIsConnected}
|
||||
surveys={surveys}
|
||||
locale={locale}
|
||||
showReconnectButton={showReconnectButton}
|
||||
handleAirtableAuthorization={handleAirtableAuthorization}
|
||||
/>
|
||||
) : (
|
||||
<ConnectIntegration
|
||||
|
||||
+9
-38
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
|
||||
import { Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -12,11 +12,9 @@ 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 {
|
||||
@@ -26,20 +24,10 @@ interface ManageIntegrationProps {
|
||||
surveys: TSurvey[];
|
||||
airtableArray: TIntegrationItem[];
|
||||
locale: TUserLocale;
|
||||
showReconnectButton: boolean;
|
||||
handleAirtableAuthorization: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const ManageIntegration = ({
|
||||
airtableIntegration,
|
||||
environmentId,
|
||||
setIsConnected,
|
||||
surveys,
|
||||
airtableArray,
|
||||
showReconnectButton,
|
||||
handleAirtableAuthorization,
|
||||
locale,
|
||||
}: ManageIntegrationProps) => {
|
||||
export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
const { airtableIntegration, environmentId, setIsConnected, surveys, airtableArray } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tableHeaders = [
|
||||
@@ -85,34 +73,15 @@ export const ManageIntegration = ({
|
||||
: { isEditMode: false as const };
|
||||
return (
|
||||
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
|
||||
{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">
|
||||
<div className="flex w-full justify-end gap-x-6">
|
||||
<div className="flex items-center">
|
||||
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
|
||||
<span className="text-slate-500">
|
||||
<span className="cursor-pointer 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);
|
||||
@@ -153,7 +122,9 @@ export const ManageIntegration = ({
|
||||
<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(), locale)}</div>
|
||||
<div className="col-span-2 text-center">
|
||||
{timeSince(data.createdAt.toString(), props.locale)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+1
-9
@@ -1,5 +1,4 @@
|
||||
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";
|
||||
@@ -32,14 +31,8 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
);
|
||||
|
||||
let airtableArray: TIntegrationItem[] = [];
|
||||
let isTokenValid = true;
|
||||
if (airtableIntegration?.config.key) {
|
||||
try {
|
||||
airtableArray = await getAirtableTables(params.environmentId);
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to load Airtable bases — token may be expired or revoked");
|
||||
isTokenValid = false;
|
||||
}
|
||||
airtableArray = await getAirtableTables(params.environmentId);
|
||||
}
|
||||
if (isReadOnly) {
|
||||
return redirect("./");
|
||||
@@ -58,7 +51,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
surveys={surveys}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
locale={locale ?? DEFAULT_LOCALE}
|
||||
showReconnectButton={!isTokenValid}
|
||||
/>
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
|
||||
-160
@@ -1,160 +0,0 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
|
||||
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
|
||||
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { completeAccountDeletionSsoReauthenticationAndGetRedirectPath } from "./account-deletion-sso-complete";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jwt", () => ({
|
||||
verifyAccountDeletionSsoReauthIntent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/account/lib/account-deletion", () => ({
|
||||
deleteUserWithAccountDeletionAuthorization: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/authOptions", () => ({
|
||||
authOptions: {},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
queueAuditEventBackground: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockGetServerSession = vi.mocked(getServerSession);
|
||||
const mockLoggerError = vi.mocked(logger.error);
|
||||
const mockVerifyAccountDeletionSsoReauthIntent = vi.mocked(verifyAccountDeletionSsoReauthIntent);
|
||||
const mockDeleteUserWithAccountDeletionAuthorization = vi.mocked(deleteUserWithAccountDeletionAuthorization);
|
||||
const mockQueueAuditEventBackground = vi.mocked(queueAuditEventBackground);
|
||||
|
||||
const intent = {
|
||||
id: "intent-id",
|
||||
email: "delete-user@example.com",
|
||||
provider: "google",
|
||||
providerAccountId: "google-account-id",
|
||||
purpose: "account_deletion_sso_reauth" as const,
|
||||
returnToUrl: "http://localhost:3000/environments/env-id/settings/profile",
|
||||
userId: "user-id",
|
||||
};
|
||||
|
||||
describe("completeAccountDeletionSsoReauthenticationAndGetRedirectPath", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
user: {
|
||||
email: intent.email,
|
||||
id: intent.userId,
|
||||
},
|
||||
} as any);
|
||||
mockDeleteUserWithAccountDeletionAuthorization.mockResolvedValue({
|
||||
oldUser: { id: intent.userId } as any,
|
||||
});
|
||||
mockQueueAuditEventBackground.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
test("returns login without deleting when the callback has no intent", async () => {
|
||||
await expect(completeAccountDeletionSsoReauthenticationAndGetRedirectPath({})).resolves.toBe(
|
||||
"/auth/login"
|
||||
);
|
||||
|
||||
expect(mockVerifyAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
|
||||
expect(mockQueueAuditEventBackground).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("deletes the account after a completed SSO reauthentication", async () => {
|
||||
await expect(
|
||||
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
|
||||
).resolves.toBe("/auth/login");
|
||||
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalledWith({
|
||||
confirmationEmail: intent.email,
|
||||
userEmail: intent.email,
|
||||
userId: intent.userId,
|
||||
});
|
||||
expect(mockQueueAuditEventBackground).toHaveBeenCalledWith({
|
||||
action: "deleted",
|
||||
targetType: "user",
|
||||
userId: intent.userId,
|
||||
userType: "user",
|
||||
targetId: intent.userId,
|
||||
organizationId: "unknown",
|
||||
oldObject: { id: intent.userId },
|
||||
status: "success",
|
||||
});
|
||||
});
|
||||
|
||||
test("does not delete when the callback session does not match the intent user", async () => {
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
user: {
|
||||
email: "other@example.com",
|
||||
id: "other-user-id",
|
||||
},
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
|
||||
).resolves.toBe("/environments/env-id/settings/profile");
|
||||
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
|
||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||
{ error: expect.any(AuthorizationError) },
|
||||
"Failed to complete account deletion after SSO reauth"
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps the post-deletion redirect if audit logging fails after deletion", async () => {
|
||||
mockQueueAuditEventBackground.mockRejectedValue(new Error("audit unavailable"));
|
||||
|
||||
await expect(
|
||||
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
|
||||
).resolves.toBe("/auth/login");
|
||||
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalled();
|
||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||
{ error: expect.any(Error) },
|
||||
"Failed to complete account deletion after SSO reauth"
|
||||
);
|
||||
});
|
||||
|
||||
test("falls back to login when the intent return URL is not allowed", async () => {
|
||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue({
|
||||
...intent,
|
||||
returnToUrl: "https://evil.example/settings/profile",
|
||||
});
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
user: {
|
||||
email: "other@example.com",
|
||||
id: "other-user-id",
|
||||
},
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: ["intent-token"] })
|
||||
).resolves.toBe("/auth/login");
|
||||
|
||||
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
import "server-only";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@/lib/constants";
|
||||
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
|
||||
import { getValidatedCallbackUrl } from "@/lib/utils/url";
|
||||
import { FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL } from "@/modules/account/constants";
|
||||
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
|
||||
type TAccountDeletionSsoCompleteSearchParams = {
|
||||
intent?: string | string[];
|
||||
};
|
||||
|
||||
const getIntentToken = (intent: string | string[] | undefined) => {
|
||||
if (Array.isArray(intent)) {
|
||||
return intent[0];
|
||||
}
|
||||
|
||||
return intent;
|
||||
};
|
||||
|
||||
const getSafeRedirectPath = (returnToUrl: string) => {
|
||||
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL);
|
||||
|
||||
if (!validatedReturnToUrl) {
|
||||
return "/auth/login";
|
||||
}
|
||||
|
||||
const parsedReturnToUrl = new URL(validatedReturnToUrl);
|
||||
return `${parsedReturnToUrl.pathname}${parsedReturnToUrl.search}${parsedReturnToUrl.hash}`;
|
||||
};
|
||||
|
||||
const getPostDeletionRedirectPath = () =>
|
||||
IS_FORMBRICKS_CLOUD ? FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL : "/auth/login";
|
||||
|
||||
export const completeAccountDeletionSsoReauthenticationAndGetRedirectPath = async ({
|
||||
intent,
|
||||
}: TAccountDeletionSsoCompleteSearchParams): Promise<string> => {
|
||||
const intentToken = getIntentToken(intent);
|
||||
let redirectPath = "/auth/login";
|
||||
|
||||
if (!intentToken) {
|
||||
return redirectPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const verifiedIntent = verifyAccountDeletionSsoReauthIntent(intentToken);
|
||||
redirectPath = getSafeRedirectPath(verifiedIntent.returnToUrl);
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session?.user?.id || !session.user.email || session.user.id !== verifiedIntent.userId) {
|
||||
throw new AuthorizationError("Account deletion SSO reauthentication session mismatch");
|
||||
}
|
||||
|
||||
logger.info({ userId: session.user.id }, "Completing account deletion after SSO reauth");
|
||||
const { oldUser } = await deleteUserWithAccountDeletionAuthorization({
|
||||
confirmationEmail: verifiedIntent.email,
|
||||
userEmail: session.user.email,
|
||||
userId: session.user.id,
|
||||
});
|
||||
redirectPath = getPostDeletionRedirectPath();
|
||||
await queueAuditEventBackground({
|
||||
action: "deleted",
|
||||
targetType: "user",
|
||||
userId: session.user.id,
|
||||
userType: "user",
|
||||
targetId: session.user.id,
|
||||
organizationId: UNKNOWN_DATA,
|
||||
oldObject: oldUser,
|
||||
status: "success",
|
||||
});
|
||||
logger.info({ userId: session.user.id }, "Completed account deletion after SSO reauth");
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Failed to complete account deletion after SSO reauth");
|
||||
}
|
||||
|
||||
return redirectPath;
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { completeAccountDeletionSsoReauthenticationAndGetRedirectPath } from "./lib/account-deletion-sso-complete";
|
||||
|
||||
export default async function AccountDeletionSsoReauthCompletePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ intent?: string | string[] }>;
|
||||
}) {
|
||||
redirect(await completeAccountDeletionSsoReauthenticationAndGetRedirectPath(await searchParams));
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry
|
||||
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { CRON_SECRET, DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS, POSTHOG_KEY } from "@/lib/constants";
|
||||
import { CRON_SECRET, POSTHOG_KEY } from "@/lib/constants";
|
||||
import { generateStandardWebhookSignature } from "@/lib/crypto";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
@@ -91,15 +91,10 @@ export const POST = async (request: Request) => {
|
||||
const webhooks: Webhook[] = await getWebhooksForPipeline(environmentId, event, surveyId);
|
||||
// Prepare webhook and email promises
|
||||
|
||||
// Fetch with timeout of 5 seconds to prevent hanging.
|
||||
// `redirect: "manual"` blocks SSRF via redirect — webhook URLs are validated against private/internal
|
||||
// ranges before delivery, but redirect targets would otherwise bypass that check. Gated on the same
|
||||
// env var as `validateWebhookUrl`: self-hosters who opted into trusting internal URLs also get the
|
||||
// pre-patch redirect-follow behavior for consistency.
|
||||
const redirectMode: RequestRedirect = DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS ? "follow" : "manual";
|
||||
// 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, redirect: redirectMode }),
|
||||
fetch(url, options),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeout)),
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -185,20 +185,4 @@ 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,12 +26,6 @@ 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;
|
||||
|
||||
@@ -123,10 +117,6 @@ 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) {
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,15 +1,9 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
ResourceNotFoundError,
|
||||
UniqueConstraintError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { authenticateRequest, handleErrorResponse } from "./auth";
|
||||
import { authenticateRequest } from "./auth";
|
||||
|
||||
vi.mock("@/modules/organization/settings/api-keys/lib/api-key", () => ({
|
||||
getApiKeyWithPermissions: vi.fn(),
|
||||
@@ -199,53 +193,3 @@ describe("authenticateRequest", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleErrorResponse", () => {
|
||||
test("returns 401 notAuthenticated for 'NotAuthenticated' message", async () => {
|
||||
const response = handleErrorResponse(new Error("NotAuthenticated"));
|
||||
expect(response.status).toBe(401);
|
||||
const body = await response.json();
|
||||
expect(body.code).toBe("not_authenticated");
|
||||
});
|
||||
|
||||
test("returns 401 unauthorized for 'Unauthorized' message", async () => {
|
||||
const response = handleErrorResponse(new Error("Unauthorized"));
|
||||
expect(response.status).toBe(401);
|
||||
const body = await response.json();
|
||||
expect(body.code).toBe("unauthorized");
|
||||
});
|
||||
|
||||
test("returns 409 conflict for UniqueConstraintError", async () => {
|
||||
const response = handleErrorResponse(new UniqueConstraintError("Action with name foo already exists"));
|
||||
expect(response.status).toBe(409);
|
||||
const body = await response.json();
|
||||
expect(body.code).toBe("conflict");
|
||||
expect(body.message).toBe("Action with name foo already exists");
|
||||
});
|
||||
|
||||
test("returns 400 badRequest for DatabaseError", async () => {
|
||||
const response = handleErrorResponse(new DatabaseError("db boom"));
|
||||
expect(response.status).toBe(400);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("db boom");
|
||||
});
|
||||
|
||||
test("returns 400 badRequest for InvalidInputError", async () => {
|
||||
const response = handleErrorResponse(new InvalidInputError("bad input"));
|
||||
expect(response.status).toBe(400);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("bad input");
|
||||
});
|
||||
|
||||
test("returns 400 badRequest for ResourceNotFoundError", async () => {
|
||||
const response = handleErrorResponse(new ResourceNotFoundError("Survey", "id-1"));
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
test("returns 500 internalServerError for unknown errors", async () => {
|
||||
const response = handleErrorResponse(new Error("something else"));
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.message).toBe("Some error occurred");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
ResourceNotFoundError,
|
||||
UniqueConstraintError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
|
||||
@@ -45,9 +40,6 @@ export const handleErrorResponse = (error: any): Response => {
|
||||
case "Unauthorized":
|
||||
return responses.unauthorizedResponse();
|
||||
default:
|
||||
if (error instanceof UniqueConstraintError) {
|
||||
return responses.conflictResponse(error.message);
|
||||
}
|
||||
if (
|
||||
error instanceof DatabaseError ||
|
||||
error instanceof InvalidInputError ||
|
||||
|
||||
@@ -10,8 +10,6 @@ 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";
|
||||
@@ -129,114 +127,6 @@ 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, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { createOrUpdateIntegration } from "@/lib/integration/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
|
||||
@@ -78,16 +78,12 @@ 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: existingData,
|
||||
data: [],
|
||||
email,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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 { getAirtableToken, getTables } from "@/lib/airtable/service";
|
||||
import { getTables } from "@/lib/airtable/service";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { getIntegrationByType } from "@/lib/integration/service";
|
||||
|
||||
@@ -35,7 +36,7 @@ export const GET = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const integration = await getIntegrationByType(environmentId, "airtable");
|
||||
const integration = (await getIntegrationByType(environmentId, "airtable")) as TIntegrationAirtable;
|
||||
|
||||
if (!integration) {
|
||||
return {
|
||||
@@ -43,12 +44,7 @@ export const GET = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
// 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
|
||||
);
|
||||
const tables = await getTables(integration.config.key, baseId.data);
|
||||
return {
|
||||
response: responses.successResponse(tables),
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
@@ -80,11 +80,6 @@ export const POST = withV1ApiWrapper({
|
||||
response: responses.successResponse(actionClass),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof UniqueConstraintError) {
|
||||
return {
|
||||
response: responses.conflictResponse(error.message),
|
||||
};
|
||||
}
|
||||
if (error instanceof DatabaseError) {
|
||||
return {
|
||||
response: responses.badRequestResponse(error.message),
|
||||
|
||||
@@ -4,7 +4,6 @@ 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";
|
||||
|
||||
@@ -177,7 +176,6 @@ const handleSessionAuthentication = async () => {
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: sessionUser.id },
|
||||
select: publicUserSelect,
|
||||
});
|
||||
|
||||
return Response.json(user);
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("parseV3SurveysListQuery", () => {
|
||||
expect(r.invalid_params[0]).toEqual({
|
||||
name: "foo",
|
||||
reason:
|
||||
"Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
|
||||
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("parseV3SurveysListQuery", () => {
|
||||
expect(r.invalid_params[0]).toEqual({
|
||||
name: "after",
|
||||
reason:
|
||||
"Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
|
||||
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -57,7 +57,7 @@ describe("parseV3SurveysListQuery", () => {
|
||||
expect(r.invalid_params[0]).toEqual({
|
||||
name: "name",
|
||||
reason:
|
||||
"Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
|
||||
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -68,20 +68,11 @@ describe("parseV3SurveysListQuery", () => {
|
||||
if (r.ok) {
|
||||
expect(r.limit).toBe(20);
|
||||
expect(r.cursor).toBeNull();
|
||||
expect(r.includeTotalCount).toBe(true);
|
||||
expect(r.sortBy).toBe("updatedAt");
|
||||
expect(r.filterCriteria).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("parses includeTotalCount=false", () => {
|
||||
const r = parseV3SurveysListQuery(params(`workspaceId=${wid}&includeTotalCount=false`));
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.includeTotalCount).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("builds filter from explicit operator params", () => {
|
||||
const r = parseV3SurveysListQuery(
|
||||
params(
|
||||
@@ -111,7 +102,7 @@ describe("parseV3SurveysListQuery", () => {
|
||||
expect(r.invalid_params[0]).toEqual({
|
||||
name: "filter[createdBy][in]",
|
||||
reason:
|
||||
"Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
|
||||
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -28,7 +28,6 @@ const SUPPORTED_QUERY_PARAMS = [
|
||||
"workspaceId",
|
||||
"limit",
|
||||
"cursor",
|
||||
"includeTotalCount",
|
||||
FILTER_NAME_CONTAINS_QUERY_PARAM,
|
||||
FILTER_STATUS_IN_QUERY_PARAM,
|
||||
FILTER_TYPE_IN_QUERY_PARAM,
|
||||
@@ -54,11 +53,6 @@ const ZV3SurveysListQuery = z.object({
|
||||
workspaceId: ZId,
|
||||
limit: z.coerce.number().int().min(1).max(V3_SURVEYS_MAX_LIMIT).default(V3_SURVEYS_DEFAULT_LIMIT),
|
||||
cursor: z.string().min(1).optional(),
|
||||
includeTotalCount: z
|
||||
.enum(["true", "false"])
|
||||
.optional()
|
||||
.transform((value) => value !== "false")
|
||||
.default(true),
|
||||
[FILTER_NAME_CONTAINS_QUERY_PARAM]: z
|
||||
.string()
|
||||
.max(512)
|
||||
@@ -77,7 +71,6 @@ export type TV3SurveysListQueryParseResult =
|
||||
workspaceId: string;
|
||||
limit: number;
|
||||
cursor: TSurveyListPageCursor | null;
|
||||
includeTotalCount: boolean;
|
||||
sortBy: TSurveyListSort;
|
||||
filterCriteria: TSurveyFilterCriteria | undefined;
|
||||
}
|
||||
@@ -118,7 +111,6 @@ export function parseV3SurveysListQuery(searchParams: URLSearchParams): TV3Surve
|
||||
workspaceId: searchParams.get("workspaceId"),
|
||||
limit: searchParams.get("limit") ?? undefined,
|
||||
cursor: searchParams.get("cursor")?.trim() || undefined,
|
||||
includeTotalCount: searchParams.get("includeTotalCount")?.trim() || undefined,
|
||||
[FILTER_NAME_CONTAINS_QUERY_PARAM]: searchParams.get(FILTER_NAME_CONTAINS_QUERY_PARAM) ?? undefined,
|
||||
[FILTER_STATUS_IN_QUERY_PARAM]: statusVals.length > 0 ? statusVals : undefined,
|
||||
[FILTER_TYPE_IN_QUERY_PARAM]: typeVals.length > 0 ? typeVals : undefined,
|
||||
@@ -161,7 +153,6 @@ export function parseV3SurveysListQuery(searchParams: URLSearchParams): TV3Surve
|
||||
workspaceId: q.workspaceId,
|
||||
limit: q.limit,
|
||||
cursor,
|
||||
includeTotalCount: q.includeTotalCount,
|
||||
sortBy,
|
||||
filterCriteria: buildFilterCriteria(q),
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
|
||||
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
|
||||
import { encodeSurveyListPageCursor, getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
|
||||
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
|
||||
import { GET } from "./route";
|
||||
|
||||
const { mockAuthenticateRequest } = vi.hoisted(() => ({
|
||||
@@ -257,29 +257,6 @@ describe("GET /api/v3/surveys", () => {
|
||||
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, undefined);
|
||||
});
|
||||
|
||||
test("skips totalCount when includeTotalCount=false", async () => {
|
||||
vi.mocked(getSurveyListPage).mockResolvedValue({
|
||||
surveys: [],
|
||||
nextCursor: null,
|
||||
});
|
||||
const cursor = encodeSurveyListPageCursor({
|
||||
version: 1,
|
||||
sortBy: "updatedAt",
|
||||
value: "2026-04-15T10:00:00.000Z",
|
||||
id: "survey_1",
|
||||
});
|
||||
|
||||
const req = createRequest(
|
||||
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&cursor=${cursor}&includeTotalCount=false`
|
||||
);
|
||||
const res = await GET(req, {} as any);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.meta).toEqual({ limit: 20, nextCursor: null, totalCount: null });
|
||||
expect(getSurveyCount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("passes filter query to getSurveyListPage", async () => {
|
||||
const filterCriteria = { status: ["inProgress"] };
|
||||
const req = createRequest(
|
||||
|
||||
@@ -46,22 +46,21 @@ export const GET = withV3ApiWrapper({
|
||||
|
||||
const { environmentId } = authResult;
|
||||
|
||||
const surveyPagePromise = getSurveyListPage(environmentId, {
|
||||
limit: parsed.limit,
|
||||
cursor: parsed.cursor,
|
||||
sortBy: parsed.sortBy,
|
||||
filterCriteria: parsed.filterCriteria,
|
||||
});
|
||||
const totalCountPromise = parsed.includeTotalCount
|
||||
? getSurveyCount(environmentId, parsed.filterCriteria)
|
||||
: Promise.resolve(null);
|
||||
const [surveyPage, totalCount] = await Promise.all([surveyPagePromise, totalCountPromise]);
|
||||
const [{ surveys, nextCursor }, totalCount] = await Promise.all([
|
||||
getSurveyListPage(environmentId, {
|
||||
limit: parsed.limit,
|
||||
cursor: parsed.cursor,
|
||||
sortBy: parsed.sortBy,
|
||||
filterCriteria: parsed.filterCriteria,
|
||||
}),
|
||||
getSurveyCount(environmentId, parsed.filterCriteria),
|
||||
]);
|
||||
|
||||
return successListResponse(
|
||||
surveyPage.surveys.map(serializeV3SurveyListItem),
|
||||
surveys.map(serializeV3SurveyListItem),
|
||||
{
|
||||
limit: parsed.limit,
|
||||
nextCursor: surveyPage.nextCursor,
|
||||
nextCursor,
|
||||
totalCount,
|
||||
},
|
||||
{ requestId, cache: "private, no-store" }
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
"ro-RO",
|
||||
"ru-RU",
|
||||
"sv-SE",
|
||||
"tr-TR",
|
||||
"zh-Hans-CN",
|
||||
"zh-Hant-TW"
|
||||
]
|
||||
|
||||
+14
-8
@@ -170,7 +170,6 @@ checksums:
|
||||
common/created_by: 6775c2fa7d495fea48f1ad816daea93b
|
||||
common/customer_success: 2b0c99a5f57e1d16cf0a998f9bb116c4
|
||||
common/dark_overlay: 173e84b526414dbc70dbf9737e443b60
|
||||
common/data_refreshed_successfully: 85728c61a9e1a16e46af69ddf0dbcda6
|
||||
common/date: 56f41c5d30a76295bb087b20b7bee4c3
|
||||
common/days: c95fe8aedde21a0b5653dbd0b3c58b48
|
||||
common/default: d9c6dc5c412fe94143dfd1d332ec81d4
|
||||
@@ -316,6 +315,7 @@ 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,6 +333,7 @@ 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
|
||||
@@ -347,7 +348,6 @@ checksums:
|
||||
common/quotas_description: a2caa44fa74664b3b6007e813f31a754
|
||||
common/read_docs: d06513c266fdd9056e0500eab838ebac
|
||||
common/recipients: f90e7f266be3f5a724858f21a9fd855e
|
||||
common/refresh: c0aec3f31be4c984bae9a482572d2857
|
||||
common/remove: dba2fe5fe9f83f8078c687f28cba4b52
|
||||
common/remove_from_team: 69bcc7a1001c3017f9de578ee22cffd6
|
||||
common/reorder_and_hide_columns: a5e3d7c0c7ef879211d05a37be1c5069
|
||||
@@ -468,6 +468,7 @@ 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
|
||||
@@ -640,6 +641,8 @@ checksums:
|
||||
environments/contacts/attributes_msg_new_attribute_created: 5cba6158c4305c05104814ec1479267c
|
||||
environments/contacts/attributes_msg_userid_already_exists: 9c695538befc152806c460f52a73821a
|
||||
environments/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa
|
||||
environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
|
||||
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
|
||||
environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c
|
||||
environments/contacts/create_new_attribute: c17d407dacd0b90f360f9f5e899d662f
|
||||
environments/contacts/create_new_attribute_description: cc19d76bb6940537bbe3461191f25d26
|
||||
@@ -789,9 +792,6 @@ 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
|
||||
@@ -1137,7 +1137,7 @@ checksums:
|
||||
environments/settings/general/organization_invite_link_ready: e54b37c4ec2e5a9ea9f6bc6e5b512b0b
|
||||
environments/settings/general/organization_name: 73c9b31c9032a22bd84a07881942bb04
|
||||
environments/settings/general/organization_name_description: ff517b4749a332b94a26110d7c7e771f
|
||||
environments/settings/general/organization_name_placeholder: abcee7d91a848e573b63a763cfaf6a08
|
||||
environments/settings/general/organization_name_placeholder: fc91de3ddc89ab77f30d555778312380
|
||||
environments/settings/general/organization_name_updated_successfully: d36ba3a4f614d30b10e696d25da22432
|
||||
environments/settings/general/organization_settings: d31952131ad5f0ec72ad96f1ed11bef6
|
||||
environments/settings/general/please_add_a_logo: 66d6f97a2e7b27efc04bd653240ee813
|
||||
@@ -1192,7 +1192,6 @@ checksums:
|
||||
environments/settings/profile/update_personal_info: 5806f9bae0248a604cf85a2d8790a606
|
||||
environments/settings/profile/warning_cannot_delete_account: 07c25c3829149cd7171e7ad88229deac
|
||||
environments/settings/profile/warning_cannot_undo: dd1b2a59ff244b362d1d0d4eb1dbf7c6
|
||||
environments/settings/profile/wrong_password: e3523f78b302d11b33af6cc40d8df9da
|
||||
environments/settings/teams/add_members_description: 96e1e7125a0dfeaecc2c238eda3a216f
|
||||
environments/settings/teams/add_workspaces_description: f0f0cdd4d1032fbcb83d34a780bdfa52
|
||||
environments/settings/teams/all_members_added: 0541be1777b5c838f2e039035488506c
|
||||
@@ -1240,7 +1239,12 @@ 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
|
||||
@@ -1482,7 +1486,7 @@ checksums:
|
||||
environments/surveys/edit/hide_question_settings: 99127cd016db2f7fc80333b36473c0ef
|
||||
environments/surveys/edit/hostname: 9bdaa7692869999df51bb60d58d9ef62
|
||||
environments/surveys/edit/if_you_need_more_please: a7d208c283caf6b93800b809fca80768
|
||||
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 5abd8b702f9fb0e3815c3413d6f8aef6
|
||||
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 31c18a8c7c578db2ba49eed663d1739f
|
||||
environments/surveys/edit/ignore_global_waiting_time: e08db543ace4935625e0961cc6e60489
|
||||
environments/surveys/edit/ignore_global_waiting_time_description: 37d173a4d537622de40677389238d859
|
||||
environments/surveys/edit/image: 048ba7a239de0fbd883ade8558415830
|
||||
@@ -1961,6 +1965,7 @@ 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
|
||||
@@ -2045,6 +2050,7 @@ 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
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import {
|
||||
createActionClass,
|
||||
deleteActionClass,
|
||||
getActionClass,
|
||||
getActionClassByEnvironmentIdAndName,
|
||||
getActionClasses,
|
||||
updateActionClass,
|
||||
} from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
@@ -20,8 +16,6 @@ vi.mock("@formbricks/database", () => ({
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -184,147 +178,4 @@ describe("ActionClass Service", () => {
|
||||
await expect(deleteActionClass("id4")).rejects.toThrow("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createActionClass", () => {
|
||||
const codeInput: TActionClassInput = {
|
||||
name: "Code Action",
|
||||
description: "desc",
|
||||
type: "code",
|
||||
key: "code-action-key",
|
||||
environmentId: "env-create",
|
||||
};
|
||||
|
||||
const buildPrismaUniqueError = (target: string[]) =>
|
||||
Object.assign(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "test",
|
||||
}),
|
||||
{ meta: { target } }
|
||||
);
|
||||
|
||||
test("should create and return the action class", async () => {
|
||||
const created: TActionClass = {
|
||||
id: "id-create",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: codeInput.name,
|
||||
description: codeInput.description ?? null,
|
||||
type: "code",
|
||||
key: codeInput.type === "code" ? codeInput.key : null,
|
||||
noCodeConfig: null,
|
||||
environmentId: codeInput.environmentId,
|
||||
};
|
||||
vi.mocked(prisma.actionClass.create).mockResolvedValue(created as never);
|
||||
|
||||
const result = await createActionClass(codeInput.environmentId, codeInput);
|
||||
expect(result).toEqual(created);
|
||||
});
|
||||
|
||||
test("should throw UniqueConstraintError on P2002 with target field", async () => {
|
||||
vi.mocked(prisma.actionClass.create).mockRejectedValue(buildPrismaUniqueError(["name"]));
|
||||
|
||||
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
|
||||
UniqueConstraintError
|
||||
);
|
||||
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
|
||||
`Action with name ${codeInput.name} already exists`
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw UniqueConstraintError on P2002 even when target is missing", async () => {
|
||||
vi.mocked(prisma.actionClass.create).mockRejectedValue(
|
||||
Object.assign(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "test",
|
||||
}),
|
||||
{ meta: undefined }
|
||||
)
|
||||
);
|
||||
|
||||
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
|
||||
UniqueConstraintError
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError for non-P2002 errors", async () => {
|
||||
vi.mocked(prisma.actionClass.create).mockRejectedValue(new Error("boom"));
|
||||
|
||||
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(DatabaseError);
|
||||
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
|
||||
`Database error when creating an action for environment ${codeInput.environmentId}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateActionClass", () => {
|
||||
const updateInput: Partial<TActionClassInput> = {
|
||||
name: "Renamed Action",
|
||||
description: "updated desc",
|
||||
type: "code",
|
||||
key: "renamed-key",
|
||||
environmentId: "env-update",
|
||||
};
|
||||
|
||||
const buildPrismaUniqueError = (target: string[]) =>
|
||||
Object.assign(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "test",
|
||||
}),
|
||||
{ meta: { target } }
|
||||
);
|
||||
|
||||
test("should update and return the action class", async () => {
|
||||
const updated = {
|
||||
id: "id-update",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: updateInput.name,
|
||||
description: updateInput.description ?? null,
|
||||
type: "code" as const,
|
||||
key: "renamed-key",
|
||||
noCodeConfig: null,
|
||||
environmentId: updateInput.environmentId,
|
||||
surveyTriggers: [],
|
||||
};
|
||||
vi.mocked(prisma.actionClass.update).mockResolvedValue(updated as never);
|
||||
|
||||
const result = await updateActionClass(updateInput.environmentId!, "id-update", updateInput);
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
|
||||
test("should throw UniqueConstraintError on P2002 with target field", async () => {
|
||||
vi.mocked(prisma.actionClass.update).mockRejectedValue(buildPrismaUniqueError(["name"]));
|
||||
|
||||
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
|
||||
UniqueConstraintError
|
||||
);
|
||||
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
|
||||
`Action with name ${updateInput.name} already exists`
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError for other PrismaClientKnownRequestError codes", async () => {
|
||||
vi.mocked(prisma.actionClass.update).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("Record not found", {
|
||||
code: "P2025",
|
||||
clientVersion: "test",
|
||||
})
|
||||
);
|
||||
|
||||
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
|
||||
DatabaseError
|
||||
);
|
||||
});
|
||||
|
||||
test("should rethrow unknown errors", async () => {
|
||||
vi.mocked(prisma.actionClass.update).mockRejectedValue(new Error("boom"));
|
||||
|
||||
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
|
||||
"boom"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
@@ -135,7 +135,7 @@ export const createActionClass = async (
|
||||
error.code === PrismaErrorType.UniqueConstraintViolation
|
||||
) {
|
||||
const targetField = (error.meta?.target as string[] | undefined)?.[0];
|
||||
throw new UniqueConstraintError(
|
||||
throw new DatabaseError(
|
||||
`Action with ${targetField} ${targetField ? (actionClass as Record<string, unknown>)[targetField] : ""} already exists`
|
||||
);
|
||||
}
|
||||
@@ -185,7 +185,7 @@ export const updateActionClass = async (
|
||||
error.code === PrismaErrorType.UniqueConstraintViolation
|
||||
) {
|
||||
const targetField = (error.meta?.target as string[] | undefined)?.[0];
|
||||
throw new UniqueConstraintError(
|
||||
throw new DatabaseError(
|
||||
`Action with ${targetField} ${targetField ? (inputActionClass as Record<string, unknown>)[targetField] : ""} already exists`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import {
|
||||
TIntegrationAirtable,
|
||||
TIntegrationAirtableConfigData,
|
||||
TIntegrationAirtableCredential,
|
||||
ZIntegrationAirtableBases,
|
||||
@@ -23,11 +24,6 @@ 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);
|
||||
};
|
||||
@@ -39,11 +35,6 @@ 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;
|
||||
@@ -87,7 +78,10 @@ export const fetchAirtableAuthToken = async (formData: Record<string, any>) => {
|
||||
|
||||
export const getAirtableToken = async (environmentId: string) => {
|
||||
try {
|
||||
const airtableIntegration = await getIntegrationByType(environmentId, "airtable");
|
||||
const airtableIntegration = (await getIntegrationByType(
|
||||
environmentId,
|
||||
"airtable"
|
||||
)) as TIntegrationAirtable;
|
||||
|
||||
const { access_token, expiry_date, refresh_token } = ZIntegrationAirtableCredential.parse(
|
||||
airtableIntegration?.config.key
|
||||
|
||||
@@ -26,7 +26,6 @@ export const TERMS_URL = env.TERMS_URL;
|
||||
export const IMPRINT_URL = env.IMPRINT_URL;
|
||||
export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
|
||||
|
||||
export const DISABLE_ACCOUNT_DELETION_SSO_REAUTH = env.DISABLE_ACCOUNT_DELETION_SSO_REAUTH === "1";
|
||||
export const DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS = env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS === "1";
|
||||
export const DEBUG_SHOW_RESET_LINK = !IS_PRODUCTION && env.DEBUG_SHOW_RESET_LINK === "1";
|
||||
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
|
||||
@@ -34,7 +33,6 @@ export const PASSWORD_RESET_TOKEN_LIFETIME_MINUTES = env.PASSWORD_RESET_TOKEN_LI
|
||||
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
|
||||
|
||||
export const GOOGLE_OAUTH_ENABLED = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET);
|
||||
export const GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED = env.GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED === "1";
|
||||
export const GITHUB_OAUTH_ENABLED = !!(env.GITHUB_ID && env.GITHUB_SECRET);
|
||||
export const AZURE_OAUTH_ENABLED = !!(env.AZUREAD_CLIENT_ID && env.AZUREAD_CLIENT_SECRET);
|
||||
export const OIDC_OAUTH_ENABLED = !!(env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET && env.OIDC_ISSUER);
|
||||
@@ -184,7 +182,6 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
|
||||
"ro-RO",
|
||||
"ru-RU",
|
||||
"sv-SE",
|
||||
"tr-TR",
|
||||
"zh-Hans-CN",
|
||||
"zh-Hant-TW",
|
||||
];
|
||||
|
||||
@@ -123,7 +123,6 @@ const parsedEnv = createEnv({
|
||||
BREVO_API_KEY: z.string().optional(),
|
||||
BREVO_LIST_ID: z.string().optional(),
|
||||
DATABASE_URL: z.url(),
|
||||
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: z.enum(["1", "0"]).optional(),
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
|
||||
DEBUG: z.enum(["1", "0"]).optional(),
|
||||
DEBUG_SHOW_RESET_LINK: z.enum(["1", "0"]).optional(),
|
||||
@@ -137,7 +136,6 @@ const parsedEnv = createEnv({
|
||||
ENVIRONMENT: z.enum(["production", "staging"]).prefault("production"),
|
||||
GITHUB_ID: z.string().optional(),
|
||||
GITHUB_SECRET: z.string().optional(),
|
||||
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED: z.enum(["1", "0"]).optional(),
|
||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
AI_GCP_PROJECT: z.string().optional(),
|
||||
@@ -269,7 +267,6 @@ const parsedEnv = createEnv({
|
||||
BREVO_LIST_ID: process.env.BREVO_LIST_ID,
|
||||
CRON_SECRET: process.env.CRON_SECRET,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: process.env.DISABLE_ACCOUNT_DELETION_SSO_REAUTH,
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: process.env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS,
|
||||
DEBUG: process.env.DEBUG,
|
||||
DEBUG_SHOW_RESET_LINK: process.env.DEBUG_SHOW_RESET_LINK,
|
||||
@@ -283,7 +280,6 @@ const parsedEnv = createEnv({
|
||||
ENVIRONMENT: process.env.ENVIRONMENT,
|
||||
GITHUB_ID: process.env.GITHUB_ID,
|
||||
GITHUB_SECRET: process.env.GITHUB_SECRET,
|
||||
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED: process.env.GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED,
|
||||
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
|
||||
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
|
||||
AI_GCP_PROJECT: process.env.AI_GCP_PROJECT,
|
||||
|
||||
@@ -213,13 +213,6 @@ export const appLanguages = [
|
||||
native: "Svenska",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "tr-TR",
|
||||
label: {
|
||||
"en-US": "Turkish",
|
||||
native: "Türkçe",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "zh-Hans-CN",
|
||||
label: {
|
||||
|
||||
@@ -5,12 +5,7 @@ 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,
|
||||
TIntegrationByType,
|
||||
TIntegrationInput,
|
||||
ZIntegrationType,
|
||||
} from "@formbricks/types/integration";
|
||||
import { TIntegration, TIntegrationInput, ZIntegrationType } from "@formbricks/types/integration";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
@@ -99,10 +94,7 @@ export const getIntegration = reactCache(async (integrationId: string): Promise<
|
||||
});
|
||||
|
||||
export const getIntegrationByType = reactCache(
|
||||
async <T extends TIntegrationInput["type"]>(
|
||||
environmentId: string,
|
||||
type: T
|
||||
): Promise<TIntegrationByType<T> | null> => {
|
||||
async (environmentId: string, type: TIntegrationInput["type"]): Promise<TIntegration | null> => {
|
||||
validateInputs([environmentId, ZId], [type, ZIntegrationType]);
|
||||
|
||||
try {
|
||||
@@ -114,7 +106,7 @@ export const getIntegrationByType = reactCache(
|
||||
},
|
||||
},
|
||||
});
|
||||
return integration ? (transformIntegration(integration) as TIntegrationByType<T>) : null;
|
||||
return integration ? transformIntegration(integration) : null;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
|
||||
@@ -3,18 +3,14 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import * as crypto from "@/lib/crypto";
|
||||
import {
|
||||
createAccountDeletionSsoReauthIntent,
|
||||
createEmailChangeToken,
|
||||
createEmailToken,
|
||||
createInviteToken,
|
||||
createSsoRelinkIntent,
|
||||
createToken,
|
||||
createTokenForLinkSurvey,
|
||||
getEmailFromEmailToken,
|
||||
verifyAccountDeletionSsoReauthIntent,
|
||||
verifyEmailChangeToken,
|
||||
verifyInviteToken,
|
||||
verifySsoRelinkIntent,
|
||||
verifyToken,
|
||||
verifyTokenForLinkSurvey,
|
||||
} from "./jwt";
|
||||
@@ -384,7 +380,6 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
expect(verified).toEqual({
|
||||
id: mockUser.id, // Returns the decrypted user ID
|
||||
email: mockUser.email,
|
||||
purpose: "email_verification",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -419,7 +414,6 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
expect(verified).toEqual({
|
||||
id: mockUser.id, // Returns the raw ID from payload
|
||||
email: mockUser.email,
|
||||
purpose: "email_verification",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -431,7 +425,6 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
expect(verified).toEqual({
|
||||
id: mockUser.id, // Returns the decrypted user ID
|
||||
email: mockUser.email,
|
||||
purpose: "email_verification",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1011,197 +1004,5 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("account deletion SSO reauthentication intents", () => {
|
||||
const accountDeletionIntent = {
|
||||
id: "intent-id",
|
||||
userId: mockUser.id,
|
||||
email: mockUser.email,
|
||||
provider: "google",
|
||||
providerAccountId: "provider-123",
|
||||
purpose: "account_deletion_sso_reauth" as const,
|
||||
returnToUrl: "http://localhost:3000/environments/env-1/settings/profile",
|
||||
};
|
||||
|
||||
test("round-trips encrypted account deletion reauth intents", () => {
|
||||
const token = createAccountDeletionSsoReauthIntent(accountDeletionIntent);
|
||||
|
||||
expect(verifyAccountDeletionSsoReauthIntent(token)).toEqual(accountDeletionIntent);
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(accountDeletionIntent.id, TEST_ENCRYPTION_KEY);
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(accountDeletionIntent.userId, TEST_ENCRYPTION_KEY);
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(accountDeletionIntent.email, TEST_ENCRYPTION_KEY);
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(
|
||||
accountDeletionIntent.providerAccountId,
|
||||
TEST_ENCRYPTION_KEY
|
||||
);
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(
|
||||
accountDeletionIntent.returnToUrl,
|
||||
TEST_ENCRYPTION_KEY
|
||||
);
|
||||
});
|
||||
|
||||
test("creates account deletion reauth intents with a ten minute default expiry", () => {
|
||||
const token = createAccountDeletionSsoReauthIntent(accountDeletionIntent);
|
||||
const decoded = jwt.decode(token) as any;
|
||||
|
||||
expect(decoded.exp - decoded.iat).toBe(10 * 60);
|
||||
});
|
||||
|
||||
test("rejects account deletion reauth intents with the wrong purpose", () => {
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
|
||||
userId: crypto.symmetricEncrypt(accountDeletionIntent.userId, TEST_ENCRYPTION_KEY),
|
||||
email: crypto.symmetricEncrypt(accountDeletionIntent.email, TEST_ENCRYPTION_KEY),
|
||||
provider: accountDeletionIntent.provider,
|
||||
providerAccountId: crypto.symmetricEncrypt(
|
||||
accountDeletionIntent.providerAccountId,
|
||||
TEST_ENCRYPTION_KEY
|
||||
),
|
||||
purpose: "sso_recovery",
|
||||
returnToUrl: crypto.symmetricEncrypt(accountDeletionIntent.returnToUrl, TEST_ENCRYPTION_KEY),
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
expect(() => verifyAccountDeletionSsoReauthIntent(token)).toThrow(
|
||||
"Token is invalid or missing required fields"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects account deletion reauth intents missing required fields", () => {
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
|
||||
userId: crypto.symmetricEncrypt(accountDeletionIntent.userId, TEST_ENCRYPTION_KEY),
|
||||
email: crypto.symmetricEncrypt(accountDeletionIntent.email, TEST_ENCRYPTION_KEY),
|
||||
provider: accountDeletionIntent.provider,
|
||||
providerAccountId: crypto.symmetricEncrypt(
|
||||
accountDeletionIntent.providerAccountId,
|
||||
TEST_ENCRYPTION_KEY
|
||||
),
|
||||
purpose: accountDeletionIntent.purpose,
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
expect(() => verifyAccountDeletionSsoReauthIntent(token)).toThrow(
|
||||
"Token is invalid or missing required fields"
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects expired account deletion reauth intents", () => {
|
||||
const expiredToken = jwt.sign(
|
||||
{
|
||||
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
|
||||
userId: crypto.symmetricEncrypt(accountDeletionIntent.userId, TEST_ENCRYPTION_KEY),
|
||||
email: crypto.symmetricEncrypt(accountDeletionIntent.email, TEST_ENCRYPTION_KEY),
|
||||
provider: accountDeletionIntent.provider,
|
||||
providerAccountId: crypto.symmetricEncrypt(
|
||||
accountDeletionIntent.providerAccountId,
|
||||
TEST_ENCRYPTION_KEY
|
||||
),
|
||||
purpose: accountDeletionIntent.purpose,
|
||||
returnToUrl: crypto.symmetricEncrypt(accountDeletionIntent.returnToUrl, TEST_ENCRYPTION_KEY),
|
||||
exp: Math.floor(Date.now() / 1000) - 3600,
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
expect(() => verifyAccountDeletionSsoReauthIntent(expiredToken)).toThrow();
|
||||
});
|
||||
|
||||
test("throws when account deletion reauth intent secrets are missing", async () => {
|
||||
await testMissingSecretsError(createAccountDeletionSsoReauthIntent, [accountDeletionIntent]);
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: accountDeletionIntent.id,
|
||||
userId: accountDeletionIntent.userId,
|
||||
email: accountDeletionIntent.email,
|
||||
provider: accountDeletionIntent.provider,
|
||||
providerAccountId: accountDeletionIntent.providerAccountId,
|
||||
purpose: accountDeletionIntent.purpose,
|
||||
returnToUrl: accountDeletionIntent.returnToUrl,
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
await testMissingSecretsError(verifyAccountDeletionSsoReauthIntent, [token]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+5
-193
@@ -1,4 +1,4 @@
|
||||
import jwt, { JwtPayload, SignOptions } from "jsonwebtoken";
|
||||
import jwt, { JwtPayload } from "jsonwebtoken";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ENCRYPTION_KEY, NEXTAUTH_SECRET } from "@/lib/constants";
|
||||
@@ -13,49 +13,7 @@ const decryptWithFallback = (encryptedText: string, key: string): 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;
|
||||
};
|
||||
|
||||
type TAccountDeletionSsoReauthIntentPayload = {
|
||||
id: string;
|
||||
email: string;
|
||||
provider: string;
|
||||
providerAccountId: string;
|
||||
purpose: "account_deletion_sso_reauth";
|
||||
returnToUrl: 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 => {
|
||||
export const createToken = (userId: string, options = {}): string => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
@@ -65,9 +23,7 @@ export const createToken = (userId: string, options: TVerificationTokenOptions =
|
||||
}
|
||||
|
||||
const encryptedUserId = symmetricEncrypt(userId, ENCRYPTION_KEY);
|
||||
const { purpose = DEFAULT_VERIFICATION_TOKEN_PURPOSE, ...jwtOptions } = options;
|
||||
|
||||
return jwt.sign({ id: encryptedUserId, purpose }, NEXTAUTH_SECRET, jwtOptions);
|
||||
return jwt.sign({ id: encryptedUserId }, NEXTAUTH_SECRET, options);
|
||||
};
|
||||
export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
@@ -268,147 +224,7 @@ const getUserEmailForLegacyVerification = async (
|
||||
return { userId: decryptedId, userEmail: foundUser.email };
|
||||
};
|
||||
|
||||
const DEFAULT_SSO_RELINK_INTENT_OPTIONS: SignOptions = {
|
||||
expiresIn: "15m",
|
||||
};
|
||||
|
||||
const DEFAULT_ACCOUNT_DELETION_SSO_REAUTH_INTENT_OPTIONS: SignOptions = {
|
||||
expiresIn: "10m",
|
||||
};
|
||||
|
||||
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 createAccountDeletionSsoReauthIntent = (
|
||||
payload: TAccountDeletionSsoReauthIntentPayload,
|
||||
options: SignOptions = DEFAULT_ACCOUNT_DELETION_SSO_REAUTH_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(
|
||||
{
|
||||
id: symmetricEncrypt(payload.id, ENCRYPTION_KEY),
|
||||
userId: symmetricEncrypt(payload.userId, ENCRYPTION_KEY),
|
||||
email: symmetricEncrypt(payload.email, ENCRYPTION_KEY),
|
||||
provider: payload.provider,
|
||||
providerAccountId: symmetricEncrypt(payload.providerAccountId, ENCRYPTION_KEY),
|
||||
purpose: payload.purpose,
|
||||
returnToUrl: symmetricEncrypt(payload.returnToUrl, ENCRYPTION_KEY),
|
||||
},
|
||||
NEXTAUTH_SECRET,
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
export const verifyAccountDeletionSsoReauthIntent = (
|
||||
token: string
|
||||
): TAccountDeletionSsoReauthIntentPayload => {
|
||||
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 & {
|
||||
id: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
provider: string;
|
||||
providerAccountId: string;
|
||||
purpose: string;
|
||||
returnToUrl: string;
|
||||
};
|
||||
|
||||
if (
|
||||
!payload?.id ||
|
||||
!payload?.userId ||
|
||||
!payload?.email ||
|
||||
!payload?.provider ||
|
||||
!payload?.providerAccountId ||
|
||||
payload?.purpose !== "account_deletion_sso_reauth" ||
|
||||
!payload?.returnToUrl
|
||||
) {
|
||||
throw new Error("Token is invalid or missing required fields");
|
||||
}
|
||||
|
||||
return {
|
||||
id: decryptWithFallback(payload.id, ENCRYPTION_KEY),
|
||||
userId: decryptWithFallback(payload.userId, ENCRYPTION_KEY),
|
||||
email: decryptWithFallback(payload.email, ENCRYPTION_KEY),
|
||||
provider: payload.provider,
|
||||
providerAccountId: decryptWithFallback(payload.providerAccountId, ENCRYPTION_KEY),
|
||||
purpose: "account_deletion_sso_reauth",
|
||||
returnToUrl: decryptWithFallback(payload.returnToUrl, ENCRYPTION_KEY),
|
||||
};
|
||||
};
|
||||
|
||||
export const verifyToken = async (token: string): Promise<TVerifyTokenPayload> => {
|
||||
export const verifyToken = async (token: string): Promise<JwtPayload> => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
@@ -447,11 +263,7 @@ export const verifyToken = async (token: string): Promise<TVerifyTokenPayload> =
|
||||
// Get user email if we don't have it yet
|
||||
userData ??= await getUserEmailForLegacyVerification(token, payload.id);
|
||||
|
||||
return {
|
||||
id: userData.userId,
|
||||
email: userData.userEmail,
|
||||
purpose: getVerificationTokenPurpose(payload.purpose),
|
||||
};
|
||||
return { id: userData.userId, email: userData.userEmail };
|
||||
};
|
||||
|
||||
export const verifyInviteToken = (token: string): { inviteId: string; email: string } => {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { TIntegrationNotionConfig, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
|
||||
import {
|
||||
TIntegrationNotion,
|
||||
TIntegrationNotionConfig,
|
||||
TIntegrationNotionDatabase,
|
||||
} from "@formbricks/types/integration/notion";
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
import { symmetricDecrypt } from "@/lib/crypto";
|
||||
import { getIntegrationByType } from "../integration/service";
|
||||
@@ -25,7 +29,7 @@ const fetchPages = async (config: TIntegrationNotionConfig) => {
|
||||
export const getNotionDatabases = async (environmentId: string): Promise<TIntegrationNotionDatabase[]> => {
|
||||
let results: TIntegrationNotionDatabase[] = [];
|
||||
try {
|
||||
const notionIntegration = await getIntegrationByType(environmentId, "notion");
|
||||
const notionIntegration = (await getIntegrationByType(environmentId, "notion")) as TIntegrationNotion;
|
||||
if (notionIntegration && notionIntegration.config?.key.bot_id) {
|
||||
results = await fetchPages(notionIntegration.config);
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ const mapOrganizationBilling = (billing: TOrganizationWithBilling["billing"]): T
|
||||
stripeCustomerId: billing.stripeCustomerId,
|
||||
limits: billing.limits,
|
||||
usageCycleAnchor: billing.usageCycleAnchor,
|
||||
...(billing.stripe == null ? {} : { stripe: billing.stripe }),
|
||||
...(billing.stripe === undefined ? {} : { stripe: billing.stripe }),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
const structuredCloneExport = globalThis.structuredClone;
|
||||
import structuredClonePolyfill from "@ungap/structured-clone";
|
||||
|
||||
const structuredCloneExport =
|
||||
typeof structuredClone === "undefined" ? structuredClonePolyfill : structuredClone;
|
||||
|
||||
export { structuredCloneExport as structuredClone };
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getFeatureFlag: vi.fn(),
|
||||
posthog: {
|
||||
__loaded: false,
|
||||
getFeatureFlag: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("posthog-js", () => ({
|
||||
default: mocks.posthog,
|
||||
}));
|
||||
|
||||
describe("getPostHogClientFeatureFlag", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.posthog.__loaded = false;
|
||||
mocks.posthog.getFeatureFlag = mocks.getFeatureFlag;
|
||||
});
|
||||
|
||||
test("returns false before PostHog is initialized", async () => {
|
||||
const { getPostHogClientFeatureFlag } = await import("./client");
|
||||
|
||||
expect(getPostHogClientFeatureFlag("test-flag")).toBe(false);
|
||||
expect(mocks.getFeatureFlag).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns true from posthog.getFeatureFlag", async () => {
|
||||
mocks.posthog.__loaded = true;
|
||||
mocks.getFeatureFlag.mockReturnValue(true);
|
||||
|
||||
const { getPostHogClientFeatureFlag } = await import("./client");
|
||||
|
||||
expect(getPostHogClientFeatureFlag("test-flag")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false from posthog.getFeatureFlag", async () => {
|
||||
mocks.posthog.__loaded = true;
|
||||
mocks.getFeatureFlag.mockReturnValue(false);
|
||||
|
||||
const { getPostHogClientFeatureFlag } = await import("./client");
|
||||
|
||||
expect(getPostHogClientFeatureFlag("test-flag")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns variant string from posthog.getFeatureFlag", async () => {
|
||||
mocks.posthog.__loaded = true;
|
||||
mocks.getFeatureFlag.mockReturnValue("variant-a");
|
||||
|
||||
const { getPostHogClientFeatureFlag } = await import("./client");
|
||||
|
||||
expect(getPostHogClientFeatureFlag("test-flag")).toBe("variant-a");
|
||||
});
|
||||
|
||||
test("coerces undefined to false", async () => {
|
||||
mocks.posthog.__loaded = true;
|
||||
mocks.getFeatureFlag.mockReturnValue(undefined);
|
||||
|
||||
const { getPostHogClientFeatureFlag } = await import("./client");
|
||||
|
||||
expect(getPostHogClientFeatureFlag("test-flag")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import posthog from "posthog-js";
|
||||
import type { TPostHogFeatureFlagValue } from "./types";
|
||||
|
||||
export const getPostHogClientFeatureFlag = (flagKey: string): TPostHogFeatureFlagValue => {
|
||||
if (!posthog.__loaded) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const featureFlagValue = posthog.getFeatureFlag(flagKey);
|
||||
return featureFlagValue ?? false;
|
||||
};
|
||||
|
||||
export type { TPostHogFeatureFlagContext, TPostHogFeatureFlagValue } from "./types";
|
||||
@@ -1,131 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getFeatureFlag: vi.fn(),
|
||||
loggerWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getPostHogFeatureFlag", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
test("returns false when PostHog is not configured", async () => {
|
||||
vi.doMock("server-only", () => ({}));
|
||||
vi.doMock("@formbricks/logger", () => ({
|
||||
logger: { warn: mocks.loggerWarn },
|
||||
}));
|
||||
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: undefined }));
|
||||
vi.doMock("./server", () => ({
|
||||
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
|
||||
}));
|
||||
|
||||
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
|
||||
|
||||
await expect(getPostHogFeatureFlag("user123", "test-flag")).resolves.toBe(false);
|
||||
expect(mocks.getFeatureFlag).not.toHaveBeenCalled();
|
||||
expect(mocks.loggerWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns false when posthogServerClient is null", async () => {
|
||||
vi.doMock("server-only", () => ({}));
|
||||
vi.doMock("@formbricks/logger", () => ({
|
||||
logger: { warn: mocks.loggerWarn },
|
||||
}));
|
||||
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
|
||||
vi.doMock("./server", () => ({
|
||||
posthogServerClient: null,
|
||||
}));
|
||||
|
||||
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
|
||||
|
||||
await expect(getPostHogFeatureFlag("user123", "test-flag")).resolves.toBe(false);
|
||||
expect(mocks.getFeatureFlag).not.toHaveBeenCalled();
|
||||
expect(mocks.loggerWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("forwards distinctId, flagKey, and mapped groups to PostHog", async () => {
|
||||
mocks.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
vi.doMock("server-only", () => ({}));
|
||||
vi.doMock("@formbricks/logger", () => ({
|
||||
logger: { warn: mocks.loggerWarn },
|
||||
}));
|
||||
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
|
||||
vi.doMock("./server", () => ({
|
||||
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
|
||||
}));
|
||||
|
||||
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
|
||||
|
||||
await expect(
|
||||
getPostHogFeatureFlag("user123", "experiment-flag", {
|
||||
organizationId: "org_123",
|
||||
workspaceId: "ws_456",
|
||||
})
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(mocks.getFeatureFlag).toHaveBeenCalledWith("experiment-flag", "user123", {
|
||||
groups: {
|
||||
organization: "org_123",
|
||||
workspace: "ws_456",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves variant string responses", async () => {
|
||||
mocks.getFeatureFlag.mockResolvedValue("variant-a");
|
||||
|
||||
vi.doMock("server-only", () => ({}));
|
||||
vi.doMock("@formbricks/logger", () => ({
|
||||
logger: { warn: mocks.loggerWarn },
|
||||
}));
|
||||
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
|
||||
vi.doMock("./server", () => ({
|
||||
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
|
||||
}));
|
||||
|
||||
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
|
||||
|
||||
await expect(getPostHogFeatureFlag("user123", "experiment-flag")).resolves.toBe("variant-a");
|
||||
});
|
||||
|
||||
test("coerces undefined to false", async () => {
|
||||
mocks.getFeatureFlag.mockResolvedValue(undefined);
|
||||
|
||||
vi.doMock("server-only", () => ({}));
|
||||
vi.doMock("@formbricks/logger", () => ({
|
||||
logger: { warn: mocks.loggerWarn },
|
||||
}));
|
||||
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
|
||||
vi.doMock("./server", () => ({
|
||||
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
|
||||
}));
|
||||
|
||||
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
|
||||
|
||||
await expect(getPostHogFeatureFlag("user123", "experiment-flag")).resolves.toBe(false);
|
||||
});
|
||||
|
||||
test("logs and returns false when PostHog throws", async () => {
|
||||
mocks.getFeatureFlag.mockRejectedValue(new Error("network error"));
|
||||
|
||||
vi.doMock("server-only", () => ({}));
|
||||
vi.doMock("@formbricks/logger", () => ({
|
||||
logger: { warn: mocks.loggerWarn },
|
||||
}));
|
||||
vi.doMock("@/lib/constants", () => ({ POSTHOG_KEY: "phc_test_key" }));
|
||||
vi.doMock("./server", () => ({
|
||||
posthogServerClient: { getFeatureFlag: mocks.getFeatureFlag },
|
||||
}));
|
||||
|
||||
const { getPostHogFeatureFlag } = await import("./get-feature-flag");
|
||||
|
||||
await expect(getPostHogFeatureFlag("user123", "experiment-flag")).resolves.toBe(false);
|
||||
expect(mocks.loggerWarn).toHaveBeenCalledWith(
|
||||
{ error: expect.any(Error), flagKey: "experiment-flag" },
|
||||
"Failed to evaluate PostHog feature flag"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import "server-only";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { POSTHOG_KEY } from "@/lib/constants";
|
||||
import { posthogServerClient } from "./server";
|
||||
import type { TPostHogFeatureFlagContext, TPostHogFeatureFlagValue } from "./types";
|
||||
|
||||
const buildPostHogGroups = (context?: TPostHogFeatureFlagContext): Record<string, string> | undefined => {
|
||||
const groups = {
|
||||
...(context?.organizationId ? { organization: context.organizationId } : {}),
|
||||
...(context?.workspaceId ? { workspace: context.workspaceId } : {}),
|
||||
};
|
||||
|
||||
return Object.keys(groups).length > 0 ? groups : undefined;
|
||||
};
|
||||
|
||||
export const getPostHogFeatureFlag = async (
|
||||
distinctId: string,
|
||||
flagKey: string,
|
||||
context?: TPostHogFeatureFlagContext
|
||||
): Promise<TPostHogFeatureFlagValue> => {
|
||||
if (!POSTHOG_KEY || !posthogServerClient) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const featureFlagValue = await posthogServerClient.getFeatureFlag(flagKey, distinctId, {
|
||||
groups: buildPostHogGroups(context),
|
||||
});
|
||||
|
||||
return featureFlagValue ?? false;
|
||||
} catch (error) {
|
||||
logger.warn({ error, flagKey }, "Failed to evaluate PostHog feature flag");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1 @@
|
||||
import "server-only";
|
||||
|
||||
export { capturePostHogEvent } from "./capture";
|
||||
export { getPostHogFeatureFlag } from "./get-feature-flag";
|
||||
export type { TPostHogFeatureFlagContext, TPostHogFeatureFlagValue } from "./types";
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export type TPostHogFeatureFlagValue = boolean | string;
|
||||
|
||||
export type TPostHogFeatureFlagContext = {
|
||||
organizationId?: string;
|
||||
workspaceId?: string;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
|
||||
import { TIntegration, TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
|
||||
import { TIntegrationSlack, 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");
|
||||
const slackIntegration = (await getIntegrationByType(environmentId, "slack")) as TIntegrationSlack;
|
||||
if (slackIntegration && slackIntegration.config?.key) {
|
||||
channels = await fetchChannels(slackIntegration);
|
||||
}
|
||||
|
||||
@@ -190,14 +190,6 @@ const mockWelcomeCard: TSurveyWelcomeCard = {
|
||||
showResponseCount: false,
|
||||
};
|
||||
|
||||
const mockBlocks = [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [mockQuestion],
|
||||
},
|
||||
];
|
||||
|
||||
const baseSurveyProperties = {
|
||||
id: mockId,
|
||||
name: "Mock Survey",
|
||||
@@ -209,7 +201,13 @@ const baseSurveyProperties = {
|
||||
displayLimit: 3,
|
||||
welcomeCard: mockWelcomeCard,
|
||||
questions: [],
|
||||
blocks: mockBlocks as unknown as SurveyMock["blocks"],
|
||||
blocks: [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [mockQuestion],
|
||||
},
|
||||
],
|
||||
isBackButtonHidden: false,
|
||||
isAutoProgressingEnabled: false,
|
||||
isCaptureIpEnabled: false,
|
||||
@@ -306,7 +304,6 @@ export const createSurveyInput: TSurveyCreateInput = {
|
||||
displayOption: "respondMultiple",
|
||||
triggers: [{ actionClass: mockActionClass }],
|
||||
...baseSurveyProperties,
|
||||
blocks: mockBlocks,
|
||||
};
|
||||
|
||||
export const updateSurveyInput: TSurvey = {
|
||||
@@ -329,7 +326,6 @@ export const updateSurveyInput: TSurvey = {
|
||||
followUps: [],
|
||||
...baseSurveyProperties,
|
||||
...commonMockProperties,
|
||||
blocks: mockBlocks,
|
||||
slug: null,
|
||||
customHeadScripts: null,
|
||||
customHeadScriptsMode: null,
|
||||
|
||||
@@ -5,8 +5,7 @@ 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 { ZSegmentFilters } from "@formbricks/types/segment";
|
||||
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||
import { TSegment, ZSegmentFilters } from "@formbricks/types/segment";
|
||||
import { TSurvey, TSurveyCreateInput, ZSurvey, ZSurveyCreateInput } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
getOrganizationByEnvironmentId,
|
||||
@@ -559,7 +558,22 @@ export const updateSurveyInternal = async (
|
||||
select: selectSurvey,
|
||||
});
|
||||
|
||||
return transformPrismaSurvey<TSurvey>(prismaSurvey);
|
||||
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;
|
||||
} catch (error) {
|
||||
logger.error(error, "Error updating survey");
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
@@ -634,8 +648,8 @@ export const createSurvey = async (
|
||||
}
|
||||
|
||||
// Validate and prepare blocks for persistence
|
||||
if (Array.isArray(data.blocks) && data.blocks.length > 0) {
|
||||
data.blocks = validateMediaAndPrepareBlocks(data.blocks as unknown as TSurveyBlock[]);
|
||||
if (data.blocks && data.blocks.length > 0) {
|
||||
data.blocks = validateMediaAndPrepareBlocks(data.blocks);
|
||||
}
|
||||
|
||||
const survey = await prisma.survey.create({
|
||||
@@ -759,7 +773,21 @@ export const loadNewSegmentInSurvey = async (surveyId: string, newSegmentId: str
|
||||
});
|
||||
}
|
||||
|
||||
return transformPrismaSurvey<TSurvey>(prismaSurvey);
|
||||
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;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type Locale, formatDistance } from "date-fns";
|
||||
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, tr, zhCN, zhTW } from "date-fns/locale";
|
||||
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { formatDateForDisplay } from "./utils/datetime";
|
||||
|
||||
@@ -17,7 +17,6 @@ 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,
|
||||
};
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { verifyPassword } from "@/modules/auth/lib/utils";
|
||||
import { getUserAuthenticationData, verifyUserPassword } from "./password";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/utils", () => ({
|
||||
verifyPassword: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
|
||||
const mockVerifyPassword = vi.mocked(verifyPassword);
|
||||
|
||||
describe("user password helpers", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("returns authentication data for an existing user", async () => {
|
||||
const user = {
|
||||
email: "user@example.com",
|
||||
identityProvider: "email",
|
||||
identityProviderAccountId: null,
|
||||
password: "hashed-password",
|
||||
};
|
||||
mockPrismaUserFindUnique.mockResolvedValue(user as any);
|
||||
|
||||
await expect(getUserAuthenticationData("user-with-password")).resolves.toEqual(user);
|
||||
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "user-with-password",
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
password: true,
|
||||
identityProvider: true,
|
||||
identityProviderAccountId: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("throws when authentication data is missing", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(getUserAuthenticationData("missing-user")).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("verifies a password against the stored hash", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
email: "password-user@example.com",
|
||||
identityProvider: "email",
|
||||
identityProviderAccountId: null,
|
||||
password: "hashed-password",
|
||||
} as any);
|
||||
mockVerifyPassword.mockResolvedValue(true);
|
||||
|
||||
await expect(verifyUserPassword("password-user", "plain-password")).resolves.toBe(true);
|
||||
|
||||
expect(mockVerifyPassword).toHaveBeenCalledWith("plain-password", "hashed-password");
|
||||
});
|
||||
|
||||
test("returns false when the password does not match the stored hash", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
email: "password-user@example.com",
|
||||
identityProvider: "email",
|
||||
identityProviderAccountId: null,
|
||||
password: "hashed-password",
|
||||
} as any);
|
||||
mockVerifyPassword.mockResolvedValue(false);
|
||||
|
||||
await expect(verifyUserPassword("password-user", "wrong-password")).resolves.toBe(false);
|
||||
|
||||
expect(mockVerifyPassword).toHaveBeenCalledWith("wrong-password", "hashed-password");
|
||||
});
|
||||
|
||||
test("rejects password verification for users without a password", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
email: "sso-user@example.com",
|
||||
identityProvider: "google",
|
||||
identityProviderAccountId: "google-account-id",
|
||||
password: null,
|
||||
} as any);
|
||||
|
||||
await expect(verifyUserPassword("sso-user", "plain-password")).rejects.toThrow(InvalidInputError);
|
||||
|
||||
expect(mockVerifyPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import "server-only";
|
||||
import { User } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { verifyPassword } from "@/modules/auth/lib/utils";
|
||||
|
||||
export const getUserAuthenticationData = reactCache(
|
||||
async (
|
||||
userId: string
|
||||
): Promise<Pick<User, "email" | "password" | "identityProvider" | "identityProviderAccountId">> => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
password: true,
|
||||
identityProvider: true,
|
||||
identityProviderAccountId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new ResourceNotFoundError("user", userId);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
);
|
||||
|
||||
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
|
||||
const user = await getUserAuthenticationData(userId);
|
||||
|
||||
if (!user.password) {
|
||||
throw new InvalidInputError("Password is not set for this user");
|
||||
}
|
||||
|
||||
return await verifyPassword(password, user.password);
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
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;
|
||||
}>;
|
||||
@@ -6,7 +6,6 @@ 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", () => ({
|
||||
@@ -48,6 +47,11 @@ 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[] = [
|
||||
@@ -98,12 +102,8 @@ describe("User Service", () => {
|
||||
expect(result).toEqual(mockPrismaUser);
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "user1" },
|
||||
select: publicUserSelect,
|
||||
select: expect.any(Object),
|
||||
});
|
||||
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: publicUserSelect,
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,7 +176,7 @@ describe("User Service", () => {
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user1" },
|
||||
data: updateData,
|
||||
select: publicUserSelect,
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -204,7 +204,7 @@ describe("User Service", () => {
|
||||
expect(deleteOrganization).toHaveBeenCalledWith("org1");
|
||||
expect(prisma.user.delete).toHaveBeenCalledWith({
|
||||
where: { id: "user1" },
|
||||
select: publicUserSelect,
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -236,7 +236,7 @@ describe("User Service", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
select: publicUserSelect,
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,21 @@ 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";
|
||||
import { publicUserSelect } from "./public-user";
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
// function to retrive basic information about a user's user
|
||||
export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
|
||||
@@ -21,7 +35,7 @@ export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: publicUserSelect,
|
||||
select: responseSelection,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
@@ -45,7 +59,7 @@ export const getUserByEmail = reactCache(async (email: string): Promise<TUser |
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
select: publicUserSelect,
|
||||
select: responseSelection,
|
||||
});
|
||||
|
||||
return user;
|
||||
@@ -68,7 +82,7 @@ export const updateUser = async (personId: string, data: TUserUpdateInput): Prom
|
||||
id: personId,
|
||||
},
|
||||
data: data,
|
||||
select: publicUserSelect,
|
||||
select: responseSelection,
|
||||
});
|
||||
|
||||
return updatedUser;
|
||||
@@ -91,7 +105,7 @@ const deleteUserById = async (id: string): Promise<TUser> => {
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: publicUserSelect,
|
||||
select: responseSelection,
|
||||
});
|
||||
return user;
|
||||
} catch (error) {
|
||||
@@ -139,7 +153,7 @@ export const getUsersWithOrganization = async (organizationId: string): Promise<
|
||||
},
|
||||
},
|
||||
},
|
||||
select: publicUserSelect,
|
||||
select: responseSelection,
|
||||
});
|
||||
|
||||
return users;
|
||||
@@ -160,7 +174,7 @@ export const getUserLocale = reactCache(async (id: string): Promise<TUserLocale
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: publicUserSelect,
|
||||
select: responseSelection,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
OperationNotAllowedError,
|
||||
ResourceNotFoundError,
|
||||
TooManyRequestsError,
|
||||
UniqueConstraintError,
|
||||
UnknownError,
|
||||
ValidationError,
|
||||
isExpectedError,
|
||||
@@ -75,7 +74,6 @@ describe("isExpectedError (shared helper)", () => {
|
||||
"OperationNotAllowedError",
|
||||
"TooManyRequestsError",
|
||||
"InvalidPasswordResetTokenError",
|
||||
"UniqueConstraintError",
|
||||
];
|
||||
|
||||
expect(EXPECTED_ERROR_NAMES.size).toBe(expected.length);
|
||||
@@ -93,7 +91,6 @@ describe("isExpectedError (shared helper)", () => {
|
||||
{ ErrorClass: ValidationError, args: ["Invalid data"] },
|
||||
{ ErrorClass: OperationNotAllowedError, args: ["Not allowed"] },
|
||||
{ ErrorClass: InvalidPasswordResetTokenError, args: [INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE] },
|
||||
{ ErrorClass: UniqueConstraintError, args: ["Already exists"] },
|
||||
])("returns true for $ErrorClass.name", ({ ErrorClass, args }) => {
|
||||
const error = new (ErrorClass as any)(...args);
|
||||
expect(isExpectedError(error)).toBe(true);
|
||||
@@ -189,14 +186,6 @@ describe("actionClient handleServerError", () => {
|
||||
expect(result?.serverError).toBe(INVALID_PASSWORD_RESET_TOKEN_ERROR_CODE);
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("UniqueConstraintError returns its message and is not sent to Sentry", async () => {
|
||||
const result = await executeThrowingAction(
|
||||
new UniqueConstraintError("Action with name foo already exists")
|
||||
);
|
||||
expect(result?.serverError).toBe("Action with name foo already exists");
|
||||
expect(Sentry.captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unexpected errors SHOULD be reported to Sentry", () => {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@@ -1,35 +0,0 @@
|
||||
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])
|
||||
);
|
||||
};
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Dein Link ist abgelaufen.",
|
||||
"link_expired_description": "Der von dir verwendete Link ist nicht mehr gültig.",
|
||||
"link_expired_heading": "Dein Link ist abgelaufen."
|
||||
"link_expired_description": "Der von dir verwendete Link ist nicht mehr gültig."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Akzeptiert",
|
||||
@@ -198,7 +197,6 @@
|
||||
"created_by": "Erstellt von",
|
||||
"customer_success": "Kundenerfolg",
|
||||
"dark_overlay": "Dunkle Überlagerung",
|
||||
"data_refreshed_successfully": "Daten erfolgreich aktualisiert",
|
||||
"date": "Datum",
|
||||
"days": "Tage",
|
||||
"default": "Standard",
|
||||
@@ -375,7 +373,6 @@
|
||||
"quotas_description": "Begrenze die Anzahl der Antworten, die du von Teilnehmern erhältst, die bestimmte Kriterien erfüllen.",
|
||||
"read_docs": "Dokumentation lesen",
|
||||
"recipients": "Empfänger",
|
||||
"refresh": "Aktualisieren",
|
||||
"remove": "Entfernen",
|
||||
"remove_from_team": "Aus Team entfernen",
|
||||
"reorder_and_hide_columns": "Spalten neu anordnen und ausblenden",
|
||||
@@ -677,6 +674,8 @@
|
||||
"attributes_msg_new_attribute_created": "Neues Attribut “{key}” mit Typ “{dataType}” erstellt",
|
||||
"attributes_msg_userid_already_exists": "Die Benutzer-ID existiert bereits für diese Umgebung und wurde nicht aktualisiert.",
|
||||
"contact_deleted_successfully": "Kontakt erfolgreich gelöscht",
|
||||
"contacts_table_refresh": "Kontakte aktualisieren",
|
||||
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
|
||||
"create_attribute": "Attribut erstellen",
|
||||
"create_new_attribute": "Neues Attribut erstellen",
|
||||
"create_new_attribute_description": "Erstellen Sie ein neues Attribut für Segmentierungszwecke.",
|
||||
@@ -834,9 +833,6 @@
|
||||
},
|
||||
"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.",
|
||||
@@ -1199,7 +1195,7 @@
|
||||
"organization_invite_link_ready": "Dein Einladungslink für die Organisation ist fertig!",
|
||||
"organization_name": "Organisationsname",
|
||||
"organization_name_description": "Gib deiner Organisation einen Namen.",
|
||||
"organization_name_placeholder": "z. B. Acme Inc.",
|
||||
"organization_name_placeholder": "z. B. Powerpuff Girls",
|
||||
"organization_name_updated_successfully": "Organisationsname erfolgreich aktualisiert",
|
||||
"organization_settings": "Organisationseinstellungen",
|
||||
"please_add_a_logo": "Bitte füge ein Logo hinzu",
|
||||
@@ -1234,15 +1230,12 @@
|
||||
"confirm_delete_my_account": "Konto löschen",
|
||||
"confirm_your_current_password_to_get_started": "Bestätige dein aktuelles Passwort, um loszulegen.",
|
||||
"delete_account": "Konto löschen",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Zwei-Faktor-Authentifizierung deaktivieren",
|
||||
"disable_two_factor_authentication_description": "Wenn Du die Zwei-Faktor-Authentifizierung deaktivieren musst, empfehlen wir, sie so schnell wie möglich wieder zu aktivieren.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Jeder Backup-Code kann genau einmal verwendet werden, um Zugang ohne deinen Authenticator zu gewähren.",
|
||||
"email_change_initiated": "Deine Anfrage zur Änderung der E-Mail wurde eingeleitet.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Zwei-Faktor-Authentifizierung aktivieren",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Gib den Code aus deiner Authentifizierungs-App unten ein.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Zugriff verloren",
|
||||
"or_enter_the_following_code_manually": "Oder gib den folgenden Code manuell ein:",
|
||||
"organizations_delete_message": "Du bist der einzige Besitzer dieser Organisationen, also werden sie <b>auch gelöscht.</b>",
|
||||
@@ -1253,8 +1246,6 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Speichere die folgenden Backup-Codes an einem sicheren Ort.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Scanne den QR-Code unten mit deiner Authentifizierungs-App.",
|
||||
"security_description": "Verwalte dein Passwort und andere Sicherheitseinstellungen wie Zwei-Faktor-Authentifizierung (2FA).",
|
||||
"sso_reauthentication_failed": "Die SSO-Authentifizierung ist fehlgeschlagen. Bitte versuche erneut, dein Konto zu löschen.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "Bei SSO-Konten kann die Auswahl von Löschen dich zu deinem Identitätsanbieter weiterleiten. Wenn deine Identität bestätigt wird, wird dein Konto automatisch gelöscht.",
|
||||
"two_factor_authentication": "Zwei-Faktor-Authentifizierung",
|
||||
"two_factor_authentication_description": "Füge eine zusätzliche Sicherheitsebene zu deinem Konto hinzu, falls dein Passwort gestohlen wird.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Zwei-Faktor-Authentifizierung aktiviert. Bitte gib den sechsstelligen Code aus deiner Authentifizierungs-App ein.",
|
||||
@@ -1262,8 +1253,7 @@
|
||||
"unlock_two_factor_authentication": "Zwei-Faktor-Authentifizierung mit einem höheren Plan freischalten",
|
||||
"update_personal_info": "Persönliche Daten aktualisieren",
|
||||
"warning_cannot_delete_account": "Du bist der einzige Besitzer dieser Organisation. Bitte übertrage das Eigentum zuerst an ein anderes Mitglied.",
|
||||
"warning_cannot_undo": "Das kann nicht rückgängig gemacht werden",
|
||||
"wrong_password": "Falsches Passwort"
|
||||
"warning_cannot_undo": "Das kann nicht rückgängig gemacht werden"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Füge Mitglieder zum Team hinzu und bestimme ihre Rolle.",
|
||||
@@ -1559,7 +1549,7 @@
|
||||
"hide_question_settings": "Frageeinstellungen ausblenden",
|
||||
"hostname": "Hostname",
|
||||
"if_you_need_more_please": "Wenn Sie mehr benötigen, bitte",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Immer anzeigen, wenn ausgelöst, bis eine Antwort oder Teilantwort übermittelt wurde.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Weiterhin anzeigen, wenn ausgelöst, bis eine Antwort abgegeben wird.",
|
||||
"ignore_global_waiting_time": "Abkühlphase ignorieren",
|
||||
"ignore_global_waiting_time_description": "Diese Umfrage kann angezeigt werden, wenn ihre Bedingungen erfüllt sind, auch wenn kürzlich eine andere Umfrage angezeigt wurde.",
|
||||
"image": "Bild",
|
||||
@@ -2434,13 +2424,11 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "Bitte überprüfe auch deinen Spam-Ordner, falls Du die E-Mail nicht in deinem Posteingang siehst.",
|
||||
"completed": "Diese kostenlose und quelloffene Umfrage wurde geschlossen.",
|
||||
"completed_heading": "Abgeschlossen",
|
||||
"create_your_own": "Erstelle deine eigene",
|
||||
"enter_pin": "Diese Umfrage ist geschützt. Gib die PIN unten ein.",
|
||||
"just_curious": "Einfach neugierig?",
|
||||
"link_invalid": "Diese Umfrage kann nur auf Einladung durchgeführt werden.",
|
||||
"paused": "Diese Umfrage ist vorübergehend pausiert.",
|
||||
"paused_heading": "Pausiert",
|
||||
"please_try_again_with_the_original_link": "Bitte versuche es nochmal mit dem ursprünglichen Link",
|
||||
"preview_survey_questions": "Vorschau der Fragen.",
|
||||
"question_preview": "Vorschau der Frage",
|
||||
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Your link is expired.",
|
||||
"link_expired_description": "The link you used is no longer valid.",
|
||||
"link_expired_heading": "Your link is expired."
|
||||
"link_expired_description": "The link you used is no longer valid."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Accepted",
|
||||
@@ -198,7 +197,6 @@
|
||||
"created_by": "Created by",
|
||||
"customer_success": "Customer Success",
|
||||
"dark_overlay": "Dark overlay",
|
||||
"data_refreshed_successfully": "Data refreshed successfully",
|
||||
"date": "Date",
|
||||
"days": "days",
|
||||
"default": "Default",
|
||||
@@ -375,7 +373,6 @@
|
||||
"quotas_description": "Limit the amount of responses you receive from participants who meet certain criteria.",
|
||||
"read_docs": "Read docs",
|
||||
"recipients": "Recipients",
|
||||
"refresh": "Refresh",
|
||||
"remove": "Remove",
|
||||
"remove_from_team": "Remove from team",
|
||||
"reorder_and_hide_columns": "Reorder and hide columns",
|
||||
@@ -677,6 +674,8 @@
|
||||
"attributes_msg_new_attribute_created": "Created new attribute “{key}” with type “{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "The user ID already exists for this environment and was not updated.",
|
||||
"contact_deleted_successfully": "Contact deleted successfully",
|
||||
"contacts_table_refresh": "Refresh contacts",
|
||||
"contacts_table_refresh_success": "Contacts refreshed successfully",
|
||||
"create_attribute": "Create attribute",
|
||||
"create_new_attribute": "Create new attribute",
|
||||
"create_new_attribute_description": "Create a new attribute for segmentation purposes.",
|
||||
@@ -834,9 +833,6 @@
|
||||
},
|
||||
"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.",
|
||||
@@ -1199,7 +1195,7 @@
|
||||
"organization_invite_link_ready": "Your organization invite link is ready!",
|
||||
"organization_name": "Organization Name",
|
||||
"organization_name_description": "Give your organization a descriptive name.",
|
||||
"organization_name_placeholder": "e.g. Acme Inc.",
|
||||
"organization_name_placeholder": "e.g. Power Puff Girls",
|
||||
"organization_name_updated_successfully": "Organization name updated successfully",
|
||||
"organization_settings": "Organization Settings",
|
||||
"please_add_a_logo": "Please add a logo",
|
||||
@@ -1234,15 +1230,12 @@
|
||||
"confirm_delete_my_account": "Delete My Account",
|
||||
"confirm_your_current_password_to_get_started": "Confirm your current password to get started.",
|
||||
"delete_account": "Delete Account",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Disable two factor authentication",
|
||||
"disable_two_factor_authentication_description": "If you need to disable 2FA, we recommend re-enabling it as soon as possible.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Each backup code can be used exactly once to grant access without your authenticator.",
|
||||
"email_change_initiated": "Your email change request has been initiated.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Enable two factor authentication",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Enter the code from your authenticator app below.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Lost access",
|
||||
"or_enter_the_following_code_manually": "Or enter the following code manually:",
|
||||
"organizations_delete_message": "You are the only owner of these organizations, so they <b>will be deleted as well.</b>",
|
||||
@@ -1253,8 +1246,6 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Save the following backup codes in a safe place.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Scan the QR code below with your authenticator app.",
|
||||
"security_description": "Manage your password and other security settings like two-factor authentication (2FA).",
|
||||
"sso_reauthentication_failed": "SSO reauthentication failed. Please try deleting your account again.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "For SSO accounts, selecting Delete may redirect you to your identity provider. If your identity is confirmed, your account will be deleted automatically.",
|
||||
"two_factor_authentication": "Two factor authentication",
|
||||
"two_factor_authentication_description": "Add an extra layer of security to your account in case your password is stolen.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Two-factor authentication enabled. Please enter the six-digit code from your authenticator app.",
|
||||
@@ -1262,8 +1253,7 @@
|
||||
"unlock_two_factor_authentication": "Unlock two-factor authentication with a higher plan",
|
||||
"update_personal_info": "Update your personal information",
|
||||
"warning_cannot_delete_account": "You are the only owner of this organization. Please transfer ownership to another member first.",
|
||||
"warning_cannot_undo": "This cannot be undone",
|
||||
"wrong_password": "Wrong password"
|
||||
"warning_cannot_undo": "This cannot be undone"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Add members to the team and determine their role.",
|
||||
@@ -1559,7 +1549,7 @@
|
||||
"hide_question_settings": "Hide Question settings",
|
||||
"hostname": "Hostname",
|
||||
"if_you_need_more_please": "If you need more, please",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Keep showing whenever triggered until a response or partial response is submitted.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Keep showing whenever triggered until a response is submitted.",
|
||||
"ignore_global_waiting_time": "Ignore Cooldown Period",
|
||||
"ignore_global_waiting_time_description": "This survey can show whenever its conditions are met, even if another survey was shown recently.",
|
||||
"image": "Image",
|
||||
@@ -2434,13 +2424,11 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "Please also check your spam folder if you do not see the email in your inbox.",
|
||||
"completed": "This survey is closed.",
|
||||
"completed_heading": "Completed",
|
||||
"create_your_own": "Create your own open-source survey",
|
||||
"enter_pin": "This survey is protected. Enter the PIN below.",
|
||||
"just_curious": "Just curious?",
|
||||
"link_invalid": "This survey can only be taken by invitation.",
|
||||
"paused": "This survey is temporarily paused.",
|
||||
"paused_heading": "Paused",
|
||||
"please_try_again_with_the_original_link": "Please try again with the original link",
|
||||
"preview_survey_questions": "Preview survey questions.",
|
||||
"question_preview": "Question Preview",
|
||||
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Tu enlace ha caducado.",
|
||||
"link_expired_description": "El enlace que has utilizado ya no es válido.",
|
||||
"link_expired_heading": "Tu enlace ha caducado."
|
||||
"link_expired_description": "El enlace que has utilizado ya no es válido."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Aceptado",
|
||||
@@ -198,7 +197,6 @@
|
||||
"created_by": "Creado por",
|
||||
"customer_success": "Éxito del cliente",
|
||||
"dark_overlay": "Superposición oscura",
|
||||
"data_refreshed_successfully": "Datos actualizados correctamente",
|
||||
"date": "Fecha",
|
||||
"days": "días",
|
||||
"default": "Predeterminado",
|
||||
@@ -375,7 +373,6 @@
|
||||
"quotas_description": "Limita la cantidad de respuestas que recibes de participantes que cumplen ciertos criterios.",
|
||||
"read_docs": "Leer documentación",
|
||||
"recipients": "Destinatarios",
|
||||
"refresh": "Actualizar",
|
||||
"remove": "Eliminar",
|
||||
"remove_from_team": "Eliminar del equipo",
|
||||
"reorder_and_hide_columns": "Reordenar y ocultar columnas",
|
||||
@@ -677,6 +674,8 @@
|
||||
"attributes_msg_new_attribute_created": "Se creó el atributo nuevo “{key}” con el tipo “{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "El ID de usuario ya existe para este entorno y no se actualizó.",
|
||||
"contact_deleted_successfully": "Contacto eliminado correctamente",
|
||||
"contacts_table_refresh": "Actualizar contactos",
|
||||
"contacts_table_refresh_success": "Contactos actualizados correctamente",
|
||||
"create_attribute": "Crear atributo",
|
||||
"create_new_attribute": "Crear atributo nuevo",
|
||||
"create_new_attribute_description": "Crea un atributo nuevo para fines de segmentación.",
|
||||
@@ -834,9 +833,6 @@
|
||||
},
|
||||
"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.",
|
||||
@@ -1199,7 +1195,7 @@
|
||||
"organization_invite_link_ready": "¡Tu enlace de invitación a la organización está listo!",
|
||||
"organization_name": "Nombre de la organización",
|
||||
"organization_name_description": "Dale a tu organización un nombre descriptivo.",
|
||||
"organization_name_placeholder": "p. ej. Acme Inc.",
|
||||
"organization_name_placeholder": "p. ej. Power Puff Girls",
|
||||
"organization_name_updated_successfully": "Nombre de la organización actualizado correctamente",
|
||||
"organization_settings": "Configuración de la organización",
|
||||
"please_add_a_logo": "Por favor, añade un logotipo",
|
||||
@@ -1234,15 +1230,12 @@
|
||||
"confirm_delete_my_account": "Eliminar mi cuenta",
|
||||
"confirm_your_current_password_to_get_started": "Confirma tu contraseña actual para comenzar.",
|
||||
"delete_account": "Eliminar cuenta",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Desactivar autenticación de dos factores",
|
||||
"disable_two_factor_authentication_description": "Si necesitas desactivar la autenticación de dos factores, te recomendamos volver a activarla lo antes posible.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Cada código de respaldo puede utilizarse exactamente una vez para conceder acceso sin tu autenticador.",
|
||||
"email_change_initiated": "Tu solicitud de cambio de correo electrónico ha sido iniciada.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Activar autenticación de dos factores",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Introduce el código de tu aplicación de autenticación a continuación.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Acceso perdido",
|
||||
"or_enter_the_following_code_manually": "O introduce el siguiente código manualmente:",
|
||||
"organizations_delete_message": "Eres el único propietario de estas organizaciones, por lo que <b>también serán eliminadas.</b>",
|
||||
@@ -1253,8 +1246,6 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Guarda los siguientes códigos de respaldo en un lugar seguro.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Escanea el código QR a continuación con tu aplicación de autenticación.",
|
||||
"security_description": "Gestiona tu contraseña y otros ajustes de seguridad como la autenticación de dos factores (2FA).",
|
||||
"sso_reauthentication_failed": "La reautenticación SSO falló. Intenta eliminar tu cuenta de nuevo.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "En las cuentas SSO, al seleccionar Eliminar es posible que se te redirija a tu proveedor de identidad. Si se confirma tu identidad, tu cuenta se eliminará automáticamente.",
|
||||
"two_factor_authentication": "Autenticación de dos factores",
|
||||
"two_factor_authentication_description": "Añade una capa adicional de seguridad a tu cuenta en caso de que tu contraseña sea robada.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticación de dos factores activada. Por favor, introduce el código de seis dígitos de tu aplicación de autenticación.",
|
||||
@@ -1262,8 +1253,7 @@
|
||||
"unlock_two_factor_authentication": "Desbloquea la autenticación de dos factores con un plan superior",
|
||||
"update_personal_info": "Actualiza tu información personal",
|
||||
"warning_cannot_delete_account": "Eres el único propietario de esta organización. Por favor, transfiere la propiedad a otro miembro primero.",
|
||||
"warning_cannot_undo": "Esto no se puede deshacer",
|
||||
"wrong_password": "Contraseña incorrecta"
|
||||
"warning_cannot_undo": "Esto no se puede deshacer"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Añade miembros al equipo y determina su rol.",
|
||||
@@ -1559,7 +1549,7 @@
|
||||
"hide_question_settings": "Ocultar ajustes de la pregunta",
|
||||
"hostname": "Nombre de host",
|
||||
"if_you_need_more_please": "Si necesitas más, por favor",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Seguir mostrando cada vez que se active hasta que se envíe una respuesta o respuesta parcial.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Seguir mostrando cuando se active hasta que se envíe una respuesta.",
|
||||
"ignore_global_waiting_time": "Ignorar periodo de espera",
|
||||
"ignore_global_waiting_time_description": "Esta encuesta puede mostrarse siempre que se cumplan sus condiciones, incluso si otra encuesta se mostró recientemente.",
|
||||
"image": "Imagen",
|
||||
@@ -2434,13 +2424,11 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "Por favor, comprueba también tu carpeta de spam si no ves el correo electrónico en tu bandeja de entrada.",
|
||||
"completed": "Esta encuesta está cerrada.",
|
||||
"completed_heading": "Completado",
|
||||
"create_your_own": "Crea tu propia encuesta de código abierto",
|
||||
"enter_pin": "Esta encuesta está protegida. Introduce el PIN a continuación.",
|
||||
"just_curious": "¿Solo tienes curiosidad?",
|
||||
"link_invalid": "Esta encuesta solo se puede realizar por invitación.",
|
||||
"paused": "Esta encuesta está temporalmente pausada.",
|
||||
"paused_heading": "Pausado",
|
||||
"please_try_again_with_the_original_link": "Por favor, inténtalo de nuevo con el enlace original",
|
||||
"preview_survey_questions": "Vista previa de las preguntas de la encuesta.",
|
||||
"question_preview": "Vista previa de la pregunta",
|
||||
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Votre lien est expiré.",
|
||||
"link_expired_description": "Le lien que vous avez utilisé n'est plus valide.",
|
||||
"link_expired_heading": "Votre lien est expiré."
|
||||
"link_expired_description": "Le lien que vous avez utilisé n'est plus valide."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Accepté",
|
||||
@@ -198,7 +197,6 @@
|
||||
"created_by": "Créé par",
|
||||
"customer_success": "Succès Client",
|
||||
"dark_overlay": "Foncée",
|
||||
"data_refreshed_successfully": "Données actualisées avec succès",
|
||||
"date": "Date",
|
||||
"days": "jours",
|
||||
"default": "Par défaut",
|
||||
@@ -375,7 +373,6 @@
|
||||
"quotas_description": "Limitez le nombre de réponses que vous recevez de la part des participants répondant à certains critères.",
|
||||
"read_docs": "Lire la documentation",
|
||||
"recipients": "Destinataires",
|
||||
"refresh": "Actualiser",
|
||||
"remove": "Retirer",
|
||||
"remove_from_team": "Retirer de l'équipe",
|
||||
"reorder_and_hide_columns": "Réorganiser et masquer des colonnes",
|
||||
@@ -677,6 +674,8 @@
|
||||
"attributes_msg_new_attribute_created": "Nouvel attribut “{key}” créé avec le type “{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "L'identifiant utilisateur existe déjà pour cet environnement et n'a pas été mis à jour.",
|
||||
"contact_deleted_successfully": "Contact supprimé avec succès",
|
||||
"contacts_table_refresh": "Actualiser les contacts",
|
||||
"contacts_table_refresh_success": "Contacts rafraîchis avec succès",
|
||||
"create_attribute": "Créer un attribut",
|
||||
"create_new_attribute": "Créer un nouvel attribut",
|
||||
"create_new_attribute_description": "Créez un nouvel attribut à des fins de segmentation.",
|
||||
@@ -834,9 +833,6 @@
|
||||
},
|
||||
"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.",
|
||||
@@ -1199,7 +1195,7 @@
|
||||
"organization_invite_link_ready": "Le lien d'invitation de votre organisation est prêt !",
|
||||
"organization_name": "Nom de l'organisation",
|
||||
"organization_name_description": "Attribuez un nom descriptif à votre organisation.",
|
||||
"organization_name_placeholder": "e.g. Acme Inc.",
|
||||
"organization_name_placeholder": "e.g. Power Puff Girls",
|
||||
"organization_name_updated_successfully": "Nom de l'organisation mis à jour avec succès",
|
||||
"organization_settings": "Paramètres de l'organisation",
|
||||
"please_add_a_logo": "Veuillez ajouter un logo",
|
||||
@@ -1234,15 +1230,12 @@
|
||||
"confirm_delete_my_account": "Supprimer mon compte",
|
||||
"confirm_your_current_password_to_get_started": "Confirmez votre mot de passe actuel pour commencer.",
|
||||
"delete_account": "Supprimer le compte",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Désactiver l'authentification à deux facteurs",
|
||||
"disable_two_factor_authentication_description": "Si vous devez désactiver l'authentification à deux facteurs, nous vous recommandons de la réactiver dès que possible.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Chaque code de sauvegarde peut être utilisé exactement une fois pour accorder l'accès sans votre authentificateur.",
|
||||
"email_change_initiated": "Votre demande de changement d'email a été initiée.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Activer l'authentification à deux facteurs",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Entrez le code de votre application d'authentification ci-dessous.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Accès perdu",
|
||||
"or_enter_the_following_code_manually": "Ou entrez le code suivant manuellement :",
|
||||
"organizations_delete_message": "Tu es le seul propriétaire de ces organisations, elles <b>seront aussi supprimées.</b>",
|
||||
@@ -1253,8 +1246,6 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Enregistrez les codes de sauvegarde suivants dans un endroit sûr.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Scannez le code QR ci-dessous avec votre application d'authentification.",
|
||||
"security_description": "Gérez votre mot de passe et d'autres paramètres de sécurité comme l'authentification à deux facteurs (2FA).",
|
||||
"sso_reauthentication_failed": "La réauthentification SSO a échoué. Veuillez réessayer de supprimer votre compte.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "Pour les comptes SSO, sélectionner Supprimer peut vous rediriger vers votre fournisseur d'identité. Si votre identité est confirmée, votre compte sera supprimé automatiquement.",
|
||||
"two_factor_authentication": "Authentification à deux facteurs",
|
||||
"two_factor_authentication_description": "Ajoutez une couche de sécurité supplémentaire à votre compte au cas où votre mot de passe serait volé.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Authentification à deux facteurs activée. Veuillez entrer le code à six chiffres de votre application d'authentification.",
|
||||
@@ -1262,8 +1253,7 @@
|
||||
"unlock_two_factor_authentication": "Débloquez l'authentification à deux facteurs avec une offre supérieure",
|
||||
"update_personal_info": "Mettez à jour vos informations personnelles",
|
||||
"warning_cannot_delete_account": "Tu es le seul propriétaire de cette organisation. Transfère la propriété à un autre membre d'abord.",
|
||||
"warning_cannot_undo": "Cette opération est irréversible.",
|
||||
"wrong_password": "Mot de passe incorrect"
|
||||
"warning_cannot_undo": "Cette opération est irréversible."
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Ajoutez des membres à l'équipe et déterminez leur rôle.",
|
||||
@@ -1559,7 +1549,7 @@
|
||||
"hide_question_settings": "Masquer les paramètres de la question",
|
||||
"hostname": "Nom d'hôte",
|
||||
"if_you_need_more_please": "Si vous avez besoin de plus, veuillez",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuer à afficher à chaque déclenchement jusqu'à ce qu'une réponse ou une réponse partielle soit soumise.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuer à afficher à chaque déclenchement jusqu'à ce qu'une réponse soit soumise.",
|
||||
"ignore_global_waiting_time": "Ignorer la période de refroidissement",
|
||||
"ignore_global_waiting_time_description": "Cette enquête peut s'afficher chaque fois que ses conditions sont remplies, même si une autre enquête a été affichée récemment.",
|
||||
"image": "Image",
|
||||
@@ -2434,13 +2424,11 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "Veuillez également vérifier votre dossier de spam si vous ne voyez pas l'e-mail dans votre boîte de réception.",
|
||||
"completed": "Cette enquête gratuite et open-source a été fermée.",
|
||||
"completed_heading": "Terminé",
|
||||
"create_your_own": "Créez le vôtre",
|
||||
"enter_pin": "Ce sondage est protégé. Entrez le code PIN ci-dessous.",
|
||||
"just_curious": "Juste curieux ?",
|
||||
"link_invalid": "Cette enquête ne peut être réalisée que sur invitation.",
|
||||
"paused": "Cette enquête gratuite et open-source est temporairement suspendue.",
|
||||
"paused_heading": "En pause",
|
||||
"please_try_again_with_the_original_link": "Veuillez réessayer avec le lien d'origine.",
|
||||
"preview_survey_questions": "Aperçu des questions de l'enquête.",
|
||||
"question_preview": "Aperçu de la question",
|
||||
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "A hivatkozása lejárt.",
|
||||
"link_expired_description": "Az Ön által használt hivatkozás már nem érvényes.",
|
||||
"link_expired_heading": "A hivatkozása lejárt."
|
||||
"link_expired_description": "Az Ön által használt hivatkozás már nem érvényes."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Elfogadva",
|
||||
@@ -198,7 +197,6 @@
|
||||
"created_by": "Létrehozta",
|
||||
"customer_success": "Ügyfélsiker",
|
||||
"dark_overlay": "Sötét rávetítés",
|
||||
"data_refreshed_successfully": "Az adatok sikeresen frissítve lettek",
|
||||
"date": "Dátum",
|
||||
"days": "nap",
|
||||
"default": "Alapértelmezett",
|
||||
@@ -375,7 +373,6 @@
|
||||
"quotas_description": "A bizonyos feltételeknek megfelelő résztvevőktől kapott válaszok számának korlátozása.",
|
||||
"read_docs": "Dokumentáció elolvasása",
|
||||
"recipients": "Címzettek",
|
||||
"refresh": "Frissítés",
|
||||
"remove": "Eltávolítás",
|
||||
"remove_from_team": "Eltávolítás a csapatból",
|
||||
"reorder_and_hide_columns": "Oszlopok átrendezése és elrejtése",
|
||||
@@ -677,6 +674,8 @@
|
||||
"attributes_msg_new_attribute_created": "Az új „{dataType}” típusú „{key}” attribútum létrehozva",
|
||||
"attributes_msg_userid_already_exists": "A felhasználó-azonosító már létezik ennél a környezetnél, és nem lett frissítve.",
|
||||
"contact_deleted_successfully": "A partner sikeresen törölve",
|
||||
"contacts_table_refresh": "Partnerek frissítése",
|
||||
"contacts_table_refresh_success": "A partnerek sikeresen frissítve",
|
||||
"create_attribute": "Attribútum létrehozása",
|
||||
"create_new_attribute": "Új attribútum létrehozása",
|
||||
"create_new_attribute_description": "Új attribútum létrehozása szakaszolási célokhoz.",
|
||||
@@ -834,9 +833,6 @@
|
||||
},
|
||||
"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.",
|
||||
@@ -1199,7 +1195,7 @@
|
||||
"organization_invite_link_ready": "A szervezete meghívási hivatkozása készen áll!",
|
||||
"organization_name": "Szervezet neve",
|
||||
"organization_name_description": "Adjon a szervezetének egy leíró nevet.",
|
||||
"organization_name_placeholder": "például Acme Inc.",
|
||||
"organization_name_placeholder": "például Pindúr pandúrok",
|
||||
"organization_name_updated_successfully": "A szervezet neve sikeresen frissítve",
|
||||
"organization_settings": "Szervezet beállításai",
|
||||
"please_add_a_logo": "Adjon hozzá egy logót",
|
||||
@@ -1234,15 +1230,12 @@
|
||||
"confirm_delete_my_account": "Saját fiók törlése",
|
||||
"confirm_your_current_password_to_get_started": "Erősítse meg a jelenlegi jelszavát a kezdéshez.",
|
||||
"delete_account": "Fiók törlése",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Kétfaktoros hitelesítés letiltása",
|
||||
"disable_two_factor_authentication_description": "Ha le kell tiltania a kétfaktoros hitelesítést, akkor azt javasoljuk, hogy engedélyezze újra, amint lehetséges.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Minden visszaszerzési kód pontosan egyszer használható a hitelesítő nélküli hozzáférés megszerzéséhez.",
|
||||
"email_change_initiated": "Az e-mail-címe megváltoztatása iránti kérelme kezdeményezve lett.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Kétfaktoros hitelesítés engedélyezése",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Adja meg a hitelesítő alkalmazásból származó kódot lent.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Elvesztett hozzáférés",
|
||||
"or_enter_the_following_code_manually": "Vagy adja meg a következő kódot kézileg:",
|
||||
"organizations_delete_message": "Ön az egyetlen tulajdonosa ezeknek a szervezeteknek, ezért <b>azok is törölve lesznek.</b>",
|
||||
@@ -1253,8 +1246,6 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Mentse el a következő visszaszerzési kódokat egy biztonságos helyre.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Olvassa be a lenti QR-kódot a hitelesítő alkalmazásával.",
|
||||
"security_description": "A jelszava és egyéb biztonsági beállítások, például a kétfaktoros hitelesítés (2FA) kezelése.",
|
||||
"sso_reauthentication_failed": "Az SSO újrahitelesítés nem sikerült. Próbáld meg újra törölni a fiókodat.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "SSO-fiókoknál a Törlés kiválasztása átirányíthat a személyazonosság-szolgáltatódhoz. Ha a személyazonosságod megerősítést nyer, a fiókod automatikusan törlődik.",
|
||||
"two_factor_authentication": "Kétfaktoros hitelesítés",
|
||||
"two_factor_authentication_description": "További biztonsági réteg hozzáadása a fiókjához arra az esetre, ha a jelszavát ellopnák.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "A kétfaktoros hitelesítés engedélyezve van. Adja a meg a 6 számjegyű kódot a hitelesítő alkalmazásából.",
|
||||
@@ -1262,8 +1253,7 @@
|
||||
"unlock_two_factor_authentication": "A kétfaktoros hitelesítés feloldása egy magasabb csomaggal",
|
||||
"update_personal_info": "Személyes információk frissítése",
|
||||
"warning_cannot_delete_account": "Ön az egyetlen tulajdonosa ennek a szervezetnek. Először adja át a tulajdonjogot egy másik tagnak.",
|
||||
"warning_cannot_undo": "Ezt nem lehet visszavonni",
|
||||
"wrong_password": "Hibás jelszó"
|
||||
"warning_cannot_undo": "Ezt nem lehet visszavonni"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Tagok hozzáadása a csapathoz és a szerepük meghatározása.",
|
||||
@@ -1559,7 +1549,7 @@
|
||||
"hide_question_settings": "Kérdésbeállítások elrejtése",
|
||||
"hostname": "Gépnév",
|
||||
"if_you_need_more_please": "Ha többre van szüksége, akkor",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Továbbra is megjelenítés minden egyes aktiváláskor, amíg választ vagy részleges választ nem küldenek be.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Maradjon megjelenítve bármikor is aktiválódott, amíg egy választ el nem küldenek.",
|
||||
"ignore_global_waiting_time": "Várakozási időszak figyelmen kívül hagyása",
|
||||
"ignore_global_waiting_time_description": "Ez a kérdőív akkor jelenhet meg, ha a feltételei teljesülnek, még akkor is, ha egy másik kérdőív jelent meg nemrég.",
|
||||
"image": "Kép",
|
||||
@@ -2434,13 +2424,11 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "Nézze meg a levélszemét mappát is, ha nem találja az e-mailt a beérkező levelek között.",
|
||||
"completed": "Ez a kérdőív le van zárva.",
|
||||
"completed_heading": "Befejezve",
|
||||
"create_your_own": "Saját nyílt forráskódú kérdőív létrehozása",
|
||||
"enter_pin": "Ez a kérdőív védett. Adja meg a PIN-kódot lent.",
|
||||
"just_curious": "Csak kíváncsi?",
|
||||
"link_invalid": "Ez a kérdőív csak meghívás útján tölthető ki.",
|
||||
"paused": "Ez a kérdőív átmenetileg szüneteltetve van.",
|
||||
"paused_heading": "Szüneteltetve",
|
||||
"please_try_again_with_the_original_link": "Próbálja meg újra az eredeti hivatkozással",
|
||||
"preview_survey_questions": "Kérdőív kérdéseinek előnézete.",
|
||||
"question_preview": "Kérdés előnézete",
|
||||
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "リンクの有効期限が切れています。",
|
||||
"link_expired_description": "使用したリンクはすでに無効です。",
|
||||
"link_expired_heading": "リンクの有効期限が切れています。"
|
||||
"link_expired_description": "使用したリンクはすでに無効です。"
|
||||
},
|
||||
"common": {
|
||||
"accepted": "承認済み",
|
||||
@@ -198,7 +197,6 @@
|
||||
"created_by": "作成者",
|
||||
"customer_success": "カスタマーサクセス",
|
||||
"dark_overlay": "暗いオーバーレイ",
|
||||
"data_refreshed_successfully": "データが正常に更新されました",
|
||||
"date": "日付",
|
||||
"days": "日",
|
||||
"default": "デフォルト",
|
||||
@@ -375,7 +373,6 @@
|
||||
"quotas_description": "特定の基準を満たす参加者からの回答数を制限する",
|
||||
"read_docs": "ドキュメントを読む",
|
||||
"recipients": "受信者",
|
||||
"refresh": "更新",
|
||||
"remove": "削除",
|
||||
"remove_from_team": "チームから削除",
|
||||
"reorder_and_hide_columns": "列の並び替えと非表示",
|
||||
@@ -677,6 +674,8 @@
|
||||
"attributes_msg_new_attribute_created": "新しい属性“{key}”を型“{dataType}”で作成しました",
|
||||
"attributes_msg_userid_already_exists": "この環境にはすでにユーザーIDが存在するため、更新されませんでした。",
|
||||
"contact_deleted_successfully": "連絡先を正常に削除しました",
|
||||
"contacts_table_refresh": "連絡先を更新",
|
||||
"contacts_table_refresh_success": "連絡先を正常に更新しました",
|
||||
"create_attribute": "属性を作成",
|
||||
"create_new_attribute": "新しい属性を作成",
|
||||
"create_new_attribute_description": "セグメンテーション用の新しい属性を作成します。",
|
||||
@@ -834,9 +833,6 @@
|
||||
},
|
||||
"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": "このチャンネルには別のフォームがすでに接続されています。",
|
||||
@@ -1199,7 +1195,7 @@
|
||||
"organization_invite_link_ready": "組織の招待リンクが準備できました!",
|
||||
"organization_name": "組織名",
|
||||
"organization_name_description": "組織に分かりやすい名前を付けます。",
|
||||
"organization_name_placeholder": "例: Acme Inc.",
|
||||
"organization_name_placeholder": "例: パワーパフガールズ",
|
||||
"organization_name_updated_successfully": "組織名を正常に更新しました",
|
||||
"organization_settings": "組織設定",
|
||||
"please_add_a_logo": "ロゴを追加してください",
|
||||
@@ -1234,15 +1230,12 @@
|
||||
"confirm_delete_my_account": "アカウントを削除",
|
||||
"confirm_your_current_password_to_get_started": "始めるには、現在のパスワードを確認してください。",
|
||||
"delete_account": "アカウントを削除",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "二段階認証を無効にする",
|
||||
"disable_two_factor_authentication_description": "2FAを無効にする必要がある場合は、できるだけ早く再有効にすることをお勧めします。",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "各バックアップコードは、認証アプリなしでアクセスを許可するために一度だけ使用できます。",
|
||||
"email_change_initiated": "メールアドレスの変更リクエストが開始されました。",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "二段階認証を有効にする",
|
||||
"enter_the_code_from_your_authenticator_app_below": "認証アプリからコードを以下に入力してください。",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "アクセスを紛失しましたか",
|
||||
"or_enter_the_following_code_manually": "または、以下のコードを手動で入力してください:",
|
||||
"organizations_delete_message": "あなたはこれらの組織の唯一のオーナーであるため、組織も<b>削除されます。</b>",
|
||||
@@ -1253,8 +1246,6 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "以下のバックアップコードを安全な場所に保存してください。",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "以下のQRコードを認証アプリでスキャンしてください。",
|
||||
"security_description": "パスワードや二段階認証(2FA)などの他のセキュリティ設定を管理します。",
|
||||
"sso_reauthentication_failed": "SSO の再認証に失敗しました。もう一度アカウントの削除を試してください。",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "SSOアカウントの場合、削除を選択するとIDプロバイダーにリダイレクトされることがあります。本人確認が完了すると、アカウントは自動的に削除されます。",
|
||||
"two_factor_authentication": "二段階認証",
|
||||
"two_factor_authentication_description": "パスワードが盗まれた場合に備えて、アカウントにセキュリティの追加レイヤーを追加します。",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "二段階認証が有効になりました。認証アプリから6桁のコードを入力してください。",
|
||||
@@ -1262,8 +1253,7 @@
|
||||
"unlock_two_factor_authentication": "上位プランで二段階認証をアンロック",
|
||||
"update_personal_info": "個人情報を更新",
|
||||
"warning_cannot_delete_account": "あなたは、この組織の唯一のオーナーです。まず、別のメンバーにオーナーシップを譲渡してください。",
|
||||
"warning_cannot_undo": "この操作は元に戻せません",
|
||||
"wrong_password": "パスワードが間違っています"
|
||||
"warning_cannot_undo": "この操作は元に戻せません"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "チームにメンバーを追加し、役割を決定します。",
|
||||
@@ -1559,7 +1549,7 @@
|
||||
"hide_question_settings": "質問設定を非表示",
|
||||
"hostname": "ホスト名",
|
||||
"if_you_need_more_please": "さらに必要な場合は、",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "回答または一部の回答が送信されるまで、トリガーされるたびに表示し続けます。",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "回答が提出されるまで、トリガーされるたびに表示し続けます。",
|
||||
"ignore_global_waiting_time": "クールダウン期間を無視",
|
||||
"ignore_global_waiting_time_description": "このフォームは、最近別のフォームが表示されていても、条件が満たされればいつでも表示できます。",
|
||||
"image": "画像",
|
||||
@@ -2434,13 +2424,11 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "受信トレイにメールがない場合は、迷惑メールフォルダも確認してください。",
|
||||
"completed": "このフォームはクローズしました。",
|
||||
"completed_heading": "完了",
|
||||
"create_your_own": "独自のオープンソースフォームを作成",
|
||||
"enter_pin": "このアンケートは保護されています。以下にPINを入力してください。",
|
||||
"just_curious": "ただ興味があるだけですか?",
|
||||
"link_invalid": "このフォームは招待によってのみ回答できます。",
|
||||
"paused": "このフォームは一時的に一時停止されています。",
|
||||
"paused_heading": "一時停止",
|
||||
"please_try_again_with_the_original_link": "元のリンクでもう一度お試しください",
|
||||
"preview_survey_questions": "フォームの質問をプレビュー。",
|
||||
"question_preview": "質問プレビュー",
|
||||
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Uw link is verlopen.",
|
||||
"link_expired_description": "De link die u gebruikte is niet meer geldig.",
|
||||
"link_expired_heading": "Uw link is verlopen."
|
||||
"link_expired_description": "De link die u gebruikte is niet meer geldig."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Geaccepteerd",
|
||||
@@ -198,7 +197,6 @@
|
||||
"created_by": "Gemaakt door",
|
||||
"customer_success": "Klant succes",
|
||||
"dark_overlay": "Donkere overlay",
|
||||
"data_refreshed_successfully": "Gegevens succesvol vernieuwd",
|
||||
"date": "Datum",
|
||||
"days": "dagen",
|
||||
"default": "Standaard",
|
||||
@@ -375,7 +373,6 @@
|
||||
"quotas_description": "Beperk het aantal reacties dat u ontvangt van deelnemers die aan bepaalde criteria voldoen.",
|
||||
"read_docs": "Documentatie lezen",
|
||||
"recipients": "Ontvangers",
|
||||
"refresh": "Vernieuwen",
|
||||
"remove": "Verwijderen",
|
||||
"remove_from_team": "Verwijderen uit team",
|
||||
"reorder_and_hide_columns": "Kolommen opnieuw rangschikken en verbergen",
|
||||
@@ -677,6 +674,8 @@
|
||||
"attributes_msg_new_attribute_created": "Nieuw attribuut “{key}” aangemaakt met type “{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "De gebruikers-ID bestaat al voor deze omgeving en is niet bijgewerkt.",
|
||||
"contact_deleted_successfully": "Contact succesvol verwijderd",
|
||||
"contacts_table_refresh": "Vernieuw contacten",
|
||||
"contacts_table_refresh_success": "Contacten zijn vernieuwd",
|
||||
"create_attribute": "Attribuut aanmaken",
|
||||
"create_new_attribute": "Nieuw attribuut aanmaken",
|
||||
"create_new_attribute_description": "Maak een nieuw attribuut aan voor segmentatiedoeleinden.",
|
||||
@@ -834,9 +833,6 @@
|
||||
},
|
||||
"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.",
|
||||
@@ -1199,7 +1195,7 @@
|
||||
"organization_invite_link_ready": "De uitnodigingslink voor uw organisatie is gereed!",
|
||||
"organization_name": "Organisatienaam",
|
||||
"organization_name_description": "Geef uw organisatie een beschrijvende naam.",
|
||||
"organization_name_placeholder": "bijv. Acme Inc.",
|
||||
"organization_name_placeholder": "bijv. Power Puff-meisjes",
|
||||
"organization_name_updated_successfully": "Organisatienaam is succesvol bijgewerkt",
|
||||
"organization_settings": "Organisatie-instellingen",
|
||||
"please_add_a_logo": "Voeg een logo toe",
|
||||
@@ -1234,15 +1230,12 @@
|
||||
"confirm_delete_my_account": "Verwijder mijn account",
|
||||
"confirm_your_current_password_to_get_started": "Bevestig uw huidige wachtwoord om aan de slag te gaan.",
|
||||
"delete_account": "Account verwijderen",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Schakel tweefactorauthenticatie uit",
|
||||
"disable_two_factor_authentication_description": "Als u 2FA moet uitschakelen, raden wij u aan dit zo snel mogelijk opnieuw in te schakelen.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Elke back-upcode kan precies één keer worden gebruikt om toegang te verlenen zonder uw authenticator.",
|
||||
"email_change_initiated": "Uw verzoek tot wijziging van het e-mailadres is ingediend.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Schakel tweefactorauthenticatie in",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Voer hieronder de code uit uw authenticator-app in.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Toegang verloren",
|
||||
"or_enter_the_following_code_manually": "Of voer de volgende code handmatig in:",
|
||||
"organizations_delete_message": "U bent de enige eigenaar van deze organisaties, dus <b>worden ze ook verwijderd.</b>",
|
||||
@@ -1253,8 +1246,6 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Bewaar de volgende back-upcodes op een veilige plaats.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Scan onderstaande QR-code met uw authenticator-app.",
|
||||
"security_description": "Beheer uw wachtwoord en andere beveiligingsinstellingen zoals tweefactorauthenticatie (2FA).",
|
||||
"sso_reauthentication_failed": "SSO-herauthenticatie is mislukt. Probeer je account opnieuw te verwijderen.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "Bij SSO-accounts kan het selecteren van Verwijderen u doorsturen naar uw identiteitsprovider. Als uw identiteit is bevestigd, wordt uw account automatisch verwijderd.",
|
||||
"two_factor_authentication": "Tweefactorauthenticatie",
|
||||
"two_factor_authentication_description": "Voeg een extra beveiligingslaag toe aan uw account voor het geval uw wachtwoord wordt gestolen.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Tweefactorauthenticatie ingeschakeld. Voer de zescijferige code van uw authenticator-app in.",
|
||||
@@ -1262,8 +1253,7 @@
|
||||
"unlock_two_factor_authentication": "Ontgrendel tweefactorauthenticatie met een hoger abonnement",
|
||||
"update_personal_info": "Update uw persoonlijke gegevens",
|
||||
"warning_cannot_delete_account": "U bent de enige eigenaar van deze organisatie. Draag het eigendom eerst over aan een ander lid.",
|
||||
"warning_cannot_undo": "Dit kan niet ongedaan worden gemaakt",
|
||||
"wrong_password": "Verkeerd wachtwoord"
|
||||
"warning_cannot_undo": "Dit kan niet ongedaan worden gemaakt"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Voeg leden toe aan het team en bepaal hun rol.",
|
||||
@@ -1559,7 +1549,7 @@
|
||||
"hide_question_settings": "Vraaginstellingen verbergen",
|
||||
"hostname": "Hostnaam",
|
||||
"if_you_need_more_please": "Als je meer nodig hebt,",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Blijf tonen wanneer geactiveerd totdat een antwoord of gedeeltelijk antwoord is ingediend.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Blijf tonen wanneer geactiveerd totdat een reactie is ingediend.",
|
||||
"ignore_global_waiting_time": "Afkoelperiode negeren",
|
||||
"ignore_global_waiting_time_description": "Deze enquête kan worden getoond wanneer aan de voorwaarden wordt voldaan, zelfs als er onlangs een andere enquête is getoond.",
|
||||
"image": "Afbeelding",
|
||||
@@ -2434,13 +2424,11 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "Controleer ook uw spammap als u de e-mail niet in uw inbox ziet.",
|
||||
"completed": "Deze enquête is gesloten.",
|
||||
"completed_heading": "Voltooid",
|
||||
"create_your_own": "Creëer uw eigen open source-enquête",
|
||||
"enter_pin": "Deze enquête is beveiligd. Voer hieronder de pincode in.",
|
||||
"just_curious": "Gewoon nieuwsgierig?",
|
||||
"link_invalid": "Aan deze enquête kan alleen op uitnodiging worden deelgenomen.",
|
||||
"paused": "Deze enquête is tijdelijk onderbroken.",
|
||||
"paused_heading": "Gepauzeerd",
|
||||
"please_try_again_with_the_original_link": "Probeer het opnieuw met de originele link",
|
||||
"preview_survey_questions": "Bekijk enquêtevragen.",
|
||||
"question_preview": "Vraagvoorbeeld",
|
||||
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Seu link está expirado.",
|
||||
"link_expired_description": "O link que você usou não é mais válido.",
|
||||
"link_expired_heading": "Seu link está expirado."
|
||||
"link_expired_description": "O link que você usou não é mais válido."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Aceito",
|
||||
@@ -198,7 +197,6 @@
|
||||
"created_by": "Criado por",
|
||||
"customer_success": "Sucesso do Cliente",
|
||||
"dark_overlay": "sobreposição escura",
|
||||
"data_refreshed_successfully": "Dados atualizados com sucesso",
|
||||
"date": "Encontro",
|
||||
"days": "dias",
|
||||
"default": "Padrão",
|
||||
@@ -375,7 +373,6 @@
|
||||
"quotas_description": "Limite a quantidade de respostas que você recebe de participantes que atendem a determinados critérios.",
|
||||
"read_docs": "Ler documentação",
|
||||
"recipients": "Destinatários",
|
||||
"refresh": "Atualizar",
|
||||
"remove": "remover",
|
||||
"remove_from_team": "Remover da equipe",
|
||||
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
|
||||
@@ -677,6 +674,8 @@
|
||||
"attributes_msg_new_attribute_created": "Novo atributo “{key}” criado com tipo “{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "O ID de usuário já existe para este ambiente e não foi atualizado.",
|
||||
"contact_deleted_successfully": "Contato excluído com sucesso",
|
||||
"contacts_table_refresh": "Atualizar contatos",
|
||||
"contacts_table_refresh_success": "Contatos atualizados com sucesso",
|
||||
"create_attribute": "Criar atributo",
|
||||
"create_new_attribute": "Criar novo atributo",
|
||||
"create_new_attribute_description": "Crie um novo atributo para fins de segmentação.",
|
||||
@@ -834,9 +833,6 @@
|
||||
},
|
||||
"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.",
|
||||
@@ -1199,7 +1195,7 @@
|
||||
"organization_invite_link_ready": "O link de convite da sua organização está pronto!",
|
||||
"organization_name": "Nome da Organização",
|
||||
"organization_name_description": "Dê um nome descritivo pra sua organização.",
|
||||
"organization_name_placeholder": "por exemplo, Acme Inc.",
|
||||
"organization_name_placeholder": "por exemplo, Meninas Superpoderosas",
|
||||
"organization_name_updated_successfully": "Nome da organização atualizado com sucesso",
|
||||
"organization_settings": "Configurações da Organização",
|
||||
"please_add_a_logo": "Por favor, adicione um logo",
|
||||
@@ -1234,15 +1230,12 @@
|
||||
"confirm_delete_my_account": "Excluir Minha Conta",
|
||||
"confirm_your_current_password_to_get_started": "Confirme sua senha atual para começar.",
|
||||
"delete_account": "Excluir Conta",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Desativar a autenticação de dois fatores",
|
||||
"disable_two_factor_authentication_description": "Se você precisar desativar a 2FA, recomendamos reativá-la o mais rápido possível.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Cada código de backup pode ser usado exatamente uma vez para conceder acesso sem o seu autenticador.",
|
||||
"email_change_initiated": "Sua solicitação de alteração de e-mail foi iniciada.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Digite o código do seu app autenticador abaixo.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Perdi o acesso",
|
||||
"or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:",
|
||||
"organizations_delete_message": "Você é o único dono dessas organizações, então elas <b>também serão apagadas.</b>",
|
||||
@@ -1253,8 +1246,6 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Guarde os seguintes códigos de backup em um lugar seguro.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Escaneie o código QR abaixo com seu app autenticador.",
|
||||
"security_description": "Gerencie sua senha e outras configurações de segurança como a autenticação de dois fatores (2FA).",
|
||||
"sso_reauthentication_failed": "A reautenticação SSO falhou. Tente excluir sua conta novamente.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "Para contas SSO, selecionar Excluir pode redirecionar você para seu provedor de identidade. Se sua identidade for confirmada, sua conta será excluída automaticamente.",
|
||||
"two_factor_authentication": "Autenticação de dois fatores",
|
||||
"two_factor_authentication_description": "Adicione uma camada extra de segurança à sua conta caso sua senha seja roubada.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticação de dois fatores ativada. Por favor, insira o código de seis dígitos do seu app autenticador.",
|
||||
@@ -1262,8 +1253,7 @@
|
||||
"unlock_two_factor_authentication": "Desbloqueia a autenticação de dois fatores com um plano melhor",
|
||||
"update_personal_info": "Atualize suas informações pessoais",
|
||||
"warning_cannot_delete_account": "Você é o único dono desta organização. Transfere a propriedade para outra pessoa primeiro.",
|
||||
"warning_cannot_undo": "Isso não pode ser desfeito",
|
||||
"wrong_password": "Senha incorreta"
|
||||
"warning_cannot_undo": "Isso não pode ser desfeito"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Adicione membros à equipe e determine sua função.",
|
||||
@@ -1559,7 +1549,7 @@
|
||||
"hide_question_settings": "Ocultar configurações da pergunta",
|
||||
"hostname": "nome do host",
|
||||
"if_you_need_more_please": "Se você precisar de mais, por favor",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continue mostrando sempre que acionado até que uma resposta ou resposta parcial seja enviada.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuar mostrando sempre que acionada até que uma resposta seja enviada.",
|
||||
"ignore_global_waiting_time": "Ignorar período de espera",
|
||||
"ignore_global_waiting_time_description": "Esta pesquisa pode ser mostrada sempre que suas condições forem atendidas, mesmo que outra pesquisa tenha sido mostrada recentemente.",
|
||||
"image": "imagem",
|
||||
@@ -2434,13 +2424,11 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "Por favor, dá uma olhada na sua pasta de spam se você não encontrar o e-mail na sua caixa de entrada.",
|
||||
"completed": "Essa pesquisa gratuita e de código aberto foi encerrada.",
|
||||
"completed_heading": "Concluído",
|
||||
"create_your_own": "Crie o seu próprio",
|
||||
"enter_pin": "Esta pesquisa está protegida. Digite o PIN abaixo.",
|
||||
"just_curious": "Só curioso?",
|
||||
"link_invalid": "Essa pesquisa só pode ser respondida por convite.",
|
||||
"paused": "Essa pesquisa gratuita e de código aberto está temporariamente pausada.",
|
||||
"paused_heading": "Pausado",
|
||||
"please_try_again_with_the_original_link": "Por favor, tente novamente com o link original",
|
||||
"preview_survey_questions": "Visualizar perguntas da pesquisa.",
|
||||
"question_preview": "Prévia da Pergunta",
|
||||
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "O seu link expirou.",
|
||||
"link_expired_description": "O link que utilizou já não é válido.",
|
||||
"link_expired_heading": "O seu link expirou."
|
||||
"link_expired_description": "O link que utilizou já não é válido."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Aceite",
|
||||
@@ -198,7 +197,6 @@
|
||||
"created_by": "Criado por",
|
||||
"customer_success": "Sucesso do Cliente",
|
||||
"dark_overlay": "Sobreposição escura",
|
||||
"data_refreshed_successfully": "Dados atualizados com sucesso",
|
||||
"date": "Data",
|
||||
"days": "dias",
|
||||
"default": "Padrão",
|
||||
@@ -375,7 +373,6 @@
|
||||
"quotas_description": "Limitar a quantidade de respostas recebidas de participantes que atendem a certos critérios.",
|
||||
"read_docs": "Ler documentação",
|
||||
"recipients": "Destinatários",
|
||||
"refresh": "Atualizar",
|
||||
"remove": "Remover",
|
||||
"remove_from_team": "Remover da equipa",
|
||||
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
|
||||
@@ -677,6 +674,8 @@
|
||||
"attributes_msg_new_attribute_created": "Criado novo atributo “{key}” com tipo “{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "O ID de utilizador já existe para este ambiente e não foi atualizado.",
|
||||
"contact_deleted_successfully": "Contacto eliminado com sucesso",
|
||||
"contacts_table_refresh": "Atualizar contactos",
|
||||
"contacts_table_refresh_success": "Contactos atualizados com sucesso",
|
||||
"create_attribute": "Criar atributo",
|
||||
"create_new_attribute": "Criar novo atributo",
|
||||
"create_new_attribute_description": "Crie um novo atributo para fins de segmentação.",
|
||||
@@ -834,9 +833,6 @@
|
||||
},
|
||||
"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.",
|
||||
@@ -1199,7 +1195,7 @@
|
||||
"organization_invite_link_ready": "O link de convite da sua organização está pronto!",
|
||||
"organization_name": "Nome da Organização",
|
||||
"organization_name_description": "Dê à sua organização um nome descritivo.",
|
||||
"organization_name_placeholder": "por exemplo, Acme Inc.",
|
||||
"organization_name_placeholder": "por exemplo, Power Puff Girls",
|
||||
"organization_name_updated_successfully": "Nome da organização atualizado com sucesso",
|
||||
"organization_settings": "Configurações da organização",
|
||||
"please_add_a_logo": "Por favor, adicione um logótipo",
|
||||
@@ -1234,15 +1230,12 @@
|
||||
"confirm_delete_my_account": "Eliminar a Minha Conta",
|
||||
"confirm_your_current_password_to_get_started": "Confirme a sua palavra-passe atual para começar.",
|
||||
"delete_account": "Eliminar Conta",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Desativar autenticação de dois fatores",
|
||||
"disable_two_factor_authentication_description": "Se precisar de desativar a autenticação de dois fatores, recomendamos que a reative o mais rapidamente possível.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Cada código de backup pode ser usado exatamente uma vez para conceder acesso sem o seu autenticador.",
|
||||
"email_change_initiated": "O seu pedido de alteração de email foi iniciado.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Introduza o código da sua aplicação de autenticação abaixo.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Perdeu o acesso",
|
||||
"or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:",
|
||||
"organizations_delete_message": "É o único proprietário destas organizações, por isso <b>também serão eliminadas.</b>",
|
||||
@@ -1253,8 +1246,6 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Guarde os seguintes códigos de backup num local seguro.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Digitalize o código QR abaixo com a sua aplicação de autenticação.",
|
||||
"security_description": "Gerir a sua palavra-passe e outras definições de segurança, como a autenticação de dois fatores (2FA).",
|
||||
"sso_reauthentication_failed": "A reautenticação SSO falhou. Tente eliminar a sua conta novamente.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "Para contas SSO, selecionar Eliminar poderá redirecioná-lo para o seu fornecedor de identidade. Se a sua identidade for confirmada, a sua conta será eliminada automaticamente.",
|
||||
"two_factor_authentication": "Autenticação de dois fatores",
|
||||
"two_factor_authentication_description": "Adicione uma camada extra de segurança à sua conta caso a sua palavra-passe seja roubada.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticação de dois fatores ativada. Introduza o código de seis dígitos da sua aplicação de autenticação.",
|
||||
@@ -1262,8 +1253,7 @@
|
||||
"unlock_two_factor_authentication": "Desbloqueie a autenticação de dois fatores com um plano superior",
|
||||
"update_personal_info": "Atualize as suas informações pessoais",
|
||||
"warning_cannot_delete_account": "É o único proprietário desta organização. Transfira a propriedade para outro membro primeiro.",
|
||||
"warning_cannot_undo": "Isto não pode ser desfeito",
|
||||
"wrong_password": "Palavra-passe incorreta"
|
||||
"warning_cannot_undo": "Isto não pode ser desfeito"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Adicionar membros à equipa e determinar o seu papel.",
|
||||
@@ -1559,7 +1549,7 @@
|
||||
"hide_question_settings": "Ocultar definições da pergunta",
|
||||
"hostname": "Nome do host",
|
||||
"if_you_need_more_please": "Se precisar de mais, por favor",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuar a mostrar sempre que acionado até que uma resposta ou resposta parcial seja submetida.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuar a mostrar sempre que acionado até que uma resposta seja submetida.",
|
||||
"ignore_global_waiting_time": "Ignorar período de espera",
|
||||
"ignore_global_waiting_time_description": "Este inquérito pode ser mostrado sempre que as suas condições forem cumpridas, mesmo que outro inquérito tenha sido mostrado recentemente.",
|
||||
"image": "Imagem",
|
||||
@@ -2434,13 +2424,11 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "Por favor, verifique também a sua pasta de spam se não vir o email na sua caixa de entrada.",
|
||||
"completed": "Este inquérito está encerrado.",
|
||||
"completed_heading": "Concluído",
|
||||
"create_your_own": "Crie o seu próprio inquérito de código aberto",
|
||||
"enter_pin": "Este inquérito está protegido. Introduza o PIN abaixo.",
|
||||
"just_curious": "Só por curiosidade?",
|
||||
"link_invalid": "Este inquérito só pode ser respondido por convite.",
|
||||
"paused": "Este inquérito está temporariamente suspenso.",
|
||||
"paused_heading": "Em pausa",
|
||||
"please_try_again_with_the_original_link": "Por favor, tente novamente com o link original",
|
||||
"preview_survey_questions": "Pré-visualizar perguntas do inquérito.",
|
||||
"question_preview": "Pré-visualização da Pergunta",
|
||||
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Link-ul dumneavoastră a expirat.",
|
||||
"link_expired_description": "Link-ul pe care l-ați utilizat nu mai este valabil.",
|
||||
"link_expired_heading": "Link-ul dumneavoastră a expirat."
|
||||
"link_expired_description": "Link-ul pe care l-ați utilizat nu mai este valabil."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Acceptat",
|
||||
@@ -198,7 +197,6 @@
|
||||
"created_by": "Creat de",
|
||||
"customer_success": "Succesul Clientului",
|
||||
"dark_overlay": "Suprapunere întunecată",
|
||||
"data_refreshed_successfully": "Datele au fost actualizate cu succes",
|
||||
"date": "Dată",
|
||||
"days": "zile",
|
||||
"default": "Implicit",
|
||||
@@ -375,7 +373,6 @@
|
||||
"quotas_description": "Limitați numărul de răspunsuri primite de la participanții care îndeplinesc anumite criterii.",
|
||||
"read_docs": "Citește documentația",
|
||||
"recipients": "Destinatari",
|
||||
"refresh": "Actualizează",
|
||||
"remove": "Șterge",
|
||||
"remove_from_team": "Elimină din echipă",
|
||||
"reorder_and_hide_columns": "Reordonați și ascundeți coloanele",
|
||||
@@ -677,6 +674,8 @@
|
||||
"attributes_msg_new_attribute_created": "A fost creat un nou atribut „{key}” cu tipul „{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "ID-ul de utilizator există deja pentru acest mediu și nu a fost actualizat.",
|
||||
"contact_deleted_successfully": "Contact șters cu succes",
|
||||
"contacts_table_refresh": "Reîmprospătare contacte",
|
||||
"contacts_table_refresh_success": "Contactele au fost actualizate cu succes",
|
||||
"create_attribute": "Creează atribut",
|
||||
"create_new_attribute": "Creează atribut nou",
|
||||
"create_new_attribute_description": "Creează un atribut nou pentru segmentare.",
|
||||
@@ -834,9 +833,6 @@
|
||||
},
|
||||
"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.",
|
||||
@@ -1199,7 +1195,7 @@
|
||||
"organization_invite_link_ready": "Linkul de invitație al organizației tale este gata!",
|
||||
"organization_name": "Nume Organizație",
|
||||
"organization_name_description": "Oferiți organizației dumneavoastră un nume descriptiv.",
|
||||
"organization_name_placeholder": "ex. Acme Inc.",
|
||||
"organization_name_placeholder": "ex. Power Puff Girls",
|
||||
"organization_name_updated_successfully": "Numele organizației actualizat cu succes",
|
||||
"organization_settings": "Setări Organizație",
|
||||
"please_add_a_logo": "Adaugă un logo",
|
||||
@@ -1234,15 +1230,12 @@
|
||||
"confirm_delete_my_account": "Șterge contul meu",
|
||||
"confirm_your_current_password_to_get_started": "Confirmaţi parola curentă pentru a începe.",
|
||||
"delete_account": "Șterge cont",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Dezactivează autentificarea în doi pași",
|
||||
"disable_two_factor_authentication_description": "Dacă este nevoie să dezactivați autentificarea în doi pași, vă recomandăm să o reactivați cât mai curând posibil.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Fiecare cod de rezervă poate fi utilizat o singură dată pentru a acorda acces fără autentificatorul tău.",
|
||||
"email_change_initiated": "Cererea dvs. de schimbare a e-mailului a fost inițiată.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Activează autentificarea în doi pași",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Introduceți codul din aplicația dvs. de autentificare mai jos.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Acces pierdut",
|
||||
"or_enter_the_following_code_manually": "Sau introduceți manual următorul cod:",
|
||||
"organizations_delete_message": "Ești singurul proprietar al acestor organizații, deci ele <b>vor fi șterse și ele.</b>",
|
||||
@@ -1253,8 +1246,6 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Salvează următoarele coduri de rezervă într-un loc sigur.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Scanați codul QR de mai jos cu aplicația dvs. de autentificare.",
|
||||
"security_description": "Gestionează parola și alte setări de securitate, precum autentificarea în doi pași (2FA).",
|
||||
"sso_reauthentication_failed": "Reautentificarea SSO a eșuat. Te rugăm să încerci din nou să îți ștergi contul.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "Pentru conturile SSO, selectarea opțiunii Șterge te poate redirecționa către furnizorul tău de identitate. Dacă identitatea ta este confirmată, contul va fi șters automat.",
|
||||
"two_factor_authentication": "Autentificare în doi pași",
|
||||
"two_factor_authentication_description": "Adăugați un strat suplimentar de securitate la contul dvs. în cazul în care parola este furată.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autentificare în doi pași activată. Introduceți codul de șase cifre din aplicația dvs. de autentificare.",
|
||||
@@ -1262,8 +1253,7 @@
|
||||
"unlock_two_factor_authentication": "Deblocați autentificarea în doi pași cu un plan superior",
|
||||
"update_personal_info": "Actualizează informațiile tale personale",
|
||||
"warning_cannot_delete_account": "Ești singurul proprietar al acestei organizații. Te rugăm să transferi proprietatea către un alt membru mai întâi.",
|
||||
"warning_cannot_undo": "Aceasta nu poate fi anulată",
|
||||
"wrong_password": "Parolă greșită"
|
||||
"warning_cannot_undo": "Aceasta nu poate fi anulată"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Adaugă membri în echipă și stabilește rolul lor.",
|
||||
@@ -1559,7 +1549,7 @@
|
||||
"hide_question_settings": "Ascunde setările întrebării",
|
||||
"hostname": "Nume gazdă",
|
||||
"if_you_need_more_please": "Dacă aveți nevoie de mai mult, vă rugăm",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuă să afișezi de fiecare dată când este declanșat, până când se trimite un răspuns sau un răspuns parțial.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuă afișarea ori de câte ori este declanșat până când se trimite un răspuns.",
|
||||
"ignore_global_waiting_time": "Ignoră perioada de răcire",
|
||||
"ignore_global_waiting_time_description": "Acest sondaj poate fi afișat ori de câte ori condițiile sale sunt îndeplinite, chiar dacă un alt sondaj a fost afișat recent.",
|
||||
"image": "Imagine",
|
||||
@@ -2434,13 +2424,11 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "Vă rugăm să verificați și folderul de spam dacă nu vedeți emailul în inbox.",
|
||||
"completed": "Acest chestionar este închis.",
|
||||
"completed_heading": "Completat",
|
||||
"create_your_own": "Creează-ți propriul chestionar open-source",
|
||||
"enter_pin": "Acest sondaj este protejat. Introduceți PIN-ul mai jos.",
|
||||
"just_curious": "Doar curios?",
|
||||
"link_invalid": "Acest sondaj poate fi completat doar pe bază de invitație.",
|
||||
"paused": "Acest sondaj este temporar întrerupt.",
|
||||
"paused_heading": "Pauză",
|
||||
"please_try_again_with_the_original_link": "Vă rugăm să încercați din nou cu linkul original",
|
||||
"preview_survey_questions": "Previzualizare întrebări chestionar",
|
||||
"question_preview": "Previzualizare Întrebare",
|
||||
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Ваша ссылка истекла.",
|
||||
"link_expired_description": "Ссылка, которой вы воспользовались, больше не действительна.",
|
||||
"link_expired_heading": "Ваша ссылка истекла."
|
||||
"link_expired_description": "Ссылка, которой вы воспользовались, больше не действительна."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Принято",
|
||||
@@ -198,7 +197,6 @@
|
||||
"created_by": "Создано пользователем",
|
||||
"customer_success": "Customer Success",
|
||||
"dark_overlay": "Тёмный оверлей",
|
||||
"data_refreshed_successfully": "Данные успешно обновлены",
|
||||
"date": "Дата",
|
||||
"days": "дни",
|
||||
"default": "По умолчанию",
|
||||
@@ -375,7 +373,6 @@
|
||||
"quotas_description": "Ограничьте количество ответов, которые вы получаете от участников, соответствующих определённым критериям.",
|
||||
"read_docs": "Читать документацию",
|
||||
"recipients": "Получатели",
|
||||
"refresh": "Обновить",
|
||||
"remove": "Удалить",
|
||||
"remove_from_team": "Удалить из команды",
|
||||
"reorder_and_hide_columns": "Изменить порядок и скрыть столбцы",
|
||||
@@ -677,6 +674,8 @@
|
||||
"attributes_msg_new_attribute_created": "Создан новый атрибут «{key}» с типом «{dataType}»",
|
||||
"attributes_msg_userid_already_exists": "Этот user ID уже существует в данной среде и не был обновлён.",
|
||||
"contact_deleted_successfully": "Контакт успешно удалён",
|
||||
"contacts_table_refresh": "Обновить контакты",
|
||||
"contacts_table_refresh_success": "Контакты успешно обновлены",
|
||||
"create_attribute": "Создать атрибут",
|
||||
"create_new_attribute": "Создать новый атрибут",
|
||||
"create_new_attribute_description": "Создайте новый атрибут для целей сегментации.",
|
||||
@@ -834,9 +833,6 @@
|
||||
},
|
||||
"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": "Вы уже подключили другой опрос к этому каналу.",
|
||||
@@ -1199,7 +1195,7 @@
|
||||
"organization_invite_link_ready": "Ссылка для приглашения в организацию готова!",
|
||||
"organization_name": "Название организации",
|
||||
"organization_name_description": "Дайте вашей организации понятное название.",
|
||||
"organization_name_placeholder": "например, Acme Inc.",
|
||||
"organization_name_placeholder": "например, Power Puff Girls",
|
||||
"organization_name_updated_successfully": "Название организации успешно обновлено",
|
||||
"organization_settings": "Настройки организации",
|
||||
"please_add_a_logo": "Пожалуйста, добавьте логотип",
|
||||
@@ -1234,15 +1230,12 @@
|
||||
"confirm_delete_my_account": "Удалить мой аккаунт",
|
||||
"confirm_your_current_password_to_get_started": "Подтвердите текущий пароль, чтобы начать.",
|
||||
"delete_account": "Удалить аккаунт",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Отключить двухфакторную аутентификацию",
|
||||
"disable_two_factor_authentication_description": "Если вам нужно отключить 2FA, рекомендуем включить её снова как можно скорее.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Каждый резервный код можно использовать только один раз для доступа без аутентификатора.",
|
||||
"email_change_initiated": "Запрос на изменение электронной почты инициирован.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Включить двухфакторную аутентификацию",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Введите ниже код из вашего приложения-аутентификатора.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Потерян доступ",
|
||||
"or_enter_the_following_code_manually": "Или введите следующий код вручную:",
|
||||
"organizations_delete_message": "Вы являетесь единственным владельцем этих организаций, поэтому они <b>также будут удалены.</b>",
|
||||
@@ -1253,8 +1246,6 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Сохраните следующие резервные коды в безопасном месте.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Отсканируйте QR-код ниже с помощью вашего приложения-аутентификатора.",
|
||||
"security_description": "Управляйте паролем и другими настройками безопасности, такими как двухфакторная аутентификация (2FA).",
|
||||
"sso_reauthentication_failed": "Повторная аутентификация SSO не удалась. Попробуйте удалить аккаунт еще раз.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "Для учетных записей SSO выбор Удалить может перенаправить вас к поставщику удостоверений. Если ваша личность будет подтверждена, учетная запись будет удалена автоматически.",
|
||||
"two_factor_authentication": "Двухфакторная аутентификация",
|
||||
"two_factor_authentication_description": "Добавьте дополнительный уровень защиты вашему аккаунту на случай, если ваш пароль будет украден.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Двухфакторная аутентификация включена. Пожалуйста, введите шестизначный код из вашего приложения-аутентификатора.",
|
||||
@@ -1262,8 +1253,7 @@
|
||||
"unlock_two_factor_authentication": "Откройте двухфакторную аутентификацию с более высоким тарифом",
|
||||
"update_personal_info": "Обновить личную информацию",
|
||||
"warning_cannot_delete_account": "Вы являетесь единственным владельцем этой организации. Пожалуйста, сначала передайте права другому участнику.",
|
||||
"warning_cannot_undo": "Это действие необратимо",
|
||||
"wrong_password": "Неверный пароль"
|
||||
"warning_cannot_undo": "Это действие необратимо"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Добавьте участников в команду и определите их роль.",
|
||||
@@ -1559,7 +1549,7 @@
|
||||
"hide_question_settings": "Скрыть настройки вопроса",
|
||||
"hostname": "Имя хоста",
|
||||
"if_you_need_more_please": "Если вам нужно больше, пожалуйста",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Продолжай показывать при каждом срабатывании триггера, пока не будет отправлен ответ или частичный ответ.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Показывать каждый раз при срабатывании, пока не будет получен ответ.",
|
||||
"ignore_global_waiting_time": "Игнорировать период ожидания",
|
||||
"ignore_global_waiting_time_description": "Этот опрос может отображаться при выполнении условий, даже если недавно уже был показан другой опрос.",
|
||||
"image": "Изображение",
|
||||
@@ -2434,13 +2424,11 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "Если вы не видите письмо во входящих, проверьте папку со спамом.",
|
||||
"completed": "Этот опрос закрыт.",
|
||||
"completed_heading": "Завершено",
|
||||
"create_your_own": "Создайте свой собственный опрос с открытым исходным кодом",
|
||||
"enter_pin": "Этот опрос защищён. Введите PIN ниже.",
|
||||
"just_curious": "Просто интересно?",
|
||||
"link_invalid": "Пройти этот опрос можно только по приглашению.",
|
||||
"paused": "Этот опрос временно приостановлен.",
|
||||
"paused_heading": "Приостановлено",
|
||||
"please_try_again_with_the_original_link": "Пожалуйста, попробуйте снова, используя оригинальную ссылку",
|
||||
"preview_survey_questions": "Просмотреть вопросы опроса.",
|
||||
"question_preview": "Предпросмотр вопроса",
|
||||
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "Din länk har gått ut.",
|
||||
"link_expired_description": "Länken du använde är inte längre giltig.",
|
||||
"link_expired_heading": "Din länk har gått ut."
|
||||
"link_expired_description": "Länken du använde är inte längre giltig."
|
||||
},
|
||||
"common": {
|
||||
"accepted": "Accepterad",
|
||||
@@ -198,7 +197,6 @@
|
||||
"created_by": "Skapad av",
|
||||
"customer_success": "Kundframgång",
|
||||
"dark_overlay": "Mörkt överlägg",
|
||||
"data_refreshed_successfully": "Data uppdaterades",
|
||||
"date": "Datum",
|
||||
"days": "dagar",
|
||||
"default": "Standard",
|
||||
@@ -375,7 +373,6 @@
|
||||
"quotas_description": "Begränsa antalet svar du får från deltagare som uppfyller vissa kriterier.",
|
||||
"read_docs": "Läs dokumentation",
|
||||
"recipients": "Mottagare",
|
||||
"refresh": "Uppdatera",
|
||||
"remove": "Ta bort",
|
||||
"remove_from_team": "Ta bort från teamet",
|
||||
"reorder_and_hide_columns": "Ordna om och dölj kolumner",
|
||||
@@ -677,6 +674,8 @@
|
||||
"attributes_msg_new_attribute_created": "Nytt attribut ”{key}” med typen ”{dataType}” har skapats",
|
||||
"attributes_msg_userid_already_exists": "Användar-ID finns redan för denna miljö och uppdaterades inte.",
|
||||
"contact_deleted_successfully": "Kontakt borttagen",
|
||||
"contacts_table_refresh": "Uppdatera kontakter",
|
||||
"contacts_table_refresh_success": "Kontakter uppdaterade",
|
||||
"create_attribute": "Skapa attribut",
|
||||
"create_new_attribute": "Skapa nytt attribut",
|
||||
"create_new_attribute_description": "Skapa ett nytt attribut för segmenteringsändamål.",
|
||||
@@ -834,9 +833,6 @@
|
||||
},
|
||||
"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.",
|
||||
@@ -1199,7 +1195,7 @@
|
||||
"organization_invite_link_ready": "Din organisationsinbjudningslänk är redo!",
|
||||
"organization_name": "Organisationsnamn",
|
||||
"organization_name_description": "Ge din organisation ett beskrivande namn.",
|
||||
"organization_name_placeholder": "t.ex. Acme Inc.",
|
||||
"organization_name_placeholder": "t.ex. Power Puff Girls",
|
||||
"organization_name_updated_successfully": "Organisationsnamn uppdaterat",
|
||||
"organization_settings": "Organisationsinställningar",
|
||||
"please_add_a_logo": "Vänligen lägg till en logotyp",
|
||||
@@ -1234,15 +1230,12 @@
|
||||
"confirm_delete_my_account": "Ta bort mitt konto",
|
||||
"confirm_your_current_password_to_get_started": "Bekräfta ditt nuvarande lösenord för att komma igång.",
|
||||
"delete_account": "Ta bort konto",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "Inaktivera tvåfaktorsautentisering",
|
||||
"disable_two_factor_authentication_description": "Om du behöver inaktivera 2FA rekommenderar vi att du aktiverar det igen så snart som möjligt.",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "Varje reservkod kan användas exakt en gång för att ge åtkomst utan din autentiserare.",
|
||||
"email_change_initiated": "Din begäran om e-poständring har initierats.",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "Aktivera tvåfaktorsautentisering",
|
||||
"enter_the_code_from_your_authenticator_app_below": "Ange koden från din autentiseringsapp nedan.",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "Förlorad åtkomst",
|
||||
"or_enter_the_following_code_manually": "Eller ange följande kod manuellt:",
|
||||
"organizations_delete_message": "Du är den enda ägaren av dessa organisationer, så de <b>kommer också att tas bort.</b>",
|
||||
@@ -1253,8 +1246,6 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "Spara följande reservkoder på ett säkert ställe.",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "Skanna QR-koden nedan med din autentiseringsapp.",
|
||||
"security_description": "Hantera ditt lösenord och andra säkerhetsinställningar som tvåfaktorsautentisering (2FA).",
|
||||
"sso_reauthentication_failed": "SSO-återautentisering misslyckades. Försök ta bort ditt konto igen.",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "För SSO-konton kan valet Ta bort omdirigera dig till din identitetsleverantör. Om din identitet bekräftas tas ditt konto bort automatiskt.",
|
||||
"two_factor_authentication": "Tvåfaktorsautentisering",
|
||||
"two_factor_authentication_description": "Lägg till ett extra säkerhetslager till ditt konto om ditt lösenord blir stulet.",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Tvåfaktorsautentisering aktiverad. Vänligen ange den sexsiffriga koden från din autentiseringsapp.",
|
||||
@@ -1262,8 +1253,7 @@
|
||||
"unlock_two_factor_authentication": "Lås upp tvåfaktorsautentisering med en högre plan",
|
||||
"update_personal_info": "Uppdatera din personliga information",
|
||||
"warning_cannot_delete_account": "Du är den enda ägaren av denna organisation. Vänligen överför ägarskapet till en annan medlem först.",
|
||||
"warning_cannot_undo": "Detta kan inte ångras",
|
||||
"wrong_password": "Fel lösenord"
|
||||
"warning_cannot_undo": "Detta kan inte ångras"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "Lägg till medlemmar i teamet och bestäm deras roll.",
|
||||
@@ -1559,7 +1549,7 @@
|
||||
"hide_question_settings": "Dölj frågeinställningar",
|
||||
"hostname": "Värdnamn",
|
||||
"if_you_need_more_please": "Om du behöver mer, vänligen",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Fortsätt visa varje gång det utlöses tills ett svar eller ett delsvar skickas in.",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Fortsätt visa när villkoren är uppfyllda tills ett svar skickas in.",
|
||||
"ignore_global_waiting_time": "Ignorera väntetid",
|
||||
"ignore_global_waiting_time_description": "Denna enkät kan visas när dess villkor är uppfyllda, även om en annan enkät nyligen visats.",
|
||||
"image": "Bild",
|
||||
@@ -2434,13 +2424,11 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "Vänligen kontrollera även din skräppost om du inte ser e-postmeddelandet i din inkorg.",
|
||||
"completed": "Denna enkät är stängd.",
|
||||
"completed_heading": "Slutförd",
|
||||
"create_your_own": "Skapa din egen öppenkällkodsenkät",
|
||||
"enter_pin": "Denna undersökning är skyddad. Ange PIN-koden nedan.",
|
||||
"just_curious": "Bara nyfiken?",
|
||||
"link_invalid": "Denna enkät kan endast tas via inbjudan.",
|
||||
"paused": "Denna enkät är tillfälligt pausad.",
|
||||
"paused_heading": "Pausad",
|
||||
"please_try_again_with_the_original_link": "Vänligen försök igen med den ursprungliga länken",
|
||||
"preview_survey_questions": "Förhandsgranska enkätfrågor.",
|
||||
"question_preview": "Frågeförhandsgranskning",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "您的 链接 已过期。",
|
||||
"link_expired_description": "您 使用 的 链接 已失效。",
|
||||
"link_expired_heading": "您的 链接 已过期。"
|
||||
"link_expired_description": "您 使用 的 链接 已失效。"
|
||||
},
|
||||
"common": {
|
||||
"accepted": "已接受",
|
||||
@@ -198,7 +197,6 @@
|
||||
"created_by": "由 创建",
|
||||
"customer_success": "客户成功",
|
||||
"dark_overlay": "深色遮罩层",
|
||||
"data_refreshed_successfully": "数据刷新成功",
|
||||
"date": "日期",
|
||||
"days": "天",
|
||||
"default": "默认",
|
||||
@@ -375,7 +373,6 @@
|
||||
"quotas_description": "限制 符合 特定 条件 的 参与者 的 响应 数量 。",
|
||||
"read_docs": "阅读文档",
|
||||
"recipients": "收件人",
|
||||
"refresh": "刷新",
|
||||
"remove": "移除",
|
||||
"remove_from_team": "从团队中移除",
|
||||
"reorder_and_hide_columns": "重新排序和隐藏列",
|
||||
@@ -677,6 +674,8 @@
|
||||
"attributes_msg_new_attribute_created": "已创建新属性“{key}”,类型为“{dataType}”",
|
||||
"attributes_msg_userid_already_exists": "该环境下的用户ID已存在,未进行更新。",
|
||||
"contact_deleted_successfully": "联系人 删除 成功",
|
||||
"contacts_table_refresh": "刷新 联系人",
|
||||
"contacts_table_refresh_success": "联系人 已成功刷新",
|
||||
"create_attribute": "创建属性",
|
||||
"create_new_attribute": "创建新属性",
|
||||
"create_new_attribute_description": "为细分目的创建新属性。",
|
||||
@@ -834,9 +833,6 @@
|
||||
},
|
||||
"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": "您 已 经 将 另 一 个 调 查 连 接 到 此 频 道 。",
|
||||
@@ -1199,7 +1195,7 @@
|
||||
"organization_invite_link_ready": "您的组织邀请链接已准备好!",
|
||||
"organization_name": "组织 名称",
|
||||
"organization_name_description": "为您的组织 提供一个描述性的名称",
|
||||
"organization_name_placeholder": "例如 Acme Inc.",
|
||||
"organization_name_placeholder": "例如 Power Puff Girls",
|
||||
"organization_name_updated_successfully": "组织 名称 更新 成功",
|
||||
"organization_settings": "组织 设置",
|
||||
"please_add_a_logo": "请添加 徽标",
|
||||
@@ -1234,15 +1230,12 @@
|
||||
"confirm_delete_my_account": "删除我的账户",
|
||||
"confirm_your_current_password_to_get_started": "确认 您 的 当前 密码 以 开始。",
|
||||
"delete_account": "删除账号",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "禁用 双因素 认证",
|
||||
"disable_two_factor_authentication_description": "如果你需要禁用 2FA ,我们建议尽快重新启用。",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "每个备用代码只能使用一次,以在没有您的身份验证器的情况下授予访问权限。",
|
||||
"email_change_initiated": "您的 邮箱 更改 请求 已启动。",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "启用 双因素 认证",
|
||||
"enter_the_code_from_your_authenticator_app_below": "从 你的 身份验证 应用 中 输入 代码 。",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "失去访问",
|
||||
"or_enter_the_following_code_manually": "或者 手动输入 以下 代码:",
|
||||
"organizations_delete_message": "您 是 这些 组织 的 唯一 所有者,因此它们 <b> 也将 被 删除。 </b>",
|
||||
@@ -1253,8 +1246,6 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "请 将 以下 备份 代码 保存在 安全地 方。",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "用 你的 身份验证 应用 扫描 下方 的 二维码。",
|
||||
"security_description": "管理你的密码和其他安全设置,如双因素认证 (2FA)。",
|
||||
"sso_reauthentication_failed": "SSO 重新认证失败。请再次尝试删除您的账号。",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "对于 SSO 账户,选择删除可能会将您重定向到身份提供商。如果您的身份确认成功,您的账户将自动删除。",
|
||||
"two_factor_authentication": "双因素 认证",
|
||||
"two_factor_authentication_description": "为你的账户增加额外的安全层,以防密码被盗。",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "双因素 认证 已启用 。 请输入 从 你的 身份验证 应用 中 的 六 位 数 字 代码 。",
|
||||
@@ -1262,8 +1253,7 @@
|
||||
"unlock_two_factor_authentication": "使用 更高 级 方案 解锁 双 重 因素 验证",
|
||||
"update_personal_info": "更新你的个人信息",
|
||||
"warning_cannot_delete_account": "您 是 该 组织 的 唯一 拥有者 。 请 先 将 所有权 转移 给 其他 成员 。",
|
||||
"warning_cannot_undo": "此 无法 撤销。",
|
||||
"wrong_password": "密码错误"
|
||||
"warning_cannot_undo": "此 无法 撤销。"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "将 成员 添加到 团队 ,并 确定 他们 的 角色",
|
||||
@@ -1559,7 +1549,7 @@
|
||||
"hide_question_settings": "隐藏问题设置",
|
||||
"hostname": "主 机 名",
|
||||
"if_you_need_more_please": "如果您需要更多,请",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "持续在触发时显示,直到提交响应或部分响应。",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "每次触发时都会显示,直到提交回应为止。",
|
||||
"ignore_global_waiting_time": "忽略冷却期",
|
||||
"ignore_global_waiting_time_description": "只要满足条件,此调查即可显示,即使最近刚显示过其他调查。",
|
||||
"image": "图片",
|
||||
@@ -2434,13 +2424,11 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "请 也 检查 您 的 垃圾邮件 文件夹 如果 您 没有 在 收件箱 中 看到 邮件。",
|
||||
"completed": "此 调查 关闭",
|
||||
"completed_heading": "完成",
|
||||
"create_your_own": "创建 你 的 开源 调查",
|
||||
"enter_pin": "本调查已受保护。请在下方输入PIN码。",
|
||||
"just_curious": "只是好奇 ?",
|
||||
"link_invalid": "此调查只能通过邀请参加。",
|
||||
"paused": "此调查暂时暂停。",
|
||||
"paused_heading": "暂停",
|
||||
"please_try_again_with_the_original_link": "请 尝试 使用 原始 链接",
|
||||
"preview_survey_questions": "预览 问卷调查 问题。",
|
||||
"question_preview": "问题 预览",
|
||||
|
||||
@@ -111,8 +111,7 @@
|
||||
},
|
||||
"c": {
|
||||
"link_expired": "您 的 連結 已過期。",
|
||||
"link_expired_description": "您 使用 的 連結 已無效。",
|
||||
"link_expired_heading": "您 的 連結 已過期。"
|
||||
"link_expired_description": "您 使用 的 連結 已無效。"
|
||||
},
|
||||
"common": {
|
||||
"accepted": "已接受",
|
||||
@@ -198,7 +197,6 @@
|
||||
"created_by": "建立者",
|
||||
"customer_success": "客戶成功",
|
||||
"dark_overlay": "深色覆蓋",
|
||||
"data_refreshed_successfully": "資料已成功重新整理",
|
||||
"date": "日期",
|
||||
"days": "天",
|
||||
"default": "預設",
|
||||
@@ -375,7 +373,6 @@
|
||||
"quotas_description": "限制 擁有 特定 條件 的 參與者 所 提供 的 回應 數量。",
|
||||
"read_docs": "閱讀文件",
|
||||
"recipients": "收件者",
|
||||
"refresh": "重新整理",
|
||||
"remove": "移除",
|
||||
"remove_from_team": "從團隊中移除",
|
||||
"reorder_and_hide_columns": "重新排序和隱藏欄位",
|
||||
@@ -677,6 +674,8 @@
|
||||
"attributes_msg_new_attribute_created": "已建立新屬性「{key}」,型別為「{dataType}」",
|
||||
"attributes_msg_userid_already_exists": "此環境已存在該使用者 ID,未進行更新。",
|
||||
"contact_deleted_successfully": "聯絡人已成功刪除",
|
||||
"contacts_table_refresh": "重新整理聯絡人",
|
||||
"contacts_table_refresh_success": "聯絡人已成功重新整理",
|
||||
"create_attribute": "建立屬性",
|
||||
"create_new_attribute": "建立新屬性",
|
||||
"create_new_attribute_description": "建立新屬性以進行分群用途。",
|
||||
@@ -834,9 +833,6 @@
|
||||
},
|
||||
"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": "您已將另一個問卷連線到此頻道。",
|
||||
@@ -1199,7 +1195,7 @@
|
||||
"organization_invite_link_ready": "您的組織邀請連結已準備就緒!",
|
||||
"organization_name": "組織名稱",
|
||||
"organization_name_description": "為您的組織提供描述性名稱。",
|
||||
"organization_name_placeholder": "例如:Acme Inc.",
|
||||
"organization_name_placeholder": "例如:飛天小女警",
|
||||
"organization_name_updated_successfully": "組織名稱已成功更新",
|
||||
"organization_settings": "組織設定",
|
||||
"please_add_a_logo": "請新增標誌",
|
||||
@@ -1234,15 +1230,12 @@
|
||||
"confirm_delete_my_account": "刪除我的帳戶",
|
||||
"confirm_your_current_password_to_get_started": "確認您目前的密碼以開始使用。",
|
||||
"delete_account": "刪除帳戶",
|
||||
"delete_account_confirmation_required": "Email confirmation is required to delete your account.",
|
||||
"disable_two_factor_authentication": "停用雙重驗證",
|
||||
"disable_two_factor_authentication_description": "如果您需要停用 2FA,我們建議您盡快重新啟用它。",
|
||||
"each_backup_code_can_be_used_exactly_once_to_grant_access_without_your_authenticator": "每個備份碼只能使用一次,以便在沒有驗證器的情況下授予存取權限。",
|
||||
"email_change_initiated": "您的 email 更改請求已啟動。",
|
||||
"email_confirmation_does_not_match": "Email confirmation does not match.",
|
||||
"enable_two_factor_authentication": "啟用雙重驗證",
|
||||
"enter_the_code_from_your_authenticator_app_below": "在下方輸入您驗證器應用程式中的程式碼。",
|
||||
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
|
||||
"lost_access": "無法存取",
|
||||
"or_enter_the_following_code_manually": "或手動輸入下列程式碼:",
|
||||
"organizations_delete_message": "您是這些組織的唯一擁有者,因此它們也 <b>將被刪除。</b>",
|
||||
@@ -1253,8 +1246,6 @@
|
||||
"save_the_following_backup_codes_in_a_safe_place": "將下列備份碼儲存在安全的地方。",
|
||||
"scan_the_qr_code_below_with_your_authenticator_app": "使用您的驗證器應用程式掃描下方的 QR 碼。",
|
||||
"security_description": "管理您的密碼和其他安全性設定,例如雙重驗證 (2FA)。",
|
||||
"sso_reauthentication_failed": "SSO 重新驗證失敗。請再次嘗試刪除您的帳號。",
|
||||
"sso_reauthentication_may_be_required_for_deletion": "對於 SSO 帳戶,選取刪除可能會將您重新導向至身分提供者。如果您的身分確認成功,您的帳戶將自動刪除。",
|
||||
"two_factor_authentication": "雙重驗證",
|
||||
"two_factor_authentication_description": "在您的密碼被盜時,為您的帳戶新增額外的安全層。",
|
||||
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "已啟用雙重驗證。請輸入您驗證器應用程式中的六位數程式碼。",
|
||||
@@ -1262,8 +1253,7 @@
|
||||
"unlock_two_factor_authentication": "使用更高等級的方案解鎖雙重驗證",
|
||||
"update_personal_info": "更新您的個人資訊",
|
||||
"warning_cannot_delete_account": "您是此組織的唯一擁有者。請先將所有權轉讓給其他成員。",
|
||||
"warning_cannot_undo": "此操作無法復原",
|
||||
"wrong_password": "密碼錯誤"
|
||||
"warning_cannot_undo": "此操作無法復原"
|
||||
},
|
||||
"teams": {
|
||||
"add_members_description": "將成員新增至團隊並確定其角色。",
|
||||
@@ -1559,7 +1549,7 @@
|
||||
"hide_question_settings": "隱藏問題設定",
|
||||
"hostname": "主機名稱",
|
||||
"if_you_need_more_please": "如果您需要更多,請",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "持續顯示,直到使用者提交回應或部分回應為止。",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "每次觸發時都顯示,直到提交回應為止。",
|
||||
"ignore_global_waiting_time": "忽略冷卻期",
|
||||
"ignore_global_waiting_time_description": "此問卷在符合條件時即可顯示,即使最近已顯示過其他問卷。",
|
||||
"image": "圖片",
|
||||
@@ -2434,13 +2424,11 @@
|
||||
"s": {
|
||||
"check_inbox_or_spam": "如果您的收件匣中沒有看到電子郵件,也請檢查您的垃圾郵件資料夾。",
|
||||
"completed": "此免費且開源的問卷已關閉。",
|
||||
"completed_heading": "已完成",
|
||||
"create_your_own": "建立您自己的",
|
||||
"enter_pin": "此問卷已受保護。請在下方輸入 PIN 碼。",
|
||||
"just_curious": "只是好奇?",
|
||||
"link_invalid": "此問卷只能透過邀請填寫。",
|
||||
"paused": "此免費且開源的問卷已暫時暫停。",
|
||||
"paused_heading": "已暫停",
|
||||
"please_try_again_with_the_original_link": "請使用原始連結再試一次",
|
||||
"preview_survey_questions": "預覽問卷問題。",
|
||||
"question_preview": "問題預覽",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user