Compare commits

...

45 Commits

Author SHA1 Message Date
Tiago Farto ce0a943cf4 fix(api): reject unreadable org-only survey keys 2026-05-08 15:18:36 +00:00
Tiago Farto 401276dae6 test(api): cover org-read v1 me shape 2026-05-08 15:12:33 +00:00
Tiago Farto 51583a422b Merge remote-tracking branch 'origin/main' into chore/readonly_org_fix 2026-05-08 14:51:52 +00:00
Tiago 69ead97965 fix: sso account deletion password check (#7930) 2026-05-08 12:07:58 +00:00
Tiago Farto ff20e7b074 fix(api): scope org-only v1 API key auth 2026-05-08 11:10:35 +00:00
Tiago Farto b92ea5f64a chore: read only org fix 2026-05-08 09:57:26 +00:00
Dhruwang Jariwala 7e2c439325 feat: add PostHog group analytics and feature events (#7914)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Harsh Bhat <harsh121102@gmail.com>
2026-05-07 07:49:27 +00:00
Javi Aguilar a2177eec96 fix: survey modal accessibility issues by using a focus trap (#7939) 2026-05-07 06:14:06 +00:00
Javi Aguilar 255c97854f fix: survey runtime accessibility for keyboard controls (#7927) 2026-05-06 10:21:42 +00:00
Matti Nannt d103499496 fix(security): strip sensitive survey and segment metadata from public client API (#7931)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-05-06 09:54:15 +00:00
Matti Nannt b863238f15 refactor: rename gethasNoOrganizations to getHasNoOrganizations (#7940)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 05:03:02 +00:00
Johannes 28280899ea fix: recover incomplete initial setup (#7912) 2026-05-05 14:28:23 +00:00
Matti Nannt bc63870289 feat: add Linear Releases integration to CI pipeline (#7921)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 14:17:48 +00:00
Javi Aguilar 9a04e95d15 fix: cal and open text fields a11y semantic improvements (#7936) 2026-05-05 12:31:09 +00:00
Bhagya Amarasinghe 9d9f38515d fix: omit replicas when HPA is enabled (#7934) 2026-05-05 10:32:16 +00:00
Tiago fae00f6a82 fix: outlook preview (#7803)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-05-04 15:10:14 +00:00
Anshuman Pandey a274c444ad fix: reject SSO auto-provisioning when AUTH_SSO_DEFAULT_TEAM_ID is missing (#7926) 2026-05-04 10:57:44 +00:00
Anshuman Pandey 5fae207cd7 fix: duplicate action class name error (#7919) 2026-04-30 09:17:09 +00:00
Johannes 654539d320 fix: removed dead menu item & theme import (#7909)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-04-30 08:09:40 +00:00
Johannes 44aac89d41 fix: return generic credentials error for SSO-only accounts (#7911)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-04-30 08:09:34 +00:00
Johannes e0250b2a58 fix: include partial response in trigger description (#7908)
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-30 06:27:48 +00:00
Labeeb a8d6cd8a9f fix: 7817 use fully translated inactive survey headings (#7836) 2026-04-30 04:46:13 +00:00
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
357 changed files with 19758 additions and 7052 deletions
+10
View File
@@ -106,6 +106,13 @@ PASSWORD_RESET_DISABLED=1
# Organization Invite. Disable the ability for invited users to create an account.
# INVITE_DISABLED=1
###########################################
# Account deletion reauthentication #
###########################################
# Danger: disables fresh SSO reauthentication for passwordless account deletion. Keep unset unless you accept the risk.
# DISABLE_ACCOUNT_DELETION_SSO_REAUTH=1
##########
# Other #
@@ -132,6 +139,9 @@ GITHUB_SECRET=
# Configure Google Login
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Google only returns the auth_time proof after Auth Platform Security Bundle "Session age claims" is enabled.
# Keep this unset until that setting is active for the OAuth app.
# GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED=1
# Configure Azure Active Directory Login
AZUREAD_CLIENT_ID=
+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
+28
View File
@@ -155,3 +155,31 @@ jobs:
commit_sha: ${{ github.sha }}
is_prerelease: ${{ github.event.release.prerelease }}
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
linear-release-complete:
name: Mark Linear release as complete
runs-on: ubuntu-latest
timeout-minutes: 5
needs:
- docker-build-community
- docker-build-cloud
- helm-chart-release
- move-stable-tag
if: ${{ !github.event.release.prerelease }}
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Complete Linear release
uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
command: complete
version: ${{ github.event.release.tag_name }}
+30
View File
@@ -0,0 +1,30 @@
name: Linear Release Sync
on:
push:
branches:
- main
permissions:
contents: read
jobs:
linear-release:
name: Sync release to Linear
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Sync Linear release
uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
+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);
}
@@ -2,6 +2,7 @@ import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { capturePostHogEvent } from "@/lib/posthog";
import { getUserProjects } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
@@ -41,6 +42,16 @@ const Page = async (props: ChannelPageProps) => {
const projects = await getUserProjects(session.user.id, params.organizationId);
capturePostHogEvent(
session.user.id,
"organization_mode_selected",
{
organization_id: params.organizationId,
mode: "surveys",
},
{ organizationId: params.organizationId }
);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
@@ -18,6 +18,7 @@ import { createProjectAction } from "@/app/(app)/environments/[environmentId]/ac
import { previewSurvey } from "@/app/lib/templates";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
@@ -242,7 +243,7 @@ export const ProjectSettings = ({
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || t("common.my_product"), t)}
survey={toJsEnvironmentStateSurvey(previewSurvey(projectName || t("common.my_product"), t))}
styling={previewStyling}
isBrandingEnabled={false}
languageCode="default"
@@ -7,6 +7,7 @@ import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboardin
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { capturePostHogEvent } from "@/lib/posthog";
import { getUserProjects } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
@@ -51,6 +52,18 @@ const Page = async (props: ProjectSettingsPageProps) => {
const publicDomain = getPublicDomain();
if (searchParams.mode === "cx") {
capturePostHogEvent(
session.user.id,
"organization_mode_selected",
{
organization_id: params.organizationId,
mode: "cx",
},
{ organizationId: params.organizationId }
);
}
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
@@ -10,6 +10,7 @@ import {
import { ZProjectUpdateInput } from "@formbricks/types/project";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
@@ -80,6 +81,19 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
notificationSettings: updatedNotificationSettings,
});
groupIdentifyPostHog("workspace", project.id, { name: project.name });
capturePostHogEvent(
user.id,
"workspace_created",
{
organization_id: organizationId,
workspace_id: project.id,
name: project.name,
},
{ organizationId, workspaceId: project.id }
);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = project;
@@ -2,6 +2,8 @@ import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { PostHogGroupIdentify } from "@/app/posthog/PostHogGroupIdentify";
import { POSTHOG_KEY } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
@@ -25,6 +27,14 @@ const EnvLayout = async (props: {
return (
<>
<EnvironmentStorageHandler environmentId={params.environmentId} />
{POSTHOG_KEY && (
<PostHogGroupIdentify
organizationId={layoutData.organization.id}
organizationName={layoutData.organization.name}
workspaceId={layoutData.project.id}
workspaceName={layoutData.project.name}
/>
)}
<EnvironmentContextWrapper
environment={layoutData.environment}
project={layoutData.project}
@@ -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,30 +1,68 @@
"use client";
import type { Session } from "next-auth";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountModal";
import {
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
} from "@/modules/account/constants";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
interface DeleteAccountProps {
session: Session | null;
IS_FORMBRICKS_CLOUD: boolean;
user: TUser;
organizationsWithSingleOwner: TOrganization[];
accountDeletionError?: string | string[];
isMultiOrgEnabled: boolean;
requiresPasswordConfirmation: boolean;
}
export const DeleteAccount = ({
session,
IS_FORMBRICKS_CLOUD,
user,
organizationsWithSingleOwner,
accountDeletionError,
isMultiOrgEnabled,
}: {
session: Session | null;
IS_FORMBRICKS_CLOUD: boolean;
user: TUser;
organizationsWithSingleOwner: TOrganization[];
isMultiOrgEnabled: boolean;
}) => {
requiresPasswordConfirmation,
}: Readonly<DeleteAccountProps>) => {
const [isModalOpen, setModalOpen] = useState(false);
const isDeleteDisabled = !isMultiOrgEnabled && organizationsWithSingleOwner.length > 0;
const { t } = useTranslation();
const accountDeletionErrorCode = Array.isArray(accountDeletionError)
? accountDeletionError[0]
: accountDeletionError;
const hasShownAccountDeletionError = useRef(false);
useEffect(() => {
if (!accountDeletionErrorCode || hasShownAccountDeletionError.current) {
return;
}
hasShownAccountDeletionError.current = true;
if (accountDeletionErrorCode === ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE) {
toast.error(t("environments.settings.profile.google_sso_account_deletion_requires_setup"), {
id: "account-deletion-sso-reauth-error",
});
} else {
toast.error(t("environments.settings.profile.sso_reauthentication_failed"), {
id: "account-deletion-sso-reauth-error",
});
}
const url = new URL(globalThis.location.href);
url.searchParams.delete(ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM);
globalThis.history.replaceState(null, "", url.toString());
}, [accountDeletionErrorCode, t]);
if (!session) {
return null;
}
@@ -32,6 +70,7 @@ export const DeleteAccount = ({
return (
<div>
<DeleteAccountModal
requiresPasswordConfirmation={requiresPasswordConfirmation}
open={isModalOpen}
setOpen={setModalOpen}
user={user}
@@ -1,12 +1,6 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib/utils";
import { getIsEmailUnique, verifyUserPassword } from "./user";
vi.mock("@/modules/auth/lib/utils", () => ({
verifyPassword: vi.fn(),
}));
import { getIsEmailUnique } from "./user";
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -17,92 +11,12 @@ vi.mock("@formbricks/database", () => ({
}));
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
const mockVerifyPasswordUtil = vi.mocked(mockVerifyPasswordImported);
describe("User Library Tests", () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe("verifyUserPassword", () => {
const userId = "test-user-id";
const password = "test-password";
test("should return true for correct password", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "email",
} as any);
mockVerifyPasswordUtil.mockResolvedValue(true);
const result = await verifyUserPassword(userId, password);
expect(result).toBe(true);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
});
test("should return false for incorrect password", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "email",
} as any);
mockVerifyPasswordUtil.mockResolvedValue(false);
const result = await verifyUserPassword(userId, password);
expect(result).toBe(false);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
});
test("should throw ResourceNotFoundError if user not found", async () => {
mockPrismaUserFindUnique.mockResolvedValue(null);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(ResourceNotFoundError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(`user with ID ${userId} not found`);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
test("should throw InvalidInputError if identityProvider is not email", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "google", // Not 'email'
} as any);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
test("should throw InvalidInputError if password is not set for email provider", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: null, // Password not set
identityProvider: "email",
} as any);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
});
describe("getIsEmailUnique", () => {
const email = "test@example.com";
@@ -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({
@@ -5,6 +5,7 @@ import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABL
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { requiresPasswordConfirmationForAccountDeletion } from "@/modules/account/lib/account-deletion-auth";
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
@@ -15,10 +16,14 @@ import { SettingsCard } from "../../components/SettingsCard";
import { DeleteAccount } from "./components/DeleteAccount";
import { EditProfileDetailsForm } from "./components/EditProfileDetailsForm";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const Page = async (props: {
params: Promise<{ environmentId: string }>;
searchParams: Promise<{ accountDeletionError?: string | string[] }>;
}) => {
const isTwoFactorAuthEnabled = await getIsTwoFactorAuthEnabled();
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const params = await props.params;
const searchParams = await props.searchParams;
const t = await getTranslate();
const { environmentId } = params;
@@ -33,6 +38,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
}
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
const requiresPasswordConfirmation = requiresPasswordConfirmationForAccountDeletion(user);
return (
<PageContentWrapper>
@@ -90,6 +96,8 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
user={user}
organizationsWithSingleOwner={organizationsWithSingleOwner}
isMultiOrgEnabled={isMultiOrgEnabled}
accountDeletionError={searchParams.accountDeletionError}
requiresPasswordConfirmation={requiresPasswordConfirmation}
/>
</SettingsCard>
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
@@ -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,6 +4,7 @@ import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { capturePostHogEvent } from "@/lib/posthog";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -146,6 +147,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
.inputSchema(ZGeneratePersonalLinksAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
@@ -153,7 +155,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
organizationId,
access: [
{
type: "organization",
@@ -161,7 +163,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
projectId,
minPermission: "readWrite",
},
],
@@ -178,6 +180,18 @@ export const generatePersonalLinksAction = authenticatedActionClient
throw new UnknownError("No contacts found for the selected segment");
}
capturePostHogEvent(
ctx.user.id,
"personal_link_created",
{
organization_id: organizationId,
workspace_id: projectId,
survey_id: parsedInput.surveyId,
link_count: contactsResult.length,
},
{ organizationId, workspaceId: projectId }
);
// Prepare CSV data with the specified headers and order
const csvHeaders = [
"Formbricks Contact ID",
@@ -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
@@ -2,7 +2,7 @@
import DOMPurify from "dompurify";
import { CopyIcon, SendIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { type SyntheticEvent, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { AuthenticationError } from "@formbricks/types/errors";
@@ -21,6 +21,7 @@ interface EmailTabProps {
export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
const [activeTab, setActiveTab] = useState("preview");
const [emailHtmlPreview, setEmailHtmlPreview] = useState<string>("");
const [previewFrameHeight, setPreviewFrameHeight] = useState(560);
const { t } = useTranslation();
const emailHtml = useMemo(() => {
@@ -31,6 +32,40 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
.replaceAll("?preview=true", "");
}, [emailHtmlPreview]);
const sanitizedEmailHtml = useMemo(() => {
if (!emailHtmlPreview) return "";
return DOMPurify.sanitize(emailHtmlPreview, { ADD_ATTR: ["bgcolor", "target"] });
}, [emailHtmlPreview]);
const emailPreviewDocument = useMemo(() => {
if (!sanitizedEmailHtml) return "";
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="only light" />
<meta name="supported-color-schemes" content="light" />
<base target="_blank" />
<style>
:root {
color-scheme: only light;
supported-color-schemes: light;
}
html, body {
margin: 0;
padding: 0;
background: #ffffff;
color-scheme: only light;
}
</style>
</head>
<body>${sanitizedEmailHtml}</body>
</html>`;
}, [sanitizedEmailHtml]);
const tabs = [
{
id: "preview",
@@ -51,6 +86,25 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
getData();
}, [surveyId]);
useEffect(() => {
setPreviewFrameHeight(560);
}, [emailPreviewDocument]);
const handlePreviewFrameLoad = (event: SyntheticEvent<HTMLIFrameElement>) => {
const { contentDocument } = event.currentTarget;
if (!contentDocument) {
return;
}
const nextHeight = Math.max(
contentDocument.body.scrollHeight,
contentDocument.documentElement.scrollHeight,
560
);
setPreviewFrameHeight(nextHeight);
};
const sendPreviewEmail = async () => {
try {
const val = await sendEmbedSurveyPreviewEmailAction({ surveyId });
@@ -73,7 +127,9 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
if (activeTab === "preview") {
return (
<div className="space-y-4 pb-4">
<div className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4">
<div
className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4"
data-testid="survey-email-preview-shell">
<div className="mb-6 flex gap-2">
<div className="h-3 w-3 rounded-full bg-red-500" />
<div className="h-3 w-3 rounded-full bg-amber-500" />
@@ -87,9 +143,17 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
{t("environments.surveys.share.send_email.email_subject_label")} :{" "}
{t("environments.surveys.share.send_email.formbricks_email_survey_preview")}
</div>
<div className="p-2">
{emailHtml ? (
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(emailHtml) }} />
<div data-testid="survey-email-preview-content">
{emailPreviewDocument ? (
<iframe
className="mt-2 w-full rounded-md border-0 bg-white"
data-testid="survey-email-preview-frame"
onLoad={handlePreviewFrameLoad}
sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin"
srcDoc={emailPreviewDocument}
style={{ height: `${previewFrameHeight}px` }}
title={t("environments.surveys.share.send_email.email_preview_tab")}
/>
) : (
<LoadingSpinner />
)}
@@ -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>
</>
);
};
@@ -0,0 +1,59 @@
import { describe, expect, test } from "vitest";
import { extractEmailBodyFragment } from "./emailTemplateFragment";
describe("extractEmailBodyFragment", () => {
test("returns the body contents for rendered email documents", () => {
const html = `
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
<head>
<style>.foo { color: red; }</style>
</head>
<body class="email-body">
<table>
<tr>
<td>Preview content</td>
</tr>
</table>
</body>
</html>
`;
expect(extractEmailBodyFragment(html)).toBe(
"<table>\n <tr>\n <td>Preview content</td>\n </tr>\n </table>"
);
});
test("removes document-level tags from rendered survey email markup", () => {
const fragment = extractEmailBodyFragment(`
<!DOCTYPE html>
<html>
<head>
<style>.foo { color: red; }</style>
</head>
<body>
<table>
<tr>
<td>Which fruits do you like</td>
</tr>
</table>
</body>
</html>
`);
expect(fragment).toBe(
"<table>\n <tr>\n <td>Which fruits do you like</td>\n </tr>\n </table>"
);
expect(fragment).not.toMatch(/<!DOCTYPE|<html|<head|<body/i);
});
test("falls back to the original markup when no body tag exists", () => {
expect(extractEmailBodyFragment("<div>Preview content</div>")).toBe("<div>Preview content</div>");
});
test("removes React server markers from rendered fragments", () => {
expect(extractEmailBodyFragment("<body><!--$--><div>Preview content</div><!--/$--></body>")).toBe(
"<div>Preview content</div>"
);
});
});
@@ -1,10 +1,12 @@
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
import { getSurvey } from "@/lib/survey/service";
import { getStyling } from "@/lib/utils/styling";
import { getTranslate } from "@/lingodotdev/server";
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
import { extractEmailBodyFragment } from "./emailTemplateFragment";
export const getEmailTemplateHtml = async (surveyId: string, locale: string) => {
const t = await getTranslate();
@@ -17,12 +19,9 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const styling = getStyling(project, survey);
const styling = getStyling(project, toJsEnvironmentStateSurvey(survey));
const surveyUrl = getPublicDomain() + "/s/" + survey.id;
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
const htmlCleaned = html.toString().replace(doctype, "");
return htmlCleaned;
return extractEmailBodyFragment(html.toString());
};
@@ -0,0 +1,11 @@
const EMAIL_DOCTYPE_PATTERN = /<!DOCTYPE[^>]*>/i;
const EMAIL_BODY_PATTERN = /<body\b[^>]*>([\s\S]*?)<\/body>/i;
const EMAIL_REACT_SERVER_MARKER_PATTERN = /<!--\/?\$-->/g;
export const extractEmailBodyFragment = (html: string): string => {
const htmlWithoutDoctype = html.replace(EMAIL_DOCTYPE_PATTERN, "").trim();
const bodyMatch = EMAIL_BODY_PATTERN.exec(htmlWithoutDoctype);
const fragment = bodyMatch?.[1].trim() ?? htmlWithoutDoctype;
return fragment.replaceAll(EMAIL_REACT_SERVER_MARKER_PATTERN, "").trim();
};
@@ -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}
@@ -42,18 +42,25 @@ export const getResponsesDownloadUrlAction = authenticatedActionClient
],
});
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
const result = await getResponseDownloadFile(
parsedInput.surveyId,
parsedInput.format,
parsedInput.filterCriteria
);
capturePostHogEvent(ctx.user.id, "responses_exported", {
survey_id: parsedInput.surveyId,
format: parsedInput.format,
filter_applied: Object.keys(parsedInput.filterCriteria ?? {}).length > 0,
organization_id: organizationId,
});
capturePostHogEvent(
ctx.user.id,
"responses_exported",
{
survey_id: parsedInput.surveyId,
format: parsedInput.format,
filter_applied: Object.keys(parsedInput.filterCriteria ?? {}).length > 0,
organization_id: organizationId,
workspace_id: projectId,
},
{ organizationId, workspaceId: projectId }
);
return result;
});
@@ -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);
@@ -43,14 +43,22 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
});
ctx.auditLoggingCtx.organizationId = organizationId;
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
const result = await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData);
ctx.auditLoggingCtx.integrationId = result.id;
ctx.auditLoggingCtx.newObject = result;
capturePostHogEvent(ctx.user.id, "integration_connected", {
integration_type: parsedInput.integrationData.type,
organization_id: organizationId,
});
capturePostHogEvent(
ctx.user.id,
"integration_connected",
{
integration_type: parsedInput.integrationData.type,
organization_id: organizationId,
workspace_id: projectId,
environment_id: parsedInput.environmentId,
},
{ organizationId, workspaceId: projectId }
);
return result;
})
@@ -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>
@@ -0,0 +1,160 @@
import { getServerSession } from "next-auth";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { AuthorizationError } from "@formbricks/types/errors";
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { completeAccountDeletionSsoReauthenticationAndGetRedirectPath } from "./account-deletion-sso-complete";
vi.mock("server-only", () => ({}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
},
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
WEBAPP_URL: "http://localhost:3000",
}));
vi.mock("@/lib/jwt", () => ({
verifyAccountDeletionSsoReauthIntent: vi.fn(),
}));
vi.mock("@/modules/account/lib/account-deletion", () => ({
deleteUserWithAccountDeletionAuthorization: vi.fn(),
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventBackground: vi.fn(),
}));
const mockGetServerSession = vi.mocked(getServerSession);
const mockLoggerError = vi.mocked(logger.error);
const mockVerifyAccountDeletionSsoReauthIntent = vi.mocked(verifyAccountDeletionSsoReauthIntent);
const mockDeleteUserWithAccountDeletionAuthorization = vi.mocked(deleteUserWithAccountDeletionAuthorization);
const mockQueueAuditEventBackground = vi.mocked(queueAuditEventBackground);
const intent = {
id: "intent-id",
email: "delete-user@example.com",
provider: "google",
providerAccountId: "google-account-id",
purpose: "account_deletion_sso_reauth" as const,
returnToUrl: "http://localhost:3000/environments/env-id/settings/profile",
userId: "user-id",
};
describe("completeAccountDeletionSsoReauthenticationAndGetRedirectPath", () => {
beforeEach(() => {
vi.clearAllMocks();
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockGetServerSession.mockResolvedValue({
user: {
email: intent.email,
id: intent.userId,
},
} as any);
mockDeleteUserWithAccountDeletionAuthorization.mockResolvedValue({
oldUser: { id: intent.userId } as any,
});
mockQueueAuditEventBackground.mockResolvedValue(undefined);
});
test("returns login without deleting when the callback has no intent", async () => {
await expect(completeAccountDeletionSsoReauthenticationAndGetRedirectPath({})).resolves.toBe(
"/auth/login"
);
expect(mockVerifyAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
expect(mockQueueAuditEventBackground).not.toHaveBeenCalled();
});
test("deletes the account after a completed SSO reauthentication", async () => {
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalledWith({
confirmationEmail: intent.email,
userEmail: intent.email,
userId: intent.userId,
});
expect(mockQueueAuditEventBackground).toHaveBeenCalledWith({
action: "deleted",
targetType: "user",
userId: intent.userId,
userType: "user",
targetId: intent.userId,
organizationId: "unknown",
oldObject: { id: intent.userId },
status: "success",
});
});
test("does not delete when the callback session does not match the intent user", async () => {
mockGetServerSession.mockResolvedValue({
user: {
email: "other@example.com",
id: "other-user-id",
},
} as any);
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/environments/env-id/settings/profile");
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
expect(mockLoggerError).toHaveBeenCalledWith(
{ error: expect.any(AuthorizationError) },
"Failed to complete account deletion after SSO reauth"
);
});
test("keeps the post-deletion redirect if audit logging fails after deletion", async () => {
mockQueueAuditEventBackground.mockRejectedValue(new Error("audit unavailable"));
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalled();
expect(mockLoggerError).toHaveBeenCalledWith(
{ error: expect.any(Error) },
"Failed to complete account deletion after SSO reauth"
);
});
test("falls back to login when the intent return URL is not allowed", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue({
...intent,
returnToUrl: "https://evil.example/settings/profile",
});
mockGetServerSession.mockResolvedValue({
user: {
email: "other@example.com",
id: "other-user-id",
},
} as any);
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: ["intent-token"] })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,82 @@
import "server-only";
import { getServerSession } from "next-auth";
import { logger } from "@formbricks/logger";
import { AuthorizationError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@/lib/constants";
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
import { getValidatedCallbackUrl } from "@/lib/utils/url";
import { FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL } from "@/modules/account/constants";
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
type TAccountDeletionSsoCompleteSearchParams = {
intent?: string | string[];
};
const getIntentToken = (intent: string | string[] | undefined) => {
if (Array.isArray(intent)) {
return intent[0];
}
return intent;
};
const getSafeRedirectPath = (returnToUrl: string) => {
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL);
if (!validatedReturnToUrl) {
return "/auth/login";
}
const parsedReturnToUrl = new URL(validatedReturnToUrl);
return `${parsedReturnToUrl.pathname}${parsedReturnToUrl.search}${parsedReturnToUrl.hash}`;
};
const getPostDeletionRedirectPath = () =>
IS_FORMBRICKS_CLOUD ? FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL : "/auth/login";
export const completeAccountDeletionSsoReauthenticationAndGetRedirectPath = async ({
intent,
}: TAccountDeletionSsoCompleteSearchParams): Promise<string> => {
const intentToken = getIntentToken(intent);
let redirectPath = "/auth/login";
if (!intentToken) {
return redirectPath;
}
try {
const verifiedIntent = verifyAccountDeletionSsoReauthIntent(intentToken);
redirectPath = getSafeRedirectPath(verifiedIntent.returnToUrl);
const session = await getServerSession(authOptions);
if (!session?.user?.id || !session.user.email || session.user.id !== verifiedIntent.userId) {
throw new AuthorizationError("Account deletion SSO reauthentication session mismatch");
}
logger.info({ userId: session.user.id }, "Completing account deletion after SSO reauth");
const { oldUser } = await deleteUserWithAccountDeletionAuthorization({
confirmationEmail: verifiedIntent.email,
userEmail: session.user.email,
userId: session.user.id,
});
redirectPath = getPostDeletionRedirectPath();
await queueAuditEventBackground({
action: "deleted",
targetType: "user",
userId: session.user.id,
userType: "user",
targetId: session.user.id,
organizationId: UNKNOWN_DATA,
oldObject: oldUser,
status: "success",
});
logger.info({ userId: session.user.id }, "Completed account deletion after SSO reauth");
} catch (error) {
logger.error({ error }, "Failed to complete account deletion after SSO reauth");
}
return redirectPath;
};
@@ -0,0 +1,10 @@
import { redirect } from "next/navigation";
import { completeAccountDeletionSsoReauthenticationAndGetRedirectPath } from "./lib/account-deletion-sso-complete";
export default async function AccountDeletionSsoReauthCompletePage({
searchParams,
}: {
searchParams: Promise<{ intent?: string | string[] }>;
}) {
redirect(await completeAccountDeletionSsoReauthenticationAndGetRedirectPath(await searchParams));
}
@@ -12,6 +12,7 @@ describe("captureSurveyResponsePostHogEvent", () => {
const makeParams = (responseCount: number) => ({
organizationId: "org-1",
workspaceId: "ws-1",
surveyId: "survey-1",
surveyType: "link",
environmentId: "env-1",
@@ -23,15 +24,21 @@ describe("captureSurveyResponsePostHogEvent", () => {
captureSurveyResponsePostHogEvent(makeParams(1));
expect(capturePostHogEvent).toHaveBeenCalledWith("org-1", "survey_response_received", {
survey_id: "survey-1",
survey_type: "link",
organization_id: "org-1",
environment_id: "env-1",
response_count: 1,
is_first_response: true,
milestone: "first",
});
expect(capturePostHogEvent).toHaveBeenCalledWith(
"org-1",
"survey_response_received",
{
survey_id: "survey-1",
survey_type: "link",
organization_id: "org-1",
workspace_id: "ws-1",
environment_id: "env-1",
response_count: 1,
is_first_response: true,
milestone: "first",
},
{ organizationId: "org-1", workspaceId: "ws-1" }
);
});
test("fires on every 100th response", async () => {
@@ -44,10 +51,20 @@ describe("captureSurveyResponsePostHogEvent", () => {
expect(capturePostHogEvent).toHaveBeenCalledTimes(6);
});
test("does NOT fire for 2nd through 99th responses", async () => {
test("fires on every 10th response up to 100", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
for (const count of [2, 5, 10, 50, 99]) {
for (const count of [10, 20, 30, 40, 50, 60, 70, 80, 90, 100]) {
captureSurveyResponsePostHogEvent(makeParams(count));
}
expect(capturePostHogEvent).toHaveBeenCalledTimes(10);
});
test("does NOT fire for non-milestone responses under 100", async () => {
const { capturePostHogEvent } = await import("@/lib/posthog");
for (const count of [2, 5, 11, 25, 49, 51, 99]) {
captureSurveyResponsePostHogEvent(makeParams(count));
}
@@ -75,7 +92,8 @@ describe("captureSurveyResponsePostHogEvent", () => {
expect.objectContaining({
is_first_response: false,
milestone: "200",
})
}),
{ organizationId: "org-1", workspaceId: "ws-1" }
);
});
});
@@ -2,6 +2,7 @@ import { capturePostHogEvent } from "@/lib/posthog";
interface SurveyResponsePostHogEventParams {
organizationId: string;
workspaceId: string;
surveyId: string;
surveyType: string;
environmentId: string;
@@ -10,24 +11,36 @@ interface SurveyResponsePostHogEventParams {
/**
* Captures a PostHog event for survey responses at milestones:
* 1st response, then every 100th (100, 200, 300, ...).
* 1st response, every 10th for the first 100 (10, 20, ..., 100),
* then every 100th (200, 300, 400, ...).
*/
export const captureSurveyResponsePostHogEvent = ({
organizationId,
workspaceId,
surveyId,
surveyType,
environmentId,
responseCount,
}: SurveyResponsePostHogEventParams): void => {
if (responseCount !== 1 && responseCount % 100 !== 0) return;
const isFirst = responseCount === 1;
const isEvery10thUnder100 = responseCount <= 100 && responseCount % 10 === 0;
const isEvery100thAbove100 = responseCount > 100 && responseCount % 100 === 0;
capturePostHogEvent(organizationId, "survey_response_received", {
survey_id: surveyId,
survey_type: surveyType,
organization_id: organizationId,
environment_id: environmentId,
response_count: responseCount,
is_first_response: responseCount === 1,
milestone: responseCount === 1 ? "first" : String(responseCount),
});
if (!isFirst && !isEvery10thUnder100 && !isEvery100thAbove100) return;
capturePostHogEvent(
organizationId,
"survey_response_received",
{
survey_id: surveyId,
survey_type: surveyType,
organization_id: organizationId,
workspace_id: workspaceId,
environment_id: environmentId,
response_count: responseCount,
is_first_response: responseCount === 1,
milestone: responseCount === 1 ? "first" : String(responseCount),
},
{ organizationId, workspaceId }
);
};
+11 -3
View File
@@ -8,13 +8,14 @@ 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";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
@@ -91,10 +92,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)),
]);
};
@@ -302,9 +308,11 @@ export const POST = async (request: Request) => {
if (POSTHOG_KEY) {
const responseCount = await getResponseCountBySurveyId(surveyId);
const workspaceId = await getProjectIdFromEnvironmentId(environmentId);
captureSurveyResponsePostHogEvent({
organizationId: organization.id,
workspaceId,
surveyId,
surveyType: survey.type,
environmentId,
@@ -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);
}
}
};
@@ -12,7 +12,7 @@ import {
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { authOptions } from "@/modules/auth/lib/authOptions";
export const GET = async (req: Request) => {
@@ -87,10 +87,18 @@ export const GET = async (req: Request) => {
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(session.user.id, "integration_connected", {
integration_type: "googleSheets",
organization_id: organizationId,
});
const projectId = await getProjectIdFromEnvironmentId(environmentId);
capturePostHogEvent(
session.user.id,
"integration_connected",
{
integration_type: "googleSheets",
organization_id: organizationId,
workspace_id: projectId,
environment_id: environmentId,
},
{ organizationId, workspaceId: projectId }
);
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
}
+124 -2
View File
@@ -1,9 +1,15 @@
import { NextRequest } from "next/server";
import { describe, expect, test, vi } from "vitest";
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { authenticateRequest } from "./auth";
import { authenticateRequest, handleErrorResponse } from "./auth";
vi.mock("@/modules/organization/settings/api-keys/lib/api-key", () => ({
getApiKeyWithPermissions: vi.fn(),
@@ -171,7 +177,7 @@ describe("authenticateRequest", () => {
expect(result).toBeNull();
});
test("returns null when API key has no environment permissions", async () => {
test("returns null by default when API key has no environment permissions", async () => {
const request = new NextRequest("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
@@ -192,4 +198,120 @@ describe("authenticateRequest", () => {
const result = await authenticateRequest(request);
expect(result).toBeNull();
});
test("authenticates a valid API key with no environment permissions when explicitly allowed", async () => {
const request = new NextRequest("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
const mockApiKeyData = {
id: "api-key-id",
organizationId: "org-id",
organizationAccess: "all" as const,
createdAt: new Date(),
createdBy: "user-id",
lastUsedAt: null,
label: "Test API Key",
apiKeyEnvironments: [],
};
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
const result = await authenticateRequest(request, { allowOrganizationOnlyApiKey: true });
expect(result).toEqual({
type: "apiKey",
environmentPermissions: [],
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: "all",
});
});
test("authenticates a read-only organization API key with no environment permissions", async () => {
const request = new NextRequest("http://localhost/api/v1/management/surveys", {
headers: { "x-api-key": "read-only-org-api-key" },
});
const mockApiKeyData = {
id: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
createdAt: new Date(),
createdBy: "user-id",
lastUsedAt: null,
label: "Read-only Organization API Key",
apiKeyEnvironments: [],
};
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
const result = await authenticateRequest(request, { allowOrganizationOnlyApiKey: true });
expect(result).toEqual({
type: "apiKey",
environmentPermissions: [],
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
});
});
});
describe("handleErrorResponse", () => {
test("returns 401 notAuthenticated for 'NotAuthenticated' message", async () => {
const response = handleErrorResponse(new Error("NotAuthenticated"));
expect(response.status).toBe(401);
const body = await response.json();
expect(body.code).toBe("not_authenticated");
});
test("returns 401 unauthorized for 'Unauthorized' message", async () => {
const response = handleErrorResponse(new Error("Unauthorized"));
expect(response.status).toBe(401);
const body = await response.json();
expect(body.code).toBe("unauthorized");
});
test("returns 409 conflict for UniqueConstraintError", async () => {
const response = handleErrorResponse(new UniqueConstraintError("Action with name foo already exists"));
expect(response.status).toBe(409);
const body = await response.json();
expect(body.code).toBe("conflict");
expect(body.message).toBe("Action with name foo already exists");
});
test("returns 400 badRequest for DatabaseError", async () => {
const response = handleErrorResponse(new DatabaseError("db boom"));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.message).toBe("db boom");
});
test("returns 400 badRequest for InvalidInputError", async () => {
const response = handleErrorResponse(new InvalidInputError("bad input"));
expect(response.status).toBe(400);
const body = await response.json();
expect(body.message).toBe("bad input");
});
test("returns 400 badRequest for ResourceNotFoundError", async () => {
const response = handleErrorResponse(new ResourceNotFoundError("Survey", "id-1"));
expect(response.status).toBe(400);
});
test("returns 500 internalServerError for unknown errors", async () => {
const response = handleErrorResponse(new Error("something else"));
expect(response.status).toBe(500);
const body = await response.json();
expect(body.message).toBe("Some error occurred");
});
});
+30 -8
View File
@@ -1,21 +1,30 @@
import { NextRequest } from "next/server";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
UniqueConstraintError,
} from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
export const authenticateRequest = async (request: NextRequest): Promise<TAuthenticationApiKey | null> => {
const apiKey = request.headers.get("x-api-key");
if (!apiKey) return null;
type AuthenticateApiKeyOptions = {
allowOrganizationOnlyApiKey?: boolean;
};
// Get API key with permissions
export const authenticateApiKey = async (
apiKey: string,
options: AuthenticateApiKeyOptions = {}
): Promise<TAuthenticationApiKey | null> => {
const apiKeyData = await getApiKeyWithPermissions(apiKey);
if (!apiKeyData) return null;
// In the route handlers, we'll do more specific permission checks
const environmentIds = apiKeyData.apiKeyEnvironments.map((env) => env.environmentId);
if (environmentIds.length === 0) return null;
if (!options.allowOrganizationOnlyApiKey && apiKeyData.apiKeyEnvironments.length === 0) {
return null;
}
// In the route handlers, we'll do more specific permission checks
const authentication: TAuthenticationApiKey = {
type: "apiKey",
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
@@ -33,6 +42,16 @@ export const authenticateRequest = async (request: NextRequest): Promise<TAuthen
return authentication;
};
export const authenticateRequest = async (
request: NextRequest,
options: AuthenticateApiKeyOptions = {}
): Promise<TAuthenticationApiKey | null> => {
const apiKey = request.headers.get("x-api-key");
if (!apiKey) return null;
return authenticateApiKey(apiKey, options);
};
export const handleErrorResponse = (error: any): Response => {
switch (error.message) {
case "NotAuthenticated":
@@ -40,6 +59,9 @@ export const handleErrorResponse = (error: any): Response => {
case "Unauthorized":
return responses.unauthorizedResponse();
default:
if (error instanceof UniqueConstraintError) {
return responses.conflictResponse(error.message);
}
if (
error instanceof DatabaseError ||
error instanceof InvalidInputError ||
@@ -80,7 +80,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
select: {
id: true,
welcomeCard: true,
name: true,
// name intentionally omitted — internal label not needed by the SDK
questions: true,
blocks: true,
variables: true,
@@ -107,13 +107,13 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
styling: true,
status: true,
recaptcha: true,
// Fetch only what's needed to compute the minimal segment shape.
// Titles, descriptions, and filter conditions are evaluated server-side
// and must not be sent to the browser.
segment: {
include: {
surveys: {
select: {
id: true,
},
},
select: {
id: true,
filters: true,
},
},
recontactDays: true,
@@ -147,10 +147,28 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
throw new ResourceNotFoundError("project", null);
}
// Transform surveys using existing utility
const transformedSurveys = environmentData.surveys.map((survey) =>
transformPrismaSurvey<TJsEnvironmentStateSurvey>(survey)
);
// Transform surveys using the shared utility, then replace the segment with
// the minimal public shape (id + hasFilters). We null out segment before
// calling transformPrismaSurvey because that function expects a surveys[]
// relation on the segment object (used by the management API), which we
// intentionally don't fetch here.
const transformedSurveys = environmentData.surveys.map((survey) => {
const minimalSegment = survey.segment
? {
id: survey.segment.id,
hasFilters:
Array.isArray(survey.segment.filters) && (survey.segment.filters as unknown[]).length > 0,
}
: null;
const { segment: _segment, ...surveyWithoutSegment } = survey;
const transformed = transformPrismaSurvey<TJsEnvironmentStateSurvey>({
...surveyWithoutSegment,
segment: null,
});
return { ...transformed, segment: minimalSegment };
});
return {
environment: {
@@ -10,6 +10,11 @@ import { capturePostHogEvent } from "@/lib/posthog";
import { EnvironmentStateData, getEnvironmentStateData } from "./data";
import { getEnvironmentState } from "./environmentState";
vi.mock("server-only", () => ({}));
vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() }));
vi.mock("@/modules/storage/utils", () => ({ resolveStorageUrlsInObject: vi.fn((obj: unknown) => obj) }));
vi.mock("@/modules/survey/lib/utils", () => ({ transformPrismaSurvey: vi.fn() }));
// Mock dependencies
vi.mock("@/lib/cache", () => ({
cache: {
@@ -22,6 +27,9 @@ vi.mock("@formbricks/database", () => ({
environment: {
update: vi.fn(),
},
project: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
@@ -163,6 +171,7 @@ describe("getEnvironmentState", () => {
// Default mocks for successful retrieval
vi.mocked(getEnvironmentStateData).mockResolvedValue(mockEnvironmentStateData);
vi.mocked(prisma.project.findUnique).mockResolvedValue({ organizationId: "test-org-id" });
});
afterEach(() => {
@@ -329,11 +338,18 @@ describe("getEnvironmentState", () => {
await getEnvironmentState(environmentId);
expect(capturePostHogEvent).toHaveBeenCalledWith(environmentId, "app_connected", {
num_surveys: 1,
num_code_actions: 1,
num_no_code_actions: 1,
});
expect(capturePostHogEvent).toHaveBeenCalledWith(
environmentId,
"app_connected",
{
num_surveys: 1,
num_code_actions: 1,
num_no_code_actions: 1,
organization_id: "test-org-id",
workspace_id: "test-project-id",
},
{ organizationId: "test-org-id", workspaceId: "test-project-id" }
);
});
test("should not capture app_connected event when app setup already completed", async () => {
@@ -33,11 +33,25 @@ export const getEnvironmentState = async (
});
if (POSTHOG_KEY) {
capturePostHogEvent(environmentId, "app_connected", {
num_surveys: surveys.length,
num_code_actions: actionClasses.filter((ac) => ac.type === "code").length,
num_no_code_actions: actionClasses.filter((ac) => ac.type === "noCode").length,
const workspaceId = environment.project.id;
const project = await prisma.project.findUnique({
where: { id: workspaceId },
select: { organizationId: true },
});
const organizationId = project?.organizationId;
capturePostHogEvent(
environmentId,
"app_connected",
{
num_surveys: surveys.length,
num_code_actions: actionClasses.filter((ac) => ac.type === "code").length,
num_no_code_actions: actionClasses.filter((ac) => ac.type === "noCode").length,
organization_id: organizationId ?? "",
workspace_id: workspaceId,
},
organizationId ? { organizationId, workspaceId } : undefined
);
}
}
@@ -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,9 +5,9 @@ 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";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
const getEmail = async (token: string) => {
const req_ = await fetch("https://api.airtable.com/v0/meta/whoami", {
@@ -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,
},
};
@@ -91,10 +95,18 @@ export const GET = withV1ApiWrapper({
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "airtable",
organization_id: organizationId,
});
const projectId = await getProjectIdFromEnvironmentId(environmentId);
capturePostHogEvent(
authentication.user.id,
"integration_connected",
{
integration_type: "airtable",
organization_id: organizationId,
workspace_id: projectId,
environment_id: environmentId,
},
{ organizationId, workspaceId: projectId }
);
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for airtable");
}
@@ -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),
};
@@ -13,7 +13,7 @@ import { symmetricEncrypt } from "@/lib/crypto";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
export const GET = withV1ApiWrapper({
handler: async ({ req, authentication }) => {
@@ -101,10 +101,18 @@ export const GET = withV1ApiWrapper({
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "notion",
organization_id: organizationId,
});
const projectId = await getProjectIdFromEnvironmentId(environmentId);
capturePostHogEvent(
authentication.user.id,
"integration_connected",
{
integration_type: "notion",
organization_id: organizationId,
workspace_id: projectId,
environment_id: environmentId,
},
{ organizationId, workspaceId: projectId }
);
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for notion");
}
@@ -10,7 +10,7 @@ import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL }
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
export const GET = withV1ApiWrapper({
handler: async ({ req, authentication }) => {
@@ -109,10 +109,18 @@ export const GET = withV1ApiWrapper({
if (result) {
try {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
capturePostHogEvent(authentication.user.id, "integration_connected", {
integration_type: "slack",
organization_id: organizationId,
});
const projectId = await getProjectIdFromEnvironmentId(environmentId);
capturePostHogEvent(
authentication.user.id,
"integration_connected",
{
integration_type: "slack",
organization_id: organizationId,
workspace_id: projectId,
environment_id: environmentId,
},
{ organizationId, workspaceId: projectId }
);
} catch (err) {
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for slack");
}
@@ -1,6 +1,6 @@
import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError } from "@formbricks/types/errors";
import { DatabaseError, UniqueConstraintError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -80,6 +80,11 @@ export const POST = withV1ApiWrapper({
response: responses.successResponse(actionClass),
};
} catch (error) {
if (error instanceof UniqueConstraintError) {
return {
response: responses.conflictResponse(error.message),
};
}
if (error instanceof DatabaseError) {
return {
response: responses.badRequestResponse(error.message),
@@ -0,0 +1,199 @@
import { EnvironmentType } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import { getEnvironment } from "@/lib/environment/service";
import { buildApiKeyMeResponse } from "./api-key-response";
vi.mock("@/lib/environment/service", () => ({
getEnvironment: vi.fn(),
}));
const baseAuthentication = {
type: "apiKey" as const,
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: false,
write: false,
},
},
environmentPermissions: [],
};
const environmentPermission = (
environmentId: string,
permission: "read" | "write" | "manage" = "read"
): TAuthenticationApiKey["environmentPermissions"][number] => ({
environmentId,
permission,
environmentType: EnvironmentType.development,
projectId: "project-id",
projectName: "Project Name",
});
describe("buildApiKeyMeResponse", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns auth metadata for an organization-read API key without environment permissions", async () => {
const response = await buildApiKeyMeResponse({
...baseAuthentication,
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
});
expect(response?.status).toBe(200);
expect(await response?.json()).toEqual({
environmentPermissions: [],
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
});
expect(getEnvironment).not.toHaveBeenCalled();
});
test("returns auth metadata with permissions for organization-read API keys with multiple environments", async () => {
const response = await buildApiKeyMeResponse({
...baseAuthentication,
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
environmentPermissions: [
environmentPermission("env-1", "read"),
environmentPermission("env-2", "write"),
],
});
expect(response?.status).toBe(200);
expect(await response?.json()).toEqual({
environmentPermissions: [
{
environmentId: "env-1",
environmentType: EnvironmentType.development,
permissions: "read",
projectId: "project-id",
projectName: "Project Name",
},
{
environmentId: "env-2",
environmentType: EnvironmentType.development,
permissions: "write",
projectId: "project-id",
projectName: "Project Name",
},
],
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
});
expect(getEnvironment).not.toHaveBeenCalled();
});
test("returns the legacy environment response for a single environment permission", async () => {
const createdAt = new Date("2026-01-01T00:00:00.000Z");
const updatedAt = new Date("2026-01-02T00:00:00.000Z");
vi.mocked(getEnvironment).mockResolvedValue({
id: "env-id",
type: EnvironmentType.development,
createdAt,
updatedAt,
projectId: "project-id",
appSetupCompleted: true,
});
const response = await buildApiKeyMeResponse({
...baseAuthentication,
environmentPermissions: [environmentPermission("env-id", "read")],
});
expect(response?.status).toBe(200);
expect(await response?.json()).toEqual({
id: "env-id",
type: EnvironmentType.development,
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
appSetupCompleted: true,
project: {
id: "project-id",
name: "Project Name",
},
});
expect(getEnvironment).toHaveBeenCalledWith("env-id");
});
test("returns the legacy environment response for an organization-read API key with one environment", async () => {
const createdAt = new Date("2026-01-01T00:00:00.000Z");
const updatedAt = new Date("2026-01-02T00:00:00.000Z");
vi.mocked(getEnvironment).mockResolvedValue({
id: "env-id",
type: EnvironmentType.development,
createdAt,
updatedAt,
projectId: "project-id",
appSetupCompleted: true,
});
const response = await buildApiKeyMeResponse({
...baseAuthentication,
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
environmentPermissions: [environmentPermission("env-id", "read")],
});
expect(response?.status).toBe(200);
expect(await response?.json()).toEqual({
id: "env-id",
type: EnvironmentType.development,
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
appSetupCompleted: true,
project: {
id: "project-id",
name: "Project Name",
},
});
expect(getEnvironment).toHaveBeenCalledWith("env-id");
});
test("returns null when an API key has neither organization read nor exactly one environment", async () => {
const response = await buildApiKeyMeResponse(baseAuthentication);
expect(response).toBeNull();
expect(getEnvironment).not.toHaveBeenCalled();
});
test("returns null when the single permitted environment no longer exists", async () => {
vi.mocked(getEnvironment).mockResolvedValue(null);
const response = await buildApiKeyMeResponse({
...baseAuthentication,
environmentPermissions: [environmentPermission("env-id", "read")],
});
expect(response).toBeNull();
expect(getEnvironment).toHaveBeenCalledWith("env-id");
});
});
@@ -0,0 +1,49 @@
import { OrganizationAccessType } from "@formbricks/types/api-key";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import { getEnvironment } from "@/lib/environment/service";
import { hasOrganizationAccess } from "@/modules/organization/settings/api-keys/lib/utils";
const buildApiKeyMetadataResponse = (authentication: TAuthenticationApiKey) => ({
environmentPermissions: authentication.environmentPermissions.map((permission) => ({
environmentId: permission.environmentId,
environmentType: permission.environmentType,
permissions: permission.permission,
projectId: permission.projectId,
projectName: permission.projectName,
})),
organizationId: authentication.organizationId,
organizationAccess: authentication.organizationAccess,
});
export const buildApiKeyMeResponse = async (
authentication: TAuthenticationApiKey
): Promise<Response | null> => {
const environmentPermissionCount = authentication.environmentPermissions.length;
if (environmentPermissionCount !== 1) {
if (hasOrganizationAccess(authentication, OrganizationAccessType.Read)) {
return Response.json(buildApiKeyMetadataResponse(authentication));
}
return null;
}
const permission = authentication.environmentPermissions[0];
const environment = await getEnvironment(permission.environmentId);
if (!environment) {
return null;
}
return Response.json({
id: environment.id,
type: environment.type,
createdAt: environment.createdAt,
updatedAt: environment.updatedAt,
appSetupCompleted: environment.appSetupCompleted,
project: {
id: permission.projectId,
name: permission.projectName,
},
});
};
@@ -0,0 +1,128 @@
import { EnvironmentType } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { authenticateApiKey } from "@/app/api/v1/auth";
import { getEnvironment } from "@/lib/environment/service";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { GET } from "./route";
const { mockHeaders, mockPrisma } = vi.hoisted(() => ({
mockHeaders: vi.fn(),
mockPrisma: {
user: {
findUnique: vi.fn(),
},
},
}));
vi.mock("next/headers", () => ({
headers: mockHeaders,
}));
vi.mock("@formbricks/database", () => ({
prisma: mockPrisma,
}));
vi.mock("@/app/api/v1/auth", () => ({
authenticateApiKey: vi.fn(),
}));
vi.mock("@/app/api/v1/management/me/lib/utils", () => ({
getSessionUser: vi.fn(),
}));
vi.mock("@/lib/environment/service", () => ({
getEnvironment: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
}));
describe("GET /api/v1/management/me", () => {
beforeEach(() => {
vi.clearAllMocks();
mockHeaders.mockResolvedValue(new Headers({ "x-api-key": "read-only-org-api-key" }));
});
test("accepts a read-only organization API key without environment permissions", async () => {
vi.mocked(authenticateApiKey).mockResolvedValue({
type: "apiKey",
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
environmentPermissions: [],
});
const response = await GET();
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
environmentPermissions: [],
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
});
expect(authenticateApiKey).toHaveBeenCalledWith("read-only-org-api-key", {
allowOrganizationOnlyApiKey: true,
});
expect(applyRateLimit).toHaveBeenCalledWith(expect.any(Object), "api-key-id");
});
test("preserves the legacy environment response for single-environment API keys", async () => {
const createdAt = new Date("2026-01-01T00:00:00.000Z");
const updatedAt = new Date("2026-01-02T00:00:00.000Z");
vi.mocked(authenticateApiKey).mockResolvedValue({
type: "apiKey",
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: false,
write: false,
},
},
environmentPermissions: [
{
environmentId: "env-id",
environmentType: EnvironmentType.development,
permission: "read",
projectId: "project-id",
projectName: "Project Name",
},
],
});
vi.mocked(getEnvironment).mockResolvedValue({
id: "env-id",
type: EnvironmentType.development,
createdAt,
updatedAt,
projectId: "project-id",
appSetupCompleted: true,
});
const response = await GET();
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
id: "env-id",
type: EnvironmentType.development,
createdAt: createdAt.toISOString(),
updatedAt: updatedAt.toISOString(),
appSetupCompleted: true,
project: {
id: "project-id",
name: "Project Name",
},
});
});
});
+11 -136
View File
@@ -1,103 +1,13 @@
import { headers } from "next/headers";
import { prisma } from "@formbricks/database";
import { authenticateApiKey } from "@/app/api/v1/auth";
import { buildApiKeyMeResponse } from "@/app/api/v1/management/me/lib/api-key-response";
import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
import { responses } from "@/app/lib/api/response";
import { CONTROL_HASH } from "@/lib/constants";
import { hashSha256, parseApiKeyV2, verifySecret } from "@/lib/crypto";
import { publicUserSelect } from "@/lib/user/public-user";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
const ALLOWED_PERMISSIONS = ["manage", "read", "write"] as const;
const apiKeySelect = {
id: true,
organizationId: true,
lastUsedAt: true,
apiKeyEnvironments: {
select: {
environment: {
select: {
id: true,
type: true,
createdAt: true,
updatedAt: true,
projectId: true,
appSetupCompleted: true,
project: {
select: {
id: true,
name: true,
},
},
},
},
permission: true,
},
},
hashedKey: true,
};
type ApiKeyData = {
id: string;
hashedKey: string;
organizationId: string;
lastUsedAt: Date | null;
apiKeyEnvironments: Array<{
permission: string;
environment: {
id: string;
type: string;
createdAt: Date;
updatedAt: Date;
projectId: string;
appSetupCompleted: boolean;
project: {
id: string;
name: string;
};
};
}>;
};
const validateApiKey = async (apiKey: string): Promise<ApiKeyData | null> => {
const v2Parsed = parseApiKeyV2(apiKey);
if (v2Parsed) {
return validateV2ApiKey(v2Parsed);
}
return validateLegacyApiKey(apiKey);
};
const validateV2ApiKey = async (v2Parsed: { secret: string }): Promise<ApiKeyData | null> => {
// Step 1: Fast SHA-256 lookup by indexed lookupHash
const lookupHash = hashSha256(v2Parsed.secret);
const apiKeyData = await prisma.apiKey.findUnique({
where: { lookupHash },
select: apiKeySelect,
});
// Step 2: Security verification with bcrypt
// Always perform bcrypt verification to prevent timing attacks
// Use a control hash when API key doesn't exist to maintain constant timing
const hashToVerify = apiKeyData?.hashedKey || CONTROL_HASH;
const isValid = await verifySecret(v2Parsed.secret, hashToVerify);
if (!apiKeyData || !isValid) return null;
return apiKeyData;
};
const validateLegacyApiKey = async (apiKey: string): Promise<ApiKeyData | null> => {
const hashedKey = hashSha256(apiKey);
const result = await prisma.apiKey.findFirst({
where: { hashedKey },
select: apiKeySelect,
});
return result;
};
const checkRateLimit = async (userId: string) => {
try {
await applyRateLimit(rateLimitConfigs.api.v1, userId);
@@ -109,59 +19,23 @@ const checkRateLimit = async (userId: string) => {
return null;
};
const updateApiKeyUsage = async (apiKeyId: string) => {
await prisma.apiKey.update({
where: { id: apiKeyId },
data: { lastUsedAt: new Date() },
});
};
const buildEnvironmentResponse = (apiKeyData: ApiKeyData) => {
const env = apiKeyData.apiKeyEnvironments[0].environment;
return Response.json({
id: env.id,
type: env.type,
createdAt: env.createdAt,
updatedAt: env.updatedAt,
appSetupCompleted: env.appSetupCompleted,
project: {
id: env.projectId,
name: env.project.name,
},
});
};
const isValidApiKeyEnvironment = (apiKeyData: ApiKeyData): boolean => {
return (
apiKeyData.apiKeyEnvironments.length === 1 &&
ALLOWED_PERMISSIONS.includes(
apiKeyData.apiKeyEnvironments[0].permission as (typeof ALLOWED_PERMISSIONS)[number]
)
);
};
const handleApiKeyAuthentication = async (apiKey: string) => {
const apiKeyData = await validateApiKey(apiKey);
const authentication = await authenticateApiKey(apiKey, { allowOrganizationOnlyApiKey: true });
if (!apiKeyData) {
if (!authentication) {
return responses.notAuthenticatedResponse();
}
if (!apiKeyData.lastUsedAt || apiKeyData.lastUsedAt <= new Date(Date.now() - 1000 * 30)) {
// Fire-and-forget: update lastUsedAt in the background without blocking the response
updateApiKeyUsage(apiKeyData.id).catch((error) => {
console.error("Failed to update API key usage:", error);
});
}
const rateLimitError = await checkRateLimit(apiKeyData.id);
const rateLimitError = await checkRateLimit(authentication.apiKeyId);
if (rateLimitError) return rateLimitError;
if (!isValidApiKeyEnvironment(apiKeyData)) {
const apiKeyMeResponse = await buildApiKeyMeResponse(authentication);
if (!apiKeyMeResponse) {
return responses.badRequestResponse("You can't use this method with this API key");
}
return buildEnvironmentResponse(apiKeyData);
return apiKeyMeResponse;
};
const handleSessionAuthentication = async () => {
@@ -176,6 +50,7 @@ const handleSessionAuthentication = async () => {
const user = await prisma.user.findUnique({
where: { id: sessionUser.id },
select: publicUserSelect,
});
return Response.json(user);
@@ -0,0 +1,111 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import { getEnvironmentIdsByOrganizationId } from "@/lib/environment/organization";
import { getReadableEnvironmentIds } from "./access";
vi.mock("@/lib/environment/organization", () => ({
getEnvironmentIdsByOrganizationId: vi.fn(),
}));
const baseAuthentication = {
type: "apiKey" as const,
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: false,
write: false,
},
},
environmentPermissions: [],
};
const environmentPermission = (
environmentId: string,
permission: "read" | "write" | "manage"
): TAuthenticationApiKey["environmentPermissions"][number] => ({
environmentId,
permission,
environmentType: "development",
projectId: `project-${environmentId}`,
projectName: `Project ${environmentId}`,
});
describe("getReadableEnvironmentIds", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns all organization environments when API key has organization read access", async () => {
vi.mocked(getEnvironmentIdsByOrganizationId).mockResolvedValue(["env-1", "env-2"]);
const result = await getReadableEnvironmentIds({
...baseAuthentication,
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
});
expect(result).toEqual(["env-1", "env-2"]);
expect(getEnvironmentIdsByOrganizationId).toHaveBeenCalledWith("org-id");
});
test("returns an empty list when an organization-read API key belongs to an organization without environments", async () => {
vi.mocked(getEnvironmentIdsByOrganizationId).mockResolvedValue([]);
const result = await getReadableEnvironmentIds({
...baseAuthentication,
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
});
expect(result).toEqual([]);
expect(getEnvironmentIdsByOrganizationId).toHaveBeenCalledWith("org-id");
});
test("returns all organization environments when API key has organization write access", async () => {
vi.mocked(getEnvironmentIdsByOrganizationId).mockResolvedValue(["env-1"]);
const result = await getReadableEnvironmentIds({
...baseAuthentication,
organizationAccess: {
accessControl: {
read: false,
write: true,
},
},
});
expect(result).toEqual(["env-1"]);
expect(getEnvironmentIdsByOrganizationId).toHaveBeenCalledWith("org-id");
});
test("returns de-duplicated environment permissions that allow GET without organization access", async () => {
const result = await getReadableEnvironmentIds({
...baseAuthentication,
environmentPermissions: [
environmentPermission("env-1", "read"),
environmentPermission("env-2", "write"),
environmentPermission("env-3", "manage"),
environmentPermission("env-1", "read"),
],
});
expect(result).toEqual(["env-1", "env-2", "env-3"]);
expect(getEnvironmentIdsByOrganizationId).not.toHaveBeenCalled();
});
test("returns null when the API key has no readable access", async () => {
const result = await getReadableEnvironmentIds(baseAuthentication);
expect(result).toBeNull();
expect(getEnvironmentIdsByOrganizationId).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,22 @@
import { OrganizationAccessType } from "@formbricks/types/api-key";
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
import { getEnvironmentIdsByOrganizationId } from "@/lib/environment/organization";
import { hasOrganizationAccess, hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
export const getReadableEnvironmentIds = async (
authentication: TAuthenticationApiKey
): Promise<string[] | null> => {
if (hasOrganizationAccess(authentication, OrganizationAccessType.Read)) {
return getEnvironmentIdsByOrganizationId(authentication.organizationId);
}
const environmentIds = authentication.environmentPermissions
.filter((permission) =>
hasPermission(authentication.environmentPermissions, permission.environmentId, "GET")
)
.map((permission) => permission.environmentId);
const readableEnvironmentIds = Array.from(new Set(environmentIds));
return readableEnvironmentIds.length > 0 ? readableEnvironmentIds : null;
};
@@ -0,0 +1,149 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getEnvironmentIdsByOrganizationId } from "@/lib/environment/organization";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import { getSurveys } from "./lib/surveys";
import { GET } from "./route";
vi.mock("@/modules/organization/settings/api-keys/lib/api-key", () => ({
getApiKeyWithPermissions: vi.fn(),
}));
vi.mock("@/lib/environment/organization", () => ({
getEnvironmentIdsByOrganizationId: vi.fn(),
}));
vi.mock("./lib/surveys", () => ({
getSurveys: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/app/lib/api/api-error-reporter", () => ({
reportApiError: vi.fn(),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return { ...actual, AUDIT_LOG_ENABLED: false };
});
describe("GET /api/v1/management/surveys", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getEnvironmentIdsByOrganizationId).mockResolvedValue(["env-1", "env-2"]);
vi.mocked(getSurveys).mockResolvedValue([]);
});
test("accepts a read-only organization API key without environment permissions", async () => {
vi.mocked(getApiKeyWithPermissions).mockResolvedValue({
id: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
apiKeyEnvironments: [],
} as any);
const request = new NextRequest("http://localhost/api/v1/management/surveys", {
headers: { "x-api-key": "read-only-org-api-key" },
});
const response = await GET(request, {} as any);
expect(response.status).toBe(200);
expect(getEnvironmentIdsByOrganizationId).toHaveBeenCalledWith("org-id");
expect(getSurveys).toHaveBeenCalledWith(["env-1", "env-2"], undefined, undefined);
});
test("returns an empty survey list for an organization-read API key when the organization has no environments", async () => {
vi.mocked(getEnvironmentIdsByOrganizationId).mockResolvedValue([]);
vi.mocked(getApiKeyWithPermissions).mockResolvedValue({
id: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: true,
write: false,
},
},
apiKeyEnvironments: [],
} as any);
const request = new NextRequest("http://localhost/api/v1/management/surveys", {
headers: { "x-api-key": "read-only-org-api-key" },
});
const response = await GET(request, {} as any);
expect(response.status).toBe(200);
expect(getEnvironmentIdsByOrganizationId).toHaveBeenCalledWith("org-id");
expect(getSurveys).toHaveBeenCalledWith([], undefined, undefined);
});
test("rejects an organization-only API key without readable organization access", async () => {
vi.mocked(getApiKeyWithPermissions).mockResolvedValue({
id: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: false,
write: false,
},
},
apiKeyEnvironments: [],
} as any);
const request = new NextRequest("http://localhost/api/v1/management/surveys", {
headers: { "x-api-key": "no-access-org-api-key" },
});
const response = await GET(request, {} as any);
expect(response.status).toBe(401);
expect(await response.json()).toMatchObject({ code: "unauthorized" });
expect(getEnvironmentIdsByOrganizationId).not.toHaveBeenCalled();
expect(getSurveys).not.toHaveBeenCalled();
});
test("uses explicit readable environment permissions without organization read access", async () => {
vi.mocked(getApiKeyWithPermissions).mockResolvedValue({
id: "api-key-id",
organizationId: "org-id",
organizationAccess: {
accessControl: {
read: false,
write: false,
},
},
apiKeyEnvironments: [
{
environmentId: "env-1",
permission: "read",
environment: {
id: "env-1",
type: "development",
projectId: "project-1",
project: { id: "project-1", name: "Project 1" },
},
},
],
} as any);
const request = new NextRequest("http://localhost/api/v1/management/surveys?limit=10&offset=5", {
headers: { "x-api-key": "environment-read-api-key" },
});
const response = await GET(request, {} as any);
expect(response.status).toBe(200);
expect(getEnvironmentIdsByOrganizationId).not.toHaveBeenCalled();
expect(getSurveys).toHaveBeenCalledWith(["env-1"], 10, 5);
});
});
@@ -14,9 +14,11 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { createSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { getReadableEnvironmentIds } from "./lib/access";
import { getSurveys } from "./lib/surveys";
export const GET = withV1ApiWrapper({
allowOrganizationOnlyApiKey: true,
handler: async ({ req, authentication }) => {
if (!authentication || !("apiKeyId" in authentication)) {
return { response: responses.notAuthenticatedResponse() };
@@ -27,9 +29,10 @@ export const GET = withV1ApiWrapper({
const limit = searchParams.has("limit") ? Number(searchParams.get("limit")) : undefined;
const offset = searchParams.has("offset") ? Number(searchParams.get("offset")) : undefined;
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const environmentIds = await getReadableEnvironmentIds(authentication);
if (!environmentIds) {
return { response: responses.unauthorizedResponse() };
}
const surveys = await getSurveys(environmentIds, limit, offset);
@@ -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" }
@@ -587,6 +587,56 @@ describe("withV1ApiWrapper", () => {
});
expect(mockedQueueAuditEvent).not.toHaveBeenCalled();
});
test("does not allow organization-only API keys by default", async () => {
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const handler = vi.fn();
const req = createMockRequest({ url: "https://api.test/api/v1/management/action-classes" });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const response = await wrapped(req, undefined);
expect(response.status).toBe(401);
expect(handler).not.toHaveBeenCalled();
expect(authenticateRequest).toHaveBeenCalledWith(req, { allowOrganizationOnlyApiKey: false });
});
test("allows organization-only API keys when the route opts in", async () => {
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
});
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler, allowOrganizationOnlyApiKey: true });
const response = await wrapped(req, undefined);
expect(response.status).toBe(200);
expect(handler).toHaveBeenCalled();
expect(authenticateRequest).toHaveBeenCalledWith(req, { allowOrganizationOnlyApiKey: true });
});
});
describe("buildAuditLogBaseObject", () => {
+18 -5
View File
@@ -46,6 +46,11 @@ export interface TWithV1ApiWrapperParams<
* the legacy JSON 401. Use this to return a custom response (e.g. RFC 9457 problem+json for V3).
*/
unauthenticatedResponse?: (req: NextRequest) => Response;
/**
* Most v1 management routes are environment-scoped. Enable this only for routes that explicitly
* support organization-only API keys.
*/
allowOrganizationOnlyApiKey?: boolean;
}
enum ApiV1RouteTypeEnum {
@@ -142,16 +147,17 @@ const setupAuditLog = (
*/
const handleAuthentication = async (
authenticationMethod: AuthenticationMethod,
req: NextRequest
req: NextRequest,
allowOrganizationOnlyApiKey = false
): Promise<TApiV1Authentication> => {
switch (authenticationMethod) {
case AuthenticationMethod.ApiKey:
return await authenticateRequest(req);
return await authenticateRequest(req, { allowOrganizationOnlyApiKey });
case AuthenticationMethod.Session:
return await getServerSession(authOptions);
case AuthenticationMethod.Both: {
const session = await getServerSession(authOptions);
return session ?? (await authenticateRequest(req));
return session ?? (await authenticateRequest(req, { allowOrganizationOnlyApiKey }));
}
case AuthenticationMethod.None:
return null;
@@ -251,7 +257,14 @@ const getRouteType = (
export const withV1ApiWrapper = <TResult extends { response: Response; error?: unknown }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const { handler, action, targetType, customRateLimitConfig, unauthenticatedResponse } = params;
const {
handler,
action,
targetType,
customRateLimitConfig,
unauthenticatedResponse,
allowOrganizationOnlyApiKey,
} = params;
return async (req: NextRequest, props: TProps): Promise<Response> => {
// === Audit Log Setup ===
const saveAuditLog = action && targetType;
@@ -270,7 +283,7 @@ export const withV1ApiWrapper = <TResult extends { response: Response; error?: u
}
// === Authentication ===
const authentication = await handleAuthentication(authenticationMethod, req);
const authentication = await handleAuthentication(authenticationMethod, req, allowOrganizationOnlyApiKey);
if (!authentication && routeType !== ApiV1RouteTypeEnum.Client) {
if (unauthenticatedResponse) {
@@ -0,0 +1,57 @@
"use client";
import posthog from "posthog-js";
import { useEffect, useRef } from "react";
interface PostHogGroupIdentifyProps {
organizationId: string;
organizationName: string;
workspaceId: string;
workspaceName: string;
}
export const PostHogGroupIdentify = ({
organizationId,
organizationName,
workspaceId,
workspaceName,
}: PostHogGroupIdentifyProps) => {
const cancelledRef = useRef(false);
useEffect(() => {
cancelledRef.current = false;
const applyGroups = () => {
posthog.group("organization", organizationId, { name: organizationName });
posthog.group("workspace", workspaceId, { name: workspaceName });
};
if (posthog.__loaded) {
applyGroups();
return;
}
// PostHogIdentify (in app layout) initialises posthog from a sibling
// useEffect; effect order isn't guaranteed, so poll briefly until loaded.
const intervalId = setInterval(() => {
if (cancelledRef.current) return;
if (posthog.__loaded) {
applyGroups();
clearInterval(intervalId);
}
}, 50);
const timeoutId = setTimeout(() => {
cancelledRef.current = true;
clearInterval(intervalId);
}, 5000);
return () => {
cancelledRef.current = true;
clearInterval(intervalId);
clearTimeout(timeoutId);
};
}, [organizationId, organizationName, workspaceId, workspaceName]);
return null;
};
@@ -4,10 +4,10 @@ import { z } from "zod";
import { logger } from "@formbricks/logger";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { gethasNoOrganizations } from "@/lib/instance/service";
import { getHasNoOrganizations } from "@/lib/instance/service";
import { createMembership } from "@/lib/membership/service";
import { createOrganization } from "@/lib/organization/service";
import { capturePostHogEvent } from "@/lib/posthog";
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { ensureCloudStripeSetupForOrganization } from "@/modules/ee/billing/lib/organization-billing";
@@ -21,7 +21,7 @@ export const createOrganizationAction = authenticatedActionClient
.inputSchema(ZCreateOrganizationAction)
.action(
withAuditLogging("created", "organization", async ({ ctx, parsedInput }) => {
const hasNoOrganizations = await gethasNoOrganizations();
const hasNoOrganizations = await getHasNoOrganizations();
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!hasNoOrganizations && !isMultiOrgEnabled) {
@@ -50,10 +50,17 @@ export const createOrganizationAction = authenticatedActionClient
ctx.auditLoggingCtx.organizationId = newOrganization.id;
ctx.auditLoggingCtx.newObject = newOrganization;
capturePostHogEvent(ctx.user.id, "organization_created", {
organization_id: newOrganization.id,
is_first_org: hasNoOrganizations,
});
groupIdentifyPostHog("organization", newOrganization.id, { name: newOrganization.name });
capturePostHogEvent(
ctx.user.id,
"organization_created",
{
organization_id: newOrganization.id,
is_first_org: hasNoOrganizations,
},
{ organizationId: newOrganization.id }
);
return newOrganization;
})
+28
View File
@@ -0,0 +1,28 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { getHasNoOrganizations, getIsFreshInstance } from "@/lib/instance/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
const Page = async () => {
const [session, isFreshInstance, hasNoOrganizations] = await Promise.all([
getServerSession(authOptions),
getIsFreshInstance(),
getHasNoOrganizations(),
]);
if (isFreshInstance) {
return redirect("/setup/intro");
}
if (hasNoOrganizations) {
if (session) {
return redirect("/setup/organization/create");
}
return redirect("/auth/login?callbackUrl=%2Fsetup%2Forganization%2Fcreate");
}
return redirect("/");
};
export default Page;
+8 -4
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
@@ -1189,6 +1192,7 @@ checksums:
environments/settings/profile/update_personal_info: 5806f9bae0248a604cf85a2d8790a606
environments/settings/profile/warning_cannot_delete_account: 07c25c3829149cd7171e7ad88229deac
environments/settings/profile/warning_cannot_undo: dd1b2a59ff244b362d1d0d4eb1dbf7c6
environments/settings/profile/wrong_password: e3523f78b302d11b33af6cc40d8df9da
environments/settings/teams/add_members_description: 96e1e7125a0dfeaecc2c238eda3a216f
environments/settings/teams/add_workspaces_description: f0f0cdd4d1032fbcb83d34a780bdfa52
environments/settings/teams/all_members_added: 0541be1777b5c838f2e039035488506c
@@ -1478,7 +1482,7 @@ checksums:
environments/surveys/edit/hide_question_settings: 99127cd016db2f7fc80333b36473c0ef
environments/surveys/edit/hostname: 9bdaa7692869999df51bb60d58d9ef62
environments/surveys/edit/if_you_need_more_please: a7d208c283caf6b93800b809fca80768
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 31c18a8c7c578db2ba49eed663d1739f
environments/surveys/edit/if_you_really_want_that_answer_ask_until_you_get_it: 5abd8b702f9fb0e3815c3413d6f8aef6
environments/surveys/edit/ignore_global_waiting_time: e08db543ace4935625e0961cc6e60489
environments/surveys/edit/ignore_global_waiting_time_description: 37d173a4d537622de40677389238d859
environments/surveys/edit/image: 048ba7a239de0fbd883ade8558415830
+151 -2
View File
@@ -1,12 +1,16 @@
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TActionClass } from "@formbricks/types/action-classes";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import {
createActionClass,
deleteActionClass,
getActionClass,
getActionClassByEnvironmentIdAndName,
getActionClasses,
updateActionClass,
} from "./service";
vi.mock("@formbricks/database", () => ({
@@ -16,6 +20,8 @@ vi.mock("@formbricks/database", () => ({
findFirst: vi.fn(),
findUnique: vi.fn(),
delete: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
},
}));
@@ -178,4 +184,147 @@ describe("ActionClass Service", () => {
await expect(deleteActionClass("id4")).rejects.toThrow("unknown");
});
});
describe("createActionClass", () => {
const codeInput: TActionClassInput = {
name: "Code Action",
description: "desc",
type: "code",
key: "code-action-key",
environmentId: "env-create",
};
const buildPrismaUniqueError = (target: string[]) =>
Object.assign(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "test",
}),
{ meta: { target } }
);
test("should create and return the action class", async () => {
const created: TActionClass = {
id: "id-create",
createdAt: new Date(),
updatedAt: new Date(),
name: codeInput.name,
description: codeInput.description ?? null,
type: "code",
key: codeInput.type === "code" ? codeInput.key : null,
noCodeConfig: null,
environmentId: codeInput.environmentId,
};
vi.mocked(prisma.actionClass.create).mockResolvedValue(created as never);
const result = await createActionClass(codeInput.environmentId, codeInput);
expect(result).toEqual(created);
});
test("should throw UniqueConstraintError on P2002 with target field", async () => {
vi.mocked(prisma.actionClass.create).mockRejectedValue(buildPrismaUniqueError(["name"]));
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
UniqueConstraintError
);
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
`Action with name ${codeInput.name} already exists`
);
});
test("should throw UniqueConstraintError on P2002 even when target is missing", async () => {
vi.mocked(prisma.actionClass.create).mockRejectedValue(
Object.assign(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "test",
}),
{ meta: undefined }
)
);
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
UniqueConstraintError
);
});
test("should throw DatabaseError for non-P2002 errors", async () => {
vi.mocked(prisma.actionClass.create).mockRejectedValue(new Error("boom"));
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(DatabaseError);
await expect(createActionClass(codeInput.environmentId, codeInput)).rejects.toThrow(
`Database error when creating an action for environment ${codeInput.environmentId}`
);
});
});
describe("updateActionClass", () => {
const updateInput: Partial<TActionClassInput> = {
name: "Renamed Action",
description: "updated desc",
type: "code",
key: "renamed-key",
environmentId: "env-update",
};
const buildPrismaUniqueError = (target: string[]) =>
Object.assign(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "test",
}),
{ meta: { target } }
);
test("should update and return the action class", async () => {
const updated = {
id: "id-update",
createdAt: new Date(),
updatedAt: new Date(),
name: updateInput.name,
description: updateInput.description ?? null,
type: "code" as const,
key: "renamed-key",
noCodeConfig: null,
environmentId: updateInput.environmentId,
surveyTriggers: [],
};
vi.mocked(prisma.actionClass.update).mockResolvedValue(updated as never);
const result = await updateActionClass(updateInput.environmentId!, "id-update", updateInput);
expect(result).toEqual(updated);
});
test("should throw UniqueConstraintError on P2002 with target field", async () => {
vi.mocked(prisma.actionClass.update).mockRejectedValue(buildPrismaUniqueError(["name"]));
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
UniqueConstraintError
);
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
`Action with name ${updateInput.name} already exists`
);
});
test("should throw DatabaseError for other PrismaClientKnownRequestError codes", async () => {
vi.mocked(prisma.actionClass.update).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Record not found", {
code: "P2025",
clientVersion: "test",
})
);
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
DatabaseError
);
});
test("should rethrow unknown errors", async () => {
vi.mocked(prisma.actionClass.update).mockRejectedValue(new Error("boom"));
await expect(updateActionClass(updateInput.environmentId!, "id-update", updateInput)).rejects.toThrow(
"boom"
);
});
});
});
+3 -3
View File
@@ -7,7 +7,7 @@ import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { ITEMS_PER_PAGE } from "../constants";
import { validateInputs } from "../utils/validate";
@@ -135,7 +135,7 @@ export const createActionClass = async (
error.code === PrismaErrorType.UniqueConstraintViolation
) {
const targetField = (error.meta?.target as string[] | undefined)?.[0];
throw new DatabaseError(
throw new UniqueConstraintError(
`Action with ${targetField} ${targetField ? (actionClass as Record<string, unknown>)[targetField] : ""} already exists`
);
}
@@ -185,7 +185,7 @@ export const updateActionClass = async (
error.code === PrismaErrorType.UniqueConstraintViolation
) {
const targetField = (error.meta?.target as string[] | undefined)?.[0];
throw new DatabaseError(
throw new UniqueConstraintError(
`Action with ${targetField} ${targetField ? (inputActionClass as Record<string, unknown>)[targetField] : ""} already exists`
);
}
+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
+2
View File
@@ -26,6 +26,7 @@ export const TERMS_URL = env.TERMS_URL;
export const IMPRINT_URL = env.IMPRINT_URL;
export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
export const DISABLE_ACCOUNT_DELETION_SSO_REAUTH = env.DISABLE_ACCOUNT_DELETION_SSO_REAUTH === "1";
export const DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS = env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS === "1";
export const DEBUG_SHOW_RESET_LINK = !IS_PRODUCTION && env.DEBUG_SHOW_RESET_LINK === "1";
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
@@ -33,6 +34,7 @@ export const PASSWORD_RESET_TOKEN_LIFETIME_MINUTES = env.PASSWORD_RESET_TOKEN_LI
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
export const GOOGLE_OAUTH_ENABLED = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET);
export const GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED = env.GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED === "1";
export const GITHUB_OAUTH_ENABLED = !!(env.GITHUB_ID && env.GITHUB_SECRET);
export const AZURE_OAUTH_ENABLED = !!(env.AZUREAD_CLIENT_ID && env.AZUREAD_CLIENT_SECRET);
export const OIDC_OAUTH_ENABLED = !!(env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET && env.OIDC_ISSUER);
+4
View File
@@ -123,6 +123,7 @@ const parsedEnv = createEnv({
BREVO_API_KEY: z.string().optional(),
BREVO_LIST_ID: z.string().optional(),
DATABASE_URL: z.url(),
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: z.enum(["1", "0"]).optional(),
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
DEBUG: z.enum(["1", "0"]).optional(),
DEBUG_SHOW_RESET_LINK: z.enum(["1", "0"]).optional(),
@@ -136,6 +137,7 @@ const parsedEnv = createEnv({
ENVIRONMENT: z.enum(["production", "staging"]).prefault("production"),
GITHUB_ID: z.string().optional(),
GITHUB_SECRET: z.string().optional(),
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED: z.enum(["1", "0"]).optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
AI_GCP_PROJECT: z.string().optional(),
@@ -267,6 +269,7 @@ const parsedEnv = createEnv({
BREVO_LIST_ID: process.env.BREVO_LIST_ID,
CRON_SECRET: process.env.CRON_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: process.env.DISABLE_ACCOUNT_DELETION_SSO_REAUTH,
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: process.env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS,
DEBUG: process.env.DEBUG,
DEBUG_SHOW_RESET_LINK: process.env.DEBUG_SHOW_RESET_LINK,
@@ -280,6 +283,7 @@ const parsedEnv = createEnv({
ENVIRONMENT: process.env.ENVIRONMENT,
GITHUB_ID: process.env.GITHUB_ID,
GITHUB_SECRET: process.env.GITHUB_SECRET,
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED: process.env.GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
AI_GCP_PROJECT: process.env.AI_GCP_PROJECT,
@@ -0,0 +1,68 @@
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { getEnvironmentIdsByOrganizationId } from "./organization";
vi.mock("@formbricks/database", () => ({
prisma: {
environment: {
findMany: vi.fn(),
},
},
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
describe("getEnvironmentIdsByOrganizationId", () => {
afterEach(() => {
vi.clearAllMocks();
});
test("returns environment IDs for all projects in an organization", async () => {
vi.mocked(prisma.environment.findMany).mockResolvedValue([{ id: "env-1" }, { id: "env-2" }] as any);
const result = await getEnvironmentIdsByOrganizationId("clh6pzwx90000e9ogjr0mf7so");
expect(result).toEqual(["env-1", "env-2"]);
expect(prisma.environment.findMany).toHaveBeenCalledWith({
where: {
project: {
organizationId: "clh6pzwx90000e9ogjr0mf7so",
},
},
select: {
id: true,
},
});
});
test("returns an empty list when the organization has no environments", async () => {
vi.mocked(prisma.environment.findMany).mockResolvedValue([]);
const result = await getEnvironmentIdsByOrganizationId("clh6pzwx90000e9ogjr0mf7so");
expect(result).toEqual([]);
});
test("throws DatabaseError for known Prisma errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.environment.findMany).mockRejectedValue(prismaError);
await expect(getEnvironmentIdsByOrganizationId("clh6pzwx90000e9ogjr0mf7so")).rejects.toThrow(
DatabaseError
);
});
test("rethrows unknown errors", async () => {
const error = new Error("boom");
vi.mocked(prisma.environment.findMany).mockRejectedValue(error);
await expect(getEnvironmentIdsByOrganizationId("clh6pzwx90000e9ogjr0mf7so")).rejects.toThrow(error);
});
});
+34
View File
@@ -0,0 +1,34 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
export const getEnvironmentIdsByOrganizationId = reactCache(
async (organizationId: string): Promise<string[]> => {
validateInputs([organizationId, ZId]);
try {
const environments = await prisma.environment.findMany({
where: {
project: {
organizationId,
},
},
select: {
id: true,
},
});
return environments.map((environment) => environment.id);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);
+1 -1
View File
@@ -19,7 +19,7 @@ export const getIsFreshInstance = reactCache(async (): Promise<boolean> => {
});
// Function to check if there are any organizations in the database
export const gethasNoOrganizations = reactCache(async (): Promise<boolean> => {
export const getHasNoOrganizations = reactCache(async (): Promise<boolean> => {
try {
const organizationCount = await prisma.organization.count();
return organizationCount === 0;
+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);
+199
View File
@@ -3,14 +3,18 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import * as crypto from "@/lib/crypto";
import {
createAccountDeletionSsoReauthIntent,
createEmailChangeToken,
createEmailToken,
createInviteToken,
createSsoRelinkIntent,
createToken,
createTokenForLinkSurvey,
getEmailFromEmailToken,
verifyAccountDeletionSsoReauthIntent,
verifyEmailChangeToken,
verifyInviteToken,
verifySsoRelinkIntent,
verifyToken,
verifyTokenForLinkSurvey,
} from "./jwt";
@@ -380,6 +384,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 +419,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 +431,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 +1011,197 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
expect(results.every((result: any) => result.id === mockUser.id)).toBe(true); // Returns decrypted user ID
});
});
describe("SSO recovery support", () => {
test("creates verification tokens that preserve the recovery purpose", async () => {
const token = createToken(mockUser.id, { purpose: "sso_recovery", expiresIn: "15m" });
await expect(verifyToken(token)).resolves.toEqual(
expect.objectContaining({
id: mockUser.id,
email: mockUser.email,
purpose: "sso_recovery",
})
);
});
test("defaults legacy verification tokens to email_verification when purpose is missing", async () => {
const legacyToken = jwt.sign({ id: `encrypted_${mockUser.id}` }, TEST_NEXTAUTH_SECRET);
await expect(verifyToken(legacyToken)).resolves.toEqual(
expect.objectContaining({
id: mockUser.id,
email: mockUser.email,
purpose: "email_verification",
})
);
});
test("round-trips SSO relink intents without losing callback state", () => {
const intent = createSsoRelinkIntent({
userId: mockUser.id,
email: mockUser.email,
provider: "google",
providerAccountId: "provider-123",
callbackUrl: "http://localhost:3000/invite?token=invite-token",
});
expect(verifySsoRelinkIntent(intent)).toEqual({
userId: mockUser.id,
email: mockUser.email,
provider: "google",
providerAccountId: "provider-123",
callbackUrl: "http://localhost:3000/invite?token=invite-token",
});
});
test("rejects expired SSO relink intents", () => {
const expiredIntent = jwt.sign(
{
userId: crypto.symmetricEncrypt(mockUser.id, TEST_ENCRYPTION_KEY),
email: crypto.symmetricEncrypt(mockUser.email, TEST_ENCRYPTION_KEY),
provider: "google",
providerAccountId: crypto.symmetricEncrypt("provider-123", TEST_ENCRYPTION_KEY),
callbackUrl: crypto.symmetricEncrypt("http://localhost:3000", TEST_ENCRYPTION_KEY),
exp: Math.floor(Date.now() / 1000) - 3600,
},
TEST_NEXTAUTH_SECRET
);
expect(() => verifySsoRelinkIntent(expiredIntent)).toThrow();
});
test("rejects tampered SSO relink intents", () => {
const intent = createSsoRelinkIntent({
userId: mockUser.id,
email: mockUser.email,
provider: "google",
providerAccountId: "provider-123",
callbackUrl: "http://localhost:3000",
});
const tamperedIntent = `${intent.slice(0, -1)}x`;
expect(() => verifySsoRelinkIntent(tamperedIntent)).toThrow();
});
});
describe("account deletion SSO reauthentication intents", () => {
const accountDeletionIntent = {
id: "intent-id",
userId: mockUser.id,
email: mockUser.email,
provider: "google",
providerAccountId: "provider-123",
purpose: "account_deletion_sso_reauth" as const,
returnToUrl: "http://localhost:3000/environments/env-1/settings/profile",
};
test("round-trips encrypted account deletion reauth intents", () => {
const token = createAccountDeletionSsoReauthIntent(accountDeletionIntent);
expect(verifyAccountDeletionSsoReauthIntent(token)).toEqual(accountDeletionIntent);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(accountDeletionIntent.id, TEST_ENCRYPTION_KEY);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(accountDeletionIntent.userId, TEST_ENCRYPTION_KEY);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(accountDeletionIntent.email, TEST_ENCRYPTION_KEY);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(
accountDeletionIntent.providerAccountId,
TEST_ENCRYPTION_KEY
);
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(
accountDeletionIntent.returnToUrl,
TEST_ENCRYPTION_KEY
);
});
test("creates account deletion reauth intents with a ten minute default expiry", () => {
const token = createAccountDeletionSsoReauthIntent(accountDeletionIntent);
const decoded = jwt.decode(token) as any;
expect(decoded.exp - decoded.iat).toBe(10 * 60);
});
test("rejects account deletion reauth intents with the wrong purpose", () => {
const token = jwt.sign(
{
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
userId: crypto.symmetricEncrypt(accountDeletionIntent.userId, TEST_ENCRYPTION_KEY),
email: crypto.symmetricEncrypt(accountDeletionIntent.email, TEST_ENCRYPTION_KEY),
provider: accountDeletionIntent.provider,
providerAccountId: crypto.symmetricEncrypt(
accountDeletionIntent.providerAccountId,
TEST_ENCRYPTION_KEY
),
purpose: "sso_recovery",
returnToUrl: crypto.symmetricEncrypt(accountDeletionIntent.returnToUrl, TEST_ENCRYPTION_KEY),
},
TEST_NEXTAUTH_SECRET
);
expect(() => verifyAccountDeletionSsoReauthIntent(token)).toThrow(
"Token is invalid or missing required fields"
);
});
test("rejects account deletion reauth intents missing required fields", () => {
const token = jwt.sign(
{
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
userId: crypto.symmetricEncrypt(accountDeletionIntent.userId, TEST_ENCRYPTION_KEY),
email: crypto.symmetricEncrypt(accountDeletionIntent.email, TEST_ENCRYPTION_KEY),
provider: accountDeletionIntent.provider,
providerAccountId: crypto.symmetricEncrypt(
accountDeletionIntent.providerAccountId,
TEST_ENCRYPTION_KEY
),
purpose: accountDeletionIntent.purpose,
},
TEST_NEXTAUTH_SECRET
);
expect(() => verifyAccountDeletionSsoReauthIntent(token)).toThrow(
"Token is invalid or missing required fields"
);
});
test("rejects expired account deletion reauth intents", () => {
const expiredToken = jwt.sign(
{
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
userId: crypto.symmetricEncrypt(accountDeletionIntent.userId, TEST_ENCRYPTION_KEY),
email: crypto.symmetricEncrypt(accountDeletionIntent.email, TEST_ENCRYPTION_KEY),
provider: accountDeletionIntent.provider,
providerAccountId: crypto.symmetricEncrypt(
accountDeletionIntent.providerAccountId,
TEST_ENCRYPTION_KEY
),
purpose: accountDeletionIntent.purpose,
returnToUrl: crypto.symmetricEncrypt(accountDeletionIntent.returnToUrl, TEST_ENCRYPTION_KEY),
exp: Math.floor(Date.now() / 1000) - 3600,
},
TEST_NEXTAUTH_SECRET
);
expect(() => verifyAccountDeletionSsoReauthIntent(expiredToken)).toThrow();
});
test("throws when account deletion reauth intent secrets are missing", async () => {
await testMissingSecretsError(createAccountDeletionSsoReauthIntent, [accountDeletionIntent]);
const token = jwt.sign(
{
id: accountDeletionIntent.id,
userId: accountDeletionIntent.userId,
email: accountDeletionIntent.email,
provider: accountDeletionIntent.provider,
providerAccountId: accountDeletionIntent.providerAccountId,
purpose: accountDeletionIntent.purpose,
returnToUrl: accountDeletionIntent.returnToUrl,
},
TEST_NEXTAUTH_SECRET
);
await testMissingSecretsError(verifyAccountDeletionSsoReauthIntent, [token]);
});
});
});
});
+193 -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,49 @@ 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;
};
type TAccountDeletionSsoReauthIntentPayload = {
id: string;
email: string;
provider: string;
providerAccountId: string;
purpose: "account_deletion_sso_reauth";
returnToUrl: string;
userId: string;
};
const DEFAULT_VERIFICATION_TOKEN_PURPOSE: TVerificationTokenPurpose = "email_verification";
const getVerificationTokenPurpose = (purpose: unknown): TVerificationTokenPurpose => {
if (purpose && VERIFICATION_TOKEN_PURPOSES.includes(purpose as TVerificationTokenPurpose)) {
return purpose as TVerificationTokenPurpose;
}
return DEFAULT_VERIFICATION_TOKEN_PURPOSE;
};
export const createToken = (userId: string, options: TVerificationTokenOptions = {}): string => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
@@ -23,7 +65,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 +268,147 @@ 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",
};
const DEFAULT_ACCOUNT_DELETION_SSO_REAUTH_INTENT_OPTIONS: SignOptions = {
expiresIn: "10m",
};
export const createSsoRelinkIntent = (
payload: TSsoRelinkIntentPayload,
options: SignOptions = DEFAULT_SSO_RELINK_INTENT_OPTIONS
): string => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
return jwt.sign(
{
userId: symmetricEncrypt(payload.userId, ENCRYPTION_KEY),
email: symmetricEncrypt(payload.email, ENCRYPTION_KEY),
provider: payload.provider,
providerAccountId: symmetricEncrypt(payload.providerAccountId, ENCRYPTION_KEY),
callbackUrl: symmetricEncrypt(payload.callbackUrl, ENCRYPTION_KEY),
},
NEXTAUTH_SECRET,
options
);
};
export const verifySsoRelinkIntent = (token: string): TSsoRelinkIntentPayload => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
userId: string;
email: string;
provider: string;
providerAccountId: string;
callbackUrl: string;
};
if (
!payload?.userId ||
!payload?.email ||
!payload?.provider ||
!payload?.providerAccountId ||
!payload?.callbackUrl
) {
throw new Error("Token is invalid or missing required fields");
}
return {
userId: decryptWithFallback(payload.userId, ENCRYPTION_KEY),
email: decryptWithFallback(payload.email, ENCRYPTION_KEY),
provider: payload.provider,
providerAccountId: decryptWithFallback(payload.providerAccountId, ENCRYPTION_KEY),
callbackUrl: decryptWithFallback(payload.callbackUrl, ENCRYPTION_KEY),
};
};
export const createAccountDeletionSsoReauthIntent = (
payload: TAccountDeletionSsoReauthIntentPayload,
options: SignOptions = DEFAULT_ACCOUNT_DELETION_SSO_REAUTH_INTENT_OPTIONS
): string => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
return jwt.sign(
{
id: symmetricEncrypt(payload.id, ENCRYPTION_KEY),
userId: symmetricEncrypt(payload.userId, ENCRYPTION_KEY),
email: symmetricEncrypt(payload.email, ENCRYPTION_KEY),
provider: payload.provider,
providerAccountId: symmetricEncrypt(payload.providerAccountId, ENCRYPTION_KEY),
purpose: payload.purpose,
returnToUrl: symmetricEncrypt(payload.returnToUrl, ENCRYPTION_KEY),
},
NEXTAUTH_SECRET,
options
);
};
export const verifyAccountDeletionSsoReauthIntent = (
token: string
): TAccountDeletionSsoReauthIntentPayload => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
id: string;
userId: string;
email: string;
provider: string;
providerAccountId: string;
purpose: string;
returnToUrl: string;
};
if (
!payload?.id ||
!payload?.userId ||
!payload?.email ||
!payload?.provider ||
!payload?.providerAccountId ||
payload?.purpose !== "account_deletion_sso_reauth" ||
!payload?.returnToUrl
) {
throw new Error("Token is invalid or missing required fields");
}
return {
id: decryptWithFallback(payload.id, ENCRYPTION_KEY),
userId: decryptWithFallback(payload.userId, ENCRYPTION_KEY),
email: decryptWithFallback(payload.email, ENCRYPTION_KEY),
provider: payload.provider,
providerAccountId: decryptWithFallback(payload.providerAccountId, ENCRYPTION_KEY),
purpose: "account_deletion_sso_reauth",
returnToUrl: decryptWithFallback(payload.returnToUrl, ENCRYPTION_KEY),
};
};
export const verifyToken = async (token: string): Promise<TVerifyTokenPayload> => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
@@ -263,7 +447,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 };
+81 -3
View File
@@ -1,8 +1,9 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { capturePostHogEvent } from "./capture";
import { capturePostHogEvent, groupIdentifyPostHog } from "./capture";
const mocks = vi.hoisted(() => ({
capture: vi.fn(),
groupIdentify: vi.fn(),
loggerWarn: vi.fn(),
}));
@@ -13,7 +14,7 @@ vi.mock("@formbricks/logger", () => ({
}));
vi.mock("./server", () => ({
posthogServerClient: { capture: mocks.capture },
posthogServerClient: { capture: mocks.capture, groupIdentify: mocks.groupIdentify },
}));
describe("capturePostHogEvent", () => {
@@ -32,6 +33,7 @@ describe("capturePostHogEvent", () => {
$lib: "posthog-node",
source: "server",
},
groups: undefined,
});
});
@@ -45,6 +47,41 @@ describe("capturePostHogEvent", () => {
$lib: "posthog-node",
source: "server",
},
groups: undefined,
});
});
test("includes organization and workspace groups when provided", () => {
capturePostHogEvent(
"user123",
"test_event",
{ key: "value" },
{ organizationId: "org_1", workspaceId: "ws_1" }
);
expect(mocks.capture).toHaveBeenCalledWith({
distinctId: "user123",
event: "test_event",
properties: {
key: "value",
$lib: "posthog-node",
source: "server",
},
groups: { organization: "org_1", workspace: "ws_1" },
});
});
test("includes only organization group when workspaceId missing", () => {
capturePostHogEvent("user123", "test_event", undefined, { organizationId: "org_1" });
expect(mocks.capture).toHaveBeenCalledWith({
distinctId: "user123",
event: "test_event",
properties: {
$lib: "posthog-node",
source: "server",
},
groups: { organization: "org_1" },
});
});
@@ -61,6 +98,44 @@ describe("capturePostHogEvent", () => {
});
});
describe("groupIdentifyPostHog", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("calls posthog groupIdentify with correct params", () => {
groupIdentifyPostHog("organization", "org_1", { name: "Acme" });
expect(mocks.groupIdentify).toHaveBeenCalledWith({
groupType: "organization",
groupKey: "org_1",
properties: { name: "Acme" },
});
});
test("identifies workspace group", () => {
groupIdentifyPostHog("workspace", "ws_1", { name: "Marketing" });
expect(mocks.groupIdentify).toHaveBeenCalledWith({
groupType: "workspace",
groupKey: "ws_1",
properties: { name: "Marketing" },
});
});
test("does not throw when groupIdentify throws", () => {
mocks.groupIdentify.mockImplementation(() => {
throw new Error("Network error");
});
expect(() => groupIdentifyPostHog("organization", "org_1")).not.toThrow();
expect(mocks.loggerWarn).toHaveBeenCalledWith(
{ error: expect.any(Error), groupType: "organization", groupKey: "org_1" },
"Failed to identify PostHog group"
);
});
});
describe("capturePostHogEvent with null client", () => {
test("no-ops when posthogServerClient is null", async () => {
vi.clearAllMocks();
@@ -74,11 +149,14 @@ describe("capturePostHogEvent with null client", () => {
posthogServerClient: null,
}));
const { capturePostHogEvent: captureWithNullClient } = await import("./capture");
const { capturePostHogEvent: captureWithNullClient, groupIdentifyPostHog: identifyWithNullClient } =
await import("./capture");
captureWithNullClient("user123", "test_event", { key: "value" });
identifyWithNullClient("organization", "org_1");
expect(mocks.capture).not.toHaveBeenCalled();
expect(mocks.groupIdentify).not.toHaveBeenCalled();
expect(mocks.loggerWarn).not.toHaveBeenCalled();
});
});
+36 -1
View File
@@ -4,10 +4,24 @@ import { posthogServerClient } from "./server";
type PostHogEventProperties = Record<string, string | number | boolean | null | undefined>;
export type PostHogGroupContext = {
organizationId?: string;
workspaceId?: string;
};
const buildGroups = (context?: PostHogGroupContext): Record<string, string> | undefined => {
if (!context) return undefined;
const groups: Record<string, string> = {};
if (context.organizationId) groups.organization = context.organizationId;
if (context.workspaceId) groups.workspace = context.workspaceId;
return Object.keys(groups).length > 0 ? groups : undefined;
};
export function capturePostHogEvent(
distinctId: string,
eventName: string,
properties?: PostHogEventProperties
properties?: PostHogEventProperties,
groupContext?: PostHogGroupContext
): void {
if (!posthogServerClient) return;
@@ -20,8 +34,29 @@ export function capturePostHogEvent(
$lib: "posthog-node",
source: "server",
},
groups: buildGroups(groupContext),
});
} catch (error) {
logger.warn({ error, eventName }, "Failed to capture PostHog event");
}
}
type PostHogGroupType = "organization" | "workspace";
export function groupIdentifyPostHog(
groupType: PostHogGroupType,
groupKey: string,
properties?: Record<string, string | number | boolean | null | undefined>
): void {
if (!posthogServerClient) return;
try {
posthogServerClient.groupIdentify({
groupType,
groupKey,
properties,
});
} catch (error) {
logger.warn({ error, groupType, groupKey }, "Failed to identify PostHog group");
}
}

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