Compare commits

...

27 Commits

Author SHA1 Message Date
Dhruwang e319b44f0f fix: use product IDs to identify free plan subscription renewals
Metadata-based detection doesn't work because legacy free plans have
formbricks_plan set to "custom". Switch to matching against known free
plan product IDs (hobby and legacy free) instead.
2026-04-30 11:39:17 +05:30
Dhruwang d29cfc8880 fix: extend free plan renewal skip to cover legacy free and startup plans
Instead of only checking for hobby plans, skip renewals for any
subscription where all items are confirmed non-paid (not pro/scale/custom).
This covers hobby, legacy free, and legacy startup plans that all
generate no-op $0 renewal events.
2026-04-30 10:57:06 +05:30
Dhruwang 19178ca94d fix: guard against empty items array in hobby subscription renewal check
`[].every()` returns true (vacuous truth), so a subscription with no
items would be incorrectly identified as a hobby plan and skipped.
Add a length check before the every() call.
2026-04-30 10:46:42 +05:30
Dhruwang 45040c4754 fix: skip hobby subscription renewal webhooks to prevent Make credit drain
Monthly renewals of ~10k $0 hobby subscriptions trigger customer.subscription.updated
events that flood the Make.com webhook queue, consuming credits even when filtered.

Add isHobbySubscriptionRenewal() to detect pure billing-period-roll events on hobby
plans by checking that all line items are hobby and only renewal-related fields
(current_period_start/end, latest_invoice, billing_cycle_anchor) changed. These are
short-circuited before any processing or downstream webhook forwarding.

Closes ENG-698

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-29 17:33:17 +05:30
Bhagya Amarasinghe f79fe1490e feat: add PostHog experiment wrappers (#7899) 2026-04-29 08:07:11 +00:00
Dhruwang Jariwala 5cfbc671c5 fix: allow back navigation to prefilled questions in email embed surveys (#7900) 2026-04-29 08:06:08 +00:00
Tiago e6e9419b93 fix: require step-up authorization for account deletion (#7901)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-28 12:46:27 +00:00
dependabot[bot] 90eb78e571 chore(deps): bump the npm_and_yarn group across 5 directories with 2 updates (#7834)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2026-04-28 12:01:12 +02:00
Johannes 991866f549 docs: document option ID prefilling (#7893)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-28 08:17:05 +00:00
Bhagya Amarasinghe 6d1b3475d4 fix(survey-list): reduce v3 overview query cost (#7812) 2026-04-27 16:53:53 +00:00
Johannes 5e967a2b67 fix: use app.formbricks.com in v1 API docs (#7894)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
2026-04-27 13:19:12 +00:00
Johannes 23a8fd6a47 fix: replace organization name placeholder example (#7832)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Johannes <jobenjada@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-27 12:49:16 +00:00
Dhruwang Jariwala 0686eb3cbb feat: add refetch button to refresh responses table (#7808)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-27 10:10:53 +00:00
Anshuman Pandey 6d9ab315c2 fix: prevent SSRF via redirect following in webhook delivery (#7877) 2026-04-27 08:50:21 +00:00
Tiago 4128731c5f fix: password hash visibility improvement (#7814)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-24 18:16:59 +00:00
Matti Nannt ef96426ca0 chore(security): dependency audit — reduce attack surface & resolve all vulnerabilities (#7801)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 20:32:32 +02:00
Dhruwang Jariwala ce1dbe8b00 fix: apply plan changes immediately for non-standard plans (#7807) 2026-04-24 07:38:33 +00:00
Dhruwang Jariwala 444f043140 fix: prevent Airtable integration crash when token expires (#7811)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 08:36:10 +00:00
Dhruwang Jariwala 2d32c0d671 feat: add iframe preview to website embed tab (#7791)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 06:36:55 +00:00
Dhruwang Jariwala 8dc70a5e30 fix: prevent survey widget CSS from polluting host page styles (#7805)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 12:24:44 +00:00
Aryan Ghugare 3e4e55fbf1 fix: prevent bypass of single-use survey restriction via v1 API (#7735)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-22 09:48:18 +00:00
Tiago fcfedd6e15 feat: replace minio with rustfs (#7742) 2026-04-22 08:07:38 +00:00
Tiago 6c4342690f fix: harden legacy SSO relinking (#7755) 2026-04-22 07:35:23 +00:00
arasucar b8c361fcf3 refactor: use context instead of prop drilling in survey analysis components (#6223) (#7754)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-22 06:00:13 +00:00
Matti Nannt 8771a0ec91 fix: lodash vulnerability (#7800) 2026-04-22 05:57:08 +00:00
Bhagya Amarasinghe fc33c52133 fix: patch protobufjs transitive vulnerabilities (#7790) 2026-04-21 09:59:12 +00:00
Balázs Úr 75cf9293b1 fix: Hungarian translation (#7752) 2026-04-21 08:41:53 +00:00
212 changed files with 10866 additions and 6205 deletions
+4 -4
View File
@@ -20,12 +20,12 @@ runs:
using: "composite"
steps:
- name: Checkout repo
uses: actions/checkout@v3
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./.github/actions/dangerous-git-checkout
- name: Cache Build
uses: actions/cache@v3
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
id: cache-build
env:
cache-name: prod-build
@@ -43,7 +43,7 @@ runs:
shell: bash
- name: Setup Node.js 20.x
uses: actions/setup-node@v3
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20.x
if: steps.cache-build.outputs.cache-hit != 'true'
@@ -53,7 +53,7 @@ runs:
if: steps.cache-build.outputs.cache-hit != 'true'
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
if: steps.cache-build.outputs.cache-hit != 'true'
shell: bash
@@ -4,7 +4,7 @@ runs:
using: "composite"
steps:
- name: Checkout repo
uses: actions/checkout@v3
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 2
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: Run Chromatic
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
+37 -48
View File
@@ -57,7 +57,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 22.x
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
@@ -65,7 +65,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
shell: bash
- name: create .env
@@ -85,65 +85,48 @@ jobs:
echo "S3_REGION=us-east-1" >> .env
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
echo "S3_ACCESS_KEY=devminio" >> .env
echo "S3_SECRET_KEY=devminio123" >> .env
echo "S3_ACCESS_KEY=devrustfs-service" >> .env
echo "S3_SECRET_KEY=devrustfs-service123" >> .env
echo "S3_FORCE_PATH_STYLE=1" >> .env
shell: bash
- name: Install MinIO client (mc)
run: |
set -euo pipefail
MC_VERSION="RELEASE.2025-08-13T08-35-41Z"
MC_BASE="https://dl.min.io/client/mc/release/linux-amd64/archive"
MC_BIN="mc.${MC_VERSION}"
MC_SUM="${MC_BIN}.sha256sum"
curl -fsSL "${MC_BASE}/${MC_BIN}" -o "${MC_BIN}"
curl -fsSL "${MC_BASE}/${MC_SUM}" -o "${MC_SUM}"
sha256sum -c "${MC_SUM}"
chmod +x "${MC_BIN}"
sudo mv "${MC_BIN}" /usr/local/bin/mc
- name: Start MinIO Server
- name: Start RustFS Server
run: |
set -euo pipefail
# Start MinIO server in background
# Start RustFS server in background
docker run -d \
--name minio-server \
--name rustfs-server \
-p 9000:9000 \
-p 9001:9001 \
-e MINIO_ROOT_USER=devminio \
-e MINIO_ROOT_PASSWORD=devminio123 \
minio/minio:RELEASE.2025-09-07T16-13-09Z \
server /data --console-address :9001
-e RUSTFS_ACCESS_KEY=devrustfs \
-e RUSTFS_SECRET_KEY=devrustfs123 \
-e RUSTFS_ADDRESS=:9000 \
-e RUSTFS_CONSOLE_ENABLE=true \
-e RUSTFS_CONSOLE_ADDRESS=:9001 \
rustfs/rustfs:1.0.0-alpha.93 \
/data
echo "MinIO server started"
echo "RustFS server started"
- name: Wait for MinIO and create S3 bucket
- name: Bootstrap RustFS bucket and browser upload CORS
run: |
set -euo pipefail
echo "Waiting for MinIO to be ready..."
ready=0
for i in {1..60}; do
if curl -fsS http://localhost:9000/minio/health/live >/dev/null; then
echo "MinIO is up after ${i} seconds"
ready=1
break
fi
sleep 1
done
if [ "$ready" -ne 1 ]; then
echo "::error::MinIO did not become ready within 60 seconds"
exit 1
fi
mc alias set local http://localhost:9000 devminio devminio123
mc mb --ignore-existing local/formbricks-e2e
docker run --rm \
--network host \
--entrypoint /bin/sh \
-e RUSTFS_ENDPOINT_URL=http://127.0.0.1:9000 \
-e RUSTFS_ADMIN_USER=devrustfs \
-e RUSTFS_ADMIN_PASSWORD=devrustfs123 \
-e RUSTFS_SERVICE_USER=devrustfs-service \
-e RUSTFS_SERVICE_PASSWORD=devrustfs-service123 \
-e RUSTFS_BUCKET_NAME=formbricks-e2e \
-e RUSTFS_POLICY_NAME=formbricks-e2e-policy \
-e RUSTFS_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 \
-v "$PWD/docker/rustfs-init.sh:/tmp/rustfs-init.sh:ro" \
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 \
/tmp/rustfs-init.sh
- name: Build App
run: |
@@ -242,8 +225,14 @@ jobs:
if: failure()
with:
name: app-logs
if-no-files-found: ignore
path: app.log
- name: Output App Logs
if: failure()
run: cat app.log
run: |
if [ -f app.log ]; then
cat app.log
else
echo "app.log not found because the Run App step did not execute or failed before log creation."
fi
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20.x
@@ -29,7 +29,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Setup Node.js 22.x
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
@@ -33,7 +33,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20.x
@@ -30,7 +30,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
+3 -2
View File
@@ -2,6 +2,7 @@ name: Translation Validation
permissions:
contents: read
pull-requests: read
on:
pull_request:
@@ -39,7 +40,7 @@ jobs:
- name: Setup Node.js 22.x
if: steps.changes.outputs.translations == 'true'
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
@@ -49,7 +50,7 @@ jobs:
- name: Install dependencies
if: steps.changes.outputs.translations == 'true'
run: pnpm install --config.platform=linux --config.architecture=x64
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
- name: Validate translation keys
if: steps.changes.outputs.translations == 'true'
+12 -12
View File
@@ -11,19 +11,19 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"devDependencies": {
"@chromatic-com/storybook": "^5.0.1",
"@storybook/addon-a11y": "10.2.17",
"@storybook/addon-links": "10.2.17",
"@storybook/addon-onboarding": "10.2.17",
"@storybook/react-vite": "10.2.17",
"@typescript-eslint/eslint-plugin": "8.57.0",
"@tailwindcss/vite": "4.2.1",
"@typescript-eslint/parser": "8.57.0",
"@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",
"@vitejs/plugin-react": "5.1.4",
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.2.17",
"storybook": "10.2.17",
"vite": "7.3.2",
"@storybook/addon-docs": "10.2.17"
"eslint-plugin-storybook": "10.3.5",
"storybook": "10.3.5",
"vite": "7.3.2"
}
}
@@ -1,4 +1,4 @@
import { Prisma } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
@@ -32,7 +32,7 @@ describe("getTeamsByOrganizationId", () => {
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
new PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
});
@@ -1,6 +1,6 @@
"use server";
import { Prisma } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
@@ -27,7 +27,7 @@ export const getTeamsByOrganizationId = reactCache(
name: team.name,
}));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error instanceof PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
@@ -6,11 +6,9 @@ import {
TUserUpdateInput,
ZUserPersonalInfoUpdateInput,
} from "@formbricks/types/user";
import {
getIsEmailUnique,
verifyUserPassword,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { getIsEmailUnique } 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";
@@ -1,8 +1,9 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyUserPassword } from "@/lib/user/password";
import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib/utils";
import { getIsEmailUnique, verifyUserPassword } from "./user";
import { getIsEmailUnique } from "./user";
vi.mock("@/modules/auth/lib/utils", () => ({
verifyPassword: vi.fn(),
@@ -1,42 +1,5 @@
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({
@@ -3,25 +3,22 @@
import { InboxIcon, PresentationIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { revalidateSurveyIdPath } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
interface SurveyAnalysisNavigationProps {
environmentId: string;
survey: TSurvey;
activeId: string;
}
export const SurveyAnalysisNavigation = ({
environmentId,
survey,
activeId,
}: SurveyAnalysisNavigationProps) => {
export const SurveyAnalysisNavigation = ({ activeId }: SurveyAnalysisNavigationProps) => {
const pathname = usePathname();
const { t } = useTranslation();
const { environment } = useEnvironment();
const { survey } = useSurvey();
const url = `/environments/${environmentId}/surveys/${survey.id}`;
const url = `/environments/${environment.id}/surveys/${survey.id}`;
const navigation = [
{
@@ -31,7 +28,7 @@ export const SurveyAnalysisNavigation = ({
href: `${url}/summary?referer=true`,
current: pathname?.includes("/summary"),
onClick: () => {
revalidateSurveyIdPath(environmentId, survey.id);
revalidateSurveyIdPath(environment.id, survey.id);
},
},
{
@@ -41,7 +38,7 @@ export const SurveyAnalysisNavigation = ({
href: `${url}/responses?referer=true`,
current: pathname?.includes("/responses"),
onClick: () => {
revalidateSurveyIdPath(environmentId, survey.id);
revalidateSurveyIdPath(environment.id, survey.id);
},
},
];
@@ -1,6 +1,6 @@
"use client";
import React, { createContext, useCallback, useContext, useState } from "react";
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import {
ElementOption,
ElementOptions,
@@ -30,7 +30,7 @@ interface SelectedFilterOptions {
export interface DateRange {
from: Date | undefined;
to?: Date | undefined;
to?: Date;
}
interface FilterDateContextProps {
@@ -41,6 +41,8 @@ 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);
@@ -61,6 +63,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
from: undefined,
to: getTodayDate(),
});
const refreshHandlerRef = useRef<(() => Promise<void>) | null>(null);
const resetState = useCallback(() => {
setDateRange({
@@ -73,20 +76,43 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
});
}, []);
return (
<ResponseFilterContext.Provider
value={{
setSelectedFilter,
selectedFilter,
selectedOptions,
setSelectedOptions,
dateRange,
setDateRange,
resetState,
}}>
{children}
</ResponseFilterContext.Provider>
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={contextValue}>{children}</ResponseFilterContext.Provider>;
};
const useResponseFilter = () => {
@@ -2,6 +2,8 @@
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";
@@ -13,6 +15,7 @@ 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 {
@@ -46,8 +49,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 } = useResponseFilter();
const { selectedFilter, dateRange, resetState, registerAnalysisRefreshHandler } = useResponseFilter();
const { t } = useTranslation();
const filters = useMemo(
() => getFormattedFilters(survey, selectedFilter, dateRange),
@@ -86,6 +89,34 @@ 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]);
@@ -134,6 +165,8 @@ 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,5 +1,4 @@
import { TFunction } from "i18next";
import { capitalize } from "lodash";
import {
AirplayIcon,
ArrowUpFromDotIcon,
@@ -9,6 +8,7 @@ import {
SmartphoneIcon,
} from "lucide-react";
import { TResponseMeta } from "@formbricks/types/responses";
import { capitalize } from "@/lib/utils/object";
export const getAddressFieldLabel = (field: string, t: TFunction) => {
switch (field) {
@@ -64,8 +64,6 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
pageTitle={survey.name}
cta={
<SurveyAnalysisCTA
environment={environment}
survey={survey}
isReadOnly={isReadOnly}
user={user}
publicDomain={publicDomain}
@@ -76,7 +74,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
isStorageConfigured={IS_STORAGE_CONFIGURED}
/>
}>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" />
<SurveyAnalysisNavigation activeId="responses" />
</PageHeader>
<ResponsePage
environment={environment}
@@ -4,16 +4,13 @@ import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { Confetti } from "@/modules/ui/components/confetti";
interface SummaryMetadataProps {
environment: TEnvironment;
survey: TSurvey;
}
export const SuccessMessage = ({ environment, survey }: SummaryMetadataProps) => {
export const SuccessMessage = () => {
const { environment } = useEnvironment();
const { survey } = useSurvey();
const { t } = useTranslation();
const searchParams = useSearchParams();
const [confetti, setConfetti] = useState(false);
@@ -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 } = useResponseFilter();
const { selectedFilter, dateRange, resetState, registerAnalysisRefreshHandler } = useResponseFilter();
const [displays, setDisplays] = useState<TDisplayWithContact[]>([]);
const [isDisplaysLoading, setIsDisplaysLoading] = useState(false);
@@ -111,7 +111,7 @@ export const SummaryPage = ({
} finally {
setIsDisplaysLoading(false);
}
}, [fetchDisplays, t]);
}, [fetchDisplays]);
const handleLoadMoreDisplays = useCallback(async () => {
try {
@@ -131,13 +131,39 @@ 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 && selectedFilter.filter.length === 0)) &&
(!selectedFilter || Object.keys(selectedFilter).length === 0 || selectedFilter.filter?.length === 0) &&
(!dateRange || (!dateRange.from && !dateRange.to));
if (initialSurveySummary && hasNoFilters) {
@@ -145,21 +171,11 @@ export const SummaryPage = ({
return;
}
const fetchSummary = async () => {
const fetchFilteredSummary = async () => {
setIsLoading(true);
try {
// 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);
await fetchSummary();
} catch (error) {
console.error(error);
} finally {
@@ -167,8 +183,8 @@ export const SummaryPage = ({
}
};
fetchSummary();
}, [selectedFilter, dateRange, survey, surveyId, initialSurveySummary]);
fetchFilteredSummary();
}, [selectedFilter, dateRange, initialSurveySummary, fetchSummary]);
const surveyMemoized = useMemo(() => {
return replaceHeadlineRecall(survey, "default");
@@ -1,18 +1,18 @@
"use client";
import { BellRing, Eye, ListRestart, SquarePenIcon } from "lucide-react";
import { BellRing, Eye, ListRestart, RefreshCcwIcon, 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,8 +23,6 @@ import { IconBar } from "@/modules/ui/components/iconbar";
import { resetSurveyAction } from "../actions";
interface SurveyAnalysisCTAProps {
survey: TSurvey;
environment: TEnvironment;
isReadOnly: boolean;
user: TUser;
publicDomain: string;
@@ -41,8 +39,6 @@ interface ModalState {
}
export const SurveyAnalysisCTA = ({
survey,
environment,
isReadOnly,
user,
publicDomain,
@@ -63,9 +59,12 @@ export const SurveyAnalysisCTA = ({
});
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const { project } = useEnvironment();
const { environment, project } = useEnvironment();
const { survey } = useSurvey();
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
const { refreshAnalysisData } = useResponseFilter();
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -77,7 +76,7 @@ export const SurveyAnalysisCTA = ({
}, [searchParams]);
const handleShareModalToggle = (open: boolean) => {
const params = new URLSearchParams(window.location.search);
const params = new URLSearchParams(globalThis.location.search);
const currentShareParam = params.get("share") === "true";
if (open && !currentShareParam) {
@@ -147,6 +146,25 @@ 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"),
@@ -183,7 +201,7 @@ export const SurveyAnalysisCTA = ({
return (
<div className="hidden justify-end gap-x-1.5 sm:flex">
{!isReadOnly && (appSetupCompleted || survey.type === "link") && survey.status !== "draft" && (
<SurveyStatusDropdown environment={environment} survey={survey} />
<SurveyStatusDropdown />
)}
<IconBar actions={iconActions} />
@@ -215,7 +233,7 @@ export const SurveyAnalysisCTA = ({
projectCustomScripts={project.customHeadScripts}
/>
)}
<SuccessMessage environment={environment} survey={survey} />
<SuccessMessage />
{responseCount > 0 && (
<EditPublicSurveyAlertDialog
@@ -16,13 +16,19 @@ export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => {
const [embedModeEnabled, setEmbedModeEnabled] = useState(false);
const { t } = useTranslation();
const iframeCode = `<div style="position: relative; height:80dvh; overflow:auto;">
<iframe
src="${surveyUrl}${embedModeEnabled ? "?embed=true" : ""}"
const separator = surveyUrl.includes("?") ? "&" : "?";
const iframeSrc = embedModeEnabled ? `${surveyUrl}${separator}embed=true` : surveyUrl;
const iframeCode = `<div style="position: relative; height:80dvh; overflow:auto;">
<iframe
src="${iframeSrc}"
frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;">
</iframe>
</div>`;
const previewSrc = `${iframeSrc}${iframeSrc.includes("?") ? "&" : "?"}preview=true`;
return (
<>
<CodeBlock language="html" noMargin>
@@ -48,6 +54,15 @@ export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => {
{t("common.copy_code")}
<CopyIcon />
</Button>
<p className="text-base font-medium text-slate-800">{t("common.preview")}</p>
<div className="relative h-[500px] w-full overflow-hidden rounded-lg border border-slate-300">
<iframe
title={t("common.preview")}
src={previewSrc}
className="absolute inset-0 h-full w-full border-0"
/>
</div>
</>
);
};
@@ -66,8 +66,6 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
pageTitle={survey.name}
cta={
<SurveyAnalysisCTA
environment={environment}
survey={survey}
isReadOnly={isReadOnly}
user={user}
publicDomain={publicDomain}
@@ -78,7 +76,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
isStorageConfigured={IS_STORAGE_CONFIGURED}
/>
}>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="summary" />
<SurveyAnalysisNavigation activeId="summary" />
</PageHeader>
<SummaryPage
environment={environment}
@@ -3,8 +3,9 @@
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateSurveyAction } from "@/modules/survey/editor/actions";
import {
@@ -16,17 +17,9 @@ import {
} from "@/modules/ui/components/select";
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
interface SurveyStatusDropdownProps {
environment: TEnvironment;
updateLocalSurveyStatus?: (status: TSurvey["status"]) => void;
survey: TSurvey;
}
export const SurveyStatusDropdown = ({
environment,
updateLocalSurveyStatus,
survey,
}: SurveyStatusDropdownProps) => {
export const SurveyStatusDropdown = () => {
const { environment } = useEnvironment();
const { survey } = useSurvey();
const { t } = useTranslation();
const router = useRouter();
@@ -46,10 +39,6 @@ export const SurveyStatusDropdown = ({
toast.success(toastMessage);
}
if (updateLocalSurveyStatus) {
updateLocalSurveyStatus(resultingStatus);
}
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse);
@@ -18,6 +18,7 @@ interface AirtableWrapperProps {
isEnabled: boolean;
webAppUrl: string;
locale: TUserLocale;
showReconnectButton?: boolean;
}
export const AirtableWrapper = ({
@@ -28,6 +29,7 @@ export const AirtableWrapper = ({
isEnabled,
webAppUrl,
locale,
showReconnectButton = false,
}: AirtableWrapperProps) => {
const [isConnected, setIsConnected] = useState(
airtableIntegration ? airtableIntegration.config?.key : false
@@ -49,6 +51,8 @@ export const AirtableWrapper = ({
setIsConnected={setIsConnected}
surveys={surveys}
locale={locale}
showReconnectButton={showReconnectButton}
handleAirtableAuthorization={handleAirtableAuthorization}
/>
) : (
<ConnectIntegration
@@ -1,6 +1,6 @@
"use client";
import { Trash2Icon } from "lucide-react";
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -12,9 +12,11 @@ import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AddIntegrationModal";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { IntegrationModalInputs } from "../lib/types";
interface ManageIntegrationProps {
@@ -24,10 +26,20 @@ interface ManageIntegrationProps {
surveys: TSurvey[];
airtableArray: TIntegrationItem[];
locale: TUserLocale;
showReconnectButton: boolean;
handleAirtableAuthorization: () => Promise<void>;
}
export const ManageIntegration = (props: ManageIntegrationProps) => {
const { airtableIntegration, environmentId, setIsConnected, surveys, airtableArray } = props;
export const ManageIntegration = ({
airtableIntegration,
environmentId,
setIsConnected,
surveys,
airtableArray,
showReconnectButton,
handleAirtableAuthorization,
locale,
}: ManageIntegrationProps) => {
const { t } = useTranslation();
const tableHeaders = [
@@ -73,15 +85,34 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
: { isEditMode: false as const };
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
<div className="flex w-full justify-end gap-x-6">
<div className="flex items-center">
{showReconnectButton && (
<Alert variant="warning" size="small" className="mb-4 w-full">
<AlertDescription>{t("environments.integrations.reconnect_button_description")}</AlertDescription>
<AlertButton onClick={handleAirtableAuthorization}>
{t("environments.integrations.reconnect_button")}
</AlertButton>
</Alert>
)}
<div className="flex w-full justify-end space-x-2">
<div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="cursor-pointer text-slate-500">
<span className="text-slate-500">
{t("environments.integrations.connected_with_email", {
email: airtableIntegration.config.email,
})}
</span>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleAirtableAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("environments.integrations.reconnect_button")}
</Button>
</TooltipTrigger>
<TooltipContent>{t("environments.integrations.reconnect_button_tooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
onClick={() => {
setDefaultValues(null);
@@ -122,9 +153,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.tableName}</div>
<div className="col-span-2 text-center">{data.elements}</div>
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), props.locale)}
</div>
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
</button>
))}
</div>
@@ -1,4 +1,5 @@
import { redirect } from "next/navigation";
import { logger } from "@formbricks/logger";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
@@ -31,8 +32,14 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
);
let airtableArray: TIntegrationItem[] = [];
let isTokenValid = true;
if (airtableIntegration?.config.key) {
airtableArray = await getAirtableTables(params.environmentId);
try {
airtableArray = await getAirtableTables(params.environmentId);
} catch (error) {
logger.error(error, "Failed to load Airtable bases — token may be expired or revoked");
isTokenValid = false;
}
}
if (isReadOnly) {
return redirect("./");
@@ -51,6 +58,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
surveys={surveys}
webAppUrl={WEBAPP_URL}
locale={locale ?? DEFAULT_LOCALE}
showReconnectButton={!isTokenValid}
/>
</div>
</PageContentWrapper>
@@ -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, POSTHOG_KEY } from "@/lib/constants";
import { CRON_SECRET, DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS, POSTHOG_KEY } from "@/lib/constants";
import { generateStandardWebhookSignature } from "@/lib/crypto";
import { getIntegrations } from "@/lib/integration/service";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
@@ -91,10 +91,15 @@ 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
// 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";
const fetchWithTimeout = (url: string, options: RequestInit, timeout: number = 5000): Promise<Response> => {
return Promise.race([
fetch(url, options),
fetch(url, { ...options, redirect: redirectMode }),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeout)),
]);
};
@@ -185,4 +185,20 @@ describe("auth route audit logging", () => {
})
);
});
test("does not log a completed sign-in for the intermediate SSO recovery verification step", async () => {
const authOptions = await getWrappedAuthOptions("req-sso-recovery");
const user = {
id: "user_4",
email: "user4@example.com",
authFlowPurpose: "sso_recovery",
};
const account = { provider: "token" };
await expect(authOptions.callbacks.signIn({ user, account })).resolves.toBe(true);
await authOptions.events.signIn({ user, account, isNewUser: false });
expect(mocks.baseEventSignIn).not.toHaveBeenCalled();
expect(mocks.queueAuditEventBackground).not.toHaveBeenCalled();
});
});
@@ -26,6 +26,12 @@ const getAuthMethod = (account: Account | null) => {
return "unknown";
};
const isSsoRecoveryVerificationFlow = (account: Account | null, user: User | AdapterUser) =>
account?.provider === "token" &&
"authFlowPurpose" in user &&
typeof user.authFlowPurpose === "string" &&
user.authFlowPurpose === "sso_recovery";
const handler = async (req: Request, ctx: any) => {
const eventId = req.headers.get("x-request-id") ?? undefined;
@@ -117,6 +123,10 @@ const handler = async (req: Request, ctx: any) => {
events: {
...baseAuthOptions.events,
async signIn({ user, account, isNewUser }: any) {
if (isSsoRecoveryVerificationFlow(account, user)) {
return;
}
try {
await baseAuthOptions.events?.signIn?.({ user, account, isNewUser });
} catch (err) {
@@ -0,0 +1,67 @@
import { getServerSession } from "next-auth";
import { NextResponse } from "next/server";
import { logger } from "@formbricks/logger";
import { verifySsoRelinkIntent } from "@/lib/jwt";
import { deleteSessionBySessionToken } from "@/modules/auth/lib/auth-session-repository";
import { authOptions } from "@/modules/auth/lib/authOptions";
import {
NEXT_AUTH_SESSION_COOKIE_NAMES,
getSessionTokenFromCookieHeader,
} from "@/modules/auth/lib/session-cookie";
import { completeSsoRecovery, getSsoRecoveryFailureRedirectUrl } from "@/modules/ee/sso/lib/sso-recovery";
const clearSessionCookies = (response: NextResponse) => {
for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) {
response.cookies.set({
name: cookieName,
value: "",
expires: new Date(0),
path: "/",
secure: cookieName.startsWith("__Secure-"),
});
}
};
const buildFailedRecoveryResponse = async (request: Request, callbackUrl?: string) => {
const response = NextResponse.redirect(getSsoRecoveryFailureRedirectUrl(callbackUrl));
clearSessionCookies(response);
const sessionToken = getSessionTokenFromCookieHeader(request.headers.get("cookie"));
if (!sessionToken) {
return response;
}
try {
await deleteSessionBySessionToken(sessionToken);
} catch (error) {
logger.error(error, "Failed to delete SSO recovery session after recovery completion error");
}
return response;
};
export const GET = async (request: Request) => {
const url = new URL(request.url);
const intentToken = url.searchParams.get("intent");
if (!intentToken) {
return NextResponse.redirect(getSsoRecoveryFailureRedirectUrl());
}
try {
const session = await getServerSession(authOptions);
const callbackUrl = await completeSsoRecovery({
intentToken,
sessionUserId: session?.user.id,
});
return NextResponse.redirect(callbackUrl);
} catch {
try {
const intent = verifySsoRelinkIntent(intentToken);
return await buildFailedRecoveryResponse(request, intent.callbackUrl);
} catch {
return await buildFailedRecoveryResponse(request);
}
}
};
@@ -10,6 +10,8 @@ import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { getSurvey } from "@/lib/survey/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
@@ -127,6 +129,114 @@ export const POST = withV1ApiWrapper({
};
}
if (survey.type === "link" && survey.singleUse?.enabled) {
if (!responseInputData.singleUseId) {
return {
response: responses.badRequestResponse(
"Missing single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
if (!responseInputData.meta?.url) {
return {
response: responses.badRequestResponse(
"Missing or invalid URL in response metadata",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
let url: URL;
try {
url = new URL(responseInputData.meta.url);
} catch (error) {
return {
response: responses.badRequestResponse(
"Invalid URL in response metadata",
{
surveyId: survey.id,
environmentId,
error: error instanceof Error ? error.message : "Unknown error occurred",
},
true
),
};
}
const suId = url.searchParams.get("suId");
if (!suId) {
return {
response: responses.badRequestResponse(
"Missing single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
if (survey.singleUse.isEncrypted) {
if (!ENCRYPTION_KEY) {
logger.error({ url: req.url, surveyId: survey.id, environmentId }, "ENCRYPTION_KEY is not set");
return {
response: responses.internalServerErrorResponse("An unexpected error occurred.", true),
};
}
let decryptedSuId: string;
try {
decryptedSuId = symmetricDecrypt(suId, ENCRYPTION_KEY);
} catch {
return {
response: responses.badRequestResponse(
"Invalid single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
if (decryptedSuId !== responseInputData.singleUseId) {
return {
response: responses.badRequestResponse(
"Invalid single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
} else if (responseInputData.singleUseId !== suId) {
return {
response: responses.badRequestResponse(
"Invalid single use id",
{
surveyId: survey.id,
environmentId,
},
true
),
};
}
}
if (!validateFileUploads(responseInputData.data, survey.questions)) {
return {
response: responses.badRequestResponse("Invalid file upload response"),
@@ -5,7 +5,7 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { fetchAirtableAuthToken } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration } from "@/lib/integration/service";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
@@ -78,12 +78,16 @@ export const GET = withV1ApiWrapper({
}
const email = await getEmail(key.access_token);
// Preserve existing integration data (survey-to-table mappings) when re-authorizing
const existingIntegration = await getIntegrationByType(environmentId, "airtable");
const existingData = existingIntegration?.config?.data ?? [];
const airtableIntegrationInput = {
type: "airtable" as "airtable",
environment: environmentId,
config: {
key,
data: [],
data: existingData,
email,
},
};
@@ -1,8 +1,7 @@
import * as z from "zod";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getTables } from "@/lib/airtable/service";
import { getAirtableToken, getTables } from "@/lib/airtable/service";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { getIntegrationByType } from "@/lib/integration/service";
@@ -36,7 +35,7 @@ export const GET = withV1ApiWrapper({
};
}
const integration = (await getIntegrationByType(environmentId, "airtable")) as TIntegrationAirtable;
const integration = await getIntegrationByType(environmentId, "airtable");
if (!integration) {
return {
@@ -44,7 +43,12 @@ export const GET = withV1ApiWrapper({
};
}
const tables = await getTables(integration.config.key, baseId.data);
// Use getAirtableToken to ensure the access token is refreshed if expired
const freshAccessToken = await getAirtableToken(environmentId);
const tables = await getTables(
{ ...integration.config.key, access_token: freshAccessToken },
baseId.data
);
return {
response: responses.successResponse(tables),
};
@@ -4,6 +4,7 @@ import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
import { responses } from "@/app/lib/api/response";
import { CONTROL_HASH } from "@/lib/constants";
import { hashSha256, parseApiKeyV2, verifySecret } from "@/lib/crypto";
import { publicUserSelect } from "@/lib/user/public-user";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
@@ -176,6 +177,7 @@ const handleSessionAuthentication = async () => {
const user = await prisma.user.findUnique({
where: { id: sessionUser.id },
select: publicUserSelect,
});
return Response.json(user);
@@ -34,7 +34,7 @@ describe("parseV3SurveysListQuery", () => {
expect(r.invalid_params[0]).toEqual({
name: "foo",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
"Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, 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, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
"Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, 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, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
"Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
}
});
@@ -68,11 +68,20 @@ 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(
@@ -102,7 +111,7 @@ describe("parseV3SurveysListQuery", () => {
expect(r.invalid_params[0]).toEqual({
name: "filter[createdBy][in]",
reason:
"Unsupported query parameter. Use only workspaceId, limit, cursor, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
"Unsupported query parameter. Use only workspaceId, limit, cursor, includeTotalCount, filter[name][contains], filter[status][in], filter[type][in], sortBy.",
});
}
});
@@ -28,6 +28,7 @@ const SUPPORTED_QUERY_PARAMS = [
"workspaceId",
"limit",
"cursor",
"includeTotalCount",
FILTER_NAME_CONTAINS_QUERY_PARAM,
FILTER_STATUS_IN_QUERY_PARAM,
FILTER_TYPE_IN_QUERY_PARAM,
@@ -53,6 +54,11 @@ 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)
@@ -71,6 +77,7 @@ export type TV3SurveysListQueryParseResult =
workspaceId: string;
limit: number;
cursor: TSurveyListPageCursor | null;
includeTotalCount: boolean;
sortBy: TSurveyListSort;
filterCriteria: TSurveyFilterCriteria | undefined;
}
@@ -111,6 +118,7 @@ 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,
@@ -153,6 +161,7 @@ export function parseV3SurveysListQuery(searchParams: URLSearchParams): TV3Surve
workspaceId: q.workspaceId,
limit: q.limit,
cursor,
includeTotalCount: q.includeTotalCount,
sortBy,
filterCriteria: buildFilterCriteria(q),
};
+24 -1
View File
@@ -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 { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
import { encodeSurveyListPageCursor, getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
import { GET } from "./route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
@@ -257,6 +257,29 @@ 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(
+12 -11
View File
@@ -46,21 +46,22 @@ export const GET = withV3ApiWrapper({
const { environmentId } = authResult;
const [{ surveys, nextCursor }, totalCount] = await Promise.all([
getSurveyListPage(environmentId, {
limit: parsed.limit,
cursor: parsed.cursor,
sortBy: parsed.sortBy,
filterCriteria: parsed.filterCriteria,
}),
getSurveyCount(environmentId, parsed.filterCriteria),
]);
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]);
return successListResponse(
surveys.map(serializeV3SurveyListItem),
surveyPage.surveys.map(serializeV3SurveyListItem),
{
limit: parsed.limit,
nextCursor,
nextCursor: surveyPage.nextCursor,
totalCount,
},
{ requestId, cache: "private, no-store" }
+6 -3
View File
@@ -170,6 +170,7 @@ 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
@@ -346,6 +347,7 @@ 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
@@ -638,8 +640,6 @@ 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,6 +789,9 @@ checksums:
environments/integrations/notion/update_connection_tooltip: 2429919f575e47f5c76e54b4442ba706
environments/integrations/notion_integration_description: 31a73dbe88fe18a078d6dc15f0c303e2
environments/integrations/please_select_a_survey_error: 465aa7048773079c8ffdde8b333b78eb
environments/integrations/reconnect_button: 8992a0f250278c116cb26be448b68ba2
environments/integrations/reconnect_button_description: 01f79dc561ff87b5f2a80bf66e492844
environments/integrations/reconnect_button_tooltip: 5552effda9df8d6778dda1cf42e5d880
environments/integrations/select_at_least_one_question_error: a3513cb02ab0de2a1531893ac0c7e089
environments/integrations/slack/already_connected_another_survey: 4508f9e4a2915e3818ea5f9e2695e000
environments/integrations/slack/channel_name: 1afcd1d0401850ff353f5ae27502b04a
@@ -1134,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: fc91de3ddc89ab77f30d555778312380
environments/settings/general/organization_name_placeholder: abcee7d91a848e573b63a763cfaf6a08
environments/settings/general/organization_name_updated_successfully: d36ba3a4f614d30b10e696d25da22432
environments/settings/general/organization_settings: d31952131ad5f0ec72ad96f1ed11bef6
environments/settings/general/please_add_a_logo: 66d6f97a2e7b27efc04bd653240ee813
+11 -5
View File
@@ -3,7 +3,6 @@ import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TIntegrationItem } from "@formbricks/types/integration";
import {
TIntegrationAirtable,
TIntegrationAirtableConfigData,
TIntegrationAirtableCredential,
ZIntegrationAirtableBases,
@@ -24,6 +23,11 @@ export const getBases = async (key: string) => {
},
});
if (!req.ok) {
const body = await req.text().catch(() => "");
throw new Error(`Airtable API error fetching bases: ${req.status} ${req.statusText} ${body}`);
}
const res = await req.json();
return ZIntegrationAirtableBases.parse(res);
};
@@ -35,6 +39,11 @@ const tableFetcher = async (key: TIntegrationAirtableCredential, baseId: string)
},
});
if (!req.ok) {
const body = await req.text().catch(() => "");
throw new Error(`Airtable API error fetching tables: ${req.status} ${req.statusText} ${body}`);
}
const res = await req.json();
return res;
@@ -78,10 +87,7 @@ export const fetchAirtableAuthToken = async (formData: Record<string, any>) => {
export const getAirtableToken = async (environmentId: string) => {
try {
const airtableIntegration = (await getIntegrationByType(
environmentId,
"airtable"
)) as TIntegrationAirtable;
const airtableIntegration = await getIntegrationByType(environmentId, "airtable");
const { access_token, expiry_date, refresh_token } = ZIntegrationAirtableCredential.parse(
airtableIntegration?.config.key
+11 -3
View File
@@ -5,7 +5,12 @@ import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TIntegration, TIntegrationInput, ZIntegrationType } from "@formbricks/types/integration";
import {
TIntegration,
TIntegrationByType,
TIntegrationInput,
ZIntegrationType,
} from "@formbricks/types/integration";
import { ITEMS_PER_PAGE } from "../constants";
import { validateInputs } from "../utils/validate";
@@ -94,7 +99,10 @@ export const getIntegration = reactCache(async (integrationId: string): Promise<
});
export const getIntegrationByType = reactCache(
async (environmentId: string, type: TIntegrationInput["type"]): Promise<TIntegration | null> => {
async <T extends TIntegrationInput["type"]>(
environmentId: string,
type: T
): Promise<TIntegrationByType<T> | null> => {
validateInputs([environmentId, ZId], [type, ZIntegrationType]);
try {
@@ -106,7 +114,7 @@ export const getIntegrationByType = reactCache(
},
},
});
return integration ? transformIntegration(integration) : null;
return integration ? (transformIntegration(integration) as TIntegrationByType<T>) : null;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
+78
View File
@@ -6,11 +6,13 @@ import {
createEmailChangeToken,
createEmailToken,
createInviteToken,
createSsoRelinkIntent,
createToken,
createTokenForLinkSurvey,
getEmailFromEmailToken,
verifyEmailChangeToken,
verifyInviteToken,
verifySsoRelinkIntent,
verifyToken,
verifyTokenForLinkSurvey,
} from "./jwt";
@@ -380,6 +382,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
expect(verified).toEqual({
id: mockUser.id, // Returns the decrypted user ID
email: mockUser.email,
purpose: "email_verification",
});
});
@@ -414,6 +417,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
expect(verified).toEqual({
id: mockUser.id, // Returns the raw ID from payload
email: mockUser.email,
purpose: "email_verification",
});
});
@@ -425,6 +429,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
expect(verified).toEqual({
id: mockUser.id, // Returns the decrypted user ID
email: mockUser.email,
purpose: "email_verification",
});
});
@@ -1004,5 +1009,78 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
expect(results.every((result: any) => result.id === mockUser.id)).toBe(true); // Returns decrypted user ID
});
});
describe("SSO recovery support", () => {
test("creates verification tokens that preserve the recovery purpose", async () => {
const token = createToken(mockUser.id, { purpose: "sso_recovery", expiresIn: "15m" });
await expect(verifyToken(token)).resolves.toEqual(
expect.objectContaining({
id: mockUser.id,
email: mockUser.email,
purpose: "sso_recovery",
})
);
});
test("defaults legacy verification tokens to email_verification when purpose is missing", async () => {
const legacyToken = jwt.sign({ id: `encrypted_${mockUser.id}` }, TEST_NEXTAUTH_SECRET);
await expect(verifyToken(legacyToken)).resolves.toEqual(
expect.objectContaining({
id: mockUser.id,
email: mockUser.email,
purpose: "email_verification",
})
);
});
test("round-trips SSO relink intents without losing callback state", () => {
const intent = createSsoRelinkIntent({
userId: mockUser.id,
email: mockUser.email,
provider: "google",
providerAccountId: "provider-123",
callbackUrl: "http://localhost:3000/invite?token=invite-token",
});
expect(verifySsoRelinkIntent(intent)).toEqual({
userId: mockUser.id,
email: mockUser.email,
provider: "google",
providerAccountId: "provider-123",
callbackUrl: "http://localhost:3000/invite?token=invite-token",
});
});
test("rejects expired SSO relink intents", () => {
const expiredIntent = jwt.sign(
{
userId: crypto.symmetricEncrypt(mockUser.id, TEST_ENCRYPTION_KEY),
email: crypto.symmetricEncrypt(mockUser.email, TEST_ENCRYPTION_KEY),
provider: "google",
providerAccountId: crypto.symmetricEncrypt("provider-123", TEST_ENCRYPTION_KEY),
callbackUrl: crypto.symmetricEncrypt("http://localhost:3000", TEST_ENCRYPTION_KEY),
exp: Math.floor(Date.now() / 1000) - 3600,
},
TEST_NEXTAUTH_SECRET
);
expect(() => verifySsoRelinkIntent(expiredIntent)).toThrow();
});
test("rejects tampered SSO relink intents", () => {
const intent = createSsoRelinkIntent({
userId: mockUser.id,
email: mockUser.email,
provider: "google",
providerAccountId: "provider-123",
callbackUrl: "http://localhost:3000",
});
const tamperedIntent = `${intent.slice(0, -1)}x`;
expect(() => verifySsoRelinkIntent(tamperedIntent)).toThrow();
});
});
});
});
+108 -5
View File
@@ -1,4 +1,4 @@
import jwt, { JwtPayload } from "jsonwebtoken";
import jwt, { JwtPayload, SignOptions } from "jsonwebtoken";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ENCRYPTION_KEY, NEXTAUTH_SECRET } from "@/lib/constants";
@@ -13,7 +13,39 @@ const decryptWithFallback = (encryptedText: string, key: string): string => {
}
};
export const createToken = (userId: string, options = {}): string => {
export const VERIFICATION_TOKEN_PURPOSES = ["email_verification", "sso_recovery"] as const;
export type TVerificationTokenPurpose = (typeof VERIFICATION_TOKEN_PURPOSES)[number];
export type TVerifyTokenPayload = JwtPayload & {
id: string;
email: string;
purpose: TVerificationTokenPurpose;
};
type TVerificationTokenOptions = SignOptions & {
purpose?: TVerificationTokenPurpose;
};
type TSsoRelinkIntentPayload = {
callbackUrl: string;
email: string;
provider: string;
providerAccountId: string;
userId: string;
};
const DEFAULT_VERIFICATION_TOKEN_PURPOSE: TVerificationTokenPurpose = "email_verification";
const getVerificationTokenPurpose = (purpose: unknown): TVerificationTokenPurpose => {
if (purpose && VERIFICATION_TOKEN_PURPOSES.includes(purpose as TVerificationTokenPurpose)) {
return purpose as TVerificationTokenPurpose;
}
return DEFAULT_VERIFICATION_TOKEN_PURPOSE;
};
export const createToken = (userId: string, options: TVerificationTokenOptions = {}): string => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
@@ -23,7 +55,9 @@ export const createToken = (userId: string, options = {}): string => {
}
const encryptedUserId = symmetricEncrypt(userId, ENCRYPTION_KEY);
return jwt.sign({ id: encryptedUserId }, NEXTAUTH_SECRET, options);
const { purpose = DEFAULT_VERIFICATION_TOKEN_PURPOSE, ...jwtOptions } = options;
return jwt.sign({ id: encryptedUserId, purpose }, NEXTAUTH_SECRET, jwtOptions);
};
export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
if (!NEXTAUTH_SECRET) {
@@ -224,7 +258,72 @@ const getUserEmailForLegacyVerification = async (
return { userId: decryptedId, userEmail: foundUser.email };
};
export const verifyToken = async (token: string): Promise<JwtPayload> => {
const DEFAULT_SSO_RELINK_INTENT_OPTIONS: SignOptions = {
expiresIn: "15m",
};
export const createSsoRelinkIntent = (
payload: TSsoRelinkIntentPayload,
options: SignOptions = DEFAULT_SSO_RELINK_INTENT_OPTIONS
): string => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
return jwt.sign(
{
userId: symmetricEncrypt(payload.userId, ENCRYPTION_KEY),
email: symmetricEncrypt(payload.email, ENCRYPTION_KEY),
provider: payload.provider,
providerAccountId: symmetricEncrypt(payload.providerAccountId, ENCRYPTION_KEY),
callbackUrl: symmetricEncrypt(payload.callbackUrl, ENCRYPTION_KEY),
},
NEXTAUTH_SECRET,
options
);
};
export const verifySsoRelinkIntent = (token: string): TSsoRelinkIntentPayload => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
userId: string;
email: string;
provider: string;
providerAccountId: string;
callbackUrl: string;
};
if (
!payload?.userId ||
!payload?.email ||
!payload?.provider ||
!payload?.providerAccountId ||
!payload?.callbackUrl
) {
throw new Error("Token is invalid or missing required fields");
}
return {
userId: decryptWithFallback(payload.userId, ENCRYPTION_KEY),
email: decryptWithFallback(payload.email, ENCRYPTION_KEY),
provider: payload.provider,
providerAccountId: decryptWithFallback(payload.providerAccountId, ENCRYPTION_KEY),
callbackUrl: decryptWithFallback(payload.callbackUrl, ENCRYPTION_KEY),
};
};
export const verifyToken = async (token: string): Promise<TVerifyTokenPayload> => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
@@ -263,7 +362,11 @@ export const verifyToken = async (token: string): Promise<JwtPayload> => {
// Get user email if we don't have it yet
userData ??= await getUserEmailForLegacyVerification(token, payload.id);
return { id: userData.userId, email: userData.userEmail };
return {
id: userData.userId,
email: userData.userEmail,
purpose: getVerificationTokenPurpose(payload.purpose),
};
};
export const verifyInviteToken = (token: string): { inviteId: string; email: string } => {
+2 -6
View File
@@ -1,8 +1,4 @@
import {
TIntegrationNotion,
TIntegrationNotionConfig,
TIntegrationNotionDatabase,
} from "@formbricks/types/integration/notion";
import { TIntegrationNotionConfig, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt } from "@/lib/crypto";
import { getIntegrationByType } from "../integration/service";
@@ -29,7 +25,7 @@ const fetchPages = async (config: TIntegrationNotionConfig) => {
export const getNotionDatabases = async (environmentId: string): Promise<TIntegrationNotionDatabase[]> => {
let results: TIntegrationNotionDatabase[] = [];
try {
const notionIntegration = (await getIntegrationByType(environmentId, "notion")) as TIntegrationNotion;
const notionIntegration = await getIntegrationByType(environmentId, "notion");
if (notionIntegration && notionIntegration.config?.key.bot_id) {
results = await fetchPages(notionIntegration.config);
}
+1 -1
View File
@@ -63,7 +63,7 @@ const mapOrganizationBilling = (billing: TOrganizationWithBilling["billing"]): T
stripeCustomerId: billing.stripeCustomerId,
limits: billing.limits,
usageCycleAnchor: billing.usageCycleAnchor,
...(billing.stripe === undefined ? {} : { stripe: billing.stripe }),
...(billing.stripe == null ? {} : { stripe: billing.stripe }),
};
};
+1 -4
View File
@@ -1,6 +1,3 @@
import structuredClonePolyfill from "@ungap/structured-clone";
const structuredCloneExport =
typeof structuredClone === "undefined" ? structuredClonePolyfill : structuredClone;
const structuredCloneExport = globalThis.structuredClone;
export { structuredCloneExport as structuredClone };
+64
View File
@@ -0,0 +1,64 @@
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);
});
});
+15
View File
@@ -0,0 +1,15 @@
"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";
@@ -0,0 +1,131 @@
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"
);
});
});
+35
View File
@@ -0,0 +1,35 @@
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;
}
};
+4
View File
@@ -1 +1,5 @@
import "server-only";
export { capturePostHogEvent } from "./capture";
export { getPostHogFeatureFlag } from "./get-feature-flag";
export type { TPostHogFeatureFlagContext, TPostHogFeatureFlagValue } from "./types";
+6
View File
@@ -0,0 +1,6 @@
export type TPostHogFeatureFlagValue = boolean | string;
export type TPostHogFeatureFlagContext = {
organizationId?: string;
workspaceId?: string;
};
+2 -2
View File
@@ -1,7 +1,7 @@
import { Prisma } from "@prisma/client";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import { TIntegration, TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationSlack, TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
import { TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
import { SLACK_MESSAGE_LIMIT } from "../constants";
import { deleteIntegration, getIntegrationByType } from "../integration/service";
import { truncateText } from "../utils/strings";
@@ -58,7 +58,7 @@ export const fetchChannels = async (slackIntegration: TIntegration): Promise<TIn
export const getSlackChannels = async (environmentId: string): Promise<TIntegrationItem[]> => {
let channels: TIntegrationItem[] = [];
try {
const slackIntegration = (await getIntegrationByType(environmentId, "slack")) as TIntegrationSlack;
const slackIntegration = await getIntegrationByType(environmentId, "slack");
if (slackIntegration && slackIntegration.config?.key) {
channels = await fetchChannels(slackIntegration);
}
+11 -7
View File
@@ -190,6 +190,14 @@ const mockWelcomeCard: TSurveyWelcomeCard = {
showResponseCount: false,
};
const mockBlocks = [
{
id: "block1",
name: "Block 1",
elements: [mockQuestion],
},
];
const baseSurveyProperties = {
id: mockId,
name: "Mock Survey",
@@ -201,13 +209,7 @@ const baseSurveyProperties = {
displayLimit: 3,
welcomeCard: mockWelcomeCard,
questions: [],
blocks: [
{
id: "block1",
name: "Block 1",
elements: [mockQuestion],
},
],
blocks: mockBlocks as unknown as SurveyMock["blocks"],
isBackButtonHidden: false,
isAutoProgressingEnabled: false,
isCaptureIpEnabled: false,
@@ -304,6 +306,7 @@ export const createSurveyInput: TSurveyCreateInput = {
displayOption: "respondMultiple",
triggers: [{ actionClass: mockActionClass }],
...baseSurveyProperties,
blocks: mockBlocks,
};
export const updateSurveyInput: TSurvey = {
@@ -326,6 +329,7 @@ export const updateSurveyInput: TSurvey = {
followUps: [],
...baseSurveyProperties,
...commonMockProperties,
blocks: mockBlocks,
slug: null,
customHeadScripts: null,
customHeadScriptsMode: null,
+6 -34
View File
@@ -5,7 +5,8 @@ import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TSegment, ZSegmentFilters } from "@formbricks/types/segment";
import { ZSegmentFilters } from "@formbricks/types/segment";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TSurvey, TSurveyCreateInput, ZSurvey, ZSurveyCreateInput } from "@formbricks/types/surveys/types";
import {
getOrganizationByEnvironmentId,
@@ -558,22 +559,7 @@ export const updateSurveyInternal = async (
select: selectSurvey,
});
let surveySegment: TSegment | null = null;
if (prismaSurvey.segment) {
surveySegment = {
...prismaSurvey.segment,
surveys: prismaSurvey.segment.surveys.map((survey) => survey.id),
};
}
const modifiedSurvey: TSurvey = {
...prismaSurvey, // Properties from prismaSurvey
displayPercentage: Number(prismaSurvey.displayPercentage) || null,
segment: surveySegment,
customHeadScriptsMode: prismaSurvey.customHeadScriptsMode,
};
return modifiedSurvey;
return transformPrismaSurvey<TSurvey>(prismaSurvey);
} catch (error) {
logger.error(error, "Error updating survey");
if (error instanceof Prisma.PrismaClientKnownRequestError) {
@@ -648,8 +634,8 @@ export const createSurvey = async (
}
// Validate and prepare blocks for persistence
if (data.blocks && data.blocks.length > 0) {
data.blocks = validateMediaAndPrepareBlocks(data.blocks);
if (Array.isArray(data.blocks) && data.blocks.length > 0) {
data.blocks = validateMediaAndPrepareBlocks(data.blocks as unknown as TSurveyBlock[]);
}
const survey = await prisma.survey.create({
@@ -773,21 +759,7 @@ export const loadNewSegmentInSurvey = async (surveyId: string, newSegmentId: str
});
}
let surveySegment: TSegment | null = null;
if (prismaSurvey.segment) {
surveySegment = {
...prismaSurvey.segment,
surveys: prismaSurvey.segment.surveys.map((survey) => survey.id),
};
}
const modifiedSurvey = {
...prismaSurvey,
segment: surveySegment,
customHeadScriptsMode: prismaSurvey.customHeadScriptsMode,
};
return modifiedSurvey as TSurvey;
return transformPrismaSurvey<TSurvey>(prismaSurvey);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
+36
View File
@@ -0,0 +1,36 @@
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";
const getUserAuthenticationData = 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 getUserAuthenticationData(userId);
if (user.identityProvider !== "email" || !user.password) {
throw new InvalidInputError("Password is not set for this user");
}
return await verifyPassword(password, user.password);
};
+20
View File
@@ -0,0 +1,20 @@
import { Prisma } from "@prisma/client";
export const publicUserSelect = {
id: true,
name: true,
email: true,
emailVerified: true,
createdAt: true,
updatedAt: true,
twoFactorEnabled: true,
identityProvider: true,
notificationSettings: true,
locale: true,
lastLoginAt: true,
isActive: true,
} as const satisfies Prisma.UserSelect;
export type TPublicUser = Prisma.UserGetPayload<{
select: typeof publicUserSelect;
}>;
+10 -10
View File
@@ -6,6 +6,7 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TOrganization } from "@formbricks/types/organizations";
import { TUserLocale, TUserUpdateInput } from "@formbricks/types/user";
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { publicUserSelect } from "./public-user";
import { deleteUser, getUser, getUserByEmail, getUsersWithOrganization, updateUser } from "./service";
vi.mock("@formbricks/database", () => ({
@@ -47,11 +48,6 @@ describe("User Service", () => {
locale: "en-US" as TUserLocale,
lastLoginAt: new Date(),
isActive: true,
twoFactorSecret: null,
backupCodes: null,
password: null,
identityProviderAccountId: null,
groupId: null,
};
const mockOrganizations: TOrganization[] = [
@@ -102,8 +98,12 @@ describe("User Service", () => {
expect(result).toEqual(mockPrismaUser);
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: "user1" },
select: expect.any(Object),
select: publicUserSelect,
});
expect(result).not.toHaveProperty("password");
expect(result).not.toHaveProperty("twoFactorSecret");
expect(result).not.toHaveProperty("backupCodes");
expect(result).not.toHaveProperty("identityProviderAccountId");
});
test("should return null when user not found", async () => {
@@ -134,7 +134,7 @@ describe("User Service", () => {
expect(result).toEqual(mockPrismaUser);
expect(prisma.user.findFirst).toHaveBeenCalledWith({
where: { email: "test@example.com" },
select: expect.any(Object),
select: publicUserSelect,
});
});
@@ -176,7 +176,7 @@ describe("User Service", () => {
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user1" },
data: updateData,
select: expect.any(Object),
select: publicUserSelect,
});
});
@@ -204,7 +204,7 @@ describe("User Service", () => {
expect(deleteOrganization).toHaveBeenCalledWith("org1");
expect(prisma.user.delete).toHaveBeenCalledWith({
where: { id: "user1" },
select: expect.any(Object),
select: publicUserSelect,
});
});
@@ -236,7 +236,7 @@ describe("User Service", () => {
},
},
},
select: expect.any(Object),
select: publicUserSelect,
});
});
+7 -21
View File
@@ -10,21 +10,7 @@ import { TUser, TUserLocale, TUserUpdateInput, ZUserUpdateInput } from "@formbri
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { deleteBrevoCustomerByEmail } from "@/modules/auth/lib/brevo";
import { validateInputs } from "../utils/validate";
const responseSelection = {
id: true,
name: true,
email: true,
emailVerified: true,
createdAt: true,
updatedAt: true,
twoFactorEnabled: true,
identityProvider: true,
notificationSettings: true,
locale: true,
lastLoginAt: true,
isActive: true,
};
import { publicUserSelect } from "./public-user";
// function to retrive basic information about a user's user
export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
@@ -35,7 +21,7 @@ export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
where: {
id,
},
select: responseSelection,
select: publicUserSelect,
});
if (!user) {
@@ -59,7 +45,7 @@ export const getUserByEmail = reactCache(async (email: string): Promise<TUser |
where: {
email,
},
select: responseSelection,
select: publicUserSelect,
});
return user;
@@ -82,7 +68,7 @@ export const updateUser = async (personId: string, data: TUserUpdateInput): Prom
id: personId,
},
data: data,
select: responseSelection,
select: publicUserSelect,
});
return updatedUser;
@@ -105,7 +91,7 @@ const deleteUserById = async (id: string): Promise<TUser> => {
where: {
id,
},
select: responseSelection,
select: publicUserSelect,
});
return user;
} catch (error) {
@@ -153,7 +139,7 @@ export const getUsersWithOrganization = async (organizationId: string): Promise<
},
},
},
select: responseSelection,
select: publicUserSelect,
});
return users;
@@ -174,7 +160,7 @@ export const getUserLocale = reactCache(async (id: string): Promise<TUserLocale
where: {
id,
},
select: responseSelection,
select: publicUserSelect,
});
if (!user) {
+27
View File
@@ -0,0 +1,27 @@
export type DebouncedFunction<T extends (...args: any[]) => void> = ((...args: Parameters<T>) => void) & {
cancel: () => void;
};
export const debounce = <T extends (...args: any[]) => void>(
callback: T,
delay: number
): DebouncedFunction<T> => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const debounced = ((...args: Parameters<T>) => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => callback(...args), delay);
}) as DebouncedFunction<T>;
debounced.cancel = () => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
timeoutId = undefined;
}
};
return debounced;
};
+35
View File
@@ -0,0 +1,35 @@
export const capitalize = (value: string): string => {
if (!value) return "";
return `${value.charAt(0).toUpperCase()}${value.slice(1).toLowerCase()}`;
};
export const isDeepEqual = (left: unknown, right: unknown): boolean => {
if (Object.is(left, right)) return true;
if (left instanceof Date && right instanceof Date) {
return left.getTime() === right.getTime();
}
if (typeof left !== "object" || left === null || typeof right !== "object" || right === null) {
return false;
}
if (Array.isArray(left) || Array.isArray(right)) {
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
return left.every((item, index) => isDeepEqual(item, right[index]));
}
const leftRecord = left as Record<string, unknown>;
const rightRecord = right as Record<string, unknown>;
const leftKeys = Object.keys(leftRecord);
const rightKeys = Object.keys(rightRecord);
if (leftKeys.length !== rightKeys.length) return false;
return leftKeys.every(
(key) =>
Object.prototype.hasOwnProperty.call(rightRecord, key) && isDeepEqual(leftRecord[key], rightRecord[key])
);
};
+8 -4
View File
@@ -197,6 +197,7 @@
"created_by": "Erstellt von",
"customer_success": "Kundenerfolg",
"dark_overlay": "Dunkle Überlagerung",
"data_refreshed_successfully": "Daten erfolgreich aktualisiert",
"date": "Datum",
"days": "Tage",
"default": "Standard",
@@ -373,6 +374,7 @@
"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",
@@ -674,8 +676,6 @@
"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.",
@@ -833,6 +833,9 @@
},
"notion_integration_description": "Sende Daten an deine Notion Datenbank",
"please_select_a_survey_error": "Bitte wähle eine Umfrage aus",
"reconnect_button": "Erneut verbinden",
"reconnect_button_description": "Deine Integrationsverbindung ist abgelaufen. Bitte verbinde dich erneut, um weiterhin Antworten zu synchronisieren. Deine bestehenden Links und Daten bleiben erhalten.",
"reconnect_button_tooltip": "Verbinde die Integration erneut, um deinen Zugriff zu aktualisieren. Deine bestehenden Links und Daten bleiben erhalten.",
"select_at_least_one_question_error": "Bitte wähle mindestens eine Frage aus",
"slack": {
"already_connected_another_survey": "Du hast bereits eine andere Umfrage mit diesem Kanal verbunden.",
@@ -1195,7 +1198,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. Powerpuff Girls",
"organization_name_placeholder": "z. B. Acme Inc.",
"organization_name_updated_successfully": "Organisationsname erfolgreich aktualisiert",
"organization_settings": "Organisationseinstellungen",
"please_add_a_logo": "Bitte füge ein Logo hinzu",
@@ -1253,7 +1256,8 @@
"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"
"warning_cannot_undo": "Das kann nicht rückgängig gemacht werden",
"wrong_password": "Falsches Passwort"
},
"teams": {
"add_members_description": "Füge Mitglieder zum Team hinzu und bestimme ihre Rolle.",
+8 -4
View File
@@ -197,6 +197,7 @@
"created_by": "Created by",
"customer_success": "Customer Success",
"dark_overlay": "Dark overlay",
"data_refreshed_successfully": "Data refreshed successfully",
"date": "Date",
"days": "days",
"default": "Default",
@@ -373,6 +374,7 @@
"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",
@@ -674,8 +676,6 @@
"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.",
@@ -833,6 +833,9 @@
},
"notion_integration_description": "Send data to your Notion database",
"please_select_a_survey_error": "Please select a survey",
"reconnect_button": "Reconnect",
"reconnect_button_description": "Your integration connection has expired. Please reconnect to continue syncing responses. Your existing links and data will be preserved.",
"reconnect_button_tooltip": "Reconnect the integration to refresh your access. Your existing links and data will be preserved.",
"select_at_least_one_question_error": "Please select at least one question",
"slack": {
"already_connected_another_survey": "You have already connected another survey to this channel.",
@@ -1195,7 +1198,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. Power Puff Girls",
"organization_name_placeholder": "e.g. Acme Inc.",
"organization_name_updated_successfully": "Organization name updated successfully",
"organization_settings": "Organization Settings",
"please_add_a_logo": "Please add a logo",
@@ -1253,7 +1256,8 @@
"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"
"warning_cannot_undo": "This cannot be undone",
"wrong_password": "Wrong password"
},
"teams": {
"add_members_description": "Add members to the team and determine their role.",
+8 -4
View File
@@ -197,6 +197,7 @@
"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",
@@ -373,6 +374,7 @@
"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",
@@ -674,8 +676,6 @@
"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.",
@@ -833,6 +833,9 @@
},
"notion_integration_description": "Envía datos a tu base de datos de Notion",
"please_select_a_survey_error": "Por favor, selecciona una encuesta",
"reconnect_button": "Reconectar",
"reconnect_button_description": "Tu conexión de integración ha caducado. Por favor, reconecta para seguir sincronizando las respuestas. Tus enlaces y datos existentes se conservarán.",
"reconnect_button_tooltip": "Reconecta la integración para actualizar tu acceso. Tus enlaces y datos existentes se conservarán.",
"select_at_least_one_question_error": "Por favor, selecciona al menos una pregunta",
"slack": {
"already_connected_another_survey": "Ya has conectado otra encuesta a este canal.",
@@ -1195,7 +1198,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. Power Puff Girls",
"organization_name_placeholder": "p. ej. Acme Inc.",
"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",
@@ -1253,7 +1256,8 @@
"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"
"warning_cannot_undo": "Esto no se puede deshacer",
"wrong_password": "Contraseña incorrecta"
},
"teams": {
"add_members_description": "Añade miembros al equipo y determina su rol.",
+8 -4
View File
@@ -197,6 +197,7 @@
"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",
@@ -373,6 +374,7 @@
"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",
@@ -674,8 +676,6 @@
"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.",
@@ -833,6 +833,9 @@
},
"notion_integration_description": "Envoyez des données à votre base de données Notion.",
"please_select_a_survey_error": "Veuillez sélectionner une enquête.",
"reconnect_button": "Reconnecter",
"reconnect_button_description": "Ta connexion à l'intégration a expiré. Reconnecte-toi pour continuer à synchroniser les réponses. Tes liens et données existants seront conservés.",
"reconnect_button_tooltip": "Reconnecte l'intégration pour actualiser ton accès. Tes liens et données existants seront conservés.",
"select_at_least_one_question_error": "Veuillez sélectionner au moins une question.",
"slack": {
"already_connected_another_survey": "Vous avez déjà connecté une autre enquête à ce canal.",
@@ -1195,7 +1198,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. Power Puff Girls",
"organization_name_placeholder": "e.g. Acme Inc.",
"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",
@@ -1253,7 +1256,8 @@
"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."
"warning_cannot_undo": "Cette opération est irréversible.",
"wrong_password": "Mot de passe incorrect"
},
"teams": {
"add_members_description": "Ajoutez des membres à l'équipe et déterminez leur rôle.",
+71 -67
View File
@@ -63,8 +63,8 @@
"login_with_email": "Bejelentkezés e-mail-címmel",
"lost_access": "Elvesztette a hozzáférést?",
"new_to_formbricks": "Új a Formbicksen?",
"oauth_account_not_linked_description": "This SSO provider is not linked to an existing Formbricks account. Please sign in with the method you used originally. If that was email and password, complete email verification first if you are prompted.",
"oauth_account_not_linked_title": "This SSO sign-in could not be linked",
"oauth_account_not_linked_description": "Ez az SSO-szolgáltató nincs összekapcsolva egy meglévő Formbricks-fiókkal. Jelentkezzen be az eredetileg használt módszerrel. Ha ez e-mail és jelszó páros volt, akkor először végezze el az e-mail-ellenőrzést, ha a rendszer erre kéri.",
"oauth_account_not_linked_title": "Ezt az SSO-bejelentkezést nem sikerült összekapcsolni",
"use_a_backup_code": "Visszaszerzési kód használata"
},
"saml_connection_error": "Valami probléma történt. A további részletekért nézze meg az alkalmazás konzolját.",
@@ -150,8 +150,8 @@
"bottom_right": "Jobbra lent",
"cancel": "Mégse",
"centered_modal": "Középre helyezett kizárólagos",
"change_organization": "Szervezet módosítása",
"change_workspace": "Munkaterület módosítása",
"change_organization": "Szervezet megváltoztatása",
"change_workspace": "Munkaterület megváltoztatása",
"choice_n": "{{n}}. választás",
"choices": "Választási lehetőségek",
"choose_environment": "Környezet kiválasztása",
@@ -173,7 +173,7 @@
"connect": "Kapcsolódás",
"connect_formbricks": "Kapcsolódás a Formbrickshez",
"connected": "Kapcsolódva",
"contact": "Kapcsolat",
"contact": "Partner",
"contacts": "Partnerek",
"continue": "Folytatás",
"copied": "Másolva",
@@ -197,6 +197,7 @@
"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",
@@ -238,7 +239,7 @@
"failed_to_copy_to_clipboard": "Nem sikerült másolni a vágólapra",
"failed_to_load_organizations": "Nem sikerült betölteni a szervezeteket",
"failed_to_load_workspaces": "Nem sikerült a munkaterületek betöltése",
"field_placeholder": "{{field}} helyőrző",
"field_placeholder": "{{field}} helykitöltője",
"filter": "Szűrő",
"finish": "Befejezés",
"first_name": "Keresztnév",
@@ -250,7 +251,7 @@
"generate": "Előállítás",
"go_back": "Vissza",
"go_to_dashboard": "Ugrás a vezérlőpultra",
"headline": "Címsor",
"headline": "Főcím",
"hidden": "Rejtett",
"hidden_field": "Rejtett mező",
"hidden_fields": "Rejtett mezők",
@@ -272,7 +273,7 @@
"invite": "Meghívás",
"invite_them": "Meghívó nekik",
"javascript_required": "JavaScript szükséges",
"javascript_required_description": "A Formbricks használatához JavaScript szükséges. Kérjük, engedélyezze a JavaScriptet a böngésző beállításaiban a folytatáshoz.",
"javascript_required_description": "A Formbricks megfelelő működéséhez JavaScript szükséges. Engedélyezze a JavaScriptet a böngésző beállításaiban a folytatáshoz.",
"key": "Kulcs",
"label": "Címke",
"language": "Nyelv",
@@ -326,9 +327,9 @@
"notifications": "Értesítések",
"number": "Szám",
"off": "Ki",
"offline_all_responses_synced": "Az Ön válasza sikeresen mentésre került.",
"offline_syncing_responses": "Az Ön válaszainak szinkronizálása folyamatban…",
"offline_you_are_offline": "Ön offline állapotban van. Az Ön válasza a böngészőjében tárolásra került, és mentésre kerül, amint ismét online lesz.",
"offline_all_responses_synced": "A válasz sikeresen el lett mentve.",
"offline_syncing_responses": "Válaszok szinkronizálása…",
"offline_you_are_offline": "Ön nem érhető el. A válasza a böngészőjében van tárolva, és akkor lesz elmentve, ha újra elérhető lesz.",
"on": "Be",
"only_one_file_allowed": "Csak egy fájl engedélyezett",
"only_owners_managers_and_manage_access_members_can_perform_this_action": "Csak tulajdonosok és kezelők hajthatják végre ezt a műveletet.",
@@ -341,7 +342,7 @@
"organization_settings": "Szervezet beállításai",
"other": "Egyéb",
"other_filters": "Egyéb szűrők",
"other_placeholder": "Egyéb helyőrző",
"other_placeholder": "Egyéb helykitöltő",
"overlay_color": "Rávetítés színe",
"overview": "Áttekintés",
"password": "Jelszó",
@@ -373,6 +374,7 @@
"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",
@@ -446,7 +448,7 @@
"team_name": "Csapat neve",
"team_role": "Csapatszerep",
"teams": "Csapatok",
"terms_of_service": "Felhasználási feltételek",
"terms_of_service": "Használati feltételek",
"text": "Szöveg",
"time": "Idő",
"time_to_finish": "Idő a befejezésig",
@@ -555,7 +557,7 @@
"verification_email_heading": "Már majdnem kész vagyunk!",
"verification_email_hey": "Helló 👋",
"verification_email_if_expired_request_new_token": "Ha lejárt, kérjen új tokent itt:",
"verification_email_link_valid_for_24_hours": "A hivatkozás 24 órán keresztül érvényes.",
"verification_email_link_valid_for_24_hours": "A hivatkozás 24 óráig érvényes.",
"verification_email_request_new_verification": "Új ellenőrzés kérése",
"verification_email_subject": "Ellenőrizze az e-mail-címét a Formbricks használatához",
"verification_email_survey_name": "Kérdőív neve",
@@ -674,8 +676,6 @@
"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.",
@@ -833,6 +833,9 @@
},
"notion_integration_description": "Adatok küldése a Notion-adatbázisba",
"please_select_a_survey_error": "Válasszon kérdőívet",
"reconnect_button": "Újracsatlakozás",
"reconnect_button_description": "Az integráció kapcsolata lejárt. Kérjük, csatlakozzon újra a válaszok szinkronizálásának folytatásához. A meglévő hivatkozások és adatok megmaradnak.",
"reconnect_button_tooltip": "Csatlakoztassa újra az integrációt a hozzáférés frissítéséhez. A meglévő hivatkozások és adatok megmaradnak.",
"select_at_least_one_question_error": "Válasszon legalább egy kérdést",
"slack": {
"already_connected_another_survey": "Már hozzákapcsolt egy másik kérdőívet ehhez a csatornához.",
@@ -864,16 +867,16 @@
"created_by_third_party": "Harmadik fél által létrehozva",
"discord_webhook_not_supported": "A Discord webhorgok jelenleg nem támogatottak.",
"empty_webhook_message": "A webhorgai itt fognak megjelenni, amint hozzáadja azokat. ⏲️",
"endpoint_bad_gateway_error": "Hibás átjáró (502): Proxy-/átjáróhiba, a szolgáltatás nem érhető el",
"endpoint_gateway_timeout_error": "Átjáró időtúllépés (504): Átjáró időtúllépés, a szolgáltatás nem érhető el",
"endpoint_internal_server_error": "Belső szerverhiba (500): A szolgáltatás váratlan hibába ütközött",
"endpoint_method_not_allowed_error": "A metódus nem engedélyezett (405): A végpont létezik, de nem fogad POST kéréseket",
"endpoint_not_found_error": "Nem található (404): A végpont nem létezik",
"endpoint_bad_gateway_error": "Hibás átjáró (502): proxy- vagy átjáróhiba, a szolgáltatás nem érhető el",
"endpoint_gateway_timeout_error": "Átjáró időtúllépés (504): átjáró időtúllépés, a szolgáltatás nem érhető el",
"endpoint_internal_server_error": "Belső kiszolgálóhiba (500): a szolgáltatás váratlan hibába ütközött",
"endpoint_method_not_allowed_error": "A módszer nem engedélyezett (405): a végpont létezik, de nem fogad POST-kéréseket",
"endpoint_not_found_error": "Nem található (404): a végpont nem létezik",
"endpoint_pinged": "Hurrá! Képesek vagyunk pingelni a webhorgot!",
"endpoint_pinged_error": "Nem lehet pingelni a webhorgot!",
"endpoint_service_unavailable_error": "A szolgáltatás nem érhető el (503): A szolgáltatás átmenetileg nem elérhető",
"endpoint_service_unavailable_error": "A szolgáltatás nem érhető el (503): a szolgáltatás átmenetileg nem érhető el",
"learn_to_verify": "Tudja meg, hogy kell ellenőrizni a webhorog aláírásait",
"no_triggers": "Nincsenek Triggerek",
"no_triggers": "Nincsenek aktiválók",
"please_check_console": "További részletekért nézze meg a konzolt",
"please_enter_a_url": "Adjon meg egy URL-t",
"response_created": "Válasz létrehozva",
@@ -889,7 +892,7 @@
"webhook_created": "Webhorog létrehozva",
"webhook_delete_confirmation": "Biztosan törölni szeretné ezt a webhorgot? Ez le fogja állítani a jövőbeli értesítések küldését.",
"webhook_deleted_successfully": "A webhorog sikeresen törölve",
"webhook_name_placeholder": "Választható: címkézze meg a webhorgot az egyszerű azonosításért",
"webhook_name_placeholder": "Elhagyható: címkézze meg a webhorgot az egyszerű azonosításért",
"webhook_test_failed_due_to": "A webhorog tesztelése sikertelen a következő miatt:",
"webhook_updated_successfully": "A webhorog sikeresen frissítve",
"webhook_url_placeholder": "Illessze be azt az URL-t, amelyen az eseményt aktiválni szeretné"
@@ -1015,9 +1018,9 @@
"plan_change_applied": "A csomag sikeresen frissítve.",
"plan_change_scheduled": "A csomagváltoztatás sikeresen ütemezve.",
"plan_custom": "Egyéni",
"plan_feature_everything_in_hobby": "Minden a Hobbi csomagban",
"plan_feature_everything_in_hobby": "Minden a Hobby csomagban",
"plan_feature_everything_in_pro": "Minden a Pro csomagban",
"plan_hobby": "Hobbi",
"plan_hobby": "Hobby",
"plan_hobby_description": "Magánszemélyeknek és kis csapatoknak, akik most teszik meg a kezdeti lépéseket a Formbricks Cloud szolgáltatással.",
"plan_hobby_feature_responses": "250 válasz/hónap",
"plan_hobby_feature_workspaces": "1 munkaterület",
@@ -1025,11 +1028,11 @@
"plan_pro_description": "Növekvő csapatoknak, akiknek magasabb korlátokra, automatizálásra és dinamikus túllépési lehetőségekre van szükségük.",
"plan_pro_feature_responses": "2000 válasz/hónap (dinamikus túllépés)",
"plan_pro_feature_workspaces": "3 munkaterület",
"plan_scale": "Méretezés",
"plan_scale": "Scale",
"plan_scale_description": "Nagyobb csapatoknak, amelyeknek több kapacitásra, erősebb irányításra és nagyobb válaszmennyiségre van szükségük.",
"plan_scale_feature_responses": "5000 válasz/hónap (dinamikus túllépés)",
"plan_scale_feature_workspaces": "5 munkaterület",
"plan_selection_description": "Hobbi, Pro és Méretezés csomagok összehasonlítása, majd csomagok közötti váltás közvetlenül a Formbricksben.",
"plan_selection_description": "Hobby, Pro és Scale csomagok összehasonlítása, majd csomagok közötti váltás közvetlenül a Formbricksben.",
"plan_selection_title": "Csomag kiválasztása",
"plan_unknown": "Ismeretlen",
"remove_branding": "Márkajel eltávolítása",
@@ -1037,7 +1040,7 @@
"select_plan_header_subtitle": "Nincs szükség hitelkártyára, nincs kötöttség.",
"select_plan_header_title": "Zökkenőmentesen integrált kérdőívek, 100%-ban az Ön márkájához igazítva.",
"status_trialing": "Próbaidőszak",
"stay_on_hobby_plan": "A Hobbi csomagnál szeretnék maradni",
"stay_on_hobby_plan": "A Hobby csomagnál szeretnék maradni",
"stripe_setup_incomplete": "A számlázási beállítás befejezetlen",
"stripe_setup_incomplete_description": "A számlázási beállítás nem fejeződött be sikeresen. Próbálja meg újra aktiválni az előfizetését.",
"subscription": "Előfizetés",
@@ -1142,17 +1145,17 @@
"unlock_the_full_power_of_formbricks_free_for_30_days": "A Formbricks teljes erejének feloldása. 30 napig ingyen."
},
"general": {
"ai_data_analysis_disabled_for_organization": "Az MI-alapú adatelemzés és adatgazdagítás ki van kapcsolva ennél a szervezetnél.",
"ai_data_analysis_enabled": "Adatgazdagítás és elemzés (AI)",
"ai_data_analysis_enabled_description": "AI segítségével többet hozhat ki az adataiból, irányítópultokat, diagramokat, jelentéseket és egyebeket állíthat be. Hozzáfér az élményekhez kapcsolódó adatokhoz.",
"ai_enabled": "Formbricks AI",
"ai_enabled_description": "AI-alapú funkciók kezelése ehhez a szervezethez.",
"ai_data_analysis_disabled_for_organization": "Az MI-adatelemzés le van tiltva ennél a szervezetnél.",
"ai_data_analysis_enabled": "Adatgazdagítás és -elemzés (MI)",
"ai_data_analysis_enabled_description": "Mesterséges intelligencia ahhoz, hogy többet hozzon ki az adataiból. Vezérlőpultok, diagramok, jelentések és még sok más beállítása. Az élményadatokra is kiterjed.",
"ai_enabled": "Formbricks MI",
"ai_enabled_description": "MI-alapú funkciók kezelése ennél a szervezetnél.",
"ai_features_not_enabled_for_organization": "Az MI-funkciók nincsenek engedélyezve ennél a szervezetnél.",
"ai_instance_not_configured": "Az MI példányszinten, környezeti változókkal van konfigurálva. Kérd meg a rendszergazdát, hogy állítsa be az AI_PROVIDER értékét, a szolgáltató hitelesítő adatait és a megfelelő modelllistát, mielőtt engedélyezné az MI-funkciókat.",
"ai_settings_updated_successfully": "AI beállítások sikeresen frissítve",
"ai_smart_tools_disabled_for_organization": "Az MI intelligens funkciói ki vannak kapcsolva ennél a szervezetnél.",
"ai_smart_tools_enabled": "Intelligens funkciók (AI)",
"ai_smart_tools_enabled_description": "AI segítségével kevesebb idő alatt többet érhet el. Soha nem fér hozzá a Formbricks által gyűjtött adatokhoz. Csak például felmérések más nyelvekre történő fordításához használatos.",
"ai_instance_not_configured": "Az MI példányszinten van beállítva környezeti változókon keresztül. Kérje meg az adminisztrátort, hogy állítsa be az AI_PROVIDER, AI_MODEL és a hozzájuk tartozó szolgáltató hitelesítési adatait, mielőtt engedélyezné az MI-funkciókat.",
"ai_settings_updated_successfully": "Az MI-beállítások sikeresen frissítve",
"ai_smart_tools_disabled_for_organization": "Az MI intelligens eszközei le vannak tiltva ennél a szervezetnél.",
"ai_smart_tools_enabled": "Intelligens funkcionalitás (MI)",
"ai_smart_tools_enabled_description": "Mesterséges intelligencia ahhoz, hogy segítsen Önnek többet elérni kevesebb idő alatt. Soha sem érinti a Formbricks segítségével gyűjtött adatokat. Csak például a kérdőívek más nyelvekre történő fordításához kerül felhasználásra.",
"bulk_invite_warning_description": "Az ingyenes csomagban az összes szervezeti tag mindig a „Tulajdonos” szerephez van hozzárendelve.",
"cannot_delete_only_organization": "Ez az egyetlen szervezete, nem lehet törölni. Először hozzon létre egy új szervezetet.",
"cannot_leave_only_organization": "Nem hagyhatja el ezt a szervezetet, mivel ez az egyetlen szervezete. Először hozzon létre egy új szervezetet.",
@@ -1195,7 +1198,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 Pindúr pandúrok",
"organization_name_placeholder": "például Acme Inc.",
"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",
@@ -1253,7 +1256,8 @@
"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"
"warning_cannot_undo": "Ezt nem lehet visszavonni",
"wrong_password": "Hibás jelszó"
},
"teams": {
"add_members_description": "Tagok hozzáadása a csapathoz és a szerepük meghatározása.",
@@ -1358,8 +1362,8 @@
"assign": "= hozzárendelése",
"audience": "Közönség",
"auto_close_on_inactivity": "Automatikus lezárás tétlenségnél",
"auto_progress_rating_and_nps": "Automatikus továbblépés értékelési és NPS kérdéseknél",
"auto_progress_rating_and_nps_description": "Automatikus továbblépés egy kérdést tartalmazó blokkokban. A kötelező kérdések elrejtik a Tovább gombot, kivéve amikor az „Egyéb“ opció van kiválasztva.",
"auto_progress_rating_and_nps": "Értékelés és valós ügyfél-támogatottsági érték kérdések automatikus feldolgozása",
"auto_progress_rating_and_nps_description": "Automatikus továbblépés az egykérdéses blokkokban. A kötelező kérdések elrejtik a Tovább gombot, kivéve ha az „Egyéb van kiválasztva.",
"auto_save_disabled": "Az automatikus mentés letiltva",
"auto_save_disabled_tooltip": "A kérdőív csak akkor kerül automatikusan mentésre, ha piszkozatban van. Ez biztosítja, hogy a nyilvános kérdőívek ne legyenek véletlenül frissítve.",
"auto_save_on": "Automatikus mentés bekapcsolva",
@@ -1405,7 +1409,7 @@
"caution_text": "A változtatások következetlenségekhez vezetnek",
"change_anyway": "Változtatás mindenképp",
"change_background": "Háttér megváltoztatása",
"change_default": "Alapértelmezett módosítása",
"change_default": "Alapértelmezett megváltoztatása",
"change_question_type": "Kérdés típusának megváltoztatása",
"change_survey_type": "A kérdőív típusának megváltoztatása befolyásolja a meglévő hozzáférést",
"change_the_background_to_a_color_image_or_animation": "A háttér megváltoztatása színre, képre vagy animációra.",
@@ -1604,7 +1608,7 @@
"matrix_rows": "Sorok",
"max_file_size": "Legnagyobb fájlméret",
"max_file_size_limit_is": "A legnagyobb fájlméretkorlát",
"missing_first": "Hiányzók először",
"missing_first": "Hiányzik az első",
"move_question_to_block": "Kérdés áthelyezése egy blokkba",
"multiply": "Szorzás *",
"needed_for_self_hosted_cal_com_instance": "Saját üzemeltetésű Cal.com-példányhoz szükséges",
@@ -1612,7 +1616,7 @@
"next_button_label": "A „Következő” gomb címkéje",
"no_hidden_fields_yet_add_first_one_below": "Még nincsenek rejtett mezők. Adja hozzá az elsőt lent.",
"no_images_found_for": "Nem találhatók képek a(z) „{query}” lekérdezéshez",
"no_languages_found_add_first_one_to_get_started": "Nem található felmérési nyelv ebben a munkaterületen. Kérem, adjon hozzá egyet a kezdéshez.",
"no_languages_found_add_first_one_to_get_started": "Nem találhatók kérdőívnyelvek ezen a munkaterületen. Adja hozzá egyet a kezdéshez.",
"no_option_found": "Nem található lehetőség",
"no_recall_items_found": "Nem találhatók visszahívási elemek",
"no_variables_yet_add_first_one_below": "Még nincsenek változók. Adja hozzá az elsőt lent.",
@@ -1623,7 +1627,7 @@
"only_people_who_match_your_targeting_can_be_surveyed": "Csak azok a személyek kérdezhetők meg, akik megfelelnek a célcsoportnak.",
"option_idx": "{choiceIndex}. lehetőség",
"option_used_in_logic_error": "Ez a lehetőség használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",
"optional": "Választható",
"optional": "Elhagyható",
"options": "Beállítások*",
"options_used_in_logic_bulk_error": "A következő lehetőségek használatban vannak a logikában: {questionIndexes}. Először távolítsa el azokat a logikából.",
"override_theme_with_individual_styles_for_this_survey": "A téma felülírása egyéni stílusokkal ennél a kérdőívnél.",
@@ -1639,7 +1643,7 @@
"please_enter_a_valid_url": "Adjon meg egy érvényes URL-t (például https://example.com)",
"please_set_a_survey_trigger": "Állítson be kérdőív-aktiválót",
"please_specify": "Adja meg",
"present_your_survey_in_multiple_languages": "Mutassa be felmérését több nyelven",
"present_your_survey_in_multiple_languages": "A kérdőív bemutatása több nyelven",
"prevent_double_submission": "Kettős beküldés megakadályozása",
"prevent_double_submission_description": "E-mail-címenként csak 1 válasz engedélyezése",
"progress_saved": "Folyamat elmentve",
@@ -1708,8 +1712,8 @@
"response_limit_needs_to_exceed_number_of_received_responses": "A válaszkorlátnak meg kell haladnia a kapott válaszok számát ({responseCount}).",
"response_limits_redirections_and_more": "Válaszkorlátok, átirányítások és egyebek.",
"response_options": "Válasz beállításai",
"reverse_order_occasionally": "Sorrend alkalmi megfordítása",
"reverse_order_occasionally_except_last": "Sorrend alkalmi megfordítása az utolsó kivételével",
"reverse_order_occasionally": "Időnként fordított sorrendben",
"reverse_order_occasionally_except_last": "Időnként fordított sorrendben, kivéve az utolsó",
"roundness": "Kerekesség",
"roundness_description": "Annak vezérlése, hogy a sarkok mennyire legyenek lekerekítve.",
"row_used_in_logic_error": "Ez a sor használatban van a(z) {questionIndex}. kérdés logikájában. Először távolítsa el a logikából.",
@@ -1731,7 +1735,7 @@
"seven_points": "7 pont",
"show_block_settings": "Blokkbeállítások megjelenítése",
"show_button": "Gomb megjelenítése",
"show_in_order": "Sorrendben megjelenítés",
"show_in_order": "Megjelenítés sorrendben",
"show_language_switch": "Nyelvválasztó megjelenítése",
"show_multiple_times": "Megjelenítés korlátozott számú alkalommal",
"show_only_once": "Megjelenítés csak egyszer",
@@ -1770,7 +1774,7 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Megjelenítés egyetlen alkalommal, még akkor is, ha nem válaszolnak.",
"then": "Azután",
"this_action_will_remove_all_the_translations_from_this_survey": "Ez a művelet eltávolítja az összes fordítást ebből a kérdőívből.",
"this_will_remove_the_language_and_all_its_translations": "Ez eltávolítja ezt a nyelvet és az összes fordítását ebből a felmérésből. Ez a művelet nem vonható vissza.",
"this_will_remove_the_language_and_all_its_translations": "Ez el fogja távolítani ezt a nyelvet és annak összes fordítását ebből a kérdőívből. Ezt a műveletet nem lehet visszavonni.",
"three_points": "3 pont",
"times": "alkalom",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Ahhoz, hogy következetesen megtartsa az elhelyezést az összes kérdőívnél, az alábbiakat teheti:",
@@ -1790,11 +1794,11 @@
"upper_label": "Felső címke",
"url_filters": "URL szűrők",
"url_not_supported": "Az URL nem támogatott",
"validate_id_duplicate": "A(z) {type} azonosító már létezik a kérdések, rejtett mezők vagy változók között.",
"validate_id_empty": "Kérjük, adjon meg egy {type} azonosítót.",
"validate_id_invalid_chars": "A(z) {type} azonosító nem engedélyezett. Kérjük, csak alfanumerikus karaktereket, kötőjeleket vagy aláhúzásjeleket használjon.",
"validate_id_no_spaces": "A(z) {type} azonosító nem tartalmazhat szóközöket. Kérjük, távolítsa el a szóközöket.",
"validate_id_reserved": "A(z) {type} azonosító \"{field}\" nem engedélyezett. Ez egy fenntartott kulcsszó.",
"validate_id_duplicate": "A {type} azonosítója már létezik a kérdésekben, rejtett mezőkben vagy változókban.",
"validate_id_empty": "Adja meg egy {type} azonosítót.",
"validate_id_invalid_chars": "A {type} azonosítója nem engedélyezett. Használjon csak alfanumerikus karaktereket, kötőjeleket vagy aláhúzásjeleket.",
"validate_id_no_spaces": "A {type} azonosítója nem tartalmazhat szóközöket. Távolítsa el a szóközöket.",
"validate_id_reserved": "A {type} „{field}” azonosítója nem engedélyezett. Ez egy foglalt kulcsszó.",
"validation": {
"add_validation_rule": "Ellenőrzési szabály hozzáadása",
"answer_all_rows": "Válaszoljon az összes sorra",
@@ -2135,7 +2139,7 @@
"this_quarter": "Ez a negyedév",
"this_year": "Ez az év",
"time_to_complete": "Kitöltéshez szükséges idő",
"ttc_survey_tooltip": "A felmérés kitöltésének átlagos ideje.",
"ttc_survey_tooltip": "A kérdőív megválaszolásának átlagos ideje.",
"ttc_tooltip": "A kérdés megválaszolásának átlagos ideje.",
"unknown_question_type": "Ismeretlen kérdéstípus",
"use_personal_links": "Személyes hivatkozások használata",
@@ -2224,7 +2228,7 @@
"languages": {
"add_language": "Nyelv hozzáadása",
"alias": "Álnév",
"alias_tooltip": "Az álnév egy alternatív név a hivatkozás-kérdőívekben és az SDK-ban lévő nyelv azonosításához (választható)",
"alias_tooltip": "Az álnév egy alternatív név a hivatkozás-kérdőívekben és az SDK-ban lévő nyelv azonosításához (elhagyható)",
"cannot_remove_language_warning": "Nem tudja eltávolítani ezt a nyelvet, mert még mindig használatban van ezekben a kérdőívekben:",
"conflict_between_identifier_and_alias": "Ütközés van egy hozzáadott nyelv azonosítója és az álnevei egyike között. Az álnevek és az azonosítók nem lehetnek azonosak.",
"conflict_between_selected_alias_and_another_language": "Ütközés van a kiválasztott álnév és egy másik, ezzel az azonosítóval rendelkező nyelv között. A következetlenségek elkerülése érdekében ezzel az azonosítóval adja hozzá a nyelvet a munkaterületéhez.",
@@ -2302,11 +2306,11 @@
"advanced_styling_field_track_height": "Követés magassága",
"advanced_styling_field_track_height_description": "A folyamatjelző vastagságát vezérli.",
"advanced_styling_field_upper_label_color": "Címke színe",
"advanced_styling_field_upper_label_color_description": "A beviteli mezők feletti kis címkék és a skálacímkék színét állítja be.",
"advanced_styling_field_upper_label_color_description": "Kiszínezi a beviteli mezők fölötti kis címkéket és a méretezés címkéit.",
"advanced_styling_field_upper_label_size": "Címke betűmérete",
"advanced_styling_field_upper_label_size_description": "A beviteli mezők feletti kis címkék és a skálacímkék betűméretét állítja be.",
"advanced_styling_field_upper_label_size_description": "Átméretezi a beviteli mezők fölötti kis címkéket és a méretezés címkéit.",
"advanced_styling_field_upper_label_weight": "Címke betűvastagsága",
"advanced_styling_field_upper_label_weight_description": "A címkék vékonyabbá vagy vastagabbá tételét teszi lehetővé.",
"advanced_styling_field_upper_label_weight_description": "Vékonyabbá vagy vastagabbá teszi a címkéket.",
"advanced_styling_section_buttons": "Gombok",
"advanced_styling_section_headlines": "Címsorok és leírások",
"advanced_styling_section_inputs": "Beviteli mezők",
@@ -2643,7 +2647,7 @@
"csat_question_1_headline": "Mennyire valószínű, hogy ezt a(z) $[projectName] projektet ajánlaná egy ismerősnek vagy kollégának?",
"csat_question_1_lower_label": "Nem valószínű",
"csat_question_1_upper_label": "Nagyon valószínű",
"csat_question_2_choice_1": "Részben elégedett",
"csat_question_2_choice_1": "Valamelyest elégedett",
"csat_question_2_choice_2": "Nagyon elégedett",
"csat_question_2_choice_3": "Sem elégedett, sem elégedetlen",
"csat_question_2_choice_4": "Valamelyest elégedetlen",
@@ -2877,10 +2881,10 @@
"gauge_feature_satisfaction_question_2_headline": "Mi az egyetlen dolog, amelyet jobban csinálhatnánk?",
"identify_customer_goals_description": "Jobban megérteni, hogy az üzenetei a termék által nyújtott érték megfelelő elvárásait keltik-e.",
"identify_customer_goals_name": "Ügyfélcélok azonosítása",
"identify_customer_goals_question_1_choice_1": "Alaposan megismerni a felhasználói bázisomat",
"identify_customer_goals_question_1_choice_1": "A felhasználói bázisom alapos megértése",
"identify_customer_goals_question_1_choice_2": "Felülértékesítési lehetőségek azonosítása",
"identify_customer_goals_question_1_choice_3": "A lehető legjobb termék elkészítése",
"identify_customer_goals_question_1_choice_4": "Világuralmat szerezni, hogy mindenki kelbimbót egyen reggelire",
"identify_customer_goals_question_1_choice_4": "Világuralom szerezése, hogy mindenki kelbimbót egyen reggelire",
"identify_customer_goals_question_1_headline": "Mi az elsődleges célja a(z) $[projectName] használatával?",
"identify_sign_up_barriers_description": "Kedvezmény felajánlása a regisztrációs akadályokkal kapcsolatos tapasztalatok gyűjtéséhez.",
"identify_sign_up_barriers_name": "Regisztrációs akadályok azonosítása",
@@ -2957,13 +2961,13 @@
"improve_trial_conversion_question_2_button_label": "Következő",
"improve_trial_conversion_question_2_headline": "Sajnálattal halljuk. Mi volt a legnagyobb probléma a(z) $[projectName] projekt használatával?",
"improve_trial_conversion_question_3_button_label": "Következő",
"improve_trial_conversion_question_3_headline": "Mit várt a(z) $[projectName] projekttől?",
"improve_trial_conversion_question_3_headline": "Mit vár el a(z) $[projectName] projekttől?",
"improve_trial_conversion_question_4_button_label": "20% kedvezmény",
"improve_trial_conversion_question_4_headline": "Sajnálattal halljuk! 20% kedvezményt kap az első évre.",
"improve_trial_conversion_question_4_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Boldogan felajánlunk 20% kedvezményt az éves csomagra.</span></p>",
"improve_trial_conversion_question_5_button_label": "Következő",
"improve_trial_conversion_question_5_headline": "Mit szeretne elérni?",
"improve_trial_conversion_question_5_subheader": "Kérjük, ismertesse az alábbiakban:",
"improve_trial_conversion_question_5_subheader": "Írja le az alábbiakban:",
"improve_trial_conversion_question_6_headline": "Hogyan oldja meg a problémáját most?",
"improve_trial_conversion_question_6_subheader": "Nevezzen meg alternatív megoldásokat:",
"integration_setup_survey_description": "Annak kiértékelése, hogy a felhasználók mennyire könnyen tudnak integrációkat hozzáadni a termékéhez. A vakfoltok megtalálása.",
+8 -4
View File
@@ -197,6 +197,7 @@
"created_by": "作成者",
"customer_success": "カスタマーサクセス",
"dark_overlay": "暗いオーバーレイ",
"data_refreshed_successfully": "データが正常に更新されました",
"date": "日付",
"days": "日",
"default": "デフォルト",
@@ -373,6 +374,7 @@
"quotas_description": "特定の基準を満たす参加者からの回答数を制限する",
"read_docs": "ドキュメントを読む",
"recipients": "受信者",
"refresh": "更新",
"remove": "削除",
"remove_from_team": "チームから削除",
"reorder_and_hide_columns": "列の並び替えと非表示",
@@ -674,8 +676,6 @@
"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": "セグメンテーション用の新しい属性を作成します。",
@@ -833,6 +833,9 @@
},
"notion_integration_description": "回答を直接Notionに送信します",
"please_select_a_survey_error": "フォームを選択してください",
"reconnect_button": "再接続",
"reconnect_button_description": "統合の接続が期限切れになりました。回答の同期を続けるには再接続してください。既存のリンクとデータは保持されます。",
"reconnect_button_tooltip": "統合を再接続してアクセスを更新します。既存のリンクとデータは保持されます。",
"select_at_least_one_question_error": "少なくとも1つの質問を選択してください",
"slack": {
"already_connected_another_survey": "このチャンネルには別のフォームがすでに接続されています。",
@@ -1195,7 +1198,7 @@
"organization_invite_link_ready": "組織の招待リンクが準備できました!",
"organization_name": "組織名",
"organization_name_description": "組織に分かりやすい名前を付けます。",
"organization_name_placeholder": "例: パワーパフガールズ",
"organization_name_placeholder": "例: Acme Inc.",
"organization_name_updated_successfully": "組織名を正常に更新しました",
"organization_settings": "組織設定",
"please_add_a_logo": "ロゴを追加してください",
@@ -1253,7 +1256,8 @@
"unlock_two_factor_authentication": "上位プランで二段階認証をアンロック",
"update_personal_info": "個人情報を更新",
"warning_cannot_delete_account": "あなたは、この組織の唯一のオーナーです。まず、別のメンバーにオーナーシップを譲渡してください。",
"warning_cannot_undo": "この操作は元に戻せません"
"warning_cannot_undo": "この操作は元に戻せません",
"wrong_password": "パスワードが間違っています"
},
"teams": {
"add_members_description": "チームにメンバーを追加し、役割を決定します。",
+8 -4
View File
@@ -197,6 +197,7 @@
"created_by": "Gemaakt door",
"customer_success": "Klant succes",
"dark_overlay": "Donkere overlay",
"data_refreshed_successfully": "Gegevens succesvol vernieuwd",
"date": "Datum",
"days": "dagen",
"default": "Standaard",
@@ -373,6 +374,7 @@
"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",
@@ -674,8 +676,6 @@
"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.",
@@ -833,6 +833,9 @@
},
"notion_integration_description": "Verzend gegevens naar uw Notion-database",
"please_select_a_survey_error": "Selecteer een enquête",
"reconnect_button": "Opnieuw verbinden",
"reconnect_button_description": "Je integratieverbinding is verlopen. Maak opnieuw verbinding om door te gaan met het synchroniseren van reacties. Je bestaande links en gegevens blijven behouden.",
"reconnect_button_tooltip": "Verbind de integratie opnieuw om je toegang te vernieuwen. Je bestaande links en gegevens blijven behouden.",
"select_at_least_one_question_error": "Selecteer minimaal één vraag",
"slack": {
"already_connected_another_survey": "U heeft al een andere enquête aan dit kanaal gekoppeld.",
@@ -1195,7 +1198,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. Power Puff-meisjes",
"organization_name_placeholder": "bijv. Acme Inc.",
"organization_name_updated_successfully": "Organisatienaam is succesvol bijgewerkt",
"organization_settings": "Organisatie-instellingen",
"please_add_a_logo": "Voeg een logo toe",
@@ -1253,7 +1256,8 @@
"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"
"warning_cannot_undo": "Dit kan niet ongedaan worden gemaakt",
"wrong_password": "Verkeerd wachtwoord"
},
"teams": {
"add_members_description": "Voeg leden toe aan het team en bepaal hun rol.",
+8 -4
View File
@@ -197,6 +197,7 @@
"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",
@@ -373,6 +374,7 @@
"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",
@@ -674,8 +676,6 @@
"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.",
@@ -833,6 +833,9 @@
},
"notion_integration_description": "Enviar dados para seu banco de dados do Notion",
"please_select_a_survey_error": "Por favor, escolha uma pesquisa",
"reconnect_button": "Reconectar",
"reconnect_button_description": "Sua conexão de integração expirou. Por favor, reconecte para continuar sincronizando respostas. Seus links e dados existentes serão preservados.",
"reconnect_button_tooltip": "Reconecte a integração para atualizar seu acesso. Seus links e dados existentes serão preservados.",
"select_at_least_one_question_error": "Por favor, selecione pelo menos uma pergunta",
"slack": {
"already_connected_another_survey": "Você já conectou outra pesquisa a este canal.",
@@ -1195,7 +1198,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, Meninas Superpoderosas",
"organization_name_placeholder": "por exemplo, Acme Inc.",
"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",
@@ -1253,7 +1256,8 @@
"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"
"warning_cannot_undo": "Isso não pode ser desfeito",
"wrong_password": "Senha incorreta"
},
"teams": {
"add_members_description": "Adicione membros à equipe e determine sua função.",
+8 -4
View File
@@ -197,6 +197,7 @@
"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",
@@ -373,6 +374,7 @@
"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",
@@ -674,8 +676,6 @@
"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.",
@@ -833,6 +833,9 @@
},
"notion_integration_description": "Enviar dados para a sua base de dados do Notion",
"please_select_a_survey_error": "Por favor, selecione um inquérito",
"reconnect_button": "Voltar a ligar",
"reconnect_button_description": "A ligação da tua integração expirou. Por favor, volta a ligar para continuar a sincronizar as respostas. As tuas ligações e dados existentes serão preservados.",
"reconnect_button_tooltip": "Volta a ligar a integração para atualizar o teu acesso. As tuas ligações e dados existentes serão preservados.",
"select_at_least_one_question_error": "Por favor, selecione pelo menos uma pergunta",
"slack": {
"already_connected_another_survey": "Já ligou outro inquérito a este canal.",
@@ -1195,7 +1198,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, Power Puff Girls",
"organization_name_placeholder": "por exemplo, Acme Inc.",
"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",
@@ -1253,7 +1256,8 @@
"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"
"warning_cannot_undo": "Isto não pode ser desfeito",
"wrong_password": "Palavra-passe incorreta"
},
"teams": {
"add_members_description": "Adicionar membros à equipa e determinar o seu papel.",
+8 -4
View File
@@ -197,6 +197,7 @@
"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",
@@ -373,6 +374,7 @@
"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",
@@ -674,8 +676,6 @@
"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.",
@@ -833,6 +833,9 @@
},
"notion_integration_description": "Trimiteți datele în baza de date Notion",
"please_select_a_survey_error": "Vă rugăm să selectați un sondaj",
"reconnect_button": "Reconectează",
"reconnect_button_description": "Conexiunea integrării tale a expirat. Te rugăm să te reconectezi pentru a continua sincronizarea răspunsurilor. Linkurile și datele tale existente vor fi păstrate.",
"reconnect_button_tooltip": "Reconectează integrarea pentru a reîmprospăta accesul. Linkurile și datele tale existente vor fi păstrate.",
"select_at_least_one_question_error": "Vă rugăm să selectați cel puțin o întrebare",
"slack": {
"already_connected_another_survey": "Ați conectat deja un alt chestionar la acest canal.",
@@ -1195,7 +1198,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. Power Puff Girls",
"organization_name_placeholder": "ex. Acme Inc.",
"organization_name_updated_successfully": "Numele organizației actualizat cu succes",
"organization_settings": "Setări Organizație",
"please_add_a_logo": "Adaugă un logo",
@@ -1253,7 +1256,8 @@
"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ă"
"warning_cannot_undo": "Aceasta nu poate fi anulată",
"wrong_password": "Parolă greșită"
},
"teams": {
"add_members_description": "Adaugă membri în echipă și stabilește rolul lor.",
+8 -4
View File
@@ -197,6 +197,7 @@
"created_by": "Создано пользователем",
"customer_success": "Customer Success",
"dark_overlay": "Тёмный оверлей",
"data_refreshed_successfully": "Данные успешно обновлены",
"date": "Дата",
"days": "дни",
"default": "По умолчанию",
@@ -373,6 +374,7 @@
"quotas_description": "Ограничьте количество ответов, которые вы получаете от участников, соответствующих определённым критериям.",
"read_docs": "Читать документацию",
"recipients": "Получатели",
"refresh": "Обновить",
"remove": "Удалить",
"remove_from_team": "Удалить из команды",
"reorder_and_hide_columns": "Изменить порядок и скрыть столбцы",
@@ -674,8 +676,6 @@
"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": "Создайте новый атрибут для целей сегментации.",
@@ -833,6 +833,9 @@
},
"notion_integration_description": "Отправляйте данные в вашу базу данных Notion",
"please_select_a_survey_error": "Пожалуйста, выберите опрос",
"reconnect_button": "Переподключить",
"reconnect_button_description": "Срок действия подключения интеграции истёк. Пожалуйста, переподключитесь, чтобы продолжить синхронизацию ответов. Ваши существующие ссылки и данные будут сохранены.",
"reconnect_button_tooltip": "Переподключите интеграцию, чтобы обновить доступ. Ваши существующие ссылки и данные будут сохранены.",
"select_at_least_one_question_error": "Пожалуйста, выберите хотя бы один вопрос",
"slack": {
"already_connected_another_survey": "Вы уже подключили другой опрос к этому каналу.",
@@ -1195,7 +1198,7 @@
"organization_invite_link_ready": "Ссылка для приглашения в организацию готова!",
"organization_name": "Название организации",
"organization_name_description": "Дайте вашей организации понятное название.",
"organization_name_placeholder": "например, Power Puff Girls",
"organization_name_placeholder": "например, Acme Inc.",
"organization_name_updated_successfully": "Название организации успешно обновлено",
"organization_settings": "Настройки организации",
"please_add_a_logo": "Пожалуйста, добавьте логотип",
@@ -1253,7 +1256,8 @@
"unlock_two_factor_authentication": "Откройте двухфакторную аутентификацию с более высоким тарифом",
"update_personal_info": "Обновить личную информацию",
"warning_cannot_delete_account": "Вы являетесь единственным владельцем этой организации. Пожалуйста, сначала передайте права другому участнику.",
"warning_cannot_undo": "Это действие необратимо"
"warning_cannot_undo": "Это действие необратимо",
"wrong_password": "Неверный пароль"
},
"teams": {
"add_members_description": "Добавьте участников в команду и определите их роль.",
+8 -4
View File
@@ -197,6 +197,7 @@
"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",
@@ -373,6 +374,7 @@
"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",
@@ -674,8 +676,6 @@
"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.",
@@ -833,6 +833,9 @@
},
"notion_integration_description": "Skicka data till din Notion-databas",
"please_select_a_survey_error": "Vänligen välj en enkät",
"reconnect_button": "Återanslut",
"reconnect_button_description": "Din integrationsanslutning har gått ut. Vänligen återanslut för att fortsätta synkronisera svar. Dina befintliga länkar och data kommer att bevaras.",
"reconnect_button_tooltip": "Återanslut integrationen för att uppdatera din åtkomst. Dina befintliga länkar och data kommer att bevaras.",
"select_at_least_one_question_error": "Vänligen välj minst en fråga",
"slack": {
"already_connected_another_survey": "Du har redan anslutit en annan enkät till denna kanal.",
@@ -1195,7 +1198,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. Power Puff Girls",
"organization_name_placeholder": "t.ex. Acme Inc.",
"organization_name_updated_successfully": "Organisationsnamn uppdaterat",
"organization_settings": "Organisationsinställningar",
"please_add_a_logo": "Vänligen lägg till en logotyp",
@@ -1253,7 +1256,8 @@
"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"
"warning_cannot_undo": "Detta kan inte ångras",
"wrong_password": "Fel lösenord"
},
"teams": {
"add_members_description": "Lägg till medlemmar i teamet och bestäm deras roll.",
+8 -4
View File
@@ -197,6 +197,7 @@
"created_by": "Oluşturan",
"customer_success": "Müşteri Başarısı",
"dark_overlay": "Koyu kaplama",
"data_refreshed_successfully": "Veriler başarıyla yenilendi",
"date": "Tarih",
"days": "gün",
"default": "Varsayılan",
@@ -373,6 +374,7 @@
"quotas_description": "Belirli kriterleri karşılayan katılımcılardan aldığınız yanıt miktarını sınırlayın.",
"read_docs": "Dokümanları oku",
"recipients": "Alıcılar",
"refresh": "Yenile",
"remove": "Kaldır",
"remove_from_team": "Takımdan çıkar",
"reorder_and_hide_columns": "Sütunları yeniden sırala ve gizle",
@@ -674,8 +676,6 @@
"attributes_msg_new_attribute_created": "\"{key}\" adında \"{dataType}\" türünde yeni özellik oluşturuldu",
"attributes_msg_userid_already_exists": "Kullanıcı kimliği bu ortamda zaten mevcut ve güncellenmedi.",
"contact_deleted_successfully": "Contact başarıyla silindi",
"contacts_table_refresh": "Kişileri yenile",
"contacts_table_refresh_success": "Kişiler başarıyla yenilendi",
"create_attribute": "Özellik oluştur",
"create_new_attribute": "Yeni özellik oluştur",
"create_new_attribute_description": "Segmentasyon amacıyla yeni bir özellik oluşturun.",
@@ -833,6 +833,9 @@
},
"notion_integration_description": "Verilerinizi Notion veritabanınıza gönderin",
"please_select_a_survey_error": "Lütfen bir survey seçin",
"reconnect_button": "Yeniden Bağlan",
"reconnect_button_description": "Entegrasyon bağlantınızın süresi doldu. Yanıtları senkronize etmeye devam etmek için lütfen yeniden bağlanın. Mevcut bağlantılarınız ve verileriniz korunacaktır.",
"reconnect_button_tooltip": "Erişiminizi yenilemek için entegrasyonu yeniden bağlayın. Mevcut bağlantılarınız ve verileriniz korunacaktır.",
"select_at_least_one_question_error": "Lütfen en az bir soru seçin",
"slack": {
"already_connected_another_survey": "Bu kanala zaten başka bir survey bağladınız.",
@@ -1195,7 +1198,7 @@
"organization_invite_link_ready": "Kuruluş davet bağlantınız hazır!",
"organization_name": "Kuruluş Adı",
"organization_name_description": "Kuruluşunuza açıklayıcı bir ad verin.",
"organization_name_placeholder": "ör. Power Puff Girls",
"organization_name_placeholder": "ör. Acme Inc.",
"organization_name_updated_successfully": "Organization name başarıyla güncellendi",
"organization_settings": "Kuruluş Ayarları",
"please_add_a_logo": "Lütfen bir logo ekleyin",
@@ -1253,7 +1256,8 @@
"unlock_two_factor_authentication": "Daha üst bir planla iki faktörlü kimlik doğrulamayı açın",
"update_personal_info": "Kişisel bilgilerinizi güncelleyin",
"warning_cannot_delete_account": "Bu organizasyonun tek sahibi sizsiniz. Lütfen önce sahipliği başka bir üyeye devredin.",
"warning_cannot_undo": "Bu işlem geri alınamaz"
"warning_cannot_undo": "Bu işlem geri alınamaz",
"wrong_password": "Yanlış parola"
},
"teams": {
"add_members_description": "Takıma üyeler ekleyin ve rollerini belirleyin.",
+8 -4
View File
@@ -197,6 +197,7 @@
"created_by": "由 创建",
"customer_success": "客户成功",
"dark_overlay": "深色遮罩层",
"data_refreshed_successfully": "数据刷新成功",
"date": "日期",
"days": "天",
"default": "默认",
@@ -373,6 +374,7 @@
"quotas_description": "限制 符合 特定 条件 的 参与者 的 响应 数量 。",
"read_docs": "阅读文档",
"recipients": "收件人",
"refresh": "刷新",
"remove": "移除",
"remove_from_team": "从团队中移除",
"reorder_and_hide_columns": "重新排序和隐藏列",
@@ -674,8 +676,6 @@
"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": "为细分目的创建新属性。",
@@ -833,6 +833,9 @@
},
"notion_integration_description": "将 数据 发送到 您的 Notion 数据库",
"please_select_a_survey_error": "请选择 一个 调查",
"reconnect_button": "重新连接",
"reconnect_button_description": "你的集成连接已过期。请重新连接以继续同步响应。你现有的链接和数据将被保留。",
"reconnect_button_tooltip": "重新连接集成以刷新你的访问权限。你现有的链接和数据将被保留。",
"select_at_least_one_question_error": "请选择至少 一个问题",
"slack": {
"already_connected_another_survey": "您 已 经 将 另 一 个 调 查 连 接 到 此 频 道 。",
@@ -1195,7 +1198,7 @@
"organization_invite_link_ready": "您的组织邀请链接已准备好!",
"organization_name": "组织 名称",
"organization_name_description": "为您的组织 提供一个描述性的名称",
"organization_name_placeholder": "例如 Power Puff Girls",
"organization_name_placeholder": "例如 Acme Inc.",
"organization_name_updated_successfully": "组织 名称 更新 成功",
"organization_settings": "组织 设置",
"please_add_a_logo": "请添加 徽标",
@@ -1253,7 +1256,8 @@
"unlock_two_factor_authentication": "使用 更高 级 方案 解锁 双 重 因素 验证",
"update_personal_info": "更新你的个人信息",
"warning_cannot_delete_account": "您 是 该 组织 的 唯一 拥有者 。 请 先 将 所有权 转移 给 其他 成员 。",
"warning_cannot_undo": "此 无法 撤销。"
"warning_cannot_undo": "此 无法 撤销。",
"wrong_password": "密码错误"
},
"teams": {
"add_members_description": "将 成员 添加到 团队 ,并 确定 他们 的 角色",
+8 -4
View File
@@ -197,6 +197,7 @@
"created_by": "建立者",
"customer_success": "客戶成功",
"dark_overlay": "深色覆蓋",
"data_refreshed_successfully": "資料已成功重新整理",
"date": "日期",
"days": "天",
"default": "預設",
@@ -373,6 +374,7 @@
"quotas_description": "限制 擁有 特定 條件 的 參與者 所 提供 的 回應 數量。",
"read_docs": "閱讀文件",
"recipients": "收件者",
"refresh": "重新整理",
"remove": "移除",
"remove_from_team": "從團隊中移除",
"reorder_and_hide_columns": "重新排序和隱藏欄位",
@@ -674,8 +676,6 @@
"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": "建立新屬性以進行分群用途。",
@@ -833,6 +833,9 @@
},
"notion_integration_description": "將資料傳送至您的 Notion 資料庫",
"please_select_a_survey_error": "請選取問卷",
"reconnect_button": "重新連接",
"reconnect_button_description": "您的整合連線已過期。請重新連接以繼續同步回應。您現有的連結和資料將會保留。",
"reconnect_button_tooltip": "重新連接整合以更新您的存取權限。您現有的連結和資料將會保留。",
"select_at_least_one_question_error": "請選取至少一個問題",
"slack": {
"already_connected_another_survey": "您已將另一個問卷連線到此頻道。",
@@ -1195,7 +1198,7 @@
"organization_invite_link_ready": "您的組織邀請連結已準備就緒!",
"organization_name": "組織名稱",
"organization_name_description": "為您的組織提供描述性名稱。",
"organization_name_placeholder": "例如:飛天小女警",
"organization_name_placeholder": "例如:Acme Inc.",
"organization_name_updated_successfully": "組織名稱已成功更新",
"organization_settings": "組織設定",
"please_add_a_logo": "請新增標誌",
@@ -1253,7 +1256,8 @@
"unlock_two_factor_authentication": "使用更高等級的方案解鎖雙重驗證",
"update_personal_info": "更新您的個人資訊",
"warning_cannot_delete_account": "您是此組織的唯一擁有者。請先將所有權轉讓給其他成員。",
"warning_cannot_undo": "此操作無法復原"
"warning_cannot_undo": "此操作無法復原",
"wrong_password": "密碼錯誤"
},
"teams": {
"add_members_description": "將成員新增至團隊並確定其角色。",
@@ -1,24 +1,89 @@
"use server";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { AuthorizationError, InvalidInputError, OperationNotAllowedError } from "@formbricks/types/errors";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { verifyUserPassword } from "@/lib/user/password";
import { deleteUser, getUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { DELETE_ACCOUNT_WRONG_PASSWORD_ERROR } from "./constants";
export const deleteUserAction = authenticatedActionClient.action(
withAuditLogging("deleted", "user", async ({ ctx }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(ctx.user.id);
if (!isMultiOrgEnabled && organizationsWithSingleOwner.length > 0) {
throw new OperationNotAllowedError(
"You are the only owner of this organization. Please transfer ownership to another member first."
);
}
const DELETE_USER_CONFIRMATION_REQUIRED_ERROR =
"Password and email confirmation are required to delete your account.";
const ZDeleteUserConfirmation = z
.object({
confirmationEmail: z.string().trim().min(1).max(255),
password: z.string().max(128).optional(),
})
.strict();
const parseDeleteUserConfirmation = (input: unknown) => {
const parsedInput = ZDeleteUserConfirmation.safeParse(input);
if (!parsedInput.success) {
throw new InvalidInputError(DELETE_USER_CONFIRMATION_REQUIRED_ERROR);
}
return parsedInput.data;
};
const getPasswordOrThrow = (password?: string) => {
if (!password) {
throw new InvalidInputError(DELETE_USER_CONFIRMATION_REQUIRED_ERROR);
}
return password;
};
const logAccountDeletionError = (userId: string, error: unknown) => {
logger.error({ error, userId }, "Account deletion failed");
};
export const deleteUserAction = authenticatedActionClient.inputSchema(z.unknown()).action(
withAuditLogging("deleted", "user", async ({ ctx, parsedInput }) => {
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = await getUser(ctx.user.id);
const result = await deleteUser(ctx.user.id);
return result;
try {
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, ctx.user.id);
const isPasswordBackedAccount = ctx.user.identityProvider === "email";
const { confirmationEmail, password } = parseDeleteUserConfirmation(parsedInput);
if (confirmationEmail.toLowerCase() !== ctx.user.email.toLowerCase()) {
throw new AuthorizationError("Email confirmation does not match");
}
if (isPasswordBackedAccount) {
const isCorrectPassword = await verifyUserPassword(ctx.user.id, getPasswordOrThrow(password));
if (!isCorrectPassword) {
throw new AuthorizationError(DELETE_ACCOUNT_WRONG_PASSWORD_ERROR);
}
}
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) {
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(ctx.user.id);
if (organizationsWithSingleOwner.length > 0) {
throw new OperationNotAllowedError(
"You are the only owner of this organization. Please transfer ownership to another member first."
);
}
}
ctx.auditLoggingCtx.oldObject = await getUser(ctx.user.id);
await deleteUser(ctx.user.id);
return { success: true };
} catch (error) {
logAccountDeletionError(ctx.user.id, error);
throw error;
}
})
);
@@ -0,0 +1 @@
export const DELETE_ACCOUNT_WRONG_PASSWORD_ERROR = "Wrong password";
@@ -3,12 +3,16 @@
import { Dispatch, SetStateAction, useState } from "react";
import toast from "react-hot-toast";
import { Trans, useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { Input } from "@/modules/ui/components/input";
import { PasswordInput } from "@/modules/ui/components/password-input";
import { deleteUserAction } from "./actions";
import { DELETE_ACCOUNT_WRONG_PASSWORD_ERROR } from "./constants";
interface DeleteAccountModalProps {
open: boolean;
@@ -28,15 +32,57 @@ export const DeleteAccountModal = ({
const { t } = useTranslation();
const [deleting, setDeleting] = useState(false);
const [inputValue, setInputValue] = useState("");
const [password, setPassword] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const isPasswordBackedAccount = user.identityProvider === "email";
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) {
setInputValue("");
setPassword("");
}
setOpen(nextOpen);
};
const hasValidEmailConfirmation = inputValue.trim().toLowerCase() === user.email.toLowerCase();
const hasValidConfirmation = hasValidEmailConfirmation && (!isPasswordBackedAccount || password.length > 0);
const isDeleteDisabled = !hasValidConfirmation;
const deleteAccount = async () => {
try {
if (!hasValidConfirmation) {
return;
}
setDeleting(true);
await deleteUserAction();
const result = await deleteUserAction(
isPasswordBackedAccount
? {
confirmationEmail: inputValue,
password,
}
: {
confirmationEmail: inputValue,
}
);
if (!result?.data?.success) {
const fallbackErrorMessage = t("common.something_went_wrong_please_try_again");
let errorMessage = fallbackErrorMessage;
if (result?.serverError === DELETE_ACCOUNT_WRONG_PASSWORD_ERROR) {
errorMessage = t("environments.settings.profile.wrong_password");
} else if (result) {
errorMessage = getFormattedErrorMessage(result);
}
logger.error({ errorMessage }, "Account deletion action failed");
toast.error(errorMessage || fallbackErrorMessage);
return;
}
// Sign out with account deletion reason (no automatic redirect)
await signOutWithAudit({
@@ -52,22 +98,22 @@ export const DeleteAccountModal = ({
window.location.replace("/auth/login");
}
} catch (error) {
toast.error("Something went wrong");
logger.error({ error }, "Account deletion failed");
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
setDeleting(false);
setOpen(false);
}
};
return (
<DeleteDialog
open={open}
setOpen={setOpen}
setOpen={handleOpenChange}
deleteWhat={t("common.account")}
onDelete={() => deleteAccount()}
text={t("environments.settings.profile.account_deletion_consequences_warning")}
isDeleting={deleting}
disabled={inputValue !== user.email}>
disabled={isDeleteDisabled}>
<div className="py-5">
<ul className="list-disc pb-6 pl-6">
<li>
@@ -110,11 +156,29 @@ export const DeleteAccountModal = ({
value={inputValue}
onChange={handleInputChange}
placeholder={user.email}
className="mt-5"
className="mt-2"
type="text"
id="deleteAccountConfirmation"
name="deleteAccountConfirmation"
/>
{isPasswordBackedAccount && (
<>
<label htmlFor="deleteAccountPassword" className="mt-4 block">
{t("common.password")}
</label>
<PasswordInput
data-testid="deleteAccountPassword"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
className="pr-10"
containerClassName="mt-2"
id="deleteAccountPassword"
name="deleteAccountPassword"
required
/>
</>
)}
</form>
</div>
</DeleteDialog>
+3 -3
View File
@@ -20,7 +20,7 @@ import { ApiErrorDetails } from "@/modules/api/v2/types/api-error";
* @returns Validation error map keyed by element ID, or null if validation passes
*/
export const validateResponseData = (
blocks: TSurveyBlock[] | undefined | null,
blocks: unknown[] | undefined | null,
responseData: TResponseData,
languageCode: string = "en",
questions?: TSurveyQuestion[] | undefined | null
@@ -28,8 +28,8 @@ export const validateResponseData = (
// Use blocks if available, otherwise transform questions to blocks
let blocksToUse: TSurveyBlock[] = [];
if (blocks && blocks.length > 0) {
blocksToUse = blocks;
if (Array.isArray(blocks) && blocks.length > 0) {
blocksToUse = blocks as TSurveyBlock[];
} else if (questions && questions.length > 0) {
// Transform legacy questions format to blocks for validation
blocksToUse = transformQuestionsToBlocks(questions, []);
+18 -10
View File
@@ -1,22 +1,32 @@
import { TResponseData } from "@formbricks/types/responses";
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurveyQuestionChoice } from "@formbricks/types/surveys/types";
import { MAX_OTHER_OPTION_LENGTH } from "@/lib/constants";
import { getLocalizedValue } from "@/lib/i18n/utils";
type TQuestionWithOtherOptionValidation = {
id: string;
type: string;
choices?: unknown[];
};
/**
* Helper function to check if a string value is a valid "other" option
* @returns BadRequestResponse if the value exceeds the limit, undefined otherwise
*/
export const validateOtherOptionLength = (
value: string,
choices: TSurveyQuestionChoice[],
choices: unknown[],
questionId: string,
language?: string
): string | undefined => {
// Check if this is an "other" option (not in predefined choices)
const matchingChoice = choices.find(
(choice) => getLocalizedValue(choice.label, language ?? "default") === value
(choice) =>
typeof choice === "object" &&
choice !== null &&
"label" in choice &&
typeof choice.label === "object" &&
choice.label !== null &&
getLocalizedValue(choice.label as Record<string, string>, language ?? "default") === value
);
// If this is an "other" option with value that's too long, reject the response
@@ -31,7 +41,7 @@ export const validateOtherOptionLengthForMultipleChoice = ({
responseLanguage,
}: {
responseData?: TResponseData;
surveyQuestions: TSurveyElement[];
surveyQuestions: TQuestionWithOtherOptionValidation[];
responseLanguage?: string;
}): string | undefined => {
if (!responseData) return undefined;
@@ -39,11 +49,9 @@ export const validateOtherOptionLengthForMultipleChoice = ({
const question = surveyQuestions.find((q) => q.id === questionId);
if (!question) continue;
const isMultiChoice =
question.type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
question.type === TSurveyElementTypeEnum.MultipleChoiceSingle;
const isMultiChoice = question.type === "multipleChoiceMulti" || question.type === "multipleChoiceSingle";
if (!isMultiChoice) continue;
if (!isMultiChoice || !question.choices) continue;
const error = validateAnswer(answer, question.choices, questionId, responseLanguage);
if (error) return error;
@@ -54,7 +62,7 @@ export const validateOtherOptionLengthForMultipleChoice = ({
function validateAnswer(
answer: unknown,
choices: TSurveyQuestionChoice[],
choices: unknown[],
questionId: string,
language?: string
): string | undefined {
@@ -13,6 +13,10 @@ const mockUser = {
createdAt: new Date(),
updatedAt: new Date(),
isActive: true,
password: "$2b$12$hashedPassword",
twoFactorSecret: "encrypted-2fa-secret",
backupCodes: "encrypted-backup-codes",
identityProviderAccountId: "provider-account-id",
role: "admin",
memberships: [{ organizationId: "org456", role: "admin" }],
teamUsers: [{ team: { name: "Test Team", id: "team123", projectTeams: [{ projectId: "proj789" }] } }],
@@ -60,6 +64,10 @@ describe("Users Lib", () => {
updatedAt: expect.any(Date),
},
]);
expect(result.data.data[0]).not.toHaveProperty("password");
expect(result.data.data[0]).not.toHaveProperty("twoFactorSecret");
expect(result.data.data[0]).not.toHaveProperty("backupCodes");
expect(result.data.data[0]).not.toHaveProperty("identityProviderAccountId");
}
});
@@ -84,6 +92,10 @@ describe("Users Lib", () => {
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.id).toBe(mockUser.id);
expect(result.data).not.toHaveProperty("password");
expect(result.data).not.toHaveProperty("twoFactorSecret");
expect(result.data).not.toHaveProperty("backupCodes");
expect(result.data).not.toHaveProperty("identityProviderAccountId");
}
});
@@ -148,6 +160,10 @@ describe("Users Lib", () => {
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.name).toBe("Updated User");
expect(result.data).not.toHaveProperty("password");
expect(result.data).not.toHaveProperty("twoFactorSecret");
expect(result.data).not.toHaveProperty("backupCodes");
expect(result.data).not.toHaveProperty("identityProviderAccountId");
}
});
@@ -1,5 +1,6 @@
import "server-only";
import { Prisma, PrismaClient } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
@@ -35,3 +36,22 @@ export const deleteSessionsByUserId = async (
return handleDatabaseError(error);
}
};
export const deleteSessionBySessionToken = async (
sessionToken: string,
tx?: Prisma.TransactionClient
): Promise<number> => {
validateInputs([sessionToken, z.string().min(1)]);
try {
const result = await getDbClient(tx).session.deleteMany({
where: {
sessionToken,
},
});
return result.count;
} catch (error) {
return handleDatabaseError(error);
}
};
@@ -3,8 +3,11 @@ import { Provider } from "next-auth/providers/index";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
import { verifyToken } from "@/lib/jwt";
import { capturePostHogEvent } from "@/lib/posthog";
import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
// Import mocked rate limiting functions
import { updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { authOptions } from "./authOptions";
@@ -133,6 +136,10 @@ vi.mock("@/lib/posthog", () => ({
capturePostHogEvent: vi.fn(),
}));
vi.mock("@/modules/auth/lib/brevo", () => ({
createBrevoCustomer: vi.fn(),
}));
// Helper to get the provider by id from authOptions.providers.
function getProviderById(id: string): Provider {
const provider = authOptions.providers.find((p) => p.options.id === id);
@@ -315,6 +322,105 @@ describe("authOptions", () => {
);
});
test("allows verified users through the token provider when the token purpose is sso_recovery", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
vi.mocked(verifyToken).mockResolvedValue({
id: mockUser.id,
email: mockUser.email,
purpose: "sso_recovery",
} as any);
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
...mockUser,
emailVerified: new Date(),
} as any);
const result = await tokenProvider.options.authorize({ token: "recovery-token" }, {});
expect(result).toEqual(
expect.objectContaining({
id: mockUser.id,
email: mockUser.email,
authFlowPurpose: "sso_recovery",
})
);
});
test("defers verification side effects for unverified users when the token purpose is sso_recovery", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
vi.mocked(verifyToken).mockResolvedValue({
id: mockUser.id,
email: mockUser.email,
purpose: "sso_recovery",
} as any);
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
...mockUser,
emailVerified: null,
} as any);
const result = await tokenProvider.options.authorize({ token: "recovery-token" }, {});
expect(result).toEqual(
expect.objectContaining({
id: mockUser.id,
email: mockUser.email,
emailVerified: null,
authFlowPurpose: "sso_recovery",
})
);
expect(updateUser).not.toHaveBeenCalled();
});
test("verifies unverified users during the standard email verification flow", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
vi.mocked(verifyToken).mockResolvedValue({
id: mockUser.id,
email: mockUser.email,
purpose: "email_verification",
} as any);
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
...mockUser,
emailVerified: null,
} as any);
vi.mocked(updateUser).mockResolvedValue({
...mockUser,
emailVerified: new Date("2026-04-16T00:00:00.000Z"),
} as any);
const result = await tokenProvider.options.authorize({ token: "verify-token" }, {});
expect(updateUser).toHaveBeenCalledWith(mockUser.id, { emailVerified: expect.any(Date) });
expect(createBrevoCustomer).toHaveBeenCalledWith({ id: mockUser.id, email: mockUser.email });
expect(result).toEqual(
expect.objectContaining({
id: mockUser.id,
email: mockUser.email,
authFlowPurpose: "email_verification",
emailVerified: new Date("2026-04-16T00:00:00.000Z"),
})
);
});
test("rejects inactive users even when the verification token is otherwise valid", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
vi.mocked(verifyToken).mockResolvedValue({
id: mockUser.id,
email: mockUser.email,
purpose: "email_verification",
} as any);
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
...mockUser,
emailVerified: null,
isActive: false,
} as any);
await expect(tokenProvider.options.authorize({ token: "inactive-token" }, {})).rejects.toThrow(
"Your account is currently inactive. Please contact the organization admin."
);
expect(updateUser).not.toHaveBeenCalled();
expect(createBrevoCustomer).not.toHaveBeenCalled();
});
describe("Rate Limiting", () => {
test("should apply rate limiting before token verification", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
@@ -432,6 +538,51 @@ describe("authOptions", () => {
expect(capturePostHogEvent).not.toHaveBeenCalled();
}
});
test("should not record a completed sign-in while the recovery token is only proving inbox ownership", async () => {
const user = {
...mockUser,
emailVerified: new Date(),
authFlowPurpose: "sso_recovery",
};
const account = { provider: "token" } as any;
if (authOptions.callbacks?.signIn) {
const result = await authOptions.callbacks.signIn({ user, account } as any);
expect(result).toBe(true);
expect(updateUserLastLoginAt).not.toHaveBeenCalled();
expect(capturePostHogEvent).not.toHaveBeenCalled();
}
});
test("should allow an unverified recovery session through until SSO completion finishes the reclaim", async () => {
const user = {
...mockUser,
emailVerified: null,
authFlowPurpose: "sso_recovery",
};
const account = { provider: "token" } as any;
if (authOptions.callbacks?.signIn) {
const result = await authOptions.callbacks.signIn({ user, account } as any);
expect(result).toBe(true);
expect(updateUserLastLoginAt).not.toHaveBeenCalled();
expect(capturePostHogEvent).not.toHaveBeenCalled();
}
});
test("should finalize successful sign-in when no provider information is available", async () => {
const user = { ...mockUser, emailVerified: new Date() };
if (authOptions.callbacks?.signIn) {
const result = await authOptions.callbacks.signIn({ user, account: undefined } as any);
expect(result).toBe(true);
expect(updateUserLastLoginAt).toHaveBeenCalledWith(user.email);
}
});
});
});
@@ -489,6 +640,57 @@ describe("authOptions", () => {
expect(mockUpdateUserLastLoginAt).not.toHaveBeenCalled();
expect(mockCapturePostHogEvent).not.toHaveBeenCalled();
});
test("should finalize successful sign-in after a successful enterprise SSO callback", async () => {
vi.resetModules();
const mockHandleSsoCallback = vi.fn().mockResolvedValueOnce(true);
const mockUpdateUserLastLoginAt = vi.fn();
const mockCapturePostHogEvent = vi.fn();
vi.doMock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
EMAIL_VERIFICATION_DISABLED: false,
SESSION_MAX_AGE: 86400,
NEXTAUTH_SECRET: "test-secret",
WEBAPP_URL: "http://localhost:3000",
ENCRYPTION_KEY: "12345678901234567890123456789012",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: false,
AUDIT_LOG_GET_USER_IP: false,
ENTERPRISE_LICENSE_KEY: "test-enterprise-license",
SENTRY_DSN: undefined,
BREVO_API_KEY: undefined,
RATE_LIMITING_DISABLED: false,
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
POSTHOG_KEY: "phc_test_key",
};
});
vi.doMock("@/modules/ee/sso/lib/providers", () => ({
getSSOProviders: vi.fn(() => []),
}));
vi.doMock("@/modules/ee/sso/lib/sso-handlers", () => ({
handleSsoCallback: mockHandleSsoCallback,
}));
vi.doMock("@/modules/auth/lib/user", () => ({
updateUser: vi.fn(),
updateUserLastLoginAt: mockUpdateUserLastLoginAt,
}));
vi.doMock("@/lib/posthog", () => ({
capturePostHogEvent: mockCapturePostHogEvent,
}));
const { authOptions: enterpriseAuthOptions } = await import("./authOptions");
const user = { ...mockUser, emailVerified: new Date() };
const account = { provider: "google", type: "oauth", providerAccountId: "provider-123" } as any;
await expect(enterpriseAuthOptions.callbacks?.signIn?.({ user, account } as any)).resolves.toBe(true);
expect(mockHandleSsoCallback).toHaveBeenCalled();
expect(mockUpdateUserLastLoginAt).toHaveBeenCalledWith(user.email);
});
});
describe("Two-Factor Authentication (TOTP)", () => {
+46 -41
View File
@@ -10,16 +10,15 @@ import {
EMAIL_VERIFICATION_DISABLED,
ENCRYPTION_KEY,
ENTERPRISE_LICENSE_KEY,
POSTHOG_KEY,
SESSION_MAX_AGE,
WEBAPP_URL,
} from "@/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { verifyToken } from "@/lib/jwt";
import { capturePostHogEvent } from "@/lib/posthog";
import { getValidatedCallbackUrl } from "@/lib/utils/url";
import { getAuthCallbackUrlFromCookies } from "@/modules/auth/lib/callback-url";
import { updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user";
import { finalizeSuccessfulSignIn } from "@/modules/auth/lib/sign-in-tracking";
import { updateUser } from "@/modules/auth/lib/user";
import {
logAuthAttempt,
logAuthEvent,
@@ -267,12 +266,13 @@ export const authOptions: NextAuthOptions = {
throw new Error("Token not found");
}
const { id } = await verifyToken(credentials?.token);
user = await prisma.user.findUnique({
const { id, purpose } = await verifyToken(credentials?.token);
const foundUser = await prisma.user.findUnique({
where: {
id: id,
},
});
user = foundUser ? { ...foundUser, authFlowPurpose: purpose } : null;
} catch (e) {
logger.error(e, "Error in CredentialsProvider authorize");
@@ -291,7 +291,10 @@ export const authOptions: NextAuthOptions = {
throw new Error("Either a user does not match the provided token or the token is invalid");
}
if (user.emailVerified) {
const authFlowPurpose = user.authFlowPurpose ?? "email_verification";
const isSsoRecovery = authFlowPurpose === "sso_recovery";
if (user.emailVerified && !isSsoRecovery) {
logEmailVerificationAttempt(false, "email_already_verified", user.id, user.email);
throw new Error("Email already verified");
}
@@ -301,14 +304,20 @@ export const authOptions: NextAuthOptions = {
throw new Error("Your account is currently inactive. Please contact the organization admin.");
}
user = await updateUser(user.id, { emailVerified: new Date() });
if (!user.emailVerified && !isSsoRecovery) {
const updatedUser = await updateUser(user.id, { emailVerified: new Date() });
user = {
...updatedUser,
authFlowPurpose,
};
logEmailVerificationAttempt(true, undefined, user.id, user.email, {
emailVerifiedAt: user.emailVerified,
});
logEmailVerificationAttempt(true, undefined, user.id, user.email, {
emailVerifiedAt: user.emailVerified,
});
// send new user to brevo after email verification
createBrevoCustomer({ id: user.id, email: user.email });
// send new user to brevo after email verification
createBrevoCustomer({ id: user.id, email: user.email });
}
return user;
},
@@ -339,37 +348,27 @@ export const authOptions: NextAuthOptions = {
const userEmail = user.email ?? "";
const userId = user.id as string;
// Capture sign-in event for PostHog (query BEFORE updating lastLoginAt)
const captureSignIn = async (provider: string) => {
if (!POSTHOG_KEY) return;
try {
const [membershipCount, userData] = await Promise.all([
prisma.membership.count({ where: { userId } }),
prisma.user.findUnique({ where: { id: userId }, select: { lastLoginAt: true } }),
]);
const isFirstLoginToday =
userData?.lastLoginAt?.toISOString().slice(0, 10) !== new Date().toISOString().slice(0, 10);
capturePostHogEvent(userId, "user_signed_in", {
auth_provider: provider,
organization_count: membershipCount,
is_first_login_today: isFirstLoginToday,
});
} catch (error) {
logger.warn({ error }, "Failed to capture PostHog sign-in event");
}
};
const authFlowPurpose =
"authFlowPurpose" in user && typeof user.authFlowPurpose === "string"
? user.authFlowPurpose
: undefined;
if (account?.provider === "credentials" || account?.provider === "token") {
if (account.provider === "token" && authFlowPurpose === "sso_recovery") {
return true;
}
// check if user's email is verified or not
if ("emailVerified" in user && !user.emailVerified && !EMAIL_VERIFICATION_DISABLED) {
logger.error("Email Verification is Pending");
throw new Error("Email Verification is Pending");
}
void captureSignIn(account.provider);
await updateUserLastLoginAt(userEmail);
await finalizeSuccessfulSignIn({
userId,
email: userEmail,
provider: account.provider,
});
return true;
}
if (ENTERPRISE_LICENSE_KEY && account) {
@@ -379,14 +378,20 @@ export const authOptions: NextAuthOptions = {
callbackUrl,
});
if (result) {
void captureSignIn(account.provider);
await updateUserLastLoginAt(userEmail);
if (result === true) {
await finalizeSuccessfulSignIn({
userId,
email: userEmail,
provider: account.provider,
});
}
return result;
}
void captureSignIn(account?.provider ?? "unknown");
await updateUserLastLoginAt(userEmail);
await finalizeSuccessfulSignIn({
userId,
email: userEmail,
provider: account?.provider ?? "unknown",
});
return true;
},
},
+2 -13
View File
@@ -1,9 +1,5 @@
import { prisma } from "@formbricks/database";
const NEXT_AUTH_SESSION_COOKIE_NAMES = [
"__Secure-next-auth.session-token",
"next-auth.session-token",
] as const;
import { getSessionTokenFromCookieStore } from "./session-cookie";
type TCookieStore = {
get: (name: string) => { value: string } | undefined;
@@ -14,14 +10,7 @@ type TRequestWithCookies = {
};
export const getSessionTokenFromRequest = (request: TRequestWithCookies): string | null => {
for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) {
const cookie = request.cookies.get(cookieName);
if (cookie?.value) {
return cookie.value;
}
}
return null;
return getSessionTokenFromCookieStore(request.cookies);
};
export const getProxySession = async (request: TRequestWithCookies) => {
@@ -0,0 +1,49 @@
export const NEXT_AUTH_SESSION_COOKIE_NAMES = [
"__Secure-next-auth.session-token",
"next-auth.session-token",
] as const;
type TCookieStore = {
get: (name: string) => { value: string } | undefined;
};
const getCookieValueFromHeader = (cookieHeader: string, cookieName: string): string | null => {
const cookies = cookieHeader.split(";").map((cookie) => cookie.trim());
for (const cookie of cookies) {
if (!cookie.startsWith(`${cookieName}=`)) {
continue;
}
const cookieValue = cookie.slice(cookieName.length + 1);
return cookieValue.length > 0 ? decodeURIComponent(cookieValue) : null;
}
return null;
};
export const getSessionTokenFromCookieStore = (cookieStore: TCookieStore): string | null => {
for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) {
const cookie = cookieStore.get(cookieName);
if (cookie?.value) {
return cookie.value;
}
}
return null;
};
export const getSessionTokenFromCookieHeader = (cookieHeader: string | null): string | null => {
if (!cookieHeader) {
return null;
}
for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) {
const cookieValue = getCookieValueFromHeader(cookieHeader, cookieName);
if (cookieValue) {
return cookieValue;
}
}
return null;
};
@@ -0,0 +1,101 @@
import { describe, expect, test, vi } from "vitest";
const prismaMembershipCount = vi.fn();
const prismaUserFindUnique = vi.fn();
const capturePostHogEvent = vi.fn();
const updateUserLastLoginAt = vi.fn();
vi.mock("@formbricks/database", () => ({
prisma: {
membership: {
count: prismaMembershipCount,
},
user: {
findUnique: prismaUserFindUnique,
},
},
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
POSTHOG_KEY: undefined,
};
});
vi.mock("@/lib/posthog", () => ({
capturePostHogEvent,
}));
vi.mock("@/modules/auth/lib/user", () => ({
updateUserLastLoginAt,
}));
describe("captureSignIn", () => {
test("returns early when PostHog is disabled", async () => {
const { captureSignIn } = await import("./sign-in-tracking");
await captureSignIn({
userId: "user_1",
provider: "google",
});
expect(prismaMembershipCount).not.toHaveBeenCalled();
expect(prismaUserFindUnique).not.toHaveBeenCalled();
expect(capturePostHogEvent).not.toHaveBeenCalled();
});
});
describe("finalizeSuccessfulSignIn", () => {
test("uses the previous lastLoginAt returned by the update path to avoid a second user lookup", async () => {
vi.resetModules();
const membershipCount = vi.fn().mockResolvedValue(3);
const userFindUnique = vi.fn();
const postHogCapture = vi.fn();
const updateLastLoginAt = vi.fn().mockResolvedValue(new Date());
vi.doMock("@formbricks/database", () => ({
prisma: {
membership: {
count: membershipCount,
},
user: {
findUnique: userFindUnique,
},
},
}));
vi.doMock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
POSTHOG_KEY: "phc_test_key",
};
});
vi.doMock("@/lib/posthog", () => ({
capturePostHogEvent: postHogCapture,
}));
vi.doMock("@/modules/auth/lib/user", () => ({
updateUserLastLoginAt: updateLastLoginAt,
}));
const { finalizeSuccessfulSignIn } = await import("./sign-in-tracking");
await finalizeSuccessfulSignIn({
userId: "user_1",
email: "john.doe@example.com",
provider: "google",
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(updateLastLoginAt).toHaveBeenCalledWith("john.doe@example.com");
expect(membershipCount).toHaveBeenCalledWith({ where: { userId: "user_1" } });
expect(userFindUnique).not.toHaveBeenCalled();
expect(postHogCapture).toHaveBeenCalledWith("user_1", "user_signed_in", {
auth_provider: "google",
organization_count: 3,
is_first_login_today: false,
});
});
});
@@ -0,0 +1,57 @@
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { POSTHOG_KEY } from "@/lib/constants";
import { capturePostHogEvent } from "@/lib/posthog";
import { updateUserLastLoginAt } from "@/modules/auth/lib/user";
const getIsFirstLoginToday = (lastLoginAt: Date | null | undefined) =>
lastLoginAt?.toISOString().slice(0, 10) !== new Date().toISOString().slice(0, 10);
export const captureSignIn = async ({
userId,
provider,
previousLastLoginAt,
}: {
userId: string;
provider: string;
previousLastLoginAt?: Date | null;
}) => {
if (!POSTHOG_KEY) {
return;
}
try {
const membershipCountPromise = prisma.membership.count({ where: { userId } });
const resolvedPreviousLastLoginAt =
previousLastLoginAt === undefined
? (
await prisma.user.findUnique({
where: { id: userId },
select: { lastLoginAt: true },
})
)?.lastLoginAt
: previousLastLoginAt;
const membershipCount = await membershipCountPromise;
capturePostHogEvent(userId, "user_signed_in", {
auth_provider: provider,
organization_count: membershipCount,
is_first_login_today: getIsFirstLoginToday(resolvedPreviousLastLoginAt),
});
} catch (error) {
logger.warn({ error }, "Failed to capture PostHog sign-in event");
}
};
export const finalizeSuccessfulSignIn = async ({
userId,
email,
provider,
}: {
userId: string;
email: string;
provider: string;
}) => {
const previousLastLoginAt = await updateUserLastLoginAt(email);
void captureSignIn({ userId, provider, previousLastLoginAt });
};
+28 -10
View File
@@ -17,6 +17,7 @@ const mockPrismaUser = {
vi.mock("@formbricks/database", () => ({
prisma: {
$transaction: vi.fn(),
user: {
create: vi.fn(),
update: vi.fn(),
@@ -29,6 +30,14 @@ vi.mock("@formbricks/database", () => ({
describe("User Management", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.$transaction).mockImplementation(async (callback) =>
callback({
$queryRaw: vi.fn(),
user: {
update: vi.mocked(prisma.user.update),
},
} as any)
);
});
describe("createUser", () => {
@@ -84,22 +93,31 @@ describe("User Management", () => {
});
describe("updateUserLastLoginAt", () => {
const mockUpdateData = { name: "Updated Name" };
test("updates a user successfully", async () => {
vi.mocked(prisma.user.update).mockResolvedValueOnce({ ...mockPrismaUser, name: mockUpdateData.name });
test("updates a user successfully and returns the previous login timestamp", async () => {
const previousLastLoginAt = new Date("2025-04-16T10:00:00.000Z");
vi.mocked(prisma.$transaction).mockImplementationOnce(async (callback) =>
callback({
$queryRaw: vi.fn().mockResolvedValue([{ id: mockUser.id, lastLoginAt: previousLastLoginAt }]),
user: {
update: vi.fn().mockResolvedValue({ ...mockPrismaUser, lastLoginAt: new Date() }),
},
} as any)
);
const result = await updateUserLastLoginAt(mockUser.email);
expect(result).toEqual(void 0);
expect(result).toEqual(previousLastLoginAt);
});
test("throws ResourceNotFoundError when user doesn't exist", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "0.0.1",
});
vi.mocked(prisma.user.update).mockRejectedValueOnce(errToThrow);
vi.mocked(prisma.$transaction).mockImplementationOnce(async (callback) =>
callback({
$queryRaw: vi.fn().mockResolvedValue([]),
user: {
update: vi.fn(),
},
} as any)
);
await expect(updateUserLastLoginAt(mockUser.email)).rejects.toThrow(ResourceNotFoundError);
});
+27 -7
View File
@@ -44,15 +44,35 @@ export const updateUserLastLoginAt = async (email: string) => {
validateInputs([email, ZUserEmail]);
try {
await prisma.user.update({
where: {
email,
},
data: {
lastLoginAt: new Date(),
},
return await prisma.$transaction(async (tx) => {
const lockedUsers = await tx.$queryRaw<Array<{ id: string; lastLoginAt: Date | null }>>`
SELECT "id", "lastLoginAt"
FROM "User"
WHERE "email" = ${email}
FOR UPDATE
`;
const lockedUser = lockedUsers[0];
if (!lockedUser) {
throw new ResourceNotFoundError("email", email);
}
await tx.user.update({
where: {
id: lockedUser.id,
},
data: {
lastLoginAt: new Date(),
},
});
return lockedUser.lastLoginAt;
});
} catch (error) {
if (error instanceof ResourceNotFoundError) {
throw error;
}
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === PrismaErrorType.RecordDoesNotExist
@@ -21,6 +21,18 @@ describe("verification link helpers", () => {
);
});
test("builds a verification requested path that preserves SSO recovery purpose", () => {
expect(
buildVerificationRequestedPath({
token: "abc123",
callbackUrl: "http://localhost:3000/invite?token=invite-token",
purpose: "sso_recovery",
})
).toBe(
"/auth/verification-requested?token=abc123&callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Finvite%3Ftoken%3Dinvite-token&purpose=sso_recovery"
);
});
test("builds absolute verification links that preserve a valid callback URL", () => {
expect(
buildVerificationLinks({
@@ -48,4 +60,21 @@ describe("verification link helpers", () => {
verifyLink: "http://localhost:3000/auth/verify?token=abc123",
});
});
test("preserves SSO recovery purpose on the verification requested email link", () => {
expect(
buildVerificationLinks({
token: "abc123",
webAppUrl: WEBAPP_URL,
callbackUrl: "http://localhost:3000/environments/test?foo=bar",
purpose: "sso_recovery",
verificationRequestToken: "email-token",
})
).toEqual({
verificationRequestLink:
"http://localhost:3000/auth/verification-requested?token=email-token&callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Fenvironments%2Ftest%3Ffoo%3Dbar&purpose=sso_recovery",
verifyLink:
"http://localhost:3000/auth/verify?token=abc123&callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Fenvironments%2Ftest%3Ffoo%3Dbar",
});
});
});
@@ -1,13 +1,18 @@
import { getValidatedCallbackUrl } from "@/lib/utils/url";
const RELATIVE_URL_BASE = "http://localhost";
export const VERIFICATION_REQUEST_PURPOSES = ["email_verification", "sso_recovery"] as const;
export type TVerificationRequestPurpose = (typeof VERIFICATION_REQUEST_PURPOSES)[number];
const DEFAULT_VERIFICATION_REQUEST_PURPOSE: TVerificationRequestPurpose = "email_verification";
export const buildVerificationRequestedPath = ({
token,
callbackUrl,
purpose = DEFAULT_VERIFICATION_REQUEST_PURPOSE,
}: {
token: string;
callbackUrl?: string | null;
purpose?: TVerificationRequestPurpose;
}): string => {
const verificationRequestedUrl = new URL("/auth/verification-requested", RELATIVE_URL_BASE);
verificationRequestedUrl.searchParams.set("token", token);
@@ -16,6 +21,10 @@ export const buildVerificationRequestedPath = ({
verificationRequestedUrl.searchParams.set("callbackUrl", callbackUrl);
}
if (purpose !== DEFAULT_VERIFICATION_REQUEST_PURPOSE) {
verificationRequestedUrl.searchParams.set("purpose", purpose);
}
return `${verificationRequestedUrl.pathname}${verificationRequestedUrl.search}`;
};
@@ -23,23 +32,31 @@ export const buildVerificationLinks = ({
token,
webAppUrl,
callbackUrl,
purpose = DEFAULT_VERIFICATION_REQUEST_PURPOSE,
verificationRequestToken = token,
}: {
token: string;
webAppUrl: string;
callbackUrl?: string | null;
purpose?: TVerificationRequestPurpose;
verificationRequestToken?: string;
}): { verificationRequestLink: string; verifyLink: string } => {
const validatedCallbackUrl = getValidatedCallbackUrl(callbackUrl, webAppUrl);
const verifyLink = new URL("/auth/verify", webAppUrl);
verifyLink.searchParams.set("token", token);
const verificationRequestLink = new URL("/auth/verification-requested", webAppUrl);
verificationRequestLink.searchParams.set("token", token);
verificationRequestLink.searchParams.set("token", verificationRequestToken);
if (validatedCallbackUrl) {
verifyLink.searchParams.set("callbackUrl", validatedCallbackUrl);
verificationRequestLink.searchParams.set("callbackUrl", validatedCallbackUrl);
}
if (purpose !== DEFAULT_VERIFICATION_REQUEST_PURPOSE) {
verificationRequestLink.searchParams.set("purpose", purpose);
}
return {
verificationRequestLink: verificationRequestLink.toString(),
verifyLink: verifyLink.toString(),
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { verifySsoRelinkIntent } from "@/lib/jwt";
import { getUserByEmail } from "@/modules/auth/lib/user";
// Import mocked functions
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
@@ -29,10 +30,18 @@ vi.mock("@/modules/email", () => ({
sendVerificationEmail: vi.fn(),
}));
vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "http://localhost:3000",
vi.mock("@/lib/jwt", () => ({
verifySsoRelinkIntent: vi.fn(),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
WEBAPP_URL: "http://localhost:3000",
};
});
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
withAuditLogging: vi.fn((_type: string, _object: string, fn: Function) => fn),
}));
@@ -71,6 +80,9 @@ describe("resendVerificationEmailAction", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(verifySsoRelinkIntent).mockImplementation(() => {
throw new Error("invalid");
});
});
afterEach(() => {
@@ -150,6 +162,7 @@ describe("resendVerificationEmailAction", () => {
expect(sendVerificationEmail).toHaveBeenCalledWith({
...mockUser,
callbackUrl: undefined,
purpose: "email_verification",
});
expect(result).toEqual({ success: true });
});
@@ -169,6 +182,7 @@ describe("resendVerificationEmailAction", () => {
expect(sendVerificationEmail).toHaveBeenCalledWith({
...mockUser,
callbackUrl: "http://localhost:3000/invite?token=invite-token",
purpose: "email_verification",
});
});
@@ -187,6 +201,7 @@ describe("resendVerificationEmailAction", () => {
expect(sendVerificationEmail).toHaveBeenCalledWith({
...mockUser,
callbackUrl: undefined,
purpose: "email_verification",
});
});
@@ -205,6 +220,86 @@ describe("resendVerificationEmailAction", () => {
expect(result).toEqual({ success: true });
});
test("should resend an SSO recovery email for a verified user", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
const verifiedUserWithLocale: NonNullable<Awaited<ReturnType<typeof getUserByEmail>>> = {
...mockVerifiedUser,
locale: "en-US",
};
vi.mocked(getUserByEmail).mockResolvedValue(verifiedUserWithLocale);
vi.mocked(verifySsoRelinkIntent).mockReturnValue({
callbackUrl: "http://localhost:3000",
email: mockVerifiedUser.email,
provider: "google",
providerAccountId: "provider_123",
userId: mockVerifiedUser.id,
});
const result = await resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: {
...validInput,
callbackUrl: "http://localhost:3000/api/auth/sso/recovery/complete?intent=test-intent",
},
} as any);
expect(sendVerificationEmail).toHaveBeenCalledWith({
id: mockVerifiedUser.id,
email: mockVerifiedUser.email,
locale: "en-US",
callbackUrl: "http://localhost:3000/api/auth/sso/recovery/complete?intent=test-intent",
purpose: "sso_recovery",
});
expect(result).toEqual({ success: true });
});
test("should not treat a client-supplied recovery callback as recovery without a valid intent", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
const verifiedUserWithLocale: NonNullable<Awaited<ReturnType<typeof getUserByEmail>>> = {
...mockVerifiedUser,
locale: "en-US",
};
vi.mocked(getUserByEmail).mockResolvedValue(verifiedUserWithLocale);
const result = await resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: {
...validInput,
callbackUrl: "http://localhost:3000/api/auth/sso/recovery/complete?intent=forged-intent",
},
} as any);
expect(sendVerificationEmail).not.toHaveBeenCalled();
expect(result).toEqual({ success: true });
});
test("should fall back to a normal verification email when the relink intent belongs to a different email", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
vi.mocked(verifySsoRelinkIntent).mockReturnValue({
callbackUrl: "http://localhost:3000",
email: "other@example.com",
provider: "google",
providerAccountId: "provider_123",
userId: "user_123",
});
const result = await resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: {
...validInput,
callbackUrl: "http://localhost:3000/api/auth/sso/recovery/complete?intent=test-intent",
},
} as any);
expect(sendVerificationEmail).toHaveBeenCalledWith({
...mockUser,
callbackUrl: "http://localhost:3000/api/auth/sso/recovery/complete?intent=test-intent",
purpose: "email_verification",
});
expect(result).toEqual({ success: true });
});
test("should throw ResourceNotFoundError when user doesn't exist", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
vi.mocked(getUserByEmail).mockResolvedValue(null);
@@ -4,12 +4,15 @@ import { z } from "zod";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZUserEmail } from "@formbricks/types/user";
import { WEBAPP_URL } from "@/lib/constants";
import { verifySsoRelinkIntent } from "@/lib/jwt";
import { actionClient } from "@/lib/utils/action-client";
import { getValidatedCallbackUrl } from "@/lib/utils/url";
import { getUserByEmail } from "@/modules/auth/lib/user";
import { TVerificationRequestPurpose } from "@/modules/auth/lib/verification-links";
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { SSO_RECOVERY_COMPLETION_PATH } from "@/modules/ee/sso/lib/constants";
import { sendVerificationEmail } from "@/modules/email";
const ZResendVerificationEmailAction = z.object({
@@ -17,6 +20,36 @@ const ZResendVerificationEmailAction = z.object({
callbackUrl: z.string().max(2000).optional(),
});
const getVerificationRequestPurpose = ({
callbackUrl,
userEmail,
}: {
callbackUrl?: string;
userEmail: string;
}): TVerificationRequestPurpose => {
const validatedCallbackUrl = getValidatedCallbackUrl(callbackUrl, WEBAPP_URL);
if (!validatedCallbackUrl) {
return "email_verification";
}
const parsedCallbackUrl = new URL(validatedCallbackUrl);
if (parsedCallbackUrl.pathname !== SSO_RECOVERY_COMPLETION_PATH) {
return "email_verification";
}
const intentToken = parsedCallbackUrl.searchParams.get("intent");
if (!intentToken) {
return "email_verification";
}
try {
const intent = verifySsoRelinkIntent(intentToken);
return intent.email.toLowerCase() === userEmail.toLowerCase() ? "sso_recovery" : "email_verification";
} catch {
return "email_verification";
}
};
export const resendVerificationEmailAction = actionClient.inputSchema(ZResendVerificationEmailAction).action(
withAuditLogging("verificationEmailSent", "user", async ({ ctx, parsedInput }) => {
await applyIPRateLimit(rateLimitConfigs.auth.verifyEmail);
@@ -25,7 +58,12 @@ export const resendVerificationEmailAction = actionClient.inputSchema(ZResendVer
if (!user) {
throw new ResourceNotFoundError("user", parsedInput.email);
}
if (user.emailVerified) {
const validatedCallbackUrl = getValidatedCallbackUrl(parsedInput.callbackUrl, WEBAPP_URL) ?? undefined;
const purpose = getVerificationRequestPurpose({
callbackUrl: validatedCallbackUrl,
userEmail: user.email,
});
if (user.emailVerified && purpose !== "sso_recovery") {
return {
success: true,
};
@@ -35,7 +73,8 @@ export const resendVerificationEmailAction = actionClient.inputSchema(ZResendVer
id: user.id,
email: user.email,
locale: user.locale,
callbackUrl: getValidatedCallbackUrl(parsedInput.callbackUrl, WEBAPP_URL) ?? undefined,
callbackUrl: validatedCallbackUrl,
purpose,
});
return {
success: true,
@@ -30,7 +30,10 @@ export const RequestVerificationEmail = ({ email, callbackUrl }: RequestVerifica
const requestVerificationEmail = async () => {
if (!email) return toast.error(t("auth.verification-requested.no_email_provided"));
const response = await resendVerificationEmailAction({ email, callbackUrl: callbackUrl ?? undefined });
const response = await resendVerificationEmailAction({
email,
callbackUrl: callbackUrl ?? undefined,
});
if (response?.data) {
toast.success(t("auth.verification-requested.verification_email_resent_successfully"));
} else {
@@ -75,6 +75,7 @@ describe("rateLimitConfigs", () => {
const actionConfigs = Object.keys(rateLimitConfigs.actions);
expect(actionConfigs).toEqual([
"emailUpdate",
"accountDeletion",
"surveyFollowUp",
"sendLinkSurveyEmail",
"licenseRecheck",
@@ -139,6 +140,7 @@ describe("rateLimitConfigs", () => {
{ config: rateLimitConfigs.api.v3, identifier: "api-v3-key" },
{ config: rateLimitConfigs.api.client, identifier: "client-api-key" },
{ config: rateLimitConfigs.actions.emailUpdate, identifier: "user-profile" },
{ config: rateLimitConfigs.actions.accountDeletion, identifier: "user-account-delete" },
{ config: rateLimitConfigs.storage.upload, identifier: "storage-upload" },
{ config: rateLimitConfigs.storage.delete, identifier: "storage-delete" },
];
@@ -18,6 +18,7 @@ export const rateLimitConfigs = {
// Server actions - varies by action type
actions: {
emailUpdate: { interval: 3600, allowedPerInterval: 3, namespace: "action:email" }, // 3 per hour
accountDeletion: { interval: 3600, allowedPerInterval: 5, namespace: "action:account-delete" }, // 5 per hour
surveyFollowUp: { interval: 3600, allowedPerInterval: 50, namespace: "action:followup" }, // 50 per hour
sendLinkSurveyEmail: {
interval: 3600,

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