mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-16 02:56:26 -05:00
Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9104c554be | |||
| fb554bfc21 | |||
| 939fedfca4 | |||
| 00f93eec10 | |||
| c286a3330a | |||
| d22db8d735 | |||
| 2db02dac5e | |||
| ce68d58aaf | |||
| 6774f220b1 | |||
| efe259d484 | |||
| 96b08fbe23 | |||
| 4eba194935 | |||
| 9d8cf5e0f7 | |||
| 3c6f6d83ea | |||
| 1380c81bff | |||
| 535c111860 | |||
| 676e31c433 | |||
| 88f17380e1 | |||
| 103775b3b1 | |||
| 3005c44c49 | |||
| 4d03ba2ff7 | |||
| bc25c482ad | |||
| b08f7e4ad9 | |||
| 89a8266ebe | |||
| cad10b8810 | |||
| 1d18b5cb83 | |||
| 69ead97965 | |||
| 7e2c439325 | |||
| a2177eec96 | |||
| 255c97854f | |||
| d103499496 | |||
| b863238f15 | |||
| 28280899ea | |||
| bc63870289 | |||
| 9a04e95d15 | |||
| 9d9f38515d | |||
| fae00f6a82 | |||
| a274c444ad | |||
| 5fae207cd7 | |||
| 654539d320 | |||
| 44aac89d41 | |||
| e0250b2a58 | |||
| a8d6cd8a9f | |||
| f79fe1490e | |||
| 5cfbc671c5 | |||
| e6e9419b93 | |||
| 90eb78e571 | |||
| 991866f549 | |||
| 6d1b3475d4 | |||
| 5e967a2b67 | |||
| 23a8fd6a47 | |||
| 0686eb3cbb | |||
| 6d9ab315c2 | |||
| 4128731c5f | |||
| ef96426ca0 | |||
| ce1dbe8b00 | |||
| 444f043140 | |||
| 2d32c0d671 | |||
| 8dc70a5e30 | |||
| 3e4e55fbf1 | |||
| fcfedd6e15 | |||
| 6c4342690f | |||
| b8c361fcf3 | |||
| 8771a0ec91 | |||
| fc33c52133 | |||
| 75cf9293b1 |
@@ -1 +0,0 @@
|
||||
{"sessionId":"f77248e2-8840-41c6-968b-c3b7d8a9e913","pid":49125,"acquiredAt":1776168010367}
|
||||
+21
-3
@@ -63,10 +63,18 @@ DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=pu
|
||||
# Set explicitly to avoid confusion; override as needed when using docker-compose.dev.yml.
|
||||
HUB_API_KEY=dev-api-key
|
||||
HUB_API_URL=http://localhost:8080
|
||||
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
|
||||
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/hub?sslmode=disable
|
||||
# Hub image tag used by docker-compose.dev.yml (hub + hub-migrate). Leave unset to use the
|
||||
# pinned default in the compose file; override here when testing a specific Hub release.
|
||||
# HUB_IMAGE_TAG=0.2.0
|
||||
# HUB_IMAGE_TAG=0.3.0
|
||||
|
||||
# Hub embeddings are optional. Set a provider and model to enable semantic search embeddings in
|
||||
# the Hub API and hub-worker. For provider-specific settings, see:
|
||||
# https://hub.formbricks.com/reference/environment-variables/#embeddings
|
||||
# Example with Google AI Studio:
|
||||
# EMBEDDING_PROVIDER=google
|
||||
# EMBEDDING_MODEL=gemini-embedding-001
|
||||
# EMBEDDING_PROVIDER_API_KEY=
|
||||
|
||||
###########################
|
||||
# CUBE ANALYTICS (XM V5) #
|
||||
@@ -112,7 +120,7 @@ SMTP_PASSWORD=smtpPassword
|
||||
# S3 STORAGE #
|
||||
##############
|
||||
|
||||
# S3 Storage is required for the file upload in serverless environments like Vercel
|
||||
# S3 Storage is required for the file upload in serverless environments
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_KEY=
|
||||
S3_REGION=
|
||||
@@ -148,6 +156,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 #
|
||||
@@ -174,6 +189,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=
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
name: Accessibility issue
|
||||
description: "Report an accessibility barrier in Formbricks (WCAG, screen reader, keyboard, contrast, etc.)"
|
||||
type: bug
|
||||
labels: ["accessibility", "bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for helping make Formbricks accessible to everyone. Please fill in as much as you can — see [ACCESSIBILITY.md](https://github.com/formbricks/formbricks/blob/main/ACCESSIBILITY.md) for context.
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: What part of Formbricks is affected and what's wrong?
|
||||
placeholder: "e.g. The language switcher in survey runtime can't be reached with Tab."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
placeholder: |
|
||||
1. Open a survey with multiple languages
|
||||
2. Press Tab repeatedly
|
||||
3. Focus never lands on the language switcher
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: wcag
|
||||
attributes:
|
||||
label: Related WCAG criterion (if known)
|
||||
placeholder: "e.g. 2.1.1 Keyboard"
|
||||
- type: dropdown
|
||||
id: severity
|
||||
attributes:
|
||||
label: Severity
|
||||
options:
|
||||
- "Critical — blocks a user from completing a core task"
|
||||
- "High — significant barrier with no easy workaround"
|
||||
- "Medium — barrier with a workaround"
|
||||
- "Low — minor friction"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: at
|
||||
attributes:
|
||||
label: Assistive technology
|
||||
placeholder: "e.g. NVDA 2026.1, VoiceOver on macOS 15, keyboard only"
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser and OS
|
||||
placeholder: "e.g. Firefox 138 on Windows 11"
|
||||
- type: dropdown
|
||||
id: environment
|
||||
attributes:
|
||||
label: Your Environment
|
||||
options:
|
||||
- Formbricks Cloud (app.formbricks.com)
|
||||
- Self-hosted Formbricks
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: other
|
||||
attributes:
|
||||
label: Other information (screenshots, recordings, axe output)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -147,6 +147,10 @@ jobs:
|
||||
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
|
||||
-e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \
|
||||
-e REDIS_URL="redis://host.docker.internal:6379" \
|
||||
-e HUB_API_URL="http://localhost:4000" \
|
||||
-e HUB_API_KEY="build-time-placeholder" \
|
||||
-e CUBEJS_API_URL="http://localhost:4000" \
|
||||
-e CUBEJS_API_SECRET="build-time-placeholder" \
|
||||
-d "formbricks-test:$GITHUB_SHA"
|
||||
|
||||
# Start health check polling immediately (every 5 seconds for up to 5 minutes)
|
||||
|
||||
+37
-48
@@ -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
|
||||
@@ -81,65 +81,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: |
|
||||
@@ -238,8 +221,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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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: pnpm dev:setup
|
||||
|
||||
@@ -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: pnpm dev:setup
|
||||
|
||||
@@ -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: pnpm dev:setup
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
apps/web/.env
|
||||
@@ -0,0 +1,48 @@
|
||||
# Accessibility
|
||||
|
||||
Formbricks is committed to making our platform usable by everyone, including people who rely on assistive technologies.
|
||||
|
||||
## Standards
|
||||
|
||||
We aim to conform to:
|
||||
|
||||
- **[WCAG 2.1 Level AA](https://www.w3.org/TR/WCAG21/)** — the web content baseline.
|
||||
- **[EN 301 549](https://www.etsi.org/deliver/etsi_en/301500_301599/301549/)** — the European harmonised standard referenced by the **European Accessibility Act (EAA)**, applicable to us as a Germany-based company.
|
||||
- **Section 508** — for users in US public-sector contexts.
|
||||
|
||||
## Priorities
|
||||
|
||||
1. **End-user surveys** (`packages/surveys`) — everything respondents see and interact with. This is our highest priority because survey takers don't choose Formbricks; the organisations running surveys choose for them.
|
||||
2. **Admin app** (`apps/web`) — survey creation, response analysis, and team management used by Formbricks customers.
|
||||
|
||||
In both areas we focus on:
|
||||
|
||||
- Keyboard navigation with a clearly visible focus indicator
|
||||
- Screen reader support through semantic HTML and correctly scoped ARIA
|
||||
- Sufficient color and contrast
|
||||
- Programmatically associated labels and announced status messages
|
||||
|
||||
## Supported Environments
|
||||
|
||||
- Latest two versions of Chrome, Firefox, Safari, and Edge
|
||||
- VoiceOver (macOS/iOS), NVDA (Windows), and TalkBack (Android)
|
||||
|
||||
## Contributing
|
||||
|
||||
When contributing UI changes:
|
||||
|
||||
- Prefer semantic HTML over ARIA.
|
||||
- Tab through your change end-to-end and confirm focus is visible at every stop.
|
||||
- Label every control. Don't convey meaning by color alone.
|
||||
- Run [axe DevTools](https://www.deque.com/axe/devtools/) or Lighthouse on the page you changed.
|
||||
|
||||
## Reporting Accessibility Issues
|
||||
|
||||
If you encounter an accessibility barrier, please [open an issue](https://github.com/formbricks/formbricks/issues/new?labels=accessibility&template=accessibility.yml) using the accessibility template. For blocking issues in a procurement or compliance context, email **[hola@formbricks.com](mailto:hola@formbricks.com)**.
|
||||
|
||||
## Resources
|
||||
|
||||
- [WCAG 2.1 Quick Reference](https://www.w3.org/WAI/WCAG21/quickref/)
|
||||
- [EN 301 549](https://www.etsi.org/deliver/etsi_en/301500_301599/301549/)
|
||||
- [European Accessibility Act overview](https://ec.europa.eu/social/main.jsp?catId=1202)
|
||||
- [MDN Accessibility Reference](https://developer.mozilla.org/en-US/docs/Web/Accessibility)
|
||||
+12
-12
@@ -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.6",
|
||||
"@storybook/addon-docs": "10.3.6",
|
||||
"@storybook/addon-links": "10.3.6",
|
||||
"@storybook/addon-onboarding": "10.3.6",
|
||||
"@storybook/react-vite": "10.3.6",
|
||||
"@tailwindcss/vite": "4.2.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.2",
|
||||
"@typescript-eslint/parser": "8.57.2",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"eslint-plugin-react-refresh": "0.4.26",
|
||||
"eslint-plugin-storybook": "10.2.17",
|
||||
"storybook": "10.2.17",
|
||||
"vite": "7.3.2",
|
||||
"@storybook/addon-docs": "10.2.17"
|
||||
"eslint-plugin-storybook": "10.3.6",
|
||||
"storybook": "10.3.6",
|
||||
"vite": "7.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+11
@@ -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 { getUserWorkspaces } from "@/lib/workspace/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
@@ -41,6 +42,16 @@ const Page = async (props: ChannelPageProps) => {
|
||||
|
||||
const workspaces = await getUserWorkspaces(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
|
||||
|
||||
+2
-1
@@ -18,6 +18,7 @@ import { createWorkspaceAction } from "@/app/(app)/workspaces/[workspaceId]/acti
|
||||
import { previewSurvey } from "@/app/lib/templates";
|
||||
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
|
||||
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
|
||||
import { toJsWorkspaceStateSurvey } from "@/lib/survey/client-utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
|
||||
import { TOrganizationTeam } from "@/modules/ee/teams/workspace-teams/types/team";
|
||||
@@ -237,7 +238,7 @@ export const WorkspaceSettings = ({
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={previewSurvey(workspaceName || t("common.my_product"), t)}
|
||||
survey={toJsWorkspaceStateSurvey(previewSurvey(workspaceName || t("common.my_product"), t))}
|
||||
styling={previewStyling}
|
||||
isBrandingEnabled={false}
|
||||
languageCode="default"
|
||||
|
||||
+13
@@ -11,6 +11,7 @@ import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboardin
|
||||
import { WorkspaceSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/WorkspaceSettings";
|
||||
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getUserWorkspaces } from "@/lib/workspace/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -55,6 +56,18 @@ const Page = async (props: WorkspaceSettingsPageProps) => {
|
||||
|
||||
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 { ZWorkspaceUpdateInput } from "@formbricks/types/workspace";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
|
||||
import { updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
@@ -80,6 +81,19 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
|
||||
groupIdentifyPostHog("workspace", workspace.id, { name: workspace.name });
|
||||
|
||||
capturePostHogEvent(
|
||||
user.id,
|
||||
"workspace_created",
|
||||
{
|
||||
organization_id: organizationId,
|
||||
workspace_id: workspace.id,
|
||||
name: workspace.name,
|
||||
},
|
||||
{ organizationId, workspaceId: workspace.id }
|
||||
);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.workspaceId = workspace.id;
|
||||
ctx.auditLoggingCtx.newObject = workspace;
|
||||
|
||||
@@ -43,6 +43,7 @@ import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -151,7 +152,17 @@ export const MainNavigation = ({
|
||||
},
|
||||
{
|
||||
id: "unify-feedback",
|
||||
name: t("workspace.unify.unify_feedback"),
|
||||
name: (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span>{t("workspace.unify.unify_feedback")}</span>
|
||||
<Badge
|
||||
text="Beta"
|
||||
type="gray"
|
||||
size="tiny"
|
||||
className="normal-case text-[10px] font-semibold tracking-normal"
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
items: [
|
||||
{
|
||||
name: t("workspace.unify.feedback_records"),
|
||||
|
||||
@@ -2,6 +2,8 @@ import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { WorkspaceLayout as WorkspaceLayoutComponent } from "@/app/(app)/workspaces/[workspaceId]/components/WorkspaceLayout";
|
||||
import { WorkspaceContextWrapper } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
|
||||
import { PostHogGroupIdentify } from "@/app/posthog/PostHogGroupIdentify";
|
||||
import { POSTHOG_KEY } from "@/lib/constants";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getWorkspaceLayoutData } from "@/modules/workspaces/lib/utils";
|
||||
import WorkspaceStorageHandler from "./components/WorkspaceStorageHandler";
|
||||
@@ -23,6 +25,14 @@ const WorkspaceLayout = async (props: {
|
||||
return (
|
||||
<>
|
||||
<WorkspaceStorageHandler workspaceId={params.workspaceId} />
|
||||
{POSTHOG_KEY && (
|
||||
<PostHogGroupIdentify
|
||||
organizationId={layoutData.organization.id}
|
||||
organizationName={layoutData.organization.name}
|
||||
workspaceId={layoutData.workspace.id}
|
||||
workspaceName={layoutData.workspace.name}
|
||||
/>
|
||||
)}
|
||||
<WorkspaceContextWrapper workspace={layoutData.workspace} organization={layoutData.organization}>
|
||||
<WorkspaceLayoutComponent layoutData={layoutData}>{children}</WorkspaceLayoutComponent>
|
||||
</WorkspaceContextWrapper>
|
||||
|
||||
@@ -6,11 +6,9 @@ import {
|
||||
TUserUpdateInput,
|
||||
ZUserPersonalInfoUpdateInput,
|
||||
} from "@formbricks/types/user";
|
||||
import {
|
||||
getIsEmailUnique,
|
||||
verifyUserPassword,
|
||||
} from "@/app/(app)/workspaces/[workspaceId]/settings/account/profile/lib/user";
|
||||
import { getIsEmailUnique } from "@/app/(app)/workspaces/[workspaceId]/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";
|
||||
|
||||
+47
-8
@@ -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("workspace.settings.profile.google_sso_account_deletion_requires_setup"), {
|
||||
id: "account-deletion-sso-reauth-error",
|
||||
});
|
||||
} else {
|
||||
toast.error(t("workspace.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
-87
@@ -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({
|
||||
|
||||
@@ -7,6 +7,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 { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
@@ -14,10 +15,14 @@ import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
|
||||
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
const Page = async (props: {
|
||||
params: Promise<{ workspaceId: 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 { session } = await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
@@ -30,6 +35,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
}
|
||||
|
||||
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
|
||||
const requiresPasswordConfirmation = requiresPasswordConfirmationForAccountDeletion(user);
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
@@ -85,6 +91,8 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
user={user}
|
||||
organizationsWithSingleOwner={organizationsWithSingleOwner}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
accountDeletionError={searchParams.accountDeletionError}
|
||||
requiresPasswordConfirmation={requiresPasswordConfirmation}
|
||||
/>
|
||||
</SettingsCard>
|
||||
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
|
||||
|
||||
+12
-7
@@ -5,7 +5,7 @@ import { RotateCcwIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
||||
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
|
||||
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
|
||||
@@ -151,12 +151,17 @@ export const EnterpriseLicenseStatus = ({
|
||||
</Alert>
|
||||
)}
|
||||
<p className="border-t border-slate-100 pt-4 text-sm text-slate-500">
|
||||
{t("workspace.settings.enterprise.questions_please_reach_out_to")}{" "}
|
||||
<a
|
||||
className="font-medium text-slate-700 underline hover:text-slate-900"
|
||||
href="mailto:hola@formbricks.com">
|
||||
hola@formbricks.com
|
||||
</a>
|
||||
<Trans
|
||||
i18nKey="workspace.settings.enterprise.questions_please_reach_out_to_email"
|
||||
components={{
|
||||
contactLink: (
|
||||
<a
|
||||
className="font-medium text-slate-700 underline hover:text-slate-900"
|
||||
href="mailto:hola@formbricks.com"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@ import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { EnterpriseLicenseFeaturesTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseFeaturesTable";
|
||||
import { EnterpriseLicenseStatus } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseStatus";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { ENTERPRISE_LICENSE_REQUEST_FORM_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -163,7 +163,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link
|
||||
href="https://app.formbricks.com/s/clvupq3y205i5yrm3sm9v1xt5"
|
||||
href={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
referrerPolicy="no-referrer">
|
||||
|
||||
+1
-3
@@ -95,9 +95,7 @@ export const AISettingsToggle = ({
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
href: isFormbricksCloud
|
||||
? `${workspaceBasePath}/settings/organization/billing`
|
||||
: "https://formbricks.com/learn-more-self-hosting-license",
|
||||
href: "https://formbricks.com/docs/platform/features/ai-features",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { isInstanceAIConfigured } from "@/lib/ai/service";
|
||||
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||
import {
|
||||
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
FB_LOGO_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_STORAGE_CONFIGURED,
|
||||
} from "@/lib/constants";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import {
|
||||
@@ -81,6 +86,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
fbLogoUrl={FB_LOGO_URL}
|
||||
user={user}
|
||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
|
||||
/>
|
||||
{isMultiOrgEnabled && (
|
||||
<SettingsCard
|
||||
|
||||
+10
-4
@@ -46,10 +46,16 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
|
||||
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: parsedInput.workspaceId,
|
||||
},
|
||||
{ organizationId, workspaceId: parsedInput.workspaceId }
|
||||
);
|
||||
|
||||
return result;
|
||||
})
|
||||
|
||||
+4
@@ -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
|
||||
|
||||
+38
-9
@@ -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)/workspaces/[workspaceId]/se
|
||||
import { AddIntegrationModal } from "@/app/(app)/workspaces/[workspaceId]/settings/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, workspaceId, setIsConnected, surveys, airtableArray } = props;
|
||||
export const ManageIntegration = ({
|
||||
airtableIntegration,
|
||||
workspaceId,
|
||||
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("workspace.integrations.reconnect_button_description")}</AlertDescription>
|
||||
<AlertButton onClick={handleAirtableAuthorization}>
|
||||
{t("workspace.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("workspace.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("workspace.integrations.reconnect_button")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("workspace.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>
|
||||
|
||||
+9
-1
@@ -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)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/AirtableWrapper";
|
||||
@@ -31,8 +32,14 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
);
|
||||
|
||||
let airtableArray: TIntegrationItem[] = [];
|
||||
let isTokenValid = true;
|
||||
if (airtableIntegration?.config.key) {
|
||||
airtableArray = await getAirtableTables(workspace.id);
|
||||
try {
|
||||
airtableArray = await getAirtableTables(workspace.id);
|
||||
} 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<{ workspaceId: string }> }) => {
|
||||
surveys={surveys}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
locale={locale ?? DEFAULT_LOCALE}
|
||||
showReconnectButton={!isTokenValid}
|
||||
/>
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
|
||||
+41
-15
@@ -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 = () => {
|
||||
|
||||
+35
-2
@@ -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 { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponseWithQuotas } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -12,6 +14,7 @@ import { useResponseFilter } from "@/app/(app)/workspaces/[workspaceId]/surveys/
|
||||
import { ResponseDataView } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
|
||||
import { CustomFilter } from "@/app/(app)/workspaces/[workspaceId]/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 {
|
||||
@@ -43,8 +46,8 @@ export const ResponsePage = ({
|
||||
const [page, setPage] = useState<number | null>(null);
|
||||
const [hasMore, setHasMore] = useState<boolean>(initialResponses.length >= responsesPerPage);
|
||||
const [isFetchingFirstPage, setIsFetchingFirstPage] = useState<boolean>(false);
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
|
||||
const { selectedFilter, dateRange, resetState, registerAnalysisRefreshHandler } = useResponseFilter();
|
||||
const { t } = useTranslation();
|
||||
const filters = useMemo(
|
||||
() => getFormattedFilters(survey, selectedFilter, dateRange),
|
||||
|
||||
@@ -83,6 +86,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]);
|
||||
@@ -131,6 +162,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
-1
@@ -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) {
|
||||
|
||||
+7
-2
@@ -2,7 +2,12 @@ import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/er
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { ResponsePage } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED, RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import {
|
||||
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_STORAGE_CONFIGURED,
|
||||
RESPONSES_PER_PAGE,
|
||||
} from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -64,7 +69,6 @@ const Page = async (props: { params: Promise<{ workspaceId: string; surveyId: st
|
||||
pageTitle={survey.name}
|
||||
cta={
|
||||
<SurveyAnalysisCTA
|
||||
survey={survey}
|
||||
isReadOnly={isReadOnly}
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
@@ -73,6 +77,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string; surveyId: st
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
|
||||
/>
|
||||
}>
|
||||
<SurveyAnalysisNavigation survey={survey} activeId="responses" />
|
||||
|
||||
+16
-3
@@ -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)/workspaces/[workspaceId]/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";
|
||||
@@ -138,7 +139,6 @@ export const getEmailHtmlAction = authenticatedActionClient
|
||||
const ZGeneratePersonalLinksAction = z.object({
|
||||
surveyId: ZId,
|
||||
segmentId: ZId,
|
||||
workspaceId: ZId,
|
||||
expirationDays: z.number().optional(),
|
||||
});
|
||||
|
||||
@@ -146,6 +146,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
|
||||
.inputSchema(ZGeneratePersonalLinksAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
const workspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId);
|
||||
const isContactsEnabled = await getIsContactsEnabled(organizationId);
|
||||
if (!isContactsEnabled) {
|
||||
throw new OperationNotAllowedError("Contacts are not enabled for this workspace");
|
||||
@@ -153,7 +154,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
@@ -161,7 +162,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
|
||||
workspaceId,
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
@@ -178,6 +179,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: workspaceId,
|
||||
survey_id: parsedInput.surveyId,
|
||||
link_count: contactsResult.length,
|
||||
},
|
||||
{ organizationId, workspaceId }
|
||||
);
|
||||
|
||||
// Prepare CSV data with the specified headers and order
|
||||
const csvHeaders = [
|
||||
"Formbricks Contact ID",
|
||||
|
||||
+3
-6
@@ -4,15 +4,12 @@ import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { useWorkspaceContext } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
|
||||
import { useSurvey } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/context/survey-context";
|
||||
import { Confetti } from "@/modules/ui/components/confetti";
|
||||
|
||||
interface SummaryMetadataProps {
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
export const SuccessMessage = ({ survey }: SummaryMetadataProps) => {
|
||||
export const SuccessMessage = () => {
|
||||
const { survey } = useSurvey();
|
||||
const { t } = useTranslation();
|
||||
const { workspace } = useWorkspaceContext();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
+35
-19
@@ -68,7 +68,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);
|
||||
@@ -108,7 +108,7 @@ export const SummaryPage = ({
|
||||
} finally {
|
||||
setIsDisplaysLoading(false);
|
||||
}
|
||||
}, [fetchDisplays, t]);
|
||||
}, [fetchDisplays]);
|
||||
|
||||
const handleLoadMoreDisplays = useCallback(async () => {
|
||||
try {
|
||||
@@ -128,13 +128,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) {
|
||||
@@ -142,21 +168,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 {
|
||||
@@ -164,8 +180,8 @@ export const SummaryPage = ({
|
||||
}
|
||||
};
|
||||
|
||||
fetchSummary();
|
||||
}, [selectedFilter, dateRange, survey, surveyId, initialSurveySummary]);
|
||||
fetchFilteredSummary();
|
||||
}, [selectedFilter, dateRange, initialSurveySummary, fetchSummary]);
|
||||
|
||||
const surveyMemoized = useMemo(() => {
|
||||
return replaceHeadlineRecall(survey, "default");
|
||||
|
||||
+37
-10
@@ -1,17 +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 { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { useWorkspaceContext } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
|
||||
import { useResponseFilter } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||
import { SuccessMessage } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
|
||||
import { ShareSurveyModal } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
|
||||
import { SurveyStatusDropdown } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/components/SurveyStatusDropdown";
|
||||
import { useSurvey } from "@/app/(app)/workspaces/[workspaceId]/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";
|
||||
@@ -22,7 +23,6 @@ import { IconBar } from "@/modules/ui/components/iconbar";
|
||||
import { resetSurveyAction } from "../actions";
|
||||
|
||||
interface SurveyAnalysisCTAProps {
|
||||
survey: TSurvey;
|
||||
isReadOnly: boolean;
|
||||
user: TUser;
|
||||
publicDomain: string;
|
||||
@@ -31,6 +31,7 @@ interface SurveyAnalysisCTAProps {
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
isStorageConfigured: boolean;
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
interface ModalState {
|
||||
@@ -39,7 +40,6 @@ interface ModalState {
|
||||
}
|
||||
|
||||
export const SurveyAnalysisCTA = ({
|
||||
survey,
|
||||
isReadOnly,
|
||||
user,
|
||||
publicDomain,
|
||||
@@ -48,6 +48,7 @@ export const SurveyAnalysisCTA = ({
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
isStorageConfigured,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: SurveyAnalysisCTAProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -60,9 +61,12 @@ export const SurveyAnalysisCTA = ({
|
||||
});
|
||||
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const { workspace } = useWorkspaceContext();
|
||||
const { survey } = useSurvey();
|
||||
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
|
||||
const { refreshAnalysisData } = useResponseFilter();
|
||||
|
||||
const appSetupCompleted = survey.type === "app" && workspace.appSetupCompleted;
|
||||
|
||||
@@ -74,7 +78,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) {
|
||||
@@ -109,9 +113,12 @@ export const SurveyAnalysisCTA = ({
|
||||
const surveyUrl = new URL(`${publicDomain}/s/${survey.id}`);
|
||||
|
||||
if (survey.singleUse?.enabled) {
|
||||
const newId = await refreshSingleUseId();
|
||||
if (newId) {
|
||||
surveyUrl.searchParams.set("suId", newId);
|
||||
const singleUseLinkParams = await refreshSingleUseId();
|
||||
if (singleUseLinkParams) {
|
||||
surveyUrl.searchParams.set("suId", singleUseLinkParams.suId);
|
||||
if (singleUseLinkParams.suToken) {
|
||||
surveyUrl.searchParams.set("suToken", singleUseLinkParams.suToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +151,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("workspace.surveys.summary.configure_alerts"),
|
||||
@@ -180,7 +206,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 survey={survey} />
|
||||
<SurveyStatusDropdown />
|
||||
)}
|
||||
|
||||
<IconBar actions={iconActions} />
|
||||
@@ -210,9 +236,10 @@ export const SurveyAnalysisCTA = ({
|
||||
isReadOnly={isReadOnly}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
workspaceCustomScripts={workspace.customHeadScripts}
|
||||
enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl}
|
||||
/>
|
||||
)}
|
||||
<SuccessMessage survey={survey} />
|
||||
<SuccessMessage />
|
||||
|
||||
{responseCount > 0 && (
|
||||
<EditPublicSurveyAlertDialog
|
||||
|
||||
+3
-1
@@ -54,6 +54,7 @@ interface ShareSurveyModalProps {
|
||||
isReadOnly: boolean;
|
||||
isStorageConfigured: boolean;
|
||||
workspaceCustomScripts?: string | null;
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
export const ShareSurveyModal = ({
|
||||
@@ -69,6 +70,7 @@ export const ShareSurveyModal = ({
|
||||
isReadOnly,
|
||||
isStorageConfigured,
|
||||
workspaceCustomScripts,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: ShareSurveyModalProps) => {
|
||||
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
|
||||
const [showView, setShowView] = useState<ModalView>(modalView);
|
||||
@@ -102,11 +104,11 @@ export const ShareSurveyModal = ({
|
||||
description: t("workspace.surveys.share.personal_links.description"),
|
||||
componentType: PersonalLinksTab,
|
||||
componentProps: {
|
||||
workspaceId: survey.workspaceId,
|
||||
surveyId: survey.id,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
},
|
||||
disabled: survey.singleUse?.enabled,
|
||||
},
|
||||
|
||||
+52
-17
@@ -2,7 +2,7 @@
|
||||
|
||||
import { CirclePlayIcon, CopyIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -41,6 +41,7 @@ export const AnonymousLinksTab = ({
|
||||
const [isSingleUseLink, setIsSingleUseLink] = useState(survey.singleUse?.enabled ?? false);
|
||||
const [singleUseEncryption, setSingleUseEncryption] = useState(survey.singleUse?.isEncrypted ?? false);
|
||||
const [numberOfLinks, setNumberOfLinks] = useState<number | string>(1);
|
||||
const [customSingleUseId, setCustomSingleUseId] = useState("");
|
||||
|
||||
const [disableLinkModal, setDisableLinkModal] = useState<{
|
||||
open: boolean;
|
||||
@@ -48,12 +49,6 @@ export const AnonymousLinksTab = ({
|
||||
pendingAction: () => Promise<void> | void;
|
||||
} | null>(null);
|
||||
|
||||
const surveyUrlWithCustomSuid = useMemo(() => {
|
||||
const url = new URL(surveyUrl);
|
||||
url.searchParams.set("suId", "CUSTOM-ID");
|
||||
return url.toString();
|
||||
}, [surveyUrl]);
|
||||
|
||||
const resetState = () => {
|
||||
const { singleUse } = survey;
|
||||
const { enabled, isEncrypted } = singleUse ?? {};
|
||||
@@ -181,10 +176,13 @@ export const AnonymousLinksTab = ({
|
||||
});
|
||||
|
||||
if (!!response?.data?.length) {
|
||||
const singleUseIds = response.data;
|
||||
const surveyLinks = singleUseIds.map((singleUseId) => {
|
||||
const singleUseLinkParams = response.data;
|
||||
const surveyLinks = singleUseLinkParams.map(({ suId, suToken }) => {
|
||||
const url = new URL(surveyUrl);
|
||||
url.searchParams.set("suId", singleUseId);
|
||||
url.searchParams.set("suId", suId);
|
||||
if (suToken) {
|
||||
url.searchParams.set("suToken", suToken);
|
||||
}
|
||||
return url.toString();
|
||||
});
|
||||
|
||||
@@ -212,6 +210,40 @@ export const AnonymousLinksTab = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyCustomSingleUseLink = async () => {
|
||||
const trimmedCustomSingleUseId = customSingleUseId.trim();
|
||||
if (!trimmedCustomSingleUseId) {
|
||||
toast.error(t("workspace.surveys.share.anonymous_links.custom_single_use_id_required"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await generateSingleUseIdsAction({
|
||||
surveyId: survey.id,
|
||||
isEncrypted: false,
|
||||
count: 1,
|
||||
singleUseId: trimmedCustomSingleUseId,
|
||||
});
|
||||
|
||||
const singleUseLinkParams = response?.data?.[0];
|
||||
if (!singleUseLinkParams) {
|
||||
toast.error(t("workspace.surveys.share.anonymous_links.generate_links_error"));
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(surveyUrl);
|
||||
url.searchParams.set("suId", singleUseLinkParams.suId);
|
||||
if (singleUseLinkParams.suToken) {
|
||||
url.searchParams.set("suToken", singleUseLinkParams.suToken);
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(url.toString());
|
||||
toast.success(t("common.copied_to_clipboard"));
|
||||
} catch {
|
||||
toast.error(t("workspace.surveys.share.anonymous_links.generate_links_error"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full flex-col justify-between space-y-4">
|
||||
@@ -277,16 +309,19 @@ export const AnonymousLinksTab = ({
|
||||
</Alert>
|
||||
|
||||
<div className="grid w-full grid-cols-6 items-center gap-2">
|
||||
<div className="col-span-5 truncate rounded-md border border-slate-200 px-2 py-1">
|
||||
<span className="truncate text-sm text-slate-900">{surveyUrlWithCustomSuid}</span>
|
||||
</div>
|
||||
<Input
|
||||
className="col-span-5 bg-white focus:border focus:border-slate-900"
|
||||
value={customSingleUseId}
|
||||
onChange={(event) => setCustomSingleUseId(event.target.value)}
|
||||
placeholder={t(
|
||||
"workspace.surveys.share.anonymous_links.custom_single_use_id_placeholder"
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(surveyUrlWithCustomSuid);
|
||||
toast.success(t("common.copied_to_clipboard"));
|
||||
}}
|
||||
disabled={!customSingleUseId.trim()}
|
||||
onClick={handleCopyCustomSingleUseLink}
|
||||
className="col-span-1 gap-1 text-sm">
|
||||
{t("common.copy")}
|
||||
<CopyIcon />
|
||||
|
||||
+69
-5
@@ -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("workspace.surveys.share.send_email.email_subject_label")} :{" "}
|
||||
{t("workspace.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("workspace.surveys.share.send_email.email_preview_tab")}
|
||||
/>
|
||||
) : (
|
||||
<LoadingSpinner />
|
||||
)}
|
||||
|
||||
+3
-4
@@ -30,11 +30,11 @@ import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { generatePersonalLinksAction } from "../../actions";
|
||||
|
||||
interface PersonalLinksTabProps {
|
||||
workspaceId: string;
|
||||
surveyId: string;
|
||||
segments: TSegment[];
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
enterpriseLicenseRequestFormUrl: string;
|
||||
}
|
||||
|
||||
interface PersonalLinksFormData {
|
||||
@@ -70,11 +70,11 @@ const RestrictedDatePicker = ({
|
||||
};
|
||||
|
||||
export const PersonalLinksTab = ({
|
||||
workspaceId,
|
||||
segments,
|
||||
surveyId,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
enterpriseLicenseRequestFormUrl,
|
||||
}: PersonalLinksTabProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { workspace } = useWorkspace();
|
||||
@@ -117,7 +117,6 @@ export const PersonalLinksTab = ({
|
||||
const result = await generatePersonalLinksAction({
|
||||
surveyId: surveyId,
|
||||
segmentId: selectedSegment,
|
||||
workspaceId: workspaceId,
|
||||
expirationDays: expiryDate
|
||||
? Math.max(1, Math.floor((expiryDate.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24)))
|
||||
: undefined,
|
||||
@@ -171,7 +170,7 @@ export const PersonalLinksTab = ({
|
||||
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
href: isFormbricksCloud
|
||||
? `/workspaces/${workspace?.id}/settings/organization/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
: enterpriseLicenseRequestFormUrl,
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
|
||||
+18
-3
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+59
@@ -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>"
|
||||
);
|
||||
});
|
||||
});
|
||||
+4
-5
@@ -1,10 +1,12 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { toJsWorkspaceStateSurvey } from "@/lib/survey/client-utils";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getStyling } from "@/lib/utils/styling";
|
||||
import { getWorkspace } from "@/lib/workspace/service";
|
||||
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(workspace, survey);
|
||||
const styling = getStyling(workspace, toJsWorkspaceStateSurvey(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());
|
||||
};
|
||||
|
||||
+11
@@ -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();
|
||||
};
|
||||
+15
@@ -1105,6 +1105,21 @@ describe("getSurveySummary", () => {
|
||||
expect.objectContaining({ responseIds: expect.any(Array) })
|
||||
);
|
||||
});
|
||||
|
||||
test("does not pass responseIds for date-only filterCriteria", async () => {
|
||||
const filterCriteria: TResponseFilterCriteria = {
|
||||
createdAt: {
|
||||
min: new Date("2024-01-01T00:00:00.000Z"),
|
||||
max: new Date("2024-01-31T23:59:59.999Z"),
|
||||
},
|
||||
};
|
||||
|
||||
await getSurveySummary(mockSurveyId, filterCriteria);
|
||||
|
||||
expect(getDisplayCountBySurveyId).toHaveBeenCalledWith(mockSurveyId, {
|
||||
createdAt: filterCriteria.createdAt,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResponsesForSummary", () => {
|
||||
|
||||
+1
-1
@@ -999,7 +999,7 @@ export const getSurveySummary = reactCache(
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
|
||||
const batchSize = 5000;
|
||||
const hasFilter = Object.keys(filterCriteria ?? {}).length > 0;
|
||||
const hasFilter = Object.keys(filterCriteria ?? {}).some((filterKey) => filterKey !== "createdAt");
|
||||
|
||||
// Use cursor-based pagination instead of count + offset to avoid expensive queries
|
||||
const responses: TSurveySummaryResponse[] = [];
|
||||
|
||||
+7
-2
@@ -4,7 +4,12 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/workspaces/[workspaceId]/s
|
||||
import { SummaryPage } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { getSurveySummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
|
||||
import { DEFAULT_LOCALE, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
ENTERPRISE_LICENSE_REQUEST_FORM_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_STORAGE_CONFIGURED,
|
||||
} from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
@@ -64,7 +69,6 @@ const SurveyPage = async (props: { params: Promise<{ workspaceId: string; survey
|
||||
pageTitle={survey.name}
|
||||
cta={
|
||||
<SurveyAnalysisCTA
|
||||
survey={survey}
|
||||
isReadOnly={isReadOnly}
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
@@ -73,6 +77,7 @@ const SurveyPage = async (props: { params: Promise<{ workspaceId: string; survey
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||
enterpriseLicenseRequestFormUrl={ENTERPRISE_LICENSE_REQUEST_FORM_URL}
|
||||
/>
|
||||
}>
|
||||
<SurveyAnalysisNavigation survey={survey} activeId="summary" />
|
||||
|
||||
@@ -42,18 +42,25 @@ export const getResponsesDownloadUrlAction = authenticatedActionClient
|
||||
],
|
||||
});
|
||||
|
||||
const workspaceId = await getWorkspaceIdFromSurveyId(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: workspaceId,
|
||||
},
|
||||
{ organizationId, workspaceId }
|
||||
);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
+3
-10
@@ -4,6 +4,7 @@ import { useRouter } from "next/navigation";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { useSurvey } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/context/survey-context";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { updateSurveyAction } from "@/modules/survey/editor/actions";
|
||||
import {
|
||||
@@ -15,12 +16,8 @@ import {
|
||||
} from "@/modules/ui/components/select";
|
||||
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
|
||||
|
||||
interface SurveyStatusDropdownProps {
|
||||
updateLocalSurveyStatus?: (status: TSurvey["status"]) => void;
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
export const SurveyStatusDropdown = ({ updateLocalSurveyStatus, survey }: SurveyStatusDropdownProps) => {
|
||||
export const SurveyStatusDropdown = () => {
|
||||
const { survey } = useSurvey();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const isScheduled = survey.status === "paused" && survey.publishOn !== null;
|
||||
@@ -42,10 +39,6 @@ export const SurveyStatusDropdown = ({ updateLocalSurveyStatus, survey }: Survey
|
||||
toast.success(toastMessage);
|
||||
}
|
||||
|
||||
if (updateLocalSurveyStatus) {
|
||||
updateLocalSurveyStatus(resultingStatus);
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse);
|
||||
|
||||
+160
@@ -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();
|
||||
});
|
||||
});
|
||||
+82
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { authorizeTraefikRequest } from "@/modules/traefik-auth/service";
|
||||
|
||||
const handler = async (request: NextRequest): Promise<Response> => {
|
||||
return await authorizeTraefikRequest(request);
|
||||
};
|
||||
|
||||
export const GET = handler;
|
||||
export const POST = handler;
|
||||
export const PUT = handler;
|
||||
export const PATCH = handler;
|
||||
export const DELETE = handler;
|
||||
export const HEAD = handler;
|
||||
export const OPTIONS = handler;
|
||||
@@ -185,4 +185,20 @@ describe("auth route audit logging", () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("does not log a completed sign-in for the intermediate SSO recovery verification step", async () => {
|
||||
const authOptions = await getWrappedAuthOptions("req-sso-recovery");
|
||||
const user = {
|
||||
id: "user_4",
|
||||
email: "user4@example.com",
|
||||
authFlowPurpose: "sso_recovery",
|
||||
};
|
||||
const account = { provider: "token" };
|
||||
|
||||
await expect(authOptions.callbacks.signIn({ user, account })).resolves.toBe(true);
|
||||
await authOptions.events.signIn({ user, account, isNewUser: false });
|
||||
|
||||
expect(mocks.baseEventSignIn).not.toHaveBeenCalled();
|
||||
expect(mocks.queueAuditEventBackground).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,12 @@ const getAuthMethod = (account: Account | null) => {
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
const isSsoRecoveryVerificationFlow = (account: Account | null, user: User | AdapterUser) =>
|
||||
account?.provider === "token" &&
|
||||
"authFlowPurpose" in user &&
|
||||
typeof user.authFlowPurpose === "string" &&
|
||||
user.authFlowPurpose === "sso_recovery";
|
||||
|
||||
const handler = async (req: Request, ctx: any) => {
|
||||
const eventId = req.headers.get("x-request-id") ?? undefined;
|
||||
|
||||
@@ -117,6 +123,10 @@ const handler = async (req: Request, ctx: any) => {
|
||||
events: {
|
||||
...baseAuthOptions.events,
|
||||
async signIn({ user, account, isNewUser }: any) {
|
||||
if (isSsoRecoveryVerificationFlow(account, user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await baseAuthOptions.events?.signIn?.({ user, account, isNewUser });
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextResponse } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { verifySsoRelinkIntent } from "@/lib/jwt";
|
||||
import { deleteSessionBySessionToken } from "@/modules/auth/lib/auth-session-repository";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import {
|
||||
NEXT_AUTH_SESSION_COOKIE_NAMES,
|
||||
getSessionTokenFromCookieHeader,
|
||||
} from "@/modules/auth/lib/session-cookie";
|
||||
import { completeSsoRecovery, getSsoRecoveryFailureRedirectUrl } from "@/modules/ee/sso/lib/sso-recovery";
|
||||
|
||||
const clearSessionCookies = (response: NextResponse) => {
|
||||
for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) {
|
||||
response.cookies.set({
|
||||
name: cookieName,
|
||||
value: "",
|
||||
expires: new Date(0),
|
||||
path: "/",
|
||||
secure: cookieName.startsWith("__Secure-"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const buildFailedRecoveryResponse = async (request: Request, callbackUrl?: string) => {
|
||||
const response = NextResponse.redirect(getSsoRecoveryFailureRedirectUrl(callbackUrl));
|
||||
clearSessionCookies(response);
|
||||
|
||||
const sessionToken = getSessionTokenFromCookieHeader(request.headers.get("cookie"));
|
||||
if (!sessionToken) {
|
||||
return response;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteSessionBySessionToken(sessionToken);
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to delete SSO recovery session after recovery completion error");
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
export const GET = async (request: Request) => {
|
||||
const url = new URL(request.url);
|
||||
const intentToken = url.searchParams.get("intent");
|
||||
|
||||
if (!intentToken) {
|
||||
return NextResponse.redirect(getSsoRecoveryFailureRedirectUrl());
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
const callbackUrl = await completeSsoRecovery({
|
||||
intentToken,
|
||||
sessionUserId: session?.user.id,
|
||||
});
|
||||
|
||||
return NextResponse.redirect(callbackUrl);
|
||||
} catch {
|
||||
try {
|
||||
const intent = verifySsoRelinkIntent(intentToken);
|
||||
return await buildFailedRecoveryResponse(request, intent.callbackUrl);
|
||||
} catch {
|
||||
return await buildFailedRecoveryResponse(request);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
|
||||
export const isPrismaKnownRequestError = (error: unknown): error is Prisma.PrismaClientKnownRequestError =>
|
||||
error instanceof Prisma.PrismaClientKnownRequestError;
|
||||
|
||||
export const isSingleUseIdUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): boolean => {
|
||||
if (error.code !== PrismaErrorType.UniqueConstraintViolation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Array.isArray(error.meta?.target) && error.meta.target.includes("singleUseId");
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
import "server-only";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TResponseInput } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
import { symmetricDecrypt } from "@/lib/crypto";
|
||||
import { validateSurveySingleUseLinkParams } from "@/lib/utils/single-use-surveys";
|
||||
|
||||
type TSingleUseResponseInput = Pick<TResponseInput, "singleUseId" | "meta">;
|
||||
|
||||
type TValidateSingleUseResponseInputResult = { singleUseId: string } | { response: Response } | null;
|
||||
|
||||
export const validateSingleUseResponseInput = (
|
||||
survey: TSurvey,
|
||||
environmentId: string,
|
||||
responseInput: TSingleUseResponseInput
|
||||
): TValidateSingleUseResponseInputResult => {
|
||||
if (survey.type !== "link" || !survey.singleUse?.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
logger.error({ surveyId: survey.id, environmentId }, "ENCRYPTION_KEY is not set");
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("An unexpected error occurred.", true),
|
||||
};
|
||||
}
|
||||
|
||||
if (!responseInput.singleUseId) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Missing single use id",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (!responseInput.meta?.url) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Missing or invalid URL in response metadata",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(responseInput.meta.url);
|
||||
} catch (error) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Invalid URL in response metadata",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
error: error instanceof Error ? error.message : "Unknown error occurred",
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const suId = url.searchParams.get("suId");
|
||||
const suToken = url.searchParams.get("suToken");
|
||||
|
||||
if (!suId) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Missing single use id",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
let canonicalSingleUseId: string | null = null;
|
||||
try {
|
||||
canonicalSingleUseId = validateSurveySingleUseLinkParams({
|
||||
surveyId: survey.id,
|
||||
suId,
|
||||
suToken,
|
||||
isEncrypted: survey.singleUse.isEncrypted,
|
||||
decrypt: (encryptedSingleUseId: string) => symmetricDecrypt(encryptedSingleUseId, ENCRYPTION_KEY),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error, surveyId: survey.id, environmentId }, "Failed to validate single-use id");
|
||||
}
|
||||
|
||||
if (!canonicalSingleUseId || canonicalSingleUseId !== responseInput.singleUseId) {
|
||||
return {
|
||||
response: responses.badRequestResponse(
|
||||
"Invalid single use id",
|
||||
{
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { singleUseId: canonicalSingleUseId };
|
||||
};
|
||||
@@ -88,6 +88,16 @@ export const GET = async (req: Request) => {
|
||||
integration_type: "googleSheets",
|
||||
organization_id: organizationId,
|
||||
});
|
||||
capturePostHogEvent(
|
||||
session.user.id,
|
||||
"integration_connected",
|
||||
{
|
||||
integration_type: "googleSheets",
|
||||
organization_id: organizationId,
|
||||
workspace_id: workspaceId,
|
||||
},
|
||||
{ organizationId, workspaceId }
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TAPIKeyWorkspacePermission } 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(),
|
||||
@@ -165,7 +171,7 @@ describe("authenticateRequest", () => {
|
||||
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
const result = await authenticateRequest(request, { allowOrganizationOnlyApiKey: true });
|
||||
expect(result).toEqual({
|
||||
type: "apiKey",
|
||||
workspacePermissions: [],
|
||||
@@ -191,7 +197,7 @@ describe("authenticateRequest", () => {
|
||||
apiKeyWorkspaces: [],
|
||||
} as any);
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
const result = await authenticateRequest(request, { allowOrganizationOnlyApiKey: true });
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "apiKey",
|
||||
@@ -202,4 +208,120 @@ describe("authenticateRequest", () => {
|
||||
});
|
||||
expect(getApiKeyWithPermissions).toHaveBeenCalledWith("fbk_valid_bearer_key");
|
||||
});
|
||||
|
||||
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",
|
||||
apiKeyWorkspaces: [],
|
||||
};
|
||||
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
|
||||
|
||||
const result = await authenticateRequest(request, { allowOrganizationOnlyApiKey: true });
|
||||
expect(result).toEqual({
|
||||
type: "apiKey",
|
||||
workspacePermissions: [],
|
||||
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",
|
||||
apiKeyWorkspaces: [],
|
||||
};
|
||||
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
|
||||
|
||||
const result = await authenticateRequest(request, { allowOrganizationOnlyApiKey: true });
|
||||
expect(result).toEqual({
|
||||
type: "apiKey",
|
||||
workspacePermissions: [],
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
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 { authenticateApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth";
|
||||
import {
|
||||
type AuthenticateApiKeyOptions,
|
||||
authenticateApiKeyFromHeaders,
|
||||
} from "@/modules/api/lib/api-key-auth";
|
||||
|
||||
export const authenticateRequest = async (request: NextRequest): Promise<TAuthenticationApiKey | null> => {
|
||||
return await authenticateApiKeyFromHeaders(request.headers);
|
||||
export const authenticateRequest = async (
|
||||
request: NextRequest,
|
||||
options: AuthenticateApiKeyOptions = {}
|
||||
): Promise<TAuthenticationApiKey | null> => {
|
||||
return await authenticateApiKeyFromHeaders(request.headers, options);
|
||||
};
|
||||
|
||||
export const handleErrorResponse = (error: any): Response => {
|
||||
@@ -15,6 +26,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 ||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { getContactByUserId } from "./contact";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
contact: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("react", async () => {
|
||||
const actual = await vi.importActual("react");
|
||||
return {
|
||||
...actual,
|
||||
cache: vi.fn((fn: Function) => fn),
|
||||
};
|
||||
});
|
||||
|
||||
const workspaceId = "test-workspace-id";
|
||||
const userId = "test-user-id";
|
||||
const contact = {
|
||||
id: "test-contact-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
workspaceId,
|
||||
};
|
||||
|
||||
describe("getContactByUserId", () => {
|
||||
test("returns the first contact whose userId attribute exactly matches in the workspace", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue({ id: contact.id });
|
||||
|
||||
const result = await getContactByUserId(workspaceId, userId);
|
||||
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "userId",
|
||||
workspaceId,
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
expect(result).toEqual({ id: contact.id });
|
||||
});
|
||||
|
||||
test("returns null when no contact matches", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
|
||||
|
||||
const result = await getContactByUserId(workspaceId, userId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,12 @@ import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TDisplayCreateInput } from "@formbricks/types/displays";
|
||||
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
ResourceNotFoundError,
|
||||
ValidationError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { getContactByUserId } from "./contact";
|
||||
import { createDisplay } from "./display";
|
||||
@@ -78,6 +83,7 @@ const mockSurvey = {
|
||||
id: surveyId,
|
||||
name: "Test Survey",
|
||||
workspaceId,
|
||||
status: "inProgress",
|
||||
} as any;
|
||||
|
||||
describe("createDisplay", () => {
|
||||
@@ -180,6 +186,17 @@ describe("createDisplay", () => {
|
||||
expect(prisma.display.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each(["draft", "paused", "completed"])(
|
||||
"should throw InvalidInputError when survey status is %s",
|
||||
async (status) => {
|
||||
vi.mocked(getContactByUserId).mockResolvedValue(mockContact);
|
||||
vi.mocked(prisma.survey.findUnique).mockResolvedValue({ ...mockSurvey, status } as any);
|
||||
|
||||
await expect(createDisplay(displayInput)).rejects.toThrow(InvalidInputError);
|
||||
expect(prisma.display.create).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
test("should throw DatabaseError on other Prisma known request errors", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TDisplayCreateInput, ZDisplayCreateInput } from "@formbricks/types/displays";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { getContactByUserId } from "./contact";
|
||||
|
||||
@@ -41,6 +41,10 @@ export const createDisplay = async (displayInput: TDisplayCreateInput): Promise<
|
||||
throw new ResourceNotFoundError("Survey", surveyId);
|
||||
}
|
||||
|
||||
if (survey.status !== "inProgress") {
|
||||
throw new InvalidInputError("Survey is not accepting submissions");
|
||||
}
|
||||
|
||||
const display = await prisma.display.create({
|
||||
data: {
|
||||
survey: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZDisplayCreateInput } from "@formbricks/types/displays";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { InvalidInputError, ResourceNotFoundError } 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";
|
||||
@@ -72,6 +72,12 @@ export const POST = withV1ApiWrapper({
|
||||
return {
|
||||
response: responses.notFoundResponse("Survey", inputValidation.data.surveyId),
|
||||
};
|
||||
} else if (error instanceof InvalidInputError) {
|
||||
return {
|
||||
response: responses.forbiddenResponse(error.message, true, {
|
||||
surveyId: inputValidation.data.surveyId,
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
logger.error({ error, url: req.url }, "Error in POST /api/v1/client/[workspaceId]/displays");
|
||||
return {
|
||||
|
||||
@@ -72,7 +72,7 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
|
||||
select: {
|
||||
id: true,
|
||||
welcomeCard: true,
|
||||
name: true,
|
||||
// name intentionally omitted — internal label not needed by the SDK
|
||||
questions: true,
|
||||
blocks: true,
|
||||
variables: true,
|
||||
@@ -99,13 +99,13 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
|
||||
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,
|
||||
@@ -135,10 +135,28 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
|
||||
throw new ResourceNotFoundError("workspace", workspaceId);
|
||||
}
|
||||
|
||||
// Transform surveys using existing utility
|
||||
const transformedSurveys = workspaceData.surveys.map((survey) =>
|
||||
transformPrismaSurvey<TJsWorkspaceStateSurvey>(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 = workspaceData.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<TJsWorkspaceStateSurvey>({
|
||||
...surveyWithoutSegment,
|
||||
segment: null,
|
||||
});
|
||||
|
||||
return { ...transformed, segment: minimalSegment };
|
||||
});
|
||||
|
||||
return {
|
||||
workspace: {
|
||||
@@ -154,7 +172,7 @@ export const getWorkspaceStateData = async (workspaceId: string): Promise<Worksp
|
||||
},
|
||||
},
|
||||
surveys: resolveStorageUrlsInObject(transformedSurveys),
|
||||
actionClasses: workspaceData.actionClasses as TJsWorkspaceStateActionClass[],
|
||||
actionClasses: workspaceData.actionClasses,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
|
||||
@@ -6,11 +6,17 @@ import { TJsWorkspaceStateWorkspaceSetting } from "@formbricks/types/js";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
import { WorkspaceStateData, getWorkspaceStateData } from "./data";
|
||||
import { getWorkspaceState } from "./environmentState";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
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: {
|
||||
@@ -23,6 +29,9 @@ vi.mock("@formbricks/database", () => ({
|
||||
workspace: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
project: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
@@ -63,6 +72,10 @@ vi.mock("@/lib/posthog", () => ({
|
||||
capturePostHogEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getOrganizationIdFromWorkspaceId: vi.fn().mockResolvedValue("mock-org-id"),
|
||||
}));
|
||||
|
||||
// Mock @formbricks/cache
|
||||
vi.mock("@formbricks/cache", () => ({
|
||||
createCacheKey: {
|
||||
@@ -156,6 +169,7 @@ describe("getWorkspaceState", () => {
|
||||
|
||||
// Default mocks for successful retrieval
|
||||
vi.mocked(getWorkspaceStateData).mockResolvedValue(mockWorkspaceStateData);
|
||||
vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue("mock-org-id");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -328,11 +342,18 @@ describe("getWorkspaceState", () => {
|
||||
|
||||
await getWorkspaceState(workspaceId);
|
||||
|
||||
expect(capturePostHogEvent).toHaveBeenCalledWith(workspaceId, "app_connected", {
|
||||
num_surveys: 1,
|
||||
num_code_actions: 1,
|
||||
num_no_code_actions: 1,
|
||||
});
|
||||
expect(capturePostHogEvent).toHaveBeenCalledWith(
|
||||
workspaceId,
|
||||
"app_connected",
|
||||
{
|
||||
num_surveys: 1,
|
||||
num_code_actions: 1,
|
||||
num_no_code_actions: 1,
|
||||
organization_id: "mock-org-id",
|
||||
workspace_id: workspaceId,
|
||||
},
|
||||
{ organizationId: "mock-org-id", workspaceId }
|
||||
);
|
||||
});
|
||||
|
||||
test("should not capture app_connected event when app setup already completed", async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { cache } from "@/lib/cache";
|
||||
import { IS_RECAPTCHA_CONFIGURED, POSTHOG_KEY, RECAPTCHA_SITE_KEY } from "@/lib/constants";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
import { getWorkspaceStateData } from "./data";
|
||||
|
||||
/**
|
||||
@@ -37,11 +38,19 @@ export const getWorkspaceState = async (
|
||||
});
|
||||
|
||||
if (POSTHOG_KEY) {
|
||||
capturePostHogEvent(workspaceId, "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 organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
|
||||
capturePostHogEvent(
|
||||
workspaceId,
|
||||
"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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponseInput } from "@formbricks/types/responses";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
@@ -10,6 +10,8 @@ import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
import { evaluateResponseQuotas } from "@/modules/ee/quotas/lib/evaluation-service";
|
||||
import { createResponse, createResponseWithQuotaEvaluation } from "./response";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
let mockIsFormbricksCloud = false;
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
@@ -143,6 +145,16 @@ describe("createResponse", () => {
|
||||
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("should throw UniqueConstraintError on P2002 with singleUseId target", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
meta: { target: ["surveyId", "singleUseId"] },
|
||||
});
|
||||
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
|
||||
await expect(createResponse(mockResponseInput, prisma)).rejects.toThrow(UniqueConstraintError);
|
||||
});
|
||||
|
||||
test("should throw original error on other Prisma errors", async () => {
|
||||
const genericError = new Error("Generic database error");
|
||||
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
|
||||
|
||||
@@ -2,10 +2,14 @@ import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import {
|
||||
isPrismaKnownRequestError,
|
||||
isSingleUseIdUniqueConstraintError,
|
||||
} from "@/app/api/client/[workspaceId]/responses/lib/response-error";
|
||||
import { buildPrismaResponseData } from "@/app/api/v1/lib/utils";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { calculateTtcTotal } from "@/lib/response/utils";
|
||||
@@ -122,7 +126,11 @@ export const createResponse = async (
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (isPrismaKnownRequestError(error)) {
|
||||
if (isSingleUseIdUniqueConstraintError(error)) {
|
||||
throw new UniqueConstraintError("Response already submitted for this single-use link");
|
||||
}
|
||||
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { headers } from "next/headers";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { InvalidInputError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { validateSingleUseResponseInput } from "@/app/api/client/[workspaceId]/responses/lib/single-use";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
@@ -94,10 +95,7 @@ export const POST = withV1ApiWrapper({
|
||||
const agent = new UAParser(userAgent);
|
||||
|
||||
const country =
|
||||
requestHeaders.get("CF-IPCountry") ||
|
||||
requestHeaders.get("X-Vercel-IP-Country") ||
|
||||
requestHeaders.get("CloudFront-Viewer-Country") ||
|
||||
undefined;
|
||||
requestHeaders.get("CF-IPCountry") || requestHeaders.get("CloudFront-Viewer-Country") || undefined;
|
||||
|
||||
const responseInputData = responseInputValidation.data;
|
||||
|
||||
@@ -133,6 +131,22 @@ export const POST = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
if (survey.status !== "inProgress") {
|
||||
return {
|
||||
response: responses.forbiddenResponse("Survey is not accepting submissions", true, {
|
||||
surveyId: survey.id,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const singleUseValidationResult = validateSingleUseResponseInput(survey, workspaceId, responseInputData);
|
||||
if (singleUseValidationResult) {
|
||||
if ("response" in singleUseValidationResult) {
|
||||
return { response: singleUseValidationResult.response };
|
||||
}
|
||||
responseInputData.singleUseId = singleUseValidationResult.singleUseId;
|
||||
}
|
||||
|
||||
if (!validateFileUploads(responseInputData.data, survey.questions)) {
|
||||
return {
|
||||
response: responses.badRequestResponse("Invalid file upload response"),
|
||||
@@ -174,6 +188,10 @@ export const POST = withV1ApiWrapper({
|
||||
return {
|
||||
response: responses.badRequestResponse(error.message),
|
||||
};
|
||||
} else if (error instanceof UniqueConstraintError) {
|
||||
return {
|
||||
response: responses.conflictResponse(error.message, undefined, true),
|
||||
};
|
||||
} else {
|
||||
logger.error({ error, url: req.url }, "Error creating response");
|
||||
return {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getOrganization } from "@/lib/organization/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getSignedUrlForUpload } from "@/modules/storage/service";
|
||||
@@ -95,6 +96,17 @@ export const POST = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await applyRateLimit(rateLimitConfigs.storage.uploadPerWorkspace, workspaceId);
|
||||
} catch (error) {
|
||||
return {
|
||||
response: responses.tooManyRequestsResponse(
|
||||
error instanceof Error ? error.message : "Rate limit exceeded",
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const isBiggerFileUploadAllowed = await getBiggerUploadFileSizePermission(organization.id);
|
||||
const maxFileUploadSize = isBiggerFileUploadAllowed
|
||||
? MAX_FILE_UPLOAD_SIZES.big
|
||||
|
||||
@@ -4,7 +4,7 @@ import { responses } from "@/app/lib/api/response";
|
||||
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 { createOrUpdateIntegration } from "@/lib/integration/service";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
|
||||
@@ -79,11 +79,15 @@ 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(workspaceId, "airtable");
|
||||
const existingData = existingIntegration?.config?.data ?? [];
|
||||
|
||||
const airtableIntegrationInput = {
|
||||
type: "airtable" as "airtable",
|
||||
type: "airtable" as const,
|
||||
config: {
|
||||
key,
|
||||
data: [],
|
||||
data: existingData,
|
||||
email,
|
||||
},
|
||||
};
|
||||
@@ -91,10 +95,16 @@ export const GET = withV1ApiWrapper({
|
||||
|
||||
try {
|
||||
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
|
||||
capturePostHogEvent(authentication.user.id, "integration_connected", {
|
||||
integration_type: "airtable",
|
||||
organization_id: organizationId,
|
||||
});
|
||||
capturePostHogEvent(
|
||||
authentication.user.id,
|
||||
"integration_connected",
|
||||
{
|
||||
integration_type: "airtable",
|
||||
organization_id: organizationId,
|
||||
workspace_id: workspaceId,
|
||||
},
|
||||
{ organizationId, workspaceId }
|
||||
);
|
||||
} 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 { getIntegrationByType } from "@/lib/integration/service";
|
||||
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
|
||||
|
||||
@@ -36,15 +35,20 @@ export const GET = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const integration = (await getIntegrationByType(workspaceId, "airtable")) as TIntegrationAirtable;
|
||||
const integration = await getIntegrationByType(workspaceId, "airtable");
|
||||
|
||||
if (!integration) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Integration not found", workspaceId),
|
||||
response: responses.notFoundResponse("Integration not found", null),
|
||||
};
|
||||
}
|
||||
|
||||
const tables = await getTables(integration.config.key, baseId.data);
|
||||
// Use getAirtableToken to ensure the access token is refreshed if expired
|
||||
const freshAccessToken = await getAirtableToken(workspaceId);
|
||||
const tables = await getTables(
|
||||
{ ...integration.config.key, access_token: freshAccessToken },
|
||||
baseId.data
|
||||
);
|
||||
return {
|
||||
response: responses.successResponse(tables),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
|
||||
import { TIntegrationNotionInput } from "@formbricks/types/integration/notion";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import {
|
||||
@@ -95,7 +95,7 @@ export const GET = withV1ApiWrapper({
|
||||
|
||||
const existingIntegration = await getIntegrationByType(workspaceId, "notion");
|
||||
if (existingIntegration) {
|
||||
notionIntegration.config.data = existingIntegration.config.data as TIntegrationNotionConfigData[];
|
||||
notionIntegration.config.data = existingIntegration.config.data;
|
||||
}
|
||||
|
||||
const result = await createOrUpdateIntegration(workspaceId, notionIntegration);
|
||||
@@ -103,10 +103,16 @@ export const GET = withV1ApiWrapper({
|
||||
if (result) {
|
||||
try {
|
||||
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
|
||||
capturePostHogEvent(authentication.user.id, "integration_connected", {
|
||||
integration_type: "notion",
|
||||
organization_id: organizationId,
|
||||
});
|
||||
capturePostHogEvent(
|
||||
authentication.user.id,
|
||||
"integration_connected",
|
||||
{
|
||||
integration_type: "notion",
|
||||
organization_id: organizationId,
|
||||
workspace_id: workspaceId,
|
||||
},
|
||||
{ organizationId, workspaceId }
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for notion");
|
||||
}
|
||||
|
||||
@@ -110,10 +110,16 @@ export const GET = withV1ApiWrapper({
|
||||
if (result) {
|
||||
try {
|
||||
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
|
||||
capturePostHogEvent(authentication.user.id, "integration_connected", {
|
||||
integration_type: "slack",
|
||||
organization_id: organizationId,
|
||||
});
|
||||
capturePostHogEvent(
|
||||
authentication.user.id,
|
||||
"integration_connected",
|
||||
{
|
||||
integration_type: "slack",
|
||||
organization_id: organizationId,
|
||||
workspace_id: workspaceId,
|
||||
},
|
||||
{ organizationId, workspaceId }
|
||||
);
|
||||
} 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 { resolveBodyIds } from "@/app/api/v1/management/lib/workspace-resolver";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
@@ -84,6 +84,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),
|
||||
|
||||
@@ -170,6 +170,20 @@ const handleSessionAuthentication = async () => {
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: sessionUser.id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
twoFactorEnabled: true,
|
||||
identityProvider: true,
|
||||
notificationSettings: true,
|
||||
locale: true,
|
||||
lastLoginAt: true,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
return Response.json(user);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { responses } from "@/app/lib/api/response";
|
||||
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
|
||||
import { generateSurveySingleUseLinkParamsList } from "@/lib/utils/single-use-surveys";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
@@ -56,13 +56,22 @@ export const GET = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted);
|
||||
const singleUseLinkParams = generateSurveySingleUseLinkParamsList(
|
||||
limit,
|
||||
survey.id,
|
||||
survey.singleUse.isEncrypted
|
||||
);
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
// map single use ids to survey links
|
||||
const surveyLinks = singleUseIds.map(
|
||||
(singleUseId) => `${publicDomain}/s/${survey.id}?suId=${singleUseId}`
|
||||
);
|
||||
const surveyLinks = singleUseLinkParams.map(({ suId, suToken }) => {
|
||||
const surveyLink = new URL(`${publicDomain}/s/${survey.id}`);
|
||||
surveyLink.searchParams.set("suId", suId);
|
||||
if (suToken) {
|
||||
surveyLink.searchParams.set("suToken", suToken);
|
||||
}
|
||||
return surveyLink.toString();
|
||||
});
|
||||
|
||||
return {
|
||||
response: responses.successResponse(surveyLinks),
|
||||
|
||||
@@ -23,6 +23,7 @@ import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
import { getSurveys } from "./lib/surveys";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
allowOrganizationOnlyApiKey: true,
|
||||
handler: async ({ req, authentication }) => {
|
||||
if (!authentication || !("apiKeyId" in authentication)) {
|
||||
return { response: responses.notAuthenticatedResponse() };
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("doesContactExist", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("should return false if contact does not exist", async () => {
|
||||
test("should return false if contact does not exist in the workspace", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
|
||||
|
||||
const result = await doesContactExist(contactId);
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
ResourceNotFoundError,
|
||||
ValidationError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { TDisplayCreateInputV2 } from "../types/display";
|
||||
import { doesContactExist } from "./contact";
|
||||
@@ -66,6 +71,7 @@ const mockSurvey = {
|
||||
id: surveyId,
|
||||
name: "Test Survey",
|
||||
workspaceId,
|
||||
status: "inProgress",
|
||||
} as any;
|
||||
|
||||
describe("createDisplay", () => {
|
||||
@@ -108,7 +114,7 @@ describe("createDisplay", () => {
|
||||
expect(result).toEqual(mockDisplayWithoutContact); // Changed this line
|
||||
});
|
||||
|
||||
test("should create a display even if contact does not exist", async () => {
|
||||
test("should create a display without contact if contact does not exist in the workspace", async () => {
|
||||
vi.mocked(doesContactExist).mockResolvedValue(false);
|
||||
vi.mocked(prisma.display.create).mockResolvedValue(mockDisplayWithoutContact); // Expect no contact connection
|
||||
|
||||
@@ -149,6 +155,17 @@ describe("createDisplay", () => {
|
||||
expect(prisma.display.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each(["draft", "paused", "completed"])(
|
||||
"should throw InvalidInputError when survey status is %s",
|
||||
async (status) => {
|
||||
vi.mocked(doesContactExist).mockResolvedValue(true);
|
||||
vi.mocked(prisma.survey.findUnique).mockResolvedValue({ ...mockSurvey, status } as any);
|
||||
|
||||
await expect(createDisplay(displayInput)).rejects.toThrow(InvalidInputError);
|
||||
expect(prisma.display.create).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
test("should throw DatabaseError on other Prisma known request errors", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
|
||||
code: "P2002",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import {
|
||||
TDisplayCreateInputV2,
|
||||
ZDisplayCreateInputV2,
|
||||
@@ -26,6 +26,10 @@ export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promis
|
||||
throw new ResourceNotFoundError("Survey", surveyId);
|
||||
}
|
||||
|
||||
if (survey.status !== "inProgress") {
|
||||
throw new InvalidInputError("Survey is not accepting submissions");
|
||||
}
|
||||
|
||||
const display = await prisma.display.create({
|
||||
data: {
|
||||
survey: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import {
|
||||
TDisplayCreateInputV2,
|
||||
ZDisplayCreateInputV2,
|
||||
@@ -52,7 +52,6 @@ export const OPTIONS = async (): Promise<Response> => {
|
||||
|
||||
export const POST = async (request: Request, context: Context): Promise<Response> => {
|
||||
const params = await context.params;
|
||||
|
||||
// Resolve: accepts either an environmentId (old SDK) or a workspaceId (new SDK)
|
||||
const resolved = await resolveClientApiIds(params.workspaceId);
|
||||
if (!resolved) {
|
||||
@@ -88,6 +87,12 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
return responses.notFoundResponse("Survey", displayInputData.surveyId, true);
|
||||
}
|
||||
|
||||
if (error instanceof InvalidInputError) {
|
||||
return responses.forbiddenResponse(error.message, true, {
|
||||
surveyId: displayInputData.surveyId,
|
||||
});
|
||||
}
|
||||
|
||||
const response = responses.internalServerErrorResponse("Something went wrong. Please try again.", true);
|
||||
reportApiError({
|
||||
request,
|
||||
|
||||
@@ -13,6 +13,7 @@ vi.mock("@formbricks/database", () => ({
|
||||
}));
|
||||
|
||||
const contactId = "test-contact-id";
|
||||
const workspaceId = "test-workspace-id";
|
||||
const mockContact = {
|
||||
id: contactId,
|
||||
attributes: [
|
||||
@@ -32,10 +33,10 @@ describe("getContact", () => {
|
||||
mockContact as unknown as Awaited<ReturnType<typeof prisma.contact.findUnique>>
|
||||
);
|
||||
|
||||
const result = await getContact(contactId);
|
||||
const result = await getContact(contactId, workspaceId);
|
||||
|
||||
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: contactId },
|
||||
where: { id: contactId, workspaceId },
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
@@ -55,10 +56,10 @@ describe("getContact", () => {
|
||||
test("should return null when contact is not found", async () => {
|
||||
vi.mocked(prisma.contact.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await getContact(contactId);
|
||||
const result = await getContact(contactId, workspaceId);
|
||||
|
||||
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: contactId },
|
||||
where: { id: contactId, workspaceId },
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
|
||||
@@ -2,9 +2,16 @@ import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
|
||||
export const getContact = reactCache(async (contactId: string) => {
|
||||
type TContactAttributeResult = {
|
||||
attributeKey: {
|
||||
key: string;
|
||||
};
|
||||
value: string;
|
||||
};
|
||||
|
||||
export const getContact = reactCache(async (contactId: string, workspaceId: string) => {
|
||||
const contact = await prisma.contact.findUnique({
|
||||
where: { id: contactId },
|
||||
where: { id: contactId, workspaceId },
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
@@ -20,10 +27,13 @@ export const getContact = reactCache(async (contactId: string) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contactAttributes = contact.attributes.reduce<TContactAttributes>((acc, attr) => {
|
||||
acc[attr.attributeKey.key] = attr.value;
|
||||
return acc;
|
||||
}, {});
|
||||
const contactAttributes = contact.attributes.reduce(
|
||||
(acc: TContactAttributes, attr: TContactAttributeResult) => {
|
||||
acc[attr.attributeKey.key] = attr.value;
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
return {
|
||||
id: contact.id,
|
||||
|
||||
@@ -239,6 +239,51 @@ describe("createResponse V2", () => {
|
||||
const result = await createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient);
|
||||
expect(result.tags).toEqual([mockTag]);
|
||||
});
|
||||
|
||||
test("should create response with contact when contact belongs to the workspace", async () => {
|
||||
const responseInputWithContact = {
|
||||
...mockResponseInput,
|
||||
contactId,
|
||||
};
|
||||
|
||||
const result = await createResponse(
|
||||
responseInputWithContact,
|
||||
mockTx as unknown as Prisma.TransactionClient
|
||||
);
|
||||
|
||||
expect(getContact).toHaveBeenCalledWith(contactId, workspaceId);
|
||||
expect(mockTx.response.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
contact: { connect: { id: contactId } },
|
||||
contactAttributes: mockContact.attributes,
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(result.contact).toEqual({
|
||||
id: contactId,
|
||||
userId,
|
||||
});
|
||||
});
|
||||
|
||||
test("should create response without contact when contact is not found in the workspace", async () => {
|
||||
vi.mocked(getContact).mockResolvedValue(null);
|
||||
const responseInputWithContact = {
|
||||
...mockResponseInput,
|
||||
contactId,
|
||||
};
|
||||
|
||||
const result = await createResponse(
|
||||
responseInputWithContact,
|
||||
mockTx as unknown as Prisma.TransactionClient
|
||||
);
|
||||
const createArgs = mockTx.response.create.mock.calls[0][0];
|
||||
|
||||
expect(getContact).toHaveBeenCalledWith(contactId, workspaceId);
|
||||
expect(createArgs.data).not.toHaveProperty("contact");
|
||||
expect(createArgs.data).not.toHaveProperty("contactAttributes");
|
||||
expect(result.contact).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createResponseWithQuotaEvaluation V2", () => {
|
||||
|
||||
@@ -6,6 +6,10 @@ import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@fo
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import {
|
||||
isPrismaKnownRequestError,
|
||||
isSingleUseIdUniqueConstraintError,
|
||||
} from "@/app/api/client/[workspaceId]/responses/lib/response-error";
|
||||
import { responseSelection } from "@/app/api/v1/client/[workspaceId]/responses/lib/response";
|
||||
import { TResponseInputV2 } from "@/app/api/v2/client/[workspaceId]/responses/types/response";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
@@ -18,7 +22,7 @@ import { getContact } from "./contact";
|
||||
export const createResponseWithQuotaEvaluation = async (
|
||||
responseInput: TResponseInputV2
|
||||
): Promise<TResponseWithQuotaFull> => {
|
||||
const txResponse = await prisma.$transaction(async (tx) => {
|
||||
const txResponse = await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
const response = await createResponse(responseInput, tx);
|
||||
|
||||
const quotaResult = await evaluateResponseQuotas({
|
||||
@@ -103,7 +107,7 @@ export const createResponse = async (
|
||||
}
|
||||
|
||||
if (contactId) {
|
||||
contact = await getContact(contactId);
|
||||
contact = await getContact(contactId, workspaceId);
|
||||
}
|
||||
|
||||
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
|
||||
@@ -130,12 +134,9 @@ export const createResponse = async (
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === "P2002") {
|
||||
const target = (error.meta?.target as string[]) ?? [];
|
||||
if (target?.includes("singleUseId")) {
|
||||
throw new UniqueConstraintError("Response already submitted for this single-use link");
|
||||
}
|
||||
if (isPrismaKnownRequestError(error)) {
|
||||
if (isSingleUseIdUniqueConstraintError(error)) {
|
||||
throw new UniqueConstraintError("Response already submitted for this single-use link");
|
||||
}
|
||||
|
||||
throw new DatabaseError(error.message);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { TResponseInputV2 } from "@/app/api/v2/client/[workspaceId]/responses/ty
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { symmetricDecrypt } from "@/lib/crypto";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
import { generateSurveySingleUseSignature } from "@/lib/utils/single-use-surveys";
|
||||
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
|
||||
vi.mock("@/lib/i18n/utils", () => ({
|
||||
@@ -25,6 +26,7 @@ vi.mock("@/app/lib/api/response", () => ({
|
||||
responses: {
|
||||
badRequestResponse: vi.fn((message) => new Response(message, { status: 400 })),
|
||||
notFoundResponse: vi.fn((message) => new Response(message, { status: 404 })),
|
||||
forbiddenResponse: vi.fn((message) => new Response(message, { status: 403 })),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -52,6 +54,11 @@ vi.mock("@/lib/crypto", () => ({
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
ENCRYPTION_KEY: "test-key",
|
||||
}));
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
ENCRYPTION_KEY: "test-key",
|
||||
},
|
||||
}));
|
||||
|
||||
const mockSurvey: TSurvey = {
|
||||
id: "survey-1",
|
||||
@@ -90,9 +97,9 @@ const mockSurvey: TSurvey = {
|
||||
showLanguageSwitch: false,
|
||||
blocks: [],
|
||||
isCaptureIpEnabled: false,
|
||||
isAutoProgressingEnabled: true,
|
||||
metadata: {},
|
||||
slug: null,
|
||||
isAutoProgressingEnabled: true,
|
||||
};
|
||||
|
||||
const mockResponseInput: TResponseInputV2 = {
|
||||
@@ -112,6 +119,7 @@ const mockBillingData: TOrganizationBilling = {
|
||||
usageCycleAnchor: new Date(),
|
||||
stripeCustomerId: "mock-stripe-customer-id",
|
||||
};
|
||||
const validSingleUseId = "cm8f4x9mm0001gx9h5b7d7h3q";
|
||||
|
||||
describe("checkSurveyValidity", () => {
|
||||
beforeEach(() => {
|
||||
@@ -133,6 +141,19 @@ describe("checkSurveyValidity", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["draft", "paused", "completed"] as const)(
|
||||
"should return forbiddenResponse when survey status is %s",
|
||||
async (status) => {
|
||||
const survey = { ...mockSurvey, status } as TSurvey;
|
||||
const result = await checkSurveyValidity(survey, "ws-1", mockResponseInput);
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(403);
|
||||
expect(responses.forbiddenResponse).toHaveBeenCalledWith("Survey is not accepting submissions", true, {
|
||||
surveyId: mockSurvey.id,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
test("should return null if recaptcha is not enabled", async () => {
|
||||
const survey = { ...mockSurvey, recaptcha: { enabled: false, threshold: 0.5 }, workspaceId: "ws-1" };
|
||||
const result = await checkSurveyValidity(survey, "ws-1", mockResponseInput);
|
||||
@@ -311,7 +332,7 @@ describe("checkSurveyValidity", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null if singleUse is enabled, not encrypted, and suId matches singleUseId", async () => {
|
||||
test("should return badRequestResponse if singleUse is enabled, not encrypted, and suId present but no suToken", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false }, workspaceId: "ws-1" };
|
||||
const url = "https://example.com/?suId=su-1";
|
||||
const result = await checkSurveyValidity(survey, "ws-1", {
|
||||
@@ -319,16 +340,35 @@ describe("checkSurveyValidity", () => {
|
||||
singleUseId: "su-1",
|
||||
meta: { url },
|
||||
});
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(400);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("Invalid single use id", {
|
||||
surveyId: survey.id,
|
||||
workspaceId: "ws-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null if singleUse is enabled, not encrypted, and signed suId matches singleUseId", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false }, workspaceId: "ws-1" };
|
||||
const suToken = generateSurveySingleUseSignature(survey.id, "su-1");
|
||||
const url = `https://example.com/?suId=su-1&suToken=${suToken}`;
|
||||
const responseInput = {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
meta: { url },
|
||||
};
|
||||
const result = await checkSurveyValidity(survey, "ws-1", responseInput);
|
||||
expect(result).toBeNull();
|
||||
expect(responseInput.singleUseId).toBe("su-1");
|
||||
});
|
||||
|
||||
test("should return null if singleUse is enabled, encrypted, and decrypted suId matches singleUseId", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true }, workspaceId: "ws-1" };
|
||||
const url = "https://example.com/?suId=encrypted-id";
|
||||
vi.mocked(symmetricDecrypt).mockReturnValue("su-1");
|
||||
vi.mocked(symmetricDecrypt).mockReturnValue(validSingleUseId);
|
||||
const _resultEncryptedMatch = await checkSurveyValidity(survey, "ws-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
singleUseId: validSingleUseId,
|
||||
meta: { url },
|
||||
});
|
||||
expect(symmetricDecrypt).toHaveBeenCalledWith("encrypted-id", "test-key");
|
||||
|
||||
@@ -7,6 +7,7 @@ import { responses } from "@/app/lib/api/response";
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
import { symmetricDecrypt } from "@/lib/crypto";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
import { validateSurveySingleUseLinkParams } from "@/lib/utils/single-use-surveys";
|
||||
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
|
||||
export const RECAPTCHA_VERIFICATION_ERROR_CODE = "recaptcha_verification_failed";
|
||||
@@ -26,6 +27,12 @@ export const checkSurveyValidity = async (
|
||||
);
|
||||
}
|
||||
|
||||
if (survey.status !== "inProgress") {
|
||||
return responses.forbiddenResponse("Survey is not accepting submissions", true, {
|
||||
surveyId: survey.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (survey.type === "link" && survey.singleUse?.enabled) {
|
||||
if (!responseInput.singleUseId) {
|
||||
return responses.badRequestResponse("Missing single use id", {
|
||||
@@ -52,6 +59,7 @@ export const checkSurveyValidity = async (
|
||||
});
|
||||
}
|
||||
const suId = url.searchParams.get("suId");
|
||||
const suToken = url.searchParams.get("suToken");
|
||||
if (!suId) {
|
||||
return responses.badRequestResponse("Missing single use id", {
|
||||
surveyId: survey.id,
|
||||
@@ -59,20 +67,27 @@ export const checkSurveyValidity = async (
|
||||
});
|
||||
}
|
||||
|
||||
if (survey.singleUse.isEncrypted) {
|
||||
const decryptedSuId = symmetricDecrypt(suId, ENCRYPTION_KEY);
|
||||
if (decryptedSuId !== responseInput.singleUseId) {
|
||||
return responses.badRequestResponse("Invalid single use id", {
|
||||
surveyId: survey.id,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
} else if (responseInput.singleUseId !== suId) {
|
||||
let canonicalSingleUseId: string | null = null;
|
||||
try {
|
||||
canonicalSingleUseId = validateSurveySingleUseLinkParams({
|
||||
surveyId: survey.id,
|
||||
suId,
|
||||
suToken,
|
||||
isEncrypted: survey.singleUse.isEncrypted,
|
||||
decrypt: (encryptedSingleUseId: string) => symmetricDecrypt(encryptedSingleUseId, ENCRYPTION_KEY),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ error, surveyId: survey.id, workspaceId }, "Failed to validate single-use id");
|
||||
}
|
||||
|
||||
if (!canonicalSingleUseId || canonicalSingleUseId !== responseInput.singleUseId) {
|
||||
return responses.badRequestResponse("Invalid single use id", {
|
||||
surveyId: survey.id,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
responseInput.singleUseId = canonicalSingleUseId;
|
||||
}
|
||||
|
||||
if (survey.recaptcha?.enabled) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user