Compare commits

...

52 Commits

Author SHA1 Message Date
Matti Nannt
782528f169 chore: update surveys package npm dependencies (#5302) 2025-04-10 10:44:56 +02:00
Piyush Gupta
104c78275f docs: fixes framework guide link (#5307) 2025-04-10 08:11:40 +00:00
Matti Nannt
d9d88f7175 chore: update eslint npm dependencies (#5313) 2025-04-10 10:22:58 +02:00
Dhruwang Jariwala
bf7e24cf11 fix: stripe issue for customers with existing stripe ID (#5308) 2025-04-10 07:56:01 +00:00
Anshuman Pandey
c8aba01db3 fix: adds isWebEnvironment check in the surveys package (#5310) 2025-04-10 09:01:36 +02:00
Piyush Gupta
a896c7e46e docs: updated API playground link in the webhooks docs (#5301) 2025-04-09 08:33:36 +00:00
Matti Nannt
8018ec14a2 chore: use remote turbocache for building formbricks (#5305) 2025-04-09 10:38:17 +02:00
victorvhs017
9c3208c860 chore: Refactored the Formbricks next public env variables and added test files (#5014)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-04-09 08:10:32 +00:00
Anshuman Pandey
e1063964cf fix: fixes segment self referencing issue (#5254)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-04-09 06:58:28 +00:00
victorvhs017
38568738cc feat: Added test configuration and initial test files to the surveys package (#5253)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-09 06:53:16 +00:00
Piyush Gupta
15b8358b14 fix: date format in response table (#5304) 2025-04-09 05:39:57 +00:00
Anshuman Pandey
2173cb2610 fix: removes sourcemaps (#5257) 2025-04-09 04:50:56 +00:00
Matti Nannt
87b925d622 chore: update apps/web npm dependencies (#5300) 2025-04-09 06:58:53 +02:00
Piyush Gupta
885b06cc26 fix: adds date value check in date question summary (#5296) 2025-04-09 04:07:39 +00:00
Matti Nannt
adb6a5f41e chore: upgrade npm dependencies (#5299) 2025-04-09 04:47:07 +02:00
Matti Nannt
3b815e22e3 chore: add docker build check github action (#4875)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-04-08 13:26:48 +00:00
Matti Nannt
4d4a5c0e64 fix: solve sonarqube security hotspots (#5292) 2025-04-08 14:58:24 +02:00
Anshuman Pandey
0e89293974 fix: appUrl fix in iOS and android packages (#5295) 2025-04-08 14:51:30 +02:00
Jakob Schott
c306911b3a fix: replace hard-coded alerts with alert component (#5156)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-04-08 10:26:28 +00:00
Piyush Gupta
4f276f0095 feat: personalized survey links for segment of users endpoint (#5032)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-08 05:54:27 +00:00
Dhruwang Jariwala
81fc97c7e9 fix: Add Cache-Control to allowed CORS headers (#5252) 2025-04-07 14:47:02 +00:00
Matti Nannt
785c5a59c6 chore: make mock passwords more obvious to test suites (#5240) 2025-04-07 12:40:40 +00:00
Piyush Gupta
25ecfaa883 fix: formbricks version on localhost (#5250) 2025-04-07 10:42:13 +00:00
Anshuman Pandey
38e2c019fa fix: ios package sonarqube fixes (#5249) 2025-04-07 08:48:56 +00:00
victorvhs017
15878a4ac5 chore: Refactored the Turnstile next public env variable and added test files (#4997)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-04-07 06:07:39 +00:00
Matti Nannt
9802536ded chore: upgrade demo app to tailwind v4 (#5237)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-04-07 05:40:10 +00:00
victorvhs017
2c7f92a4d7 feat: user endpoints (#5232)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-06 06:06:18 +00:00
Piyush Gupta
c653841037 chore: block signin with SSO when user is not found (#5233)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-06 04:22:53 +00:00
Matti Nannt
ec314c14ea fix: failing e2e test (#5234) 2025-04-05 14:20:22 +02:00
victorvhs017
c03e60ac0b feat: organization endpoints (#5076)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-05 13:54:21 +02:00
Dhruwang Jariwala
cbf2343143 feat: lastLoginAt to user model (#5216) 2025-04-05 13:22:38 +02:00
Dhruwang Jariwala
9d9b3ac543 chore: added isActive to user model (#5211)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-04-05 12:22:45 +02:00
Matti Nannt
591b35a70b fix: upgrade npm dependencies with high security risk (#5221) 2025-04-05 06:04:01 +02:00
Piyush Gupta
f0c7b881d3 fix: don't allow spaces as "other" values in select questions (#5224) 2025-04-04 08:01:26 +00:00
dependabot[bot]
3fd5515db1 chore(deps): bump SonarSource/sonarqube-scan-action from 4.2.1 to 5.1.0 (#5104)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-04 05:03:40 +02:00
Matti Nannt
f32401afd6 chore: update vite & vitest dependency versions (#5217) 2025-04-04 03:40:21 +02:00
Dhruwang Jariwala
1b9d91f1e8 chore: Api keys to org level (#5044)
Co-authored-by: Victor Santos <victor@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-03 14:59:42 +02:00
Matti Nannt
1f039d707c chore: update root npm dependencies (#5208) 2025-04-03 06:40:29 +02:00
Dhruwang Jariwala
6671d877ad fix: skip button label validation for required nps and rating questions (#5153) 2025-04-02 09:53:25 +00:00
Matti Nannt
2867c95494 chore: update SHARE_RATE_LIMIT to 50 request per 5 minute (#5194)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2025-04-02 09:48:59 +00:00
Johannes
aa55cec060 fix: bulk member invite and table layout (#5209) 2025-04-02 09:32:01 +00:00
Matti Nannt
dfb6c4cd9e chore: update demo app dependencies (#5207) 2025-04-02 06:34:15 +00:00
Dhruwang Jariwala
a9082f66e8 fix: (Security) implement HSTS (#5206) 2025-04-02 05:38:33 +00:00
Dhruwang Jariwala
bf39b0fbfb fix: added cache no-store when formbricksDebug is enabled (#5197)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-02 05:19:27 +00:00
Dhruwang Jariwala
e347f2179a fix: consent/cta back button issue (#5201) 2025-04-02 02:29:53 +00:00
Matti Nannt
d4f155b6bc chore: update storybook app dependencies (#5195) 2025-04-01 19:39:45 +02:00
Matti Nannt
da001834f5 chore: remove unused tailwind import from mobile SDK webviews (#5198) 2025-04-01 12:59:57 +00:00
Anshuman Pandey
f54352dd82 chore: changes storage cache to 5 minutes (#5196) 2025-04-01 07:25:17 +00:00
Matti Nannt
0fba0fae73 chore: remove posthog provider from top layout (#5169) 2025-04-01 06:24:17 +00:00
Anshuman Pandey
406ec88515 fix: adding back hidden fields for backwards compatibility (#5163) 2025-04-01 05:20:30 +00:00
Matti Nannt
b97957d166 chore(infra): increase ressource limits to 1 cpu & 1Gi mem (#5192) 2025-04-01 04:50:55 +00:00
Matti Nannt
655ad6b9e0 docs: fix response client api endpoint is missing environmentId (#5161) 2025-03-31 12:14:44 +02:00
410 changed files with 21125 additions and 8181 deletions

View File

@@ -117,7 +117,7 @@ IMPRINT_URL=
IMPRINT_ADDRESS=
# Configure Turnstile in signup flow
# NEXT_PUBLIC_TURNSTILE_SITE_KEY=
# TURNSTILE_SITE_KEY=
# TURNSTILE_SECRET_KEY=
# Configure Github Login
@@ -155,9 +155,8 @@ STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
# Configure Formbricks usage within Formbricks
NEXT_PUBLIC_FORMBRICKS_API_HOST=
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID=
FORMBRICKS_API_HOST=
FORMBRICKS_ENVIRONMENT_ID=
# Oauth credentials for Google sheet integration
GOOGLE_SHEETS_CLIENT_ID=

View File

@@ -8,6 +8,14 @@ on:
required: false
default: "0"
inputs:
turbo_token:
description: "Turborepo token"
required: false
turbo_team:
description: "Turborepo team"
required: false
runs:
using: "composite"
steps:
@@ -62,6 +70,8 @@ runs:
- run: |
pnpm build --filter=@formbricks/web...
if: steps.cache-build.outputs.cache-hit != 'true'
shell: bash
env:
TURBO_TOKEN: ${{ inputs.turbo_token }}
TURBO_TEAM: ${{ inputs.turbo_team }}

View File

@@ -4,7 +4,7 @@ on:
permissions:
contents: read
jobs:
build:
name: Build Formbricks-web
@@ -25,3 +25,5 @@ jobs:
id: cache-build-web
with:
e2e_testing_mode: "0"
turbo_token: ${{ secrets.TURBO_TOKEN }}
turbo_team: ${{ vars.TURBO_TEAM }}

View File

@@ -0,0 +1,167 @@
name: Docker Build Validation
on:
pull_request:
branches:
- main
merge_group:
branches:
- main
workflow_dispatch:
permissions:
contents: read
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
jobs:
validate-docker-build:
name: Validate Docker Build
runs-on: ubuntu-latest
# Add PostgreSQL service container
services:
postgres:
image: pgvector/pgvector:pg17
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: formbricks
ports:
- 5432:5432
# Health check to ensure PostgreSQL is ready before using it
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout Repository
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker Image
uses: docker/build-push-action@v6
with:
context: .
file: ./apps/web/Dockerfile
push: false
load: true
tags: formbricks-test:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
- name: Verify PostgreSQL Connection
run: |
echo "Verifying PostgreSQL connection..."
# Install PostgreSQL client to test connection
sudo apt-get update && sudo apt-get install -y postgresql-client
# Test connection using psql
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL"
# Show network configuration
echo "Network configuration:"
ip addr show
netstat -tulpn | grep 5432 || echo "No process listening on port 5432"
- name: Test Docker Image with Health Check
shell: bash
run: |
echo "🧪 Testing if the Docker image starts correctly..."
# Add extra docker run args to support host.docker.internal on Linux
DOCKER_RUN_ARGS="--add-host=host.docker.internal:host-gateway"
# Start the container with host.docker.internal pointing to the host
docker run --name formbricks-test \
$DOCKER_RUN_ARGS \
-p 3000:3000 \
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
-e ENCRYPTION_KEY="${{ secrets.DUMMY_ENCRYPTION_KEY }}" \
-d formbricks-test:${{ github.sha }}
# Give it more time to start up
echo "Waiting 45 seconds for application to start..."
sleep 45
# Check if the container is running
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test)" != "true" ]; then
echo "❌ Container failed to start properly!"
docker logs formbricks-test
exit 1
else
echo "✅ Container started successfully!"
fi
# Try connecting to PostgreSQL from inside the container
echo "Testing PostgreSQL connection from inside container..."
docker exec formbricks-test sh -c 'apt-get update && apt-get install -y postgresql-client && PGPASSWORD=test psql -h host.docker.internal -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL from container"'
# Try to access the health endpoint
echo "🏥 Testing /health endpoint..."
MAX_RETRIES=10
RETRY_COUNT=0
HEALTH_CHECK_SUCCESS=false
set +e # Disable exit on error to allow for retries
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
RETRY_COUNT=$((RETRY_COUNT + 1))
echo "Attempt $RETRY_COUNT of $MAX_RETRIES..."
# Show container logs before each attempt to help debugging
if [ $RETRY_COUNT -gt 1 ]; then
echo "📋 Current container logs:"
docker logs --tail 20 formbricks-test
fi
# Get detailed curl output for debugging
HTTP_OUTPUT=$(curl -v -s -m 30 http://localhost:3000/health 2>&1)
CURL_EXIT_CODE=$?
echo "Curl exit code: $CURL_EXIT_CODE"
echo "Curl output: $HTTP_OUTPUT"
if [ $CURL_EXIT_CODE -eq 0 ]; then
STATUS_CODE=$(echo "$HTTP_OUTPUT" | grep -oP "HTTP/\d(\.\d)? \K\d+")
echo "Status code detected: $STATUS_CODE"
if [ "$STATUS_CODE" = "200" ]; then
echo "✅ Health check successful!"
HEALTH_CHECK_SUCCESS=true
break
else
echo "❌ Health check returned non-200 status code: $STATUS_CODE"
fi
else
echo "❌ Curl command failed with exit code: $CURL_EXIT_CODE"
fi
echo "Waiting 15 seconds before next attempt..."
sleep 15
done
# Show full container logs for debugging
echo "📋 Full container logs:"
docker logs formbricks-test
# Clean up the container
echo "🧹 Cleaning up..."
docker rm -f formbricks-test
# Exit with failure if health check did not succeed
if [ "$HEALTH_CHECK_SUCCESS" != "true" ]; then
echo "❌ Health check failed after $MAX_RETRIES attempts"
exit 1
fi
echo "✨ Docker validation complete - all checks passed!"

View File

@@ -16,6 +16,8 @@ on:
env:
TELEMETRY_DISABLED: 1
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
permissions:
id-token: write

View File

@@ -23,10 +23,10 @@ jobs:
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Setup Node.js 20.x
- name: Setup Node.js 22.x
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
node-version: 20.x
node-version: 22.x
- name: Install pnpm
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
@@ -48,7 +48,7 @@ jobs:
run: |
pnpm test:coverage
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@bfd4e558cda28cda6b5defafb9232d191be8c203
uses: SonarSource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

View File

@@ -1,4 +1,8 @@
{
"sonarlint.connectedMode.project": {
"connectionId": "formbricks",
"projectKey": "formbricks_formbricks"
},
"typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.tsdk": "node_modules/typescript/lib"
}

View File

@@ -27,7 +27,7 @@ const secondaryNavigation = [
export function Sidebar(): React.JSX.Element {
return (
<div className="flex flex-grow flex-col overflow-y-auto bg-cyan-700 pb-4 pt-5">
<div className="flex grow flex-col overflow-y-auto bg-cyan-700 pb-4 pt-5">
<nav
className="mt-5 flex flex-1 flex-col divide-y divide-cyan-800 overflow-y-auto"
aria-label="Sidebar">
@@ -41,7 +41,7 @@ export function Sidebar(): React.JSX.Element {
"group flex items-center rounded-md px-2 py-2 text-sm font-medium leading-6"
)}
aria-current={item.current ? "page" : undefined}>
<item.icon className="mr-4 h-6 w-6 flex-shrink-0 text-cyan-200" aria-hidden="true" />
<item.icon className="mr-4 h-6 w-6 shrink-0 text-cyan-200" aria-hidden="true" />
{item.name}
</a>
))}

View File

@@ -1,3 +1,23 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@custom-variant dark (&:is(.dark *));
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@formbricks/demo",
"version": "0.1.0",
"version": "0.0.0",
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next",
@@ -12,10 +12,14 @@
},
"dependencies": {
"@formbricks/js": "workspace:*",
"lucide-react": "0.468.0",
"next": "15.2.3",
"@tailwindcss/forms": "0.5.9",
"@tailwindcss/postcss": "4.1.3",
"lucide-react": "0.486.0",
"next": "15.2.4",
"postcss": "8.5.3",
"react": "19.0.0",
"react-dom": "19.0.0"
"react-dom": "19.0.0",
"tailwindcss": "4.1.3"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",

View File

@@ -96,7 +96,7 @@ export default function AppPage(): React.JSX.Element {
<p className="text-slate-700 dark:text-slate-300">
Copy the environment ID of your Formbricks app to the env variable in /apps/demo/.env
</p>
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded" priority />
<Image src={fbsetup} alt="fb setup" className="rounded-xs mt-4" priority />
<div className="mt-4 flex-col items-start text-sm text-slate-700 sm:flex sm:items-center sm:text-base dark:text-slate-300">
<p className="mb-1 sm:mb-0 sm:mr-2">You&apos;re connected with env:</p>

View File

@@ -1,6 +1,5 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
"@tailwindcss/postcss": {},
},
};

View File

@@ -1,13 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/**/*.{js,ts,jsx,tsx}",
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
darkMode: "class",
theme: {
extend: {},
},
plugins: [require("@tailwindcss/forms")],
};

View File

@@ -11,30 +11,30 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"dependencies": {
"eslint-plugin-react-refresh": "0.4.16",
"react": "19.0.0",
"react-dom": "19.0.0"
"eslint-plugin-react-refresh": "0.4.19",
"react": "19.1.0",
"react-dom": "19.1.0"
},
"devDependencies": {
"@chromatic-com/storybook": "3.2.2",
"@chromatic-com/storybook": "3.2.6",
"@formbricks/config-typescript": "workspace:*",
"@storybook/addon-a11y": "8.4.7",
"@storybook/addon-essentials": "8.4.7",
"@storybook/addon-interactions": "8.4.7",
"@storybook/addon-links": "8.4.7",
"@storybook/addon-onboarding": "8.4.7",
"@storybook/blocks": "8.4.7",
"@storybook/react": "8.4.7",
"@storybook/react-vite": "8.4.7",
"@storybook/test": "8.4.7",
"@typescript-eslint/eslint-plugin": "8.18.0",
"@typescript-eslint/parser": "8.18.0",
"@storybook/addon-a11y": "8.6.12",
"@storybook/addon-essentials": "8.6.12",
"@storybook/addon-interactions": "8.6.12",
"@storybook/addon-links": "8.6.12",
"@storybook/addon-onboarding": "8.6.12",
"@storybook/blocks": "8.6.12",
"@storybook/react": "8.6.12",
"@storybook/react-vite": "8.6.12",
"@storybook/test": "8.6.12",
"@typescript-eslint/eslint-plugin": "8.29.1",
"@typescript-eslint/parser": "8.29.1",
"@vitejs/plugin-react": "4.3.4",
"esbuild": "0.25.1",
"eslint-plugin-storybook": "0.11.1",
"esbuild": "0.25.2",
"eslint-plugin-storybook": "0.12.0",
"prop-types": "15.8.1",
"storybook": "8.4.7",
"tsup": "8.3.5",
"vite": "6.0.12"
"storybook": "8.6.12",
"tsup": "8.4.0",
"vite": "6.2.5"
}
}

View File

@@ -22,7 +22,7 @@ RUN npm install -g corepack@latest
RUN corepack enable
# Install necessary build tools and compilers
RUN apk update && apk add --no-cache g++ cmake make gcc python3 openssl-dev jq
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
# BuildKit secret handling without hardcoded fallback values
# This approach relies entirely on secrets passed from GitHub Actions
@@ -40,8 +40,6 @@ RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \
echo 'exec "$@"' >> /tmp/read-secrets.sh && \
chmod +x /tmp/read-secrets.sh
ARG SENTRY_AUTH_TOKEN
# Increase Node.js memory limit as a regular build argument
ARG NODE_OPTIONS="--max_old_space_size=4096"
ENV NODE_OPTIONS=${NODE_OPTIONS}
@@ -87,31 +85,60 @@ RUN apk add --no-cache curl \
WORKDIR /home/nextjs
COPY --from=installer /app/apps/web/next.config.mjs .
COPY --from=installer /app/apps/web/package.json .
# Leverage output traces to reduce image size
# Ensure no write permissions are assigned to the copied resources
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/standalone ./
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/public ./apps/web/public
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/schema.prisma ./packages/database/schema.prisma
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/package.json ./packages/database/package.json
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/migration ./packages/database/migration
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/src ./packages/database/src
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/node_modules ./packages/database/node_modules
COPY --from=installer --chown=nextjs:nextjs /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
RUN chmod -R 755 ./
COPY --from=installer /app/apps/web/next.config.mjs .
RUN chmod 644 ./next.config.mjs
COPY --from=installer /app/apps/web/package.json .
RUN chmod 644 ./package.json
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/static ./apps/web/.next/static
RUN chmod -R 755 ./apps/web/.next/static
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/public ./apps/web/public
RUN chmod -R 755 ./apps/web/public
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/schema.prisma ./packages/database/schema.prisma
RUN chmod 644 ./packages/database/schema.prisma
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/package.json ./packages/database/package.json
RUN chmod 644 ./packages/database/package.json
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/migration ./packages/database/migration
RUN chmod -R 755 ./packages/database/migration
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/src ./packages/database/src
RUN chmod -R 755 ./packages/database/src
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/node_modules ./packages/database/node_modules
RUN chmod -R 755 ./packages/database/node_modules
COPY --from=installer --chown=nextjs:nextjs /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
RUN chmod -R 755 ./packages/database/node_modules/@formbricks/logger/dist
# Copy Prisma-specific generated files
COPY --from=installer --chown=nextjs:nextjs /app/node_modules/@prisma/client ./node_modules/@prisma/client
RUN chmod -R 755 ./node_modules/@prisma/client
COPY --from=installer --chown=nextjs:nextjs /app/node_modules/.prisma ./node_modules/.prisma
RUN chmod -R 755 ./node_modules/.prisma
COPY --from=installer --chown=nextjs:nextjs /prisma_version.txt .
COPY /docker/cronjobs /app/docker/cronjobs
RUN chmod 644 ./prisma_version.txt
COPY /docker/cronjobs /app/docker/cronjobs
RUN chmod -R 755 /app/docker/cronjobs
# Copy required dependencies
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod
RUN npm install -g tsx typescript prisma pino-pretty

View File

@@ -1,191 +1,120 @@
import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
import { cleanup, render, screen } from "@testing-library/react";
import { Session } from "next-auth";
import { redirect } from "next/navigation";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
import { TEnvironment } from "@formbricks/types/environment";
import { AuthorizationError } from "@formbricks/types/errors";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import SurveyEditorEnvironmentLayout from "./layout";
// mock all dependencies
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
// Mock sub-components to render identifiable elements
vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
EnvironmentIdBaseLayout: ({ children, environmentId }: any) => (
<div data-testid="EnvironmentIdBaseLayout">
{environmentId}
{children}
</div>
),
}));
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
DevEnvironmentBanner: ({ environment }: any) => (
<div data-testid="DevEnvironmentBanner">{environment.id}</div>
),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
vi.mock("@formbricks/lib/environment/auth", () => ({
hasUserEnvironmentAccess: vi.fn(),
// Mocks for dependencies
vi.mock("@/modules/environments/lib/utils", () => ({
environmentIdLayoutChecks: vi.fn(),
}));
vi.mock("@formbricks/lib/environment/service", () => ({
getEnvironment: vi.fn(),
}));
vi.mock("@formbricks/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn(),
}));
vi.mock("@formbricks/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: vi.fn(() => {
return (key: string) => key; // trivial translator returning the key
}),
}));
// mock child components rendered by the layout:
vi.mock("@/app/(app)/components/FormbricksClient", () => ({
FormbricksClient: () => <div data-testid="formbricks-client" />,
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/PosthogIdentify", () => ({
PosthogIdentify: () => <div data-testid="posthog-identify" />,
}));
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="mock-toaster" />,
}));
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
DevEnvironmentBanner: ({ environment }: { environment: TEnvironment }) => (
<div data-testid="dev-environment-banner">{environment?.id || "no-env"}</div>
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
ResponseFilterProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="mock-response-filter-provider">{children}</div>
),
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
describe("SurveyEditorEnvironmentLayout", () => {
beforeEach(() => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("redirects to /auth/login if there is no session", async () => {
// Mock no session
vi.mocked(getServerSession).mockResolvedValueOnce(null);
it("renders successfully when environment is found", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any, // Mock translation function, we don't need to implement it for the test
session: { user: { id: "user1" } } as Session,
user: { id: "user1", email: "user1@example.com" } as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(getEnvironment).mockResolvedValueOnce({ id: "env1" } as TEnvironment);
const layoutElement = await SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div data-testid="child-content">Hello!</div>,
const result = await SurveyEditorEnvironmentLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div data-testid="child">Survey Editor Content</div>,
});
expect(redirect).toHaveBeenCalledWith("/auth/login");
// No JSX is returned after redirect
expect(layoutElement).toBeUndefined();
render(result);
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
expect(screen.getByTestId("DevEnvironmentBanner")).toHaveTextContent("env1");
expect(screen.getByTestId("child")).toHaveTextContent("Survey Editor Content");
});
it("throws error if user does not exist in DB", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce(null); // user not found
await expect(
SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div data-testid="child-content">Hello!</div>,
})
).rejects.toThrow("common.user_not_found");
});
it("throws AuthorizationError if user does not have environment access", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", email: "test@example.com" } as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
await expect(
SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div>Child</div>,
})
).rejects.toThrow(AuthorizationError);
});
it("throws if no organization is found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(null);
await expect(
SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div data-testid="child-content">Hello from children!</div>,
})
).rejects.toThrow("common.organization_not_found");
});
it("throws if no environment is found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
it("throws an error when environment is not found", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: { user: { id: "user1" } } as Session,
user: { id: "user1", email: "user1@example.com" } as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
await expect(
SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div>Child</div>,
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("common.environment_not_found");
});
it("renders environment layout if everything is valid", async () => {
// Provide all valid data
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", email: "test@example.com" } as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env-123",
name: "My Test Environment",
} as unknown as TEnvironment);
// Because it's an async server component, we typically wrap in act(...)
let layoutElement: React.ReactNode;
await act(async () => {
layoutElement = await SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div data-testid="child-content">Hello from children!</div>,
});
render(layoutElement);
it("calls redirect when session is null", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: undefined as unknown as Session,
user: undefined as unknown as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(redirect).mockImplementationOnce(() => {
throw new Error("Redirect called");
});
// Now confirm we got the child plus all the mocked sub-components
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children!");
expect(screen.getByTestId("posthog-identify")).toBeInTheDocument();
expect(screen.getByTestId("formbricks-client")).toBeInTheDocument();
expect(screen.getByTestId("mock-toaster")).toBeInTheDocument();
expect(screen.getByTestId("mock-response-filter-provider")).toBeInTheDocument();
expect(screen.getByTestId("dev-environment-banner")).toHaveTextContent("env-123");
await expect(
SurveyEditorEnvironmentLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("Redirect called");
});
it("throws error if user is null", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: { user: { id: "user1" } } as Session,
user: undefined as unknown as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(redirect).mockImplementationOnce(() => {
throw new Error("Redirect called");
});
await expect(
SurveyEditorEnvironmentLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("common.user_not_found");
});
});

View File

@@ -1,46 +1,24 @@
import { FormbricksClient } from "@/app/(app)/components/FormbricksClient";
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
import { redirect } from "next/navigation";
import { IS_POSTHOG_CONFIGURED } from "@formbricks/lib/constants";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
import { AuthorizationError } from "@formbricks/types/errors";
const SurveyEditorEnvironmentLayout = async (props) => {
const params = await props.params;
const { children } = props;
const t = await getTranslate();
const session = await getServerSession(authOptions);
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
if (!session?.user) {
if (!session) {
return redirect(`/auth/login`);
}
const user = await getUser(session.user.id);
if (!user) {
throw new Error(t("common.user_not_found"));
}
const hasAccess = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
if (!hasAccess) {
throw new AuthorizationError(t("common.not_authorized"));
}
const organization = await getOrganizationByEnvironmentId(params.environmentId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const environment = await getEnvironment(params.environmentId);
if (!environment) {
@@ -48,23 +26,16 @@ const SurveyEditorEnvironmentLayout = async (props) => {
}
return (
<ResponseFilterProvider>
<PosthogIdentify
session={session}
user={user}
environmentId={params.environmentId}
organizationId={organization.id}
organizationName={organization.name}
organizationBilling={organization.billing}
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
/>
<FormbricksClient userId={user.id} email={user.email} />
<ToasterClient />
<EnvironmentIdBaseLayout
environmentId={params.environmentId}
session={session}
user={user}
organization={organization}>
<div className="flex h-screen flex-col">
<DevEnvironmentBanner environment={environment} />
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div>
</ResponseFilterProvider>
</EnvironmentIdBaseLayout>
);
};

View File

@@ -1,5 +1,5 @@
import { render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import formbricks from "@formbricks/js";
import { FormbricksClient } from "./FormbricksClient";
@@ -9,14 +9,6 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams("foo=bar"),
}));
// Mock the environment variables.
vi.mock("@formbricks/lib/env", () => ({
env: {
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID: "env-test",
NEXT_PUBLIC_FORMBRICKS_API_HOST: "https://api.test.com",
},
}));
// Mock the flag that enables Formbricks.
vi.mock("@/app/lib/formbricks", () => ({
formbricksEnabled: true,
@@ -34,17 +26,21 @@ vi.mock("@formbricks/js", () => ({
}));
describe("FormbricksClient", () => {
afterEach(() => {
vi.clearAllMocks();
});
test("calls setup, setUserId, setEmail and registerRouteChange on mount when enabled", () => {
const mockSetup = vi.spyOn(formbricks, "setup");
const mockSetUserId = vi.spyOn(formbricks, "setUserId");
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
render(<FormbricksClient userId="user-123" email="test@example.com" />);
render(
<FormbricksClient
userId="user-123"
email="test@example.com"
formbricksEnvironmentId="env-test"
formbricksApiHost="https://api.test.com"
formbricksEnabled={true}
/>
);
// Expect the first effect to call setup and assign the provided user details.
expect(mockSetup).toHaveBeenCalledWith({
@@ -64,7 +60,15 @@ describe("FormbricksClient", () => {
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
render(<FormbricksClient userId="" email="test@example.com" />);
render(
<FormbricksClient
userId=""
email="test@example.com"
formbricksEnvironmentId="env-test"
formbricksApiHost="https://api.test.com"
formbricksEnabled={true}
/>
);
// Since userId is falsy, the first effect should not call setup or assign user details.
expect(mockSetup).not.toHaveBeenCalled();

View File

@@ -1,32 +1,44 @@
"use client";
import { formbricksEnabled } from "@/app/lib/formbricks";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import formbricks from "@formbricks/js";
import { env } from "@formbricks/lib/env";
export const FormbricksClient = ({ userId, email }: { userId: string; email: string }) => {
interface FormbricksClientProps {
userId: string;
email: string;
formbricksEnvironmentId?: string;
formbricksApiHost?: string;
formbricksEnabled?: boolean;
}
export const FormbricksClient = ({
userId,
email,
formbricksEnvironmentId,
formbricksApiHost,
formbricksEnabled,
}: FormbricksClientProps) => {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
if (formbricksEnabled && userId) {
formbricks.setup({
environmentId: env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
appUrl: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
environmentId: formbricksEnvironmentId ?? "",
appUrl: formbricksApiHost ?? "",
});
formbricks.setUserId(userId);
formbricks.setEmail(email);
}
}, [userId, email]);
}, [userId, email, formbricksEnvironmentId, formbricksApiHost, formbricksEnabled]);
useEffect(() => {
if (formbricksEnabled) {
formbricks.registerRouteChange();
}
}, [pathname, searchParams]);
}, [pathname, searchParams, formbricksEnabled]);
return null;
};

View File

@@ -7,7 +7,7 @@ import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-bann
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
import { getTranslate } from "@/tolgee/server";
import type { Session } from "next-auth";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
@@ -111,6 +111,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
organizationProjectsLimit={organizationProjectsLimit}
user={user}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isDevelopment={IS_DEVELOPMENT}
membershipRole={membershipRole}
isMultiOrgEnabled={isMultiOrgEnabled}
isLicenseActive={active}

View File

@@ -63,6 +63,7 @@ interface NavigationProps {
projects: TProject[];
isMultiOrgEnabled: boolean;
isFormbricksCloud: boolean;
isDevelopment: boolean;
membershipRole?: TOrganizationRole;
organizationProjectsLimit: number;
isLicenseActive: boolean;
@@ -79,6 +80,7 @@ export const MainNavigation = ({
isFormbricksCloud,
organizationProjectsLimit,
isLicenseActive,
isDevelopment,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
@@ -296,7 +298,7 @@ export const MainNavigation = ({
<div>
{/* New Version Available */}
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && (
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && !isDevelopment && (
<Link
href="https://github.com/formbricks/formbricks/releases"
target="_blank"

View File

@@ -1,9 +1,7 @@
// PosthogIdentify.test.tsx
import "@testing-library/jest-dom/vitest";
import { cleanup, render } from "@testing-library/react";
import { Session } from "next-auth";
import { usePostHog } from "posthog-js/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TOrganizationBilling } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";

View File

@@ -1,250 +1,156 @@
import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
import { cleanup, render, screen } from "@testing-library/react";
import { Session } from "next-auth";
import { redirect } from "next/navigation";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getUser } from "@formbricks/lib/user/service";
import { AuthorizationError } from "@formbricks/types/errors";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import { TUser } from "@formbricks/types/user";
import EnvLayout from "./layout";
// mock all the dependencies
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
// Mock sub-components to render identifiable elements
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentLayout", () => ({
EnvironmentLayout: ({ children }: any) => <div data-testid="EnvironmentLayout">{children}</div>,
}));
vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
EnvironmentIdBaseLayout: ({ children, environmentId }: any) => (
<div data-testid="EnvironmentIdBaseLayout">
{environmentId}
{children}
</div>
),
}));
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="ToasterClient" />,
}));
vi.mock("../../components/FormbricksClient", () => ({
FormbricksClient: ({ userId, email }: any) => (
<div data-testid="FormbricksClient">
{userId}-{email}
</div>
),
}));
vi.mock("./components/EnvironmentStorageHandler", () => ({
default: ({ environmentId }: any) => <div data-testid="EnvironmentStorageHandler">{environmentId}</div>,
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: vi.fn(() => {
return (key: string) => {
return key;
};
}),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@formbricks/lib/environment/auth", () => ({
hasUserEnvironmentAccess: vi.fn(),
}));
vi.mock("@formbricks/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
vi.mock("@formbricks/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn(),
// Mocks for dependencies
vi.mock("@/modules/environments/lib/utils", () => ({
environmentIdLayoutChecks: vi.fn(),
}));
vi.mock("@formbricks/lib/project/service", () => ({
getProjectByEnvironmentId: vi.fn(),
}));
vi.mock("@formbricks/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@formbricks/lib/aiModels", () => ({
llmModel: {},
}));
// mock all the components that are rendered in the layout
vi.mock("./components/PosthogIdentify", () => ({
PosthogIdentify: () => <div data-testid="posthog-identify" />,
}));
vi.mock("@/app/(app)/components/FormbricksClient", () => ({
FormbricksClient: () => <div data-testid="formbricks-client" />,
}));
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="mock-toaster" />,
}));
vi.mock("./components/EnvironmentStorageHandler", () => ({
default: () => <div data-testid="mock-storage-handler" />,
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
ResponseFilterProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="mock-response-filter-provider">{children}</div>
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentLayout", () => ({
EnvironmentLayout: ({ children }: { children: React.ReactNode }) => (
<div data-testid="mock-environment-result">{children}</div>
),
vi.mock("@formbricks/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
describe("EnvLayout", () => {
beforeEach(() => {
afterEach(() => {
cleanup();
});
it("redirects to /auth/login if there is no session", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(null);
// Since it's an async server component, call EnvLayout yourself:
const layoutElement = await EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div data-testid="child-content">Hello!</div>,
it("renders successfully when all dependencies return valid data", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any, // Mock translation function, we don't need to implement it for the test
session: { user: { id: "user1" } } as Session,
user: { id: "user1", email: "user1@example.com" } as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj1" } as TProject);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
id: "member1",
} as unknown as TMembership);
// Because we have no session, we expect a redirect to "/auth/login"
expect(redirect).toHaveBeenCalledWith("/auth/login");
const result = await EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div data-testid="child">Content</div>,
});
render(result);
// If your code calls redirect() early and returns no JSX,
// layoutElement might be undefined or null.
expect(layoutElement).toBeUndefined();
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
expect(screen.getByTestId("EnvironmentStorageHandler")).toHaveTextContent("env1");
expect(screen.getByTestId("EnvironmentLayout")).toBeDefined();
expect(screen.getByTestId("child")).toHaveTextContent("Content");
});
it("redirects to /auth/login if user does not exist in DB", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
it("throws error if project is not found", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: { user: { id: "user1" } } as Session,
user: { id: "user1", email: "user1@example.com" } as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(getUser).mockResolvedValueOnce(null); // user not found
const layoutElement = await EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div data-testid="child-content">Hello!</div>,
});
expect(redirect).toHaveBeenCalledWith("/auth/login");
expect(layoutElement).toBeUndefined();
});
it("throws AuthorizationError if user does not have environment access", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
});
vi.mocked(getUser).mockResolvedValueOnce({
id: "user-123",
email: "test@example.com",
} as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div>Child</div>,
})
).rejects.toThrow(AuthorizationError);
});
it("throws if no organization is found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
});
vi.mocked(getUser).mockResolvedValueOnce({
id: "user-123",
email: "test@example.com",
} as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(null);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div data-testid="child-content">Hello from children!</div>,
})
).rejects.toThrow("common.organization_not_found");
});
it("throws if no project is found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
});
vi.mocked(getUser).mockResolvedValueOnce({
id: "user-123",
email: "test@example.com",
} as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(null);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
id: "member1",
} as unknown as TMembership);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div>Child</div>,
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("project_not_found");
).rejects.toThrow("common.project_not_found");
});
it("calls notFound if membership is missing", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
it("throws error if membership is not found", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: { user: { id: "user1" } } as Session,
user: { id: "user1", email: "user1@example.com" } as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(getUser).mockResolvedValueOnce({
id: "user-123",
email: "test@example.com",
} as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj-111" } as TProject);
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj1" } as TProject);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(null);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div>Child</div>,
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("membership_not_found");
).rejects.toThrow("common.membership_not_found");
});
it("renders environment layout if everything is valid", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
it("calls redirect when session is null", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: undefined as unknown as Session,
user: undefined as unknown as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(getUser).mockResolvedValueOnce({
id: "user-123",
email: "test@example.com",
} as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj-111" } as TProject);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
id: "membership-123",
} as unknown as TMembership);
let layoutElement: React.ReactNode;
await act(async () => {
layoutElement = await EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div data-testid="child-content">Hello from children!</div>,
});
// Now render the fully resolved layout
render(layoutElement);
vi.mocked(redirect).mockImplementationOnce(() => {
throw new Error("Redirect called");
});
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children!");
expect(screen.getByTestId("posthog-identify")).toBeInTheDocument();
expect(screen.getByTestId("formbricks-client")).toBeInTheDocument();
expect(screen.getByTestId("mock-toaster")).toBeInTheDocument();
expect(screen.getByTestId("mock-storage-handler")).toBeInTheDocument();
expect(screen.getByTestId("mock-response-filter-provider")).toBeInTheDocument();
expect(screen.getByTestId("mock-environment-result")).toBeInTheDocument();
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("Redirect called");
});
it("throws error if user is null", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: { user: { id: "user1" } } as Session,
user: undefined as unknown as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(redirect).mockImplementationOnce(() => {
throw new Error("Redirect called");
});
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("common.user_not_found");
});
});

View File

@@ -1,20 +1,10 @@
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
import { redirect } from "next/navigation";
import { IS_POSTHOG_CONFIGURED } from "@formbricks/lib/constants";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getUser } from "@formbricks/lib/user/service";
import { AuthorizationError } from "@formbricks/types/errors";
import { FormbricksClient } from "../../components/FormbricksClient";
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
import { PosthogIdentify } from "./components/PosthogIdentify";
const EnvLayout = async (props: {
params: Promise<{ environmentId: string }>;
@@ -24,27 +14,16 @@ const EnvLayout = async (props: {
const { children } = props;
const t = await getTranslate();
const session = await getServerSession(authOptions);
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
if (!session?.user) {
if (!session) {
return redirect(`/auth/login`);
}
const user = await getUser(session.user.id);
if (!user) {
return redirect(`/auth/login`);
throw new Error(t("common.user_not_found"));
}
const hasAccess = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
if (!hasAccess) {
throw new AuthorizationError(t("common.not_authorized"));
}
const organization = await getOrganizationByEnvironmentId(params.environmentId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const project = await getProjectByEnvironmentId(params.environmentId);
if (!project) {
throw new Error(t("common.project_not_found"));
@@ -57,23 +36,16 @@ const EnvLayout = async (props: {
}
return (
<ResponseFilterProvider>
<PosthogIdentify
session={session}
user={user}
environmentId={params.environmentId}
organizationId={organization.id}
organizationName={organization.name}
organizationBilling={organization.billing}
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
/>
<FormbricksClient userId={user.id} email={user.email} />
<ToasterClient />
<EnvironmentIdBaseLayout
environmentId={params.environmentId}
session={session}
user={user}
organization={organization}>
<EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentLayout environmentId={params.environmentId} session={session}>
{children}
</EnvironmentLayout>
</ResponseFilterProvider>
</EnvironmentIdBaseLayout>
);
};

View File

@@ -1,3 +0,0 @@
import { APIKeysLoading } from "@/modules/projects/settings/api-keys/loading";
export default APIKeysLoading;

View File

@@ -1,3 +0,0 @@
import { APIKeysPage } from "@/modules/projects/settings/api-keys/page";
export default APIKeysPage;

View File

@@ -0,0 +1,6 @@
import Loading from "@/modules/organization/settings/api-keys/loading";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
export default function LoadingPage() {
return <Loading isFormbricksCloud={IS_FORMBRICKS_CLOUD} />;
}

View File

@@ -0,0 +1,3 @@
import { APIKeysPage } from "@/modules/organization/settings/api-keys/page";
export default APIKeysPage;

View File

@@ -54,6 +54,12 @@ export const OrganizationSettingsNavbar = ({
hidden: isFormbricksCloud || isPricingDisabled,
current: pathname?.includes("/enterprise"),
},
{
id: "api-keys",
label: t("common.api_keys"),
href: `/environments/${environmentId}/settings/api-keys`,
current: pathname?.includes("/api-keys"),
},
];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;

View File

@@ -33,12 +33,16 @@ vi.mock("@formbricks/lib/constants", () => ({
WEBAPP_URL: "mock-webapp-url",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port",
AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name",
AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key",
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id",
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name",
AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key",
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id",
AI_AZURE_LLM_RESSOURCE_NAME: "mock-ai-azure-llm-ressource-name",
AI_AZURE_LLM_API_KEY: "mock-ai",
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-ai-azure-llm-deployment-id",
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-ai-azure-embeddings-ressource-name",
AI_AZURE_EMBEDDINGS_API_KEY: "mock-ai-azure-embeddings-api-key",
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-ai-azure-embeddings-deployment-id",
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({

View File

@@ -35,6 +35,16 @@ export const DateQuestionSummary = ({
);
};
const renderResponseValue = (value: string) => {
const parsedDate = new Date(value);
const formattedDate = isNaN(parsedDate.getTime())
? `${t("common.invalid_date")}(${value})`
: formatDateWithOrdinal(parsedDate);
return formattedDate;
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
@@ -70,8 +80,8 @@ export const DateQuestionSummary = ({
</div>
)}
</div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{formatDateWithOrdinal(new Date(response.value as string))}
<div className="ph-no-capture col-span-2 pl-6 font-semibold whitespace-pre-wrap">
{renderResponseValue(response.value)}
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}

View File

@@ -36,6 +36,9 @@ vi.mock("@formbricks/lib/constants", () => ({
IS_POSTHOG_CONFIGURED: true,
POSTHOG_API_HOST: "test-posthog-api-host",
POSTHOG_API_KEY: "test-posthog-api-key",
FORMBRICKS_API_HOST: "mock-formbricks-api-host",
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
IS_FORMBRICKS_ENABLED: true,
}));
vi.mock("@/app/(app)/components/FormbricksClient", () => ({
@@ -47,12 +50,6 @@ vi.mock("@/app/intercom/IntercomClientWrapper", () => ({
vi.mock("@/modules/ui/components/no-mobile-overlay", () => ({
NoMobileOverlay: () => <div data-testid="no-mobile-overlay" />,
}));
vi.mock("@/modules/ui/components/post-hog-client", () => ({
PHProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="ph-provider">{children}</div>
),
PostHogPageview: () => <div data-testid="ph-pageview" />,
}));
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="toaster-client" />,
}));
@@ -74,8 +71,6 @@ describe("(app) AppLayout", () => {
render(element);
expect(screen.getByTestId("no-mobile-overlay")).toBeInTheDocument();
expect(screen.getByTestId("ph-pageview")).toBeInTheDocument();
expect(screen.getByTestId("ph-provider")).toBeInTheDocument();
expect(screen.getByTestId("mock-intercom-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("toaster-client")).toBeInTheDocument();
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children");

View File

@@ -1,18 +1,31 @@
import { FormbricksClient } from "@/app/(app)/components/FormbricksClient";
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout";
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
import { PHProvider, PostHogPageview } from "@/modules/ui/components/post-hog-client";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getServerSession } from "next-auth";
import { Suspense } from "react";
import { IS_POSTHOG_CONFIGURED, POSTHOG_API_HOST, POSTHOG_API_KEY } from "@formbricks/lib/constants";
import {
FORMBRICKS_API_HOST,
FORMBRICKS_ENVIRONMENT_ID,
IS_FORMBRICKS_ENABLED,
IS_POSTHOG_CONFIGURED,
POSTHOG_API_HOST,
POSTHOG_API_KEY,
} from "@formbricks/lib/constants";
import { getUser } from "@formbricks/lib/user/service";
const AppLayout = async ({ children }) => {
const session = await getServerSession(authOptions);
const user = session?.user?.id ? await getUser(session.user.id) : null;
// If user account is deactivated, log them out instead of rendering the app
if (user?.isActive === false) {
return <ClientLogout />;
}
return (
<>
<NoMobileOverlay />
@@ -25,7 +38,15 @@ const AppLayout = async ({ children }) => {
</Suspense>
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
<>
{user ? <FormbricksClient userId={user.id} email={user.email} /> : null}
{user ? (
<FormbricksClient
userId={user.id}
email={user.email}
formbricksApiHost={FORMBRICKS_API_HOST}
formbricksEnvironmentId={FORMBRICKS_ENVIRONMENT_ID}
formbricksEnabled={IS_FORMBRICKS_ENABLED}
/>
) : null}
<IntercomClientWrapper user={user} />
<ToasterClient />
{children}

View File

@@ -0,0 +1,178 @@
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { describe, expect, it, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
import { authenticateRequest } from "./auth";
vi.mock("@formbricks/database", () => ({
prisma: {
apiKey: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
hashApiKey: vi.fn(),
}));
describe("getApiKeyWithPermissions", () => {
it("should return API key data with permissions when valid key is provided", async () => {
const mockApiKeyData = {
id: "api-key-id",
organizationId: "org-id",
hashedKey: "hashed-key",
createdAt: new Date(),
createdBy: "user-id",
lastUsedAt: null,
label: "Test API Key",
apiKeyEnvironments: [
{
environmentId: "env-1",
permission: "manage" as const,
environment: { id: "env-1" },
},
],
};
vi.mocked(hashApiKey).mockReturnValue("hashed-key");
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData);
vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData);
const result = await getApiKeyWithPermissions("test-api-key");
expect(result).toEqual(mockApiKeyData);
expect(prisma.apiKey.update).toHaveBeenCalledWith({
where: { id: "api-key-id" },
data: { lastUsedAt: expect.any(Date) },
});
});
it("should return null when API key is not found", async () => {
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
const result = await getApiKeyWithPermissions("invalid-key");
expect(result).toBeNull();
});
});
describe("hasPermission", () => {
const permissions: TAPIKeyEnvironmentPermission[] = [
{
environmentId: "env-1",
permission: "manage",
environmentType: "development",
projectId: "project-1",
projectName: "Project 1",
},
{
environmentId: "env-2",
permission: "write",
environmentType: "production",
projectId: "project-2",
projectName: "Project 2",
},
{
environmentId: "env-3",
permission: "read",
environmentType: "development",
projectId: "project-3",
projectName: "Project 3",
},
];
it("should return true for manage permission with any method", () => {
expect(hasPermission(permissions, "env-1", "GET")).toBe(true);
expect(hasPermission(permissions, "env-1", "POST")).toBe(true);
expect(hasPermission(permissions, "env-1", "DELETE")).toBe(true);
});
it("should handle write permission correctly", () => {
expect(hasPermission(permissions, "env-2", "GET")).toBe(true);
expect(hasPermission(permissions, "env-2", "POST")).toBe(true);
expect(hasPermission(permissions, "env-2", "DELETE")).toBe(false);
});
it("should handle read permission correctly", () => {
expect(hasPermission(permissions, "env-3", "GET")).toBe(true);
expect(hasPermission(permissions, "env-3", "POST")).toBe(false);
expect(hasPermission(permissions, "env-3", "DELETE")).toBe(false);
});
it("should return false for non-existent environment", () => {
expect(hasPermission(permissions, "env-4", "GET")).toBe(false);
});
});
describe("authenticateRequest", () => {
it("should return authentication data for valid API key", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
const mockApiKeyData = {
id: "api-key-id",
organizationId: "org-id",
hashedKey: "hashed-key",
createdAt: new Date(),
createdBy: "user-id",
lastUsedAt: null,
label: "Test API Key",
apiKeyEnvironments: [
{
environmentId: "env-1",
permission: "manage" as const,
environment: {
id: "env-1",
projectId: "project-1",
project: { name: "Project 1" },
type: "development",
},
},
],
};
vi.mocked(hashApiKey).mockReturnValue("hashed-key");
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData);
vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData);
const result = await authenticateRequest(request);
expect(result).toEqual({
type: "apiKey",
environmentPermissions: [
{
environmentId: "env-1",
permission: "manage",
environmentType: "development",
projectId: "project-1",
projectName: "Project 1",
},
],
hashedApiKey: "hashed-key",
apiKeyId: "api-key-id",
organizationId: "org-id",
});
});
it("should return null when no API key is provided", async () => {
const request = new Request("http://localhost");
const result = await authenticateRequest(request);
expect(result).toBeNull();
});
it("should return null when API key is invalid", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "invalid-api-key" },
});
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
const result = await authenticateRequest(request);
expect(result).toBeNull();
});
});

View File

@@ -1,25 +1,38 @@
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
import { responses } from "@/app/lib/api/response";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
export const authenticateRequest = async (request: Request): Promise<TAuthenticationApiKey | null> => {
const apiKey = request.headers.get("x-api-key");
if (apiKey) {
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
if (environmentId) {
const hashedApiKey = hashApiKey(apiKey);
const authentication: TAuthenticationApiKey = {
type: "apiKey",
environmentId,
hashedApiKey,
};
return authentication;
}
return null;
}
return null;
if (!apiKey) return null;
// Get API key with permissions
const apiKeyData = await getApiKeyWithPermissions(apiKey);
if (!apiKeyData) return null;
// In the route handlers, we'll do more specific permission checks
const environmentIds = apiKeyData.apiKeyEnvironments.map((env) => env.environmentId);
if (environmentIds.length === 0) return null;
const hashedApiKey = hashApiKey(apiKey);
const authentication: TAuthenticationApiKey = {
type: "apiKey",
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
environmentId: env.environmentId,
environmentType: env.environment.type,
permission: env.permission,
projectId: env.environment.projectId,
projectName: env.environment.project.name,
})),
hashedApiKey,
apiKeyId: apiKeyData.id,
organizationId: apiKeyData.organizationId,
organizationAccess: apiKeyData.organizationAccess,
};
return authentication;
};
export const handleErrorResponse = (error: any): Response => {

View File

@@ -1,49 +0,0 @@
import { apiKeyCache } from "@/lib/cache/api-key";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { getHash } from "@formbricks/lib/crypto";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZString } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
export const getEnvironmentIdFromApiKey = reactCache(async (apiKey: string): Promise<string | null> => {
const hashedKey = getHash(apiKey);
return cache(
async () => {
validateInputs([apiKey, ZString]);
if (!apiKey) {
throw new InvalidInputError("API key cannot be null or undefined.");
}
try {
const apiKeyData = await prisma.apiKey.findUnique({
where: {
hashedKey,
},
select: {
environmentId: true,
},
});
if (!apiKeyData) {
throw new ResourceNotFoundError("apiKey", apiKey);
}
return apiKeyData.environmentId;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`management-api-getEnvironmentIdFromApiKey-${apiKey}`],
{
tags: [apiKeyCache.tag.byHashedKey(hashedKey)],
}
)();
});

View File

@@ -1,6 +1,7 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { deleteActionClass, getActionClass, updateActionClass } from "@formbricks/lib/actionClass/service";
import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
@@ -8,15 +9,20 @@ import { TAuthenticationApiKey } from "@formbricks/types/auth";
const fetchAndAuthorizeActionClass = async (
authentication: TAuthenticationApiKey,
actionClassId: string
actionClassId: string,
method: "GET" | "POST" | "PUT" | "DELETE"
): Promise<TActionClass | null> => {
// Get the action class
const actionClass = await getActionClass(actionClassId);
if (!actionClass) {
return null;
}
if (actionClass.environmentId !== authentication.environmentId) {
// Check if API key has permission to access this environment with appropriate permissions
if (!hasPermission(authentication.environmentPermissions, actionClass.environmentId, method)) {
throw new Error("Unauthorized");
}
return actionClass;
};
@@ -28,7 +34,7 @@ export const GET = async (
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId);
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "GET");
if (actionClass) {
return responses.successResponse(actionClass);
}
@@ -46,7 +52,7 @@ export const PUT = async (
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId);
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "PUT");
if (!actionClass) {
return responses.notFoundResponse("Action Class", params.actionClassId);
}
@@ -88,7 +94,7 @@ export const DELETE = async (
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId);
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "DELETE");
if (!actionClass) {
return responses.notFoundResponse("Action Class", params.actionClassId);
}

View File

@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { getActionClasses } from "./action-classes";
// Mock the prisma client
vi.mock("@formbricks/database", () => ({
prisma: {
actionClass: {
findMany: vi.fn(),
},
},
}));
describe("getActionClasses", () => {
const mockEnvironmentIds = ["env1", "env2"];
const mockActionClasses = [
{
id: "action1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Action 1",
description: "Test Description 1",
type: "click",
key: "test-key-1",
noCodeConfig: {},
environmentId: "env1",
},
{
id: "action2",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Action 2",
description: "Test Description 2",
type: "pageview",
key: "test-key-2",
noCodeConfig: {},
environmentId: "env2",
},
];
beforeEach(() => {
vi.clearAllMocks();
});
it("should successfully fetch action classes for given environment IDs", async () => {
// Mock the prisma findMany response
vi.mocked(prisma.actionClass.findMany).mockResolvedValue(mockActionClasses);
const result = await getActionClasses(mockEnvironmentIds);
expect(result).toEqual(mockActionClasses);
expect(prisma.actionClass.findMany).toHaveBeenCalledWith({
where: {
environmentId: { in: mockEnvironmentIds },
},
select: expect.any(Object),
orderBy: {
createdAt: "asc",
},
});
});
it("should throw DatabaseError when prisma query fails", async () => {
// Mock the prisma findMany to throw an error
vi.mocked(prisma.actionClass.findMany).mockRejectedValue(new Error("Database error"));
await expect(getActionClasses(mockEnvironmentIds)).rejects.toThrow(DatabaseError);
});
it("should handle empty environment IDs array", async () => {
// Mock the prisma findMany response
vi.mocked(prisma.actionClass.findMany).mockResolvedValue([]);
const result = await getActionClasses([]);
expect(result).toEqual([]);
expect(prisma.actionClass.findMany).toHaveBeenCalledWith({
where: {
environmentId: { in: [] },
},
select: expect.any(Object),
orderBy: {
createdAt: "asc",
},
});
});
});

View File

@@ -0,0 +1,51 @@
"use server";
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { actionClassCache } from "@formbricks/lib/actionClass/cache";
import { cache } from "@formbricks/lib/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { TActionClass } from "@formbricks/types/action-classes";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
const selectActionClass = {
id: true,
createdAt: true,
updatedAt: true,
name: true,
description: true,
type: true,
key: true,
noCodeConfig: true,
environmentId: true,
} satisfies Prisma.ActionClassSelect;
export const getActionClasses = reactCache(
async (environmentIds: string[]): Promise<TActionClass[]> =>
cache(
async () => {
validateInputs([environmentIds, ZId.array()]);
try {
return await prisma.actionClass.findMany({
where: {
environmentId: { in: environmentIds },
},
select: selectActionClass,
orderBy: {
createdAt: "asc",
},
});
} catch (error) {
throw new DatabaseError(`Database error when fetching actions for environment ${environmentIds}`);
}
},
environmentIds.map((environmentId) => `getActionClasses-management-api-${environmentId}`),
{
tags: environmentIds.map((environmentId) => actionClassCache.tag.byEnvironmentId(environmentId)),
}
)()
);

View File

@@ -1,16 +1,24 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { createActionClass, getActionClasses } from "@formbricks/lib/actionClass/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { createActionClass } from "@formbricks/lib/actionClass/service";
import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError } from "@formbricks/types/errors";
import { getActionClasses } from "./lib/action-classes";
export const GET = async (request: Request) => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const actionClasses: TActionClass[] = await getActionClasses(authentication.environmentId!);
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const actionClasses = await getActionClasses(environmentIds);
return responses.successResponse(actionClasses);
} catch (error) {
if (error instanceof DatabaseError) {
@@ -35,6 +43,12 @@ export const POST = async (request: Request): Promise<Response> => {
const inputValidation = ZActionClassInput.safeParse(actionClassInput);
const environmentId = actionClassInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
@@ -43,10 +57,7 @@ export const POST = async (request: Request): Promise<Response> => {
);
}
const actionClass: TActionClass = await createActionClass(
authentication.environmentId!,
inputValidation.data
);
const actionClass: TActionClass = await createActionClass(environmentId, inputValidation.data);
return responses.successResponse(actionClass);
} catch (error) {
if (error instanceof DatabaseError) {

View File

@@ -12,29 +12,56 @@ export const GET = async () => {
hashedKey: hashApiKey(apiKey),
},
select: {
environment: {
apiKeyEnvironments: {
select: {
id: true,
createdAt: true,
updatedAt: true,
type: true,
project: {
environment: {
select: {
id: true,
name: true,
type: true,
createdAt: true,
updatedAt: true,
projectId: true,
widgetSetupCompleted: true,
project: {
select: {
id: true,
name: true,
},
},
},
},
appSetupCompleted: true,
permission: true,
},
},
},
});
if (!apiKeyData) {
return new Response("Not authenticated", {
status: 401,
});
}
return Response.json(apiKeyData.environment);
if (
apiKeyData.apiKeyEnvironments.length === 1 &&
apiKeyData.apiKeyEnvironments[0].permission === "manage"
) {
return Response.json({
id: apiKeyData.apiKeyEnvironments[0].environment.id,
type: apiKeyData.apiKeyEnvironments[0].environment.type,
createdAt: apiKeyData.apiKeyEnvironments[0].environment.createdAt,
updatedAt: apiKeyData.apiKeyEnvironments[0].environment.updatedAt,
widgetSetupCompleted: apiKeyData.apiKeyEnvironments[0].environment.widgetSetupCompleted,
project: {
id: apiKeyData.apiKeyEnvironments[0].environment.projectId,
name: apiKeyData.apiKeyEnvironments[0].environment.project.name,
},
});
} else {
return new Response("You can't use this method with this API key", {
status: 400,
});
}
} else {
const sessionUser = await getSessionUser();
if (!sessionUser) {

View File

@@ -1,32 +1,33 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { deleteResponse, getResponse, updateResponse } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { logger } from "@formbricks/logger";
import { TResponse, ZResponseUpdateInput } from "@formbricks/types/responses";
import { ZResponseUpdateInput } from "@formbricks/types/responses";
const fetchAndValidateResponse = async (authentication: any, responseId: string): Promise<TResponse> => {
async function fetchAndAuthorizeResponse(
responseId: string,
authentication: any,
requiredPermission: "GET" | "PUT" | "DELETE"
) {
const response = await getResponse(responseId);
if (!response || !(await canUserAccessResponse(authentication, response))) {
throw new Error("Unauthorized");
if (!response) {
return { error: responses.notFoundResponse("Response", responseId) };
}
return response;
};
const canUserAccessResponse = async (authentication: any, response: TResponse): Promise<boolean> => {
const survey = await getSurvey(response.surveyId);
if (!survey) return false;
if (authentication.type === "session") {
return await hasUserEnvironmentAccess(authentication.session.user.id, survey.environmentId);
} else if (authentication.type === "apiKey") {
return survey.environmentId === authentication.environmentId;
} else {
throw Error("Unknown authentication type");
if (!survey) {
return { error: responses.notFoundResponse("Survey", response.surveyId, true) };
}
};
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, requiredPermission)) {
return { error: responses.unauthorizedResponse() };
}
return { response };
}
export const GET = async (
request: Request,
@@ -36,11 +37,11 @@ export const GET = async (
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const response = await fetchAndValidateResponse(authentication, params.responseId);
if (response) {
return responses.successResponse(response);
}
return responses.notFoundResponse("Response", params.responseId);
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "GET");
if (result.error) return result.error;
return responses.successResponse(result.response);
} catch (error) {
return handleErrorResponse(error);
}
@@ -54,10 +55,10 @@ export const DELETE = async (
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const response = await fetchAndValidateResponse(authentication, params.responseId);
if (!response) {
return responses.notFoundResponse("Response", params.responseId);
}
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "DELETE");
if (result.error) return result.error;
const deletedResponse = await deleteResponse(params.responseId);
return responses.successResponse(deletedResponse);
} catch (error) {
@@ -73,7 +74,10 @@ export const PUT = async (
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
await fetchAndValidateResponse(authentication, params.responseId);
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "PUT");
if (result.error) return result.error;
let responseUpdate;
try {
responseUpdate = await request.json();

View File

@@ -1,6 +1,8 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import {
getMonthlyOrganizationResponseCount,
@@ -8,11 +10,13 @@ import {
} from "@formbricks/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer";
import { responseCache } from "@formbricks/lib/response/cache";
import { getResponseContact } from "@formbricks/lib/response/service";
import { calculateTtcTotal } from "@formbricks/lib/response/utils";
import { responseNoteCache } from "@formbricks/lib/responseNote/cache";
import { captureTelemetry } from "@formbricks/lib/telemetry";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
@@ -25,6 +29,7 @@ export const responseSelection = {
updatedAt: true,
surveyId: true,
finished: true,
endingId: true,
data: true,
meta: true,
ttc: true,
@@ -193,3 +198,53 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
throw error;
}
};
export const getResponsesByEnvironmentIds = reactCache(
async (environmentIds: string[], limit?: number, offset?: number): Promise<TResponse[]> =>
cache(
async () => {
validateInputs([environmentIds, ZId.array()], [limit, ZOptionalNumber], [offset, ZOptionalNumber]);
try {
const responses = await prisma.response.findMany({
where: {
survey: {
environmentId: { in: environmentIds },
},
},
select: responseSelection,
orderBy: [
{
createdAt: "desc",
},
],
take: limit ? limit : undefined,
skip: offset ? offset : undefined,
});
const transformedResponses: TResponse[] = await Promise.all(
responses.map((responsePrisma) => {
return {
...responsePrisma,
contact: getResponseContact(responsePrisma),
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
};
})
);
return transformedResponses;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
environmentIds.map(
(environmentId) => `getResponses-management-api-${environmentId}-${limit}-${offset}`
),
{
tags: environmentIds.map((environmentId) => responseCache.tag.byEnvironmentId(environmentId)),
}
)()
);

View File

@@ -1,13 +1,14 @@
import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { getResponses, getResponsesByEnvironmentId } from "@formbricks/lib/response/service";
import { getResponses } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { createResponse } from "./lib/response";
import { createResponse, getResponsesByEnvironmentIds } from "./lib/response";
export const GET = async (request: NextRequest) => {
const searchParams = request.nextUrl.searchParams;
@@ -18,14 +19,26 @@ export const GET = async (request: NextRequest) => {
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
let environmentResponses: TResponse[] = [];
let allResponses: TResponse[] = [];
if (surveyId) {
environmentResponses = await getResponses(surveyId, limit, offset);
const survey = await getSurvey(surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", surveyId, true);
}
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
return responses.unauthorizedResponse();
}
const surveyResponses = await getResponses(surveyId, limit, offset);
allResponses.push(...surveyResponses);
} else {
environmentResponses = await getResponsesByEnvironmentId(authentication.environmentId, limit, offset);
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const environmentResponses = await getResponsesByEnvironmentIds(environmentIds, limit, offset);
allResponses.push(...environmentResponses);
}
return responses.successResponse(environmentResponses);
return responses.successResponse(allResponses);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
@@ -39,8 +52,6 @@ export const POST = async (request: Request): Promise<Response> => {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const environmentId = authentication.environmentId;
let jsonInput;
try {
@@ -50,9 +61,6 @@ export const POST = async (request: Request): Promise<Response> => {
return responses.badRequestResponse("Malformed JSON input, please check your request body");
}
// add environmentId to response
jsonInput.environmentId = environmentId;
const inputValidation = ZResponseInput.safeParse(jsonInput);
if (!inputValidation.success) {
@@ -65,6 +73,12 @@ export const POST = async (request: Request): Promise<Response> => {
const responseInput = inputValidation.data;
const environmentId = responseInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
// get and check survey
const survey = await getSurvey(responseInput.surveyId);
if (!survey) {

View File

@@ -3,21 +3,28 @@ import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/sur
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
import { logger } from "@formbricks/logger";
import { TSurvey, ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
const fetchAndAuthorizeSurvey = async (authentication: any, surveyId: string): Promise<TSurvey | null> => {
const fetchAndAuthorizeSurvey = async (
surveyId: string,
authentication: TAuthenticationApiKey,
requiredPermission: "GET" | "PUT" | "DELETE"
) => {
const survey = await getSurvey(surveyId);
if (!survey) {
return null;
return { error: responses.notFoundResponse("Survey", surveyId) };
}
if (survey.environmentId !== authentication.environmentId) {
throw new Error("Unauthorized");
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, requiredPermission)) {
return { error: responses.unauthorizedResponse() };
}
return survey;
return { survey };
};
export const GET = async (
@@ -28,11 +35,9 @@ export const GET = async (
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const survey = await fetchAndAuthorizeSurvey(authentication, params.surveyId);
if (survey) {
return responses.successResponse(survey);
}
return responses.notFoundResponse("Survey", params.surveyId);
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "GET");
if (result.error) return result.error;
return responses.successResponse(result.survey);
} catch (error) {
return handleErrorResponse(error);
}
@@ -46,10 +51,8 @@ export const DELETE = async (
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const survey = await fetchAndAuthorizeSurvey(authentication, params.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", params.surveyId);
}
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "DELETE");
if (result.error) return result.error;
const deletedSurvey = await deleteSurvey(params.surveyId);
return responses.successResponse(deletedSurvey);
} catch (error) {
@@ -65,13 +68,10 @@ export const PUT = async (
try {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "PUT");
if (result.error) return result.error;
const survey = await fetchAndAuthorizeSurvey(authentication, params.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", params.surveyId);
}
const organization = await getOrganizationByEnvironmentId(authentication.environmentId);
const organization = await getOrganizationByEnvironmentId(result.survey.environmentId);
if (!organization) {
return responses.notFoundResponse("Organization", null);
}
@@ -85,7 +85,7 @@ export const PUT = async (
}
const inputValidation = ZSurveyUpdateInput.safeParse({
...survey,
...result.survey,
...surveyUpdate,
});

View File

@@ -1,5 +1,6 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
import { getSurvey } from "@formbricks/lib/survey/service";
@@ -17,8 +18,8 @@ export const GET = async (
if (!survey) {
return responses.notFoundResponse("Survey", params.surveyId);
}
if (survey.environmentId !== authentication.environmentId) {
throw new Error("Unauthorized");
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
return responses.unauthorizedResponse();
}
if (!survey.singleUse || !survey.singleUse.enabled) {

View File

@@ -0,0 +1,48 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { surveyCache } from "@formbricks/lib/survey/cache";
import { selectSurvey } from "@formbricks/lib/survey/service";
import { transformPrismaSurvey } from "@formbricks/lib/survey/utils";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { logger } from "@formbricks/logger";
import { ZOptionalNumber } from "@formbricks/types/common";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
export const getSurveys = reactCache(
async (environmentIds: string[], limit?: number, offset?: number): Promise<TSurvey[]> =>
cache(
async () => {
validateInputs([environmentIds, ZId.array()], [limit, ZOptionalNumber], [offset, ZOptionalNumber]);
try {
const surveysPrisma = await prisma.survey.findMany({
where: {
environmentId: { in: environmentIds },
},
select: selectSurvey,
orderBy: {
updatedAt: "desc",
},
take: limit,
skip: offset,
});
return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey<TSurvey>(surveyPrisma));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
logger.error(error, "Error getting surveys");
throw new DatabaseError(error.message);
}
throw error;
}
},
environmentIds.map((environmentId) => `getSurveys-management-api-${environmentId}-${limit}-${offset}`),
{
tags: environmentIds.map((environmentId) => surveyCache.tag.byEnvironmentId(environmentId)),
}
)()
);

View File

@@ -2,12 +2,14 @@ import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { createSurvey, getSurveys } from "@formbricks/lib/survey/service";
import { createSurvey } from "@formbricks/lib/survey/service";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { ZSurveyCreateInput } from "@formbricks/types/surveys/types";
import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
import { getSurveys } from "./lib/surveys";
export const GET = async (request: Request) => {
try {
@@ -18,7 +20,11 @@ export const GET = async (request: Request) => {
const limit = searchParams.has("limit") ? Number(searchParams.get("limit")) : undefined;
const offset = searchParams.has("offset") ? Number(searchParams.get("offset")) : undefined;
const surveys = await getSurveys(authentication.environmentId!, limit, offset);
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const surveys = await getSurveys(environmentIds, limit, offset);
return responses.successResponse(surveys);
} catch (error) {
if (error instanceof DatabaseError) {
@@ -33,11 +39,6 @@ export const POST = async (request: Request): Promise<Response> => {
const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse();
const organization = await getOrganizationByEnvironmentId(authentication.environmentId);
if (!organization) {
return responses.notFoundResponse("Organization", null);
}
let surveyInput;
try {
surveyInput = await request.json();
@@ -45,8 +46,7 @@ export const POST = async (request: Request): Promise<Response> => {
logger.error({ error, url: request.url }, "Error parsing JSON");
return responses.badRequestResponse("Malformed JSON input, please check your request body");
}
const inputValidation = ZSurveyCreateInput.safeParse(surveyInput);
const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput);
if (!inputValidation.success) {
return responses.badRequestResponse(
@@ -56,8 +56,18 @@ export const POST = async (request: Request): Promise<Response> => {
);
}
const environmentId = authentication.environmentId;
const surveyData = { ...inputValidation.data, environmentId: undefined };
const environmentId = inputValidation.data.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
return responses.notFoundResponse("Organization", null);
}
const surveyData = { ...inputValidation.data, environmentId };
if (surveyData.followUps?.length) {
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan);
@@ -73,7 +83,7 @@ export const POST = async (request: Request): Promise<Response> => {
}
}
const survey = await createSurvey(environmentId, surveyData);
const survey = await createSurvey(environmentId, { ...surveyData, environmentId: undefined });
return responses.successResponse(survey);
} catch (error) {
if (error instanceof DatabaseError) {

View File

@@ -1,18 +1,19 @@
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
import { authenticateRequest } from "@/app/api/v1/auth";
import { deleteWebhook, getWebhook } from "@/app/api/v1/webhooks/[webhookId]/lib/webhook";
import { responses } from "@/app/lib/api/response";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { headers } from "next/headers";
import { logger } from "@formbricks/logger";
export const GET = async (_: Request, props: { params: Promise<{ webhookId: string }> }) => {
export const GET = async (request: Request, props: { params: Promise<{ webhookId: string }> }) => {
const params = await props.params;
const headersList = await headers();
const apiKey = headersList.get("x-api-key");
if (!apiKey) {
return responses.notAuthenticatedResponse();
}
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
if (!environmentId) {
const authentication = await authenticateRequest(request);
if (!authentication) {
return responses.notAuthenticatedResponse();
}
@@ -21,7 +22,7 @@ export const GET = async (_: Request, props: { params: Promise<{ webhookId: stri
if (!webhook) {
return responses.notFoundResponse("Webhook", params.webhookId);
}
if (webhook.environmentId !== environmentId) {
if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "GET")) {
return responses.unauthorizedResponse();
}
return responses.successResponse(webhook);
@@ -34,8 +35,8 @@ export const DELETE = async (request: Request, props: { params: Promise<{ webhoo
if (!apiKey) {
return responses.notAuthenticatedResponse();
}
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
if (!environmentId) {
const authentication = await authenticateRequest(request);
if (!authentication) {
return responses.notAuthenticatedResponse();
}
@@ -44,7 +45,7 @@ export const DELETE = async (request: Request, props: { params: Promise<{ webhoo
if (!webhook) {
return responses.notFoundResponse("Webhook", params.webhookId);
}
if (webhook.environmentId !== environmentId) {
if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "DELETE")) {
return responses.unauthorizedResponse();
}

View File

@@ -8,17 +8,20 @@ import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
export const createWebhook = async (environmentId: string, webhookInput: TWebhookInput): Promise<Webhook> => {
validateInputs([environmentId, ZId], [webhookInput, ZWebhookInput]);
export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhook> => {
validateInputs([webhookInput, ZWebhookInput]);
try {
const createdWebhook = await prisma.webhook.create({
data: {
...webhookInput,
url: webhookInput.url,
name: webhookInput.name,
source: webhookInput.source,
surveyIds: webhookInput.surveyIds || [],
triggers: webhookInput.triggers || [],
environment: {
connect: {
id: environmentId,
id: webhookInput.environmentId,
},
},
},
@@ -37,22 +40,24 @@ export const createWebhook = async (environmentId: string, webhookInput: TWebhoo
}
if (!(error instanceof InvalidInputError)) {
throw new DatabaseError(`Database error when creating webhook for environment ${environmentId}`);
throw new DatabaseError(
`Database error when creating webhook for environment ${webhookInput.environmentId}`
);
}
throw error;
}
};
export const getWebhooks = (environmentId: string, page?: number): Promise<Webhook[]> =>
export const getWebhooks = (environmentIds: string[], page?: number): Promise<Webhook[]> =>
cache(
async () => {
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
validateInputs([environmentIds, ZId.array()], [page, ZOptionalNumber]);
try {
const webhooks = await prisma.webhook.findMany({
where: {
environmentId: environmentId,
environmentId: { in: environmentIds },
},
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
@@ -66,8 +71,8 @@ export const getWebhooks = (environmentId: string, page?: number): Promise<Webho
throw error;
}
},
[`getWebhooks-${environmentId}-${page}`],
environmentIds.map((environmentId) => `getWebhooks-${environmentId}-${page}`),
{
tags: [webhookCache.tag.byEnvironmentId(environmentId)],
tags: environmentIds.map((environmentId) => webhookCache.tag.byEnvironmentId(environmentId)),
}
)();

View File

@@ -1,42 +1,33 @@
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
import { authenticateRequest } from "@/app/api/v1/auth";
import { createWebhook, getWebhooks } from "@/app/api/v1/webhooks/lib/webhook";
import { ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { headers } from "next/headers";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
export const GET = async () => {
const headersList = await headers();
const apiKey = headersList.get("x-api-key");
if (!apiKey) {
export const GET = async (request: Request) => {
const authentication = await authenticateRequest(request);
if (!authentication) {
return responses.notAuthenticatedResponse();
}
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
if (!environmentId) {
return responses.notAuthenticatedResponse();
}
// get webhooks from database
try {
const webhooks = await getWebhooks(environmentId);
return Response.json({ data: webhooks });
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const webhooks = await getWebhooks(environmentIds);
return responses.successResponse(webhooks);
} catch (error) {
if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message);
return responses.internalServerErrorResponse(error.message);
}
return responses.internalServerErrorResponse(error.message);
throw error;
}
};
export const POST = async (request: Request) => {
const headersList = await headers();
const apiKey = headersList.get("x-api-key");
if (!apiKey) {
return responses.notAuthenticatedResponse();
}
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
if (!environmentId) {
const authentication = await authenticateRequest(request);
if (!authentication) {
return responses.notAuthenticatedResponse();
}
const webhookInput = await request.json();
@@ -50,9 +41,19 @@ export const POST = async (request: Request) => {
);
}
const environmentId = inputValidation.data.environmentId;
if (!environmentId) {
return responses.badRequestResponse("Environment ID is required");
}
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
// add webhook to database
try {
const webhook = await createWebhook(environmentId, inputValidation.data);
const webhook = await createWebhook(inputValidation.data);
return responses.successResponse(webhook);
} catch (error) {
if (error instanceof InvalidInputError) {

View File

@@ -11,6 +11,7 @@ export const ZWebhookInput = ZWebhook.partial({
surveyIds: true,
triggers: true,
url: true,
environmentId: true,
});
export type TWebhookInput = z.infer<typeof ZWebhookInput>;

View File

@@ -1,3 +0,0 @@
import { GET } from "@/modules/api/v2/management/roles/route";
export { GET };

View File

@@ -0,0 +1,3 @@
import { GET } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/route";
export { GET };

View File

@@ -0,0 +1,3 @@
import { GET } from "@/modules/api/v2/me/route";
export { GET };

View File

@@ -0,0 +1,3 @@
import { DELETE, GET, POST, PUT } from "@/modules/api/v2/organizations/[organizationId]/project-teams/route";
export { GET, POST, PUT, DELETE };

View File

@@ -0,0 +1,3 @@
import { DELETE, GET, PUT } from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route";
export { GET, PUT, DELETE };

View File

@@ -0,0 +1,3 @@
import { GET, POST } from "@/modules/api/v2/organizations/[organizationId]/teams/route";
export { GET, POST };

View File

@@ -0,0 +1,3 @@
import { GET, PATCH, POST } from "@/modules/api/v2/organizations/[organizationId]/users/route";
export { GET, POST, PATCH };

View File

@@ -0,0 +1,3 @@
import { GET } from "@/modules/api/v2/roles/route";
export { GET };

View File

@@ -40,10 +40,6 @@ vi.mock("@/tolgee/server", () => ({
getTolgee: vi.fn(),
}));
vi.mock("@vercel/speed-insights/next", () => ({
SpeedInsights: () => <div data-testid="speed-insights">SpeedInsights</div>,
}));
vi.mock("@/modules/ui/components/post-hog-client", () => ({
PHProvider: ({ children, posthogEnabled }: { children: React.ReactNode; posthogEnabled: boolean }) => (
<div data-testid="ph-provider">
@@ -101,11 +97,6 @@ describe("RootLayout", () => {
const element = await RootLayout({ children });
render(element);
// log env vercel
console.log("vercel", process.env.VERCEL);
expect(screen.getByTestId("speed-insights")).toBeInTheDocument();
expect(screen.getByTestId("ph-provider")).toBeInTheDocument();
expect(screen.getByTestId("tolgee-next-provider")).toBeInTheDocument();
expect(screen.getByTestId("sentry-provider")).toBeInTheDocument();
expect(screen.getByTestId("child")).toHaveTextContent("Child Content");

View File

@@ -1,13 +1,11 @@
import { SentryProvider } from "@/app/sentry/SentryProvider";
import { PHProvider } from "@/modules/ui/components/post-hog-client";
import { TolgeeNextProvider } from "@/tolgee/client";
import { getLocale } from "@/tolgee/language";
import { getTolgee } from "@/tolgee/server";
import { TolgeeStaticData } from "@tolgee/react";
import { SpeedInsights } from "@vercel/speed-insights/next";
import { Metadata } from "next";
import React from "react";
import { IS_POSTHOG_CONFIGURED, SENTRY_DSN } from "@formbricks/lib/constants";
import { SENTRY_DSN } from "@formbricks/lib/constants";
import "../modules/ui/globals.css";
export const metadata: Metadata = {
@@ -27,13 +25,10 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
return (
<html lang={locale} translate="no">
<body className="flex h-dvh flex-col transition-all ease-in-out">
{process.env.VERCEL === "1" && <SpeedInsights sampleRate={0.1} />}
<SentryProvider sentryDsn={SENTRY_DSN}>
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
{children}
</TolgeeNextProvider>
</PHProvider>
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
{children}
</TolgeeNextProvider>
</SentryProvider>
</body>
</html>

View File

@@ -1,10 +1,6 @@
import formbricks from "@formbricks/js";
import { env } from "@formbricks/lib/env";
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
export const formbricksEnabled =
typeof env.NEXT_PUBLIC_FORMBRICKS_API_HOST && env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID;
export const formbricksLogout = async () => {
const loggedInWith = localStorage.getItem(FORMBRICKS_LOGGED_IN_WITH_LS);
localStorage.clear();

View File

@@ -1,6 +1,6 @@
import * as Sentry from "@sentry/nextjs";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SentryProvider } from "./SentryProvider";
vi.mock("@sentry/nextjs", async () => {

View File

@@ -47,6 +47,8 @@ export const SentryProvider = ({ children, sentryDsn }: SentryProviderProps) =>
},
});
}
// We only want to run this once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <>{children}</>;

View File

@@ -19,7 +19,7 @@ export const getFile = async (
headers: {
"Content-Type": metaData.contentType,
"Content-Disposition": "attachment",
"Cache-Control": "public, max-age=1200, s-maxage=1200, stale-while-revalidate=300",
"Cache-Control": "public, max-age=300, s-maxage=300, stale-while-revalidate=300",
Vary: "Accept-Encoding",
},
});
@@ -35,10 +35,7 @@ export const getFile = async (
status: 302,
headers: {
Location: signedUrl,
"Cache-Control":
accessType === "public"
? `public, max-age=3600, s-maxage=3600, stale-while-revalidate=300`
: `public, max-age=600, s-maxage=3600, stale-while-revalidate=300`,
"Cache-Control": "public, max-age=300, s-maxage=300, stale-while-revalidate=300",
},
});
} catch (error: unknown) {

View File

@@ -2,8 +2,8 @@ import { revalidateTag } from "next/cache";
interface RevalidateProps {
id?: string;
environmentId?: string;
hashedKey?: string;
organizationId?: string;
}
export const apiKeyCache = {
@@ -11,24 +11,24 @@ export const apiKeyCache = {
byId(id: string) {
return `apiKeys-${id}`;
},
byEnvironmentId(environmentId: string) {
return `environments-${environmentId}-apiKeys`;
},
byHashedKey(hashedKey: string) {
return `apiKeys-${hashedKey}-apiKey`;
},
byOrganizationId(organizationId: string) {
return `organizations-${organizationId}-apiKeys`;
},
},
revalidate({ id, environmentId, hashedKey }: RevalidateProps): void {
revalidate({ id, hashedKey, organizationId }: RevalidateProps): void {
if (id) {
revalidateTag(this.tag.byId(id));
}
if (environmentId) {
revalidateTag(this.tag.byEnvironmentId(environmentId));
}
if (hashedKey) {
revalidateTag(this.tag.byHashedKey(hashedKey));
}
if (organizationId) {
revalidateTag(this.tag.byOrganizationId(organizationId));
}
},
};

View File

@@ -155,7 +155,7 @@ export const getOrganizationIdFromApiKeyId = async (apiKeyId: string) => {
throw new ResourceNotFoundError("apiKey", apiKeyId);
}
return await getOrganizationIdFromEnvironmentId(apiKeyFromServer.environmentId);
return apiKeyFromServer.organizationId;
};
export const getOrganizationIdFromInviteId = async (inviteId: string) => {
@@ -240,15 +240,6 @@ export const getProjectIdFromSegmentId = async (segmentId: string) => {
return await getProjectIdFromEnvironmentId(segment.environmentId);
};
export const getProjectIdFromApiKeyId = async (apiKeyId: string) => {
const apiKey = await getApiKey(apiKeyId);
if (!apiKey) {
throw new ResourceNotFoundError("apiKey", apiKeyId);
}
return await getProjectIdFromEnvironmentId(apiKey.environmentId);
};
export const getProjectIdFromActionClassId = async (actionClassId: string) => {
const actionClass = await getActionClass(actionClassId);
if (!actionClass) {

View File

@@ -51,7 +51,7 @@ export const getActionClass = reactCache(
);
export const getApiKey = reactCache(
async (apiKeyId: string): Promise<{ environmentId: string } | null> =>
async (apiKeyId: string): Promise<{ organizationId: string } | null> =>
cache(
async () => {
validateInputs([apiKeyId, ZString]);
@@ -66,7 +66,7 @@ export const getApiKey = reactCache(
id: apiKeyId,
},
select: {
environmentId: true,
organizationId: true,
},
});

View File

@@ -67,10 +67,11 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
break;
case TSurveyQuestionTypeEnum.Date:
if (typeof responseData === "string") {
const formattedDateString = formatDateWithOrdinal(new Date(responseData));
return (
<p className="ph-no-capture my-1 truncate font-normal text-slate-700">{formattedDateString}</p>
);
const parsedDate = new Date(responseData);
const formattedDate = isNaN(parsedDate.getTime()) ? responseData : formatDateWithOrdinal(parsedDate);
return <p className="ph-no-capture my-1 truncate font-normal text-slate-700">{formattedDate}</p>;
}
break;
case TSurveyQuestionTypeEnum.PictureSelection:
@@ -100,7 +101,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
return (
<p
key={rowValueInSelectedLanguage}
className="ph-no-capture my-1 font-normal capitalize text-slate-700">
className="ph-no-capture my-1 font-normal text-slate-700 capitalize">
{rowValueInSelectedLanguage}:{processResponseData(responseData[rowValueInSelectedLanguage])}
</p>
);

View File

@@ -0,0 +1,34 @@
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const authenticateRequest = async (
request: Request
): Promise<Result<TAuthenticationApiKey, ApiErrorResponseV2>> => {
const apiKey = request.headers.get("x-api-key");
if (!apiKey) return err({ type: "unauthorized" });
const apiKeyData = await getApiKeyWithPermissions(apiKey);
if (!apiKeyData) return err({ type: "unauthorized" });
const hashedApiKey = hashApiKey(apiKey);
const authentication: TAuthenticationApiKey = {
type: "apiKey",
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
environmentId: env.environmentId,
environmentType: env.environment.type,
permission: env.permission,
projectId: env.environment.projectId,
projectName: env.environment.project.name,
})),
hashedApiKey,
apiKeyId: apiKeyData.id,
organizationId: apiKeyData.organizationId,
organizationAccess: apiKeyData.organizationAccess,
};
return ok(authentication);
};

View File

@@ -1,7 +1,7 @@
import { apiWrapper } from "@/modules/api/v2/auth/api-wrapper";
import { authenticateRequest } from "@/modules/api/v2/auth/authenticate-request";
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { apiWrapper } from "@/modules/api/v2/management/auth/api-wrapper";
import { authenticateRequest } from "@/modules/api/v2/management/auth/authenticate-request";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { describe, expect, it, vi } from "vitest";
import { z } from "zod";

View File

@@ -0,0 +1,114 @@
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { describe, expect, it, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { authenticateRequest } from "../authenticate-request";
vi.mock("@formbricks/database", () => ({
prisma: {
apiKey: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
hashApiKey: vi.fn(),
}));
describe("authenticateRequest", () => {
it("should return authentication data if apiKey is valid", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
const mockApiKeyData = {
id: "api-key-id",
organizationId: "org-id",
createdAt: new Date(),
createdBy: "user-id",
lastUsedAt: null,
label: "Test API Key",
hashedKey: "hashed-api-key",
apiKeyEnvironments: [
{
environmentId: "env-id-1",
permission: "manage",
environment: {
id: "env-id-1",
projectId: "project-id-1",
type: "development",
project: { name: "Project 1" },
},
},
{
environmentId: "env-id-2",
permission: "read",
environment: {
id: "env-id-2",
projectId: "project-id-2",
type: "production",
project: { name: "Project 2" },
},
},
],
};
vi.mocked(hashApiKey).mockReturnValue("hashed-api-key");
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData);
vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData);
const result = await authenticateRequest(request);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
type: "apiKey",
environmentPermissions: [
{
environmentId: "env-id-1",
permission: "manage",
environmentType: "development",
projectId: "project-id-1",
projectName: "Project 1",
},
{
environmentId: "env-id-2",
permission: "read",
environmentType: "production",
projectId: "project-id-2",
projectName: "Project 2",
},
],
hashedApiKey: "hashed-api-key",
apiKeyId: "api-key-id",
organizationId: "org-id",
});
}
});
it("should return unauthorized error if apiKey is not found", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "invalid-api-key" },
});
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
const result = await authenticateRequest(request);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({ type: "unauthorized" });
}
});
it("should return unauthorized error if apiKey is missing", async () => {
const request = new Request("http://localhost");
const result = await authenticateRequest(request);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({ type: "unauthorized" });
}
});
});

View File

@@ -122,9 +122,11 @@ const notFoundResponse = ({
const conflictResponse = ({
cors = false,
cache = "private, no-store",
details = [],
}: {
cors?: boolean;
cache?: string;
details?: ApiErrorDetails;
} = {}) => {
const headers = {
...(cors && corsHeaders),
@@ -136,6 +138,7 @@ const conflictResponse = ({
error: {
code: 409,
message: "Conflict",
details,
},
},
{
@@ -232,7 +235,7 @@ const internalServerErrorResponse = ({
const successResponse = ({
data,
meta,
cors = false,
cors = true,
cache = "private, no-store",
}: {
data: Object;

View File

@@ -85,13 +85,15 @@ describe("API Responses", () => {
describe("conflictResponse", () => {
test("return a 409 response", async () => {
const res = responses.conflictResponse();
const details = [{ field: "resource", issue: "already exists" }];
const res = responses.conflictResponse({ details });
expect(res.status).toBe(409);
const body = await res.json();
expect(body).toEqual({
error: {
code: 409,
message: "Conflict",
details,
},
});
});

View File

@@ -16,7 +16,7 @@ export const handleApiError = (request: Request, err: ApiErrorResponseV2): Respo
case "not_found":
return responses.notFoundResponse({ details: err.details });
case "conflict":
return responses.conflictResponse();
return responses.conflictResponse({ details: err.details });
case "unprocessable_entity":
return responses.unprocessableEntityResponse({ details: err.details });
case "too_many_requests":

View File

@@ -1,34 +0,0 @@
import { getEnvironmentIdFromApiKey } from "@/modules/api/v2/management/lib/api-key";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const authenticateRequest = async (
request: Request
): Promise<Result<TAuthenticationApiKey, ApiErrorResponseV2>> => {
const apiKey = request.headers.get("x-api-key");
if (apiKey) {
const environmentIdResult = await getEnvironmentIdFromApiKey(apiKey);
if (!environmentIdResult.ok) {
return err(environmentIdResult.error);
}
const environmentId = environmentIdResult.data;
const hashedApiKey = hashApiKey(apiKey);
if (environmentId) {
const authentication: TAuthenticationApiKey = {
type: "apiKey",
environmentId,
hashedApiKey,
};
return ok(authentication);
}
return err({
type: "forbidden",
});
}
return err({
type: "unauthorized",
});
};

View File

@@ -1,18 +0,0 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { Result, err, okVoid } from "@formbricks/types/error-handlers";
export const checkAuthorization = ({
authentication,
environmentId,
}: {
authentication: TAuthenticationApiKey;
environmentId: string;
}): Result<void, ApiErrorResponseV2> => {
if (authentication.type === "apiKey" && authentication.environmentId !== environmentId) {
return err({
type: "unauthorized",
});
}
return okVoid();
};

View File

@@ -1,73 +0,0 @@
import { getEnvironmentIdFromApiKey } from "@/modules/api/v2/management/lib/api-key";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { describe, expect, it, vi } from "vitest";
import { err, ok } from "@formbricks/types/error-handlers";
import { authenticateRequest } from "../authenticate-request";
vi.mock("@/modules/api/v2/management/lib/api-key", () => ({
getEnvironmentIdFromApiKey: vi.fn(),
}));
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
hashApiKey: vi.fn(),
}));
describe("authenticateRequest", () => {
it("should return authentication data if apiKey is valid", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
vi.mocked(getEnvironmentIdFromApiKey).mockResolvedValue(ok("env-id"));
vi.mocked(hashApiKey).mockReturnValue("hashed-api-key");
const result = await authenticateRequest(request);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
});
}
});
it("should return forbidden error if environmentId is not found", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "invalid-api-key" },
});
vi.mocked(getEnvironmentIdFromApiKey).mockResolvedValue(err({ type: "forbidden" }));
const result = await authenticateRequest(request);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({ type: "forbidden" });
}
});
it("should return forbidden error if environmentId is empty", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "invalid-api-key" },
});
vi.mocked(getEnvironmentIdFromApiKey).mockResolvedValue(ok(""));
const result = await authenticateRequest(request);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({ type: "forbidden" });
}
});
it("should return unauthorized error if apiKey is missing", async () => {
const request = new Request("http://localhost");
const result = await authenticateRequest(request);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({ type: "unauthorized" });
}
});
});

View File

@@ -1,31 +0,0 @@
import { describe, expect, it } from "vitest";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { checkAuthorization } from "../check-authorization";
describe("checkAuthorization", () => {
it("should return ok if authentication is valid", () => {
const authentication: TAuthenticationApiKey = {
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
};
const result = checkAuthorization({ authentication, environmentId: "env-id" });
expect(result.ok).toBe(true);
});
it("should return unauthorized error if environmentId does not match", () => {
const authentication: TAuthenticationApiKey = {
type: "apiKey",
environmentId: "env-id",
hashedApiKey: "hashed-api-key",
};
const result = checkAuthorization({ authentication, environmentId: "different-env-id" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({ type: "unauthorized" });
}
});
});

View File

@@ -7,6 +7,7 @@ import {
ZContactAttributeKeyInput,
ZGetContactAttributeKeysFilter,
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZContactAttributeKey } from "@formbricks/types/contact-attribute-key";
@@ -54,10 +55,12 @@ export const createContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
export const contactAttributeKeyPaths: ZodOpenApiPathsObject = {
"/contact-attribute-keys": {
servers: managementServer,
get: getContactAttributeKeysEndpoint,
post: createContactAttributeKeyEndpoint,
},
"/contact-attribute-keys/{id}": {
servers: managementServer,
get: getContactAttributeKeyEndpoint,
put: updateContactAttributeKeyEndpoint,
delete: deleteContactAttributeKeyEndpoint,

View File

@@ -7,6 +7,7 @@ import {
ZContactAttributeInput,
ZGetContactAttributesFilter,
} from "@/modules/api/v2/management/contact-attributes/types/contact-attributes";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZContactAttribute } from "@formbricks/types/contact-attribute";
@@ -54,10 +55,12 @@ export const createContactAttributeEndpoint: ZodOpenApiOperationObject = {
export const contactAttributePaths: ZodOpenApiPathsObject = {
"/contact-attributes": {
servers: managementServer,
get: getContactAttributesEndpoint,
post: createContactAttributeEndpoint,
},
"/contact-attributes/{id}": {
servers: managementServer,
get: getContactAttributeEndpoint,
put: updateContactAttributeEndpoint,
delete: deleteContactAttributeEndpoint,

View File

@@ -4,6 +4,7 @@ import {
updateContactEndpoint,
} from "@/modules/api/v2/management/contacts/[contactId]/lib/openapi";
import { ZContactInput, ZGetContactsFilter } from "@/modules/api/v2/management/contacts/types/contacts";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZContact } from "@formbricks/database/zod/contact";
@@ -56,10 +57,12 @@ export const createContactEndpoint: ZodOpenApiOperationObject = {
export const contactPaths: ZodOpenApiPathsObject = {
"/contacts": {
servers: managementServer,
get: getContactsEndpoint,
post: createContactEndpoint,
},
"/contacts/{id}": {
servers: managementServer,
get: getContactEndpoint,
put: updateContactEndpoint,
delete: deleteContactEndpoint,

View File

@@ -1,44 +0,0 @@
import { apiKeyCache } from "@/lib/cache/api-key";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getEnvironmentIdFromApiKey = reactCache(async (apiKey: string) => {
const hashedKey = hashApiKey(apiKey);
return cache(
async (): Promise<Result<string, ApiErrorResponseV2>> => {
if (!apiKey) {
return err({
type: "bad_request",
details: [{ field: "apiKey", issue: "API key cannot be null or undefined." }],
});
}
try {
const apiKeyData = await prisma.apiKey.findUnique({
where: {
hashedKey,
},
select: {
environmentId: true,
},
});
if (!apiKeyData) {
return err({ type: "not_found", details: [{ field: "apiKey", issue: "not found" }] });
}
return ok(apiKeyData.environmentId);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "apiKey", issue: error.message }] });
}
},
[`management-api-getEnvironmentIdFromApiKey-${hashedKey}`],
{
tags: [apiKeyCache.tag.byHashedKey(hashedKey)],
}
)();
});

View File

@@ -0,0 +1,6 @@
export const managementServer = [
{
url: `https://app.formbricks.com/api/v2/management`,
description: "Formbricks Management API",
},
];

View File

@@ -1,81 +0,0 @@
import { apiKey, environmentId } from "./__mocks__/api-key.mock";
import { getEnvironmentIdFromApiKey } from "@/modules/api/v2/management/lib/api-key";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
vi.mock("@formbricks/database", () => ({
prisma: {
apiKey: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
hashApiKey: vi.fn((input: string) => `hashed-${input}`),
}));
describe("getEnvironmentIdFromApiKey", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns a bad_request error if apiKey is empty", async () => {
const result = await getEnvironmentIdFromApiKey("");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("bad_request");
expect(result.error.details).toEqual([
{ field: "apiKey", issue: "API key cannot be null or undefined." },
]);
}
});
test("returns a not_found error when no apiKey record is found in the database", async () => {
vi.mocked(hashApiKey).mockImplementation((input: string) => `hashed-${input}`);
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
const result = await getEnvironmentIdFromApiKey(apiKey);
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
where: { hashedKey: `hashed-${apiKey}` },
select: { environmentId: true },
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("not_found");
expect(result.error.details).toEqual([{ field: "apiKey", issue: "not found" }]);
}
});
test("returns ok with environmentId when a valid apiKey record is found", async () => {
vi.mocked(hashApiKey).mockImplementation((input: string) => `hashed-${input}`);
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue({ environmentId });
const result = await getEnvironmentIdFromApiKey(apiKey);
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
where: { hashedKey: `hashed-${apiKey}` },
select: { environmentId: true },
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toBe(environmentId);
}
});
test("returns internal_server_error when an exception occurs during the database lookup", async () => {
vi.mocked(hashApiKey).mockImplementation((input: string) => `hashed-${input}`);
vi.mocked(prisma.apiKey.findUnique).mockRejectedValue(new Error("Database failure"));
const result = await getEnvironmentIdFromApiKey(apiKey);
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
where: { hashedKey: `hashed-${apiKey}` },
select: { environmentId: true },
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details).toEqual([{ field: "apiKey", issue: "Database failure" }]);
}
});
});

View File

@@ -9,7 +9,12 @@ export function pickCommonFilter<T extends TGetFilter>(params: T) {
return { limit, skip, sortBy, order, startDate, endDate };
}
type HasFindMany = Prisma.WebhookFindManyArgs | Prisma.ResponseFindManyArgs;
type HasFindMany =
| Prisma.WebhookFindManyArgs
| Prisma.ResponseFindManyArgs
| Prisma.TeamFindManyArgs
| Prisma.ProjectTeamFindManyArgs
| Prisma.UserFindManyArgs;
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
const { limit, skip, sortBy, order, startDate, endDate } = params || {};

View File

@@ -1,4 +1,4 @@
import { responseIdSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
import { ZResponseIdSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
@@ -11,7 +11,7 @@ export const getResponseEndpoint: ZodOpenApiOperationObject = {
description: "Gets a response from the database.",
requestParams: {
path: z.object({
id: responseIdSchema,
id: ZResponseIdSchema,
}),
},
tags: ["Management API > Responses"],
@@ -34,7 +34,7 @@ export const deleteResponseEndpoint: ZodOpenApiOperationObject = {
tags: ["Management API > Responses"],
requestParams: {
path: z.object({
id: responseIdSchema,
id: ZResponseIdSchema,
}),
},
responses: {
@@ -56,7 +56,7 @@ export const updateResponseEndpoint: ZodOpenApiOperationObject = {
tags: ["Management API > Responses"],
requestParams: {
path: z.object({
id: responseIdSchema,
id: ZResponseIdSchema,
}),
},
requestBody: {

View File

@@ -1,7 +1,7 @@
import { deleteDisplay } from "@/modules/api/v2/management/responses/[responseId]/lib/display";
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
import { findAndDeleteUploadedFilesInResponse } from "@/modules/api/v2/management/responses/[responseId]/lib/utils";
import { responseUpdateSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
import { ZResponseUpdateSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Response } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
@@ -98,7 +98,7 @@ export const deleteResponse = async (responseId: string): Promise<Result<Respons
export const updateResponse = async (
responseId: string,
responseInput: z.infer<typeof responseUpdateSchema>
responseInput: z.infer<typeof ZResponseUpdateSchema>
): Promise<Result<Response, ApiErrorResponseV2>> => {
try {
const updatedResponse = await prisma.response.update({

View File

@@ -1,21 +1,21 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
import { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization";
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import {
deleteResponse,
getResponse,
updateResponse,
} from "@/modules/api/v2/management/responses/[responseId]/lib/response";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { z } from "zod";
import { responseIdSchema, responseUpdateSchema } from "./types/responses";
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";
export const GET = async (request: Request, props: { params: Promise<{ responseId: string }> }) =>
authenticatedApiClient({
request,
schemas: {
params: z.object({ responseId: responseIdSchema }),
params: z.object({ responseId: ZResponseIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
@@ -33,13 +33,10 @@ export const GET = async (request: Request, props: { params: Promise<{ responseI
return handleApiError(request, environmentIdResult.error);
}
const checkAuthorizationResult = await checkAuthorization({
authentication,
environmentId: environmentIdResult.data,
});
if (!checkAuthorizationResult.ok) {
return handleApiError(request, checkAuthorizationResult.error);
if (!hasPermission(authentication.environmentPermissions, environmentIdResult.data, "GET")) {
return handleApiError(request, {
type: "unauthorized",
});
}
const response = await getResponse(params.responseId);
@@ -55,7 +52,7 @@ export const DELETE = async (request: Request, props: { params: Promise<{ respon
authenticatedApiClient({
request,
schemas: {
params: z.object({ responseId: responseIdSchema }),
params: z.object({ responseId: ZResponseIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
@@ -73,13 +70,10 @@ export const DELETE = async (request: Request, props: { params: Promise<{ respon
return handleApiError(request, environmentIdResult.error);
}
const checkAuthorizationResult = await checkAuthorization({
authentication,
environmentId: environmentIdResult.data,
});
if (!checkAuthorizationResult.ok) {
return handleApiError(request, checkAuthorizationResult.error);
if (!hasPermission(authentication.environmentPermissions, environmentIdResult.data, "DELETE")) {
return handleApiError(request, {
type: "unauthorized",
});
}
const response = await deleteResponse(params.responseId);
@@ -97,8 +91,8 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
request,
externalParams: props.params,
schemas: {
params: z.object({ responseId: responseIdSchema }),
body: responseUpdateSchema,
params: z.object({ responseId: ZResponseIdSchema }),
body: ZResponseUpdateSchema,
},
handler: async ({ authentication, parsedInput }) => {
const { body, params } = parsedInput;
@@ -115,13 +109,10 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
return handleApiError(request, environmentIdResult.error);
}
const checkAuthorizationResult = await checkAuthorization({
authentication,
environmentId: environmentIdResult.data,
});
if (!checkAuthorizationResult.ok) {
return handleApiError(request, checkAuthorizationResult.error);
if (!hasPermission(authentication.environmentPermissions, environmentIdResult.data, "PUT")) {
return handleApiError(request, {
type: "unauthorized",
});
}
const response = await updateResponse(params.responseId, body);

View File

@@ -4,7 +4,7 @@ import { ZResponse } from "@formbricks/database/zod/responses";
extendZodWithOpenApi(z);
export const responseIdSchema = z
export const ZResponseIdSchema = z
.string()
.cuid2()
.openapi({
@@ -16,7 +16,7 @@ export const responseIdSchema = z
},
});
export const responseUpdateSchema = ZResponse.omit({
export const ZResponseUpdateSchema = ZResponse.omit({
id: true,
surveyId: true,
}).openapi({

View File

@@ -1,3 +1,4 @@
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import {
deleteResponseEndpoint,
getResponseEndpoint,
@@ -5,7 +6,6 @@ import {
} from "@/modules/api/v2/management/responses/[responseId]/lib/openapi";
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZResponse } from "@formbricks/database/zod/responses";
@@ -14,7 +14,7 @@ export const getResponsesEndpoint: ZodOpenApiOperationObject = {
summary: "Get responses",
description: "Gets responses from the database.",
requestParams: {
query: ZGetResponsesFilter.sourceType().required(),
query: ZGetResponsesFilter.sourceType(),
},
tags: ["Management API > Responses"],
responses: {
@@ -22,7 +22,7 @@ export const getResponsesEndpoint: ZodOpenApiOperationObject = {
description: "Responses retrieved successfully.",
content: {
"application/json": {
schema: z.array(responseWithMetaSchema(makePartialSchema(ZResponse))),
schema: responseWithMetaSchema(makePartialSchema(ZResponse)),
},
},
},
@@ -57,10 +57,12 @@ export const createResponseEndpoint: ZodOpenApiOperationObject = {
export const responsePaths: ZodOpenApiPathsObject = {
"/responses": {
servers: managementServer,
get: getResponsesEndpoint,
post: createResponseEndpoint,
},
"/responses/{id}": {
servers: managementServer,
get: getResponseEndpoint,
put: updateResponseEndpoint,
delete: deleteResponseEndpoint,

View File

@@ -130,16 +130,18 @@ export const createResponse = async (
};
export const getResponses = async (
environmentId: string,
environmentIds: string[],
params: TGetResponsesFilter
): Promise<Result<ApiResponseWithMeta<Response[]>, ApiErrorResponseV2>> => {
try {
const query = getResponsesQuery(environmentIds, params);
const [responses, count] = await prisma.$transaction([
prisma.response.findMany({
...getResponsesQuery(environmentId, params),
...query,
}),
prisma.response.count({
where: getResponsesQuery(environmentId, params).where,
where: query.where,
}),
]);

View File

@@ -11,12 +11,12 @@ vi.mock("@/modules/api/v2/management/lib/utils", () => ({
describe("getResponsesQuery", () => {
it("adds surveyId to where clause if provided", () => {
const result = getResponsesQuery("env-id", { surveyId: "survey123" } as TGetResponsesFilter);
const result = getResponsesQuery(["env-id"], { surveyId: "survey123" } as TGetResponsesFilter);
expect(result?.where?.surveyId).toBe("survey123");
});
it("adds contactId to where clause if provided", () => {
const result = getResponsesQuery("env-id", { contactId: "contact123" } as TGetResponsesFilter);
const result = getResponsesQuery(["env-id"], { contactId: "contact123" } as TGetResponsesFilter);
expect(result?.where?.contactId).toBe("contact123");
});
@@ -24,12 +24,12 @@ describe("getResponsesQuery", () => {
vi.mocked(pickCommonFilter).mockReturnValueOnce({ someFilter: true } as any);
vi.mocked(buildCommonFilterQuery).mockReturnValueOnce({ where: { combined: true } as any });
const result = getResponsesQuery("env-id", { surveyId: "test" } as TGetResponsesFilter);
const result = getResponsesQuery(["env-id"], { surveyId: "test" } as TGetResponsesFilter);
expect(pickCommonFilter).toHaveBeenCalledWith({ surveyId: "test" });
expect(buildCommonFilterQuery).toHaveBeenCalledWith(
expect.objectContaining<Prisma.ResponseFindManyArgs>({
where: {
survey: { environmentId: "env-id" },
survey: { environmentId: { in: ["env-id"] } },
surveyId: "test",
},
}),

View File

@@ -2,11 +2,11 @@ import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/manag
import { TGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses";
import { Prisma } from "@prisma/client";
export const getResponsesQuery = (environmentId: string, params?: TGetResponsesFilter) => {
export const getResponsesQuery = (environmentIds: string[], params?: TGetResponsesFilter) => {
let query: Prisma.ResponseFindManyArgs = {
where: {
survey: {
environmentId,
environmentId: { in: environmentIds },
},
},
};

View File

@@ -1,9 +1,10 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
import { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization";
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { Response } from "@prisma/client";
import { NextRequest } from "next/server";
import { createResponse, getResponses } from "./lib/response";
@@ -23,15 +24,20 @@ export const GET = async (request: NextRequest) =>
});
}
const environmentId = authentication.environmentId;
const environmentIds = authentication.environmentPermissions.map(
(permission) => permission.environmentId
);
const res = await getResponses(environmentId, query);
const environmentResponses: Response[] = [];
const res = await getResponses(environmentIds, query);
if (res.ok) {
return responses.successResponse(res.data);
if (!res.ok) {
return handleApiError(request, res.error);
}
return handleApiError(request, res.error);
environmentResponses.push(...res.data.data);
return responses.successResponse({ data: environmentResponses });
},
});
@@ -59,13 +65,10 @@ export const POST = async (request: Request) =>
const environmentId = environmentIdResult.data;
const checkAuthorizationResult = await checkAuthorization({
authentication,
environmentId,
});
if (!checkAuthorizationResult.ok) {
return handleApiError(request, checkAuthorizationResult.error);
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return handleApiError(request, {
type: "unauthorized",
});
}
// if there is a createdAt but no updatedAt, set updatedAt to createdAt
@@ -78,6 +81,6 @@ export const POST = async (request: Request) =>
return handleApiError(request, createResponseResult.error);
}
return responses.successResponse({ data: createResponseResult.data, cors: true });
return responses.successResponse({ data: createResponseResult.data });
},
});

View File

@@ -1,26 +0,0 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ApiResponse } from "@/modules/api/v2/types/api-success";
import { prisma } from "@formbricks/database";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getRoles = async (): Promise<Result<ApiResponse<string[]>, ApiErrorResponseV2>> => {
try {
// We use a raw query to get all the roles because we can't list enum options with prisma
const results = await prisma.$queryRaw<{ unnest: string }[]>`
SELECT unnest(enum_range(NULL::"OrganizationRole"));
`;
if (!results) {
// We set internal_server_error because it's an enum and we should always have the roles
return err({ type: "internal_server_error", details: [{ field: "roles", issue: "not found" }] });
}
const roles = results.map((row) => row.unnest);
return ok({
data: roles,
});
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "roles", issue: error.message }] });
}
};

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