Compare commits

..

30 Commits

Author SHA1 Message Date
pandeymangg 62b4c85a10 increases transaction timeout 2025-03-30 17:19:47 +07:00
Piyush Gupta 49fbf097f8 fix: types 2025-03-30 13:41:59 +07:00
pandeymangg 3a40568366 Merge branch 'main' into feat/bulk-contacts-api 2025-03-30 12:26:58 +07:00
pandeymangg f8c8b8c45d fix: Array type to [] 2025-03-30 12:26:07 +07:00
pandeymangg eaeaa74ba8 fix: tests 2025-03-30 12:23:15 +07:00
pandeymangg 5f90968e61 merging two queries into one 2025-03-30 00:24:09 +07:00
pandeymangg d05a7c6d98 perf 2025-03-28 14:13:54 +05:30
pandeymangg 0c6c554cef chore: merge with main 2025-03-28 09:55:16 +05:30
pandeymangg bb3ff6829d fix tests 2025-03-27 13:58:52 +05:30
pandeymangg 425edf4cac fix: fixes logic for conflicting userIds and other things 2025-03-27 13:08:55 +05:30
pandeymangg 8052ee0aaf fix: adds checks for duplicate attribute keys in the payload 2025-03-26 12:56:47 +05:30
pandeymangg 2f15312d5c dup fix 2025-03-25 17:14:21 +05:30
pandeymangg 5196c77277 adds duplication and coverage exclusion for mdx files 2025-03-25 17:12:54 +05:30
pandeymangg bd9efff3ff hello 2025-03-25 17:01:01 +05:30
pandeymangg 93907263a6 adds openapi spec for bulk contacts upload api 2025-03-25 15:37:11 +05:30
pandeymangg 3ed35523be refactor 2025-03-25 14:31:38 +05:30
pandeymangg 8da23c2e41 adds tests for upsertBulkContacts service 2025-03-25 13:22:44 +05:30
pandeymangg cea7139b40 fix: adds logic for returning the duplicate emails and userIds 2025-03-25 10:54:33 +05:30
pandeymangg d873e5b759 fix: api error 2025-03-25 09:14:55 +05:30
pandeymangg cda1109ffc Merge branch 'main' into feat/bulk-contacts-api 2025-03-25 09:07:38 +05:30
pandeymangg b120de550f fix: api route 2025-03-25 09:07:17 +05:30
pandeymangg 3f9c1c57f9 fix: skip conflicting contacts 2025-03-24 15:15:57 +05:30
pandeymangg 9abb07deba Merge branch 'main' into feat/bulk-contacts-api 2025-03-24 11:56:45 +05:30
pandeymangg f665e05723 userIds conflict 2025-03-24 10:40:17 +05:30
pandeymangg ed870ea0ce moves bulk contacts upsert logic to a service 2025-03-21 13:24:15 +05:30
pandeymangg b5212e0e0e hall 2025-03-20 12:38:39 +05:30
pandeymangg a16dcee01d raw queries 2025-03-19 20:10:00 +05:30
pandeymangg af9dfe63ca adds email and userId checks 2025-03-19 17:47:20 +05:30
pandeymangg e12d6a5d2d Merge branch 'main' into feat/bulk-contacts-api 2025-03-19 13:50:04 +05:30
pandeymangg f8bd0902d2 POC: contacts upload api 2025-03-18 17:03:23 +05:30
653 changed files with 11984 additions and 26254 deletions
+4 -3
View File
@@ -117,7 +117,7 @@ IMPRINT_URL=
IMPRINT_ADDRESS= IMPRINT_ADDRESS=
# Configure Turnstile in signup flow # Configure Turnstile in signup flow
# TURNSTILE_SITE_KEY= # NEXT_PUBLIC_TURNSTILE_SITE_KEY=
# TURNSTILE_SECRET_KEY= # TURNSTILE_SECRET_KEY=
# Configure Github Login # Configure Github Login
@@ -155,8 +155,9 @@ STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET= STRIPE_WEBHOOK_SECRET=
# Configure Formbricks usage within Formbricks # Configure Formbricks usage within Formbricks
FORMBRICKS_API_HOST= NEXT_PUBLIC_FORMBRICKS_API_HOST=
FORMBRICKS_ENVIRONMENT_ID= NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID=
# Oauth credentials for Google sheet integration # Oauth credentials for Google sheet integration
GOOGLE_SHEETS_CLIENT_ID= GOOGLE_SHEETS_CLIENT_ID=
+1 -11
View File
@@ -8,14 +8,6 @@ on:
required: false required: false
default: "0" default: "0"
inputs:
turbo_token:
description: "Turborepo token"
required: false
turbo_team:
description: "Turborepo team"
required: false
runs: runs:
using: "composite" using: "composite"
steps: steps:
@@ -70,8 +62,6 @@ runs:
- run: | - run: |
pnpm build --filter=@formbricks/web... pnpm build --filter=@formbricks/web...
if: steps.cache-build.outputs.cache-hit != 'true' if: steps.cache-build.outputs.cache-hit != 'true'
shell: bash shell: bash
env:
TURBO_TOKEN: ${{ inputs.turbo_token }}
TURBO_TEAM: ${{ inputs.turbo_team }}
+1 -3
View File
@@ -4,7 +4,7 @@ on:
permissions: permissions:
contents: read contents: read
jobs: jobs:
build: build:
name: Build Formbricks-web name: Build Formbricks-web
@@ -25,5 +25,3 @@ jobs:
id: cache-build-web id: cache-build-web
with: with:
e2e_testing_mode: "0" e2e_testing_mode: "0"
turbo_token: ${{ secrets.TURBO_TOKEN }}
turbo_team: ${{ vars.TURBO_TEAM }}
@@ -1,167 +0,0 @@
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!"
-2
View File
@@ -16,8 +16,6 @@ on:
env: env:
TELEMETRY_DISABLED: 1 TELEMETRY_DISABLED: 1
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
permissions: permissions:
id-token: write id-token: write
+3 -3
View File
@@ -23,10 +23,10 @@ jobs:
with: with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Setup Node.js 22.x - name: Setup Node.js 20.x
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with: with:
node-version: 22.x node-version: 20.x
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
@@ -48,7 +48,7 @@ jobs:
run: | run: |
pnpm test:coverage pnpm test:coverage
- name: SonarQube Scan - name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97 uses: SonarSource/sonarqube-scan-action@bfd4e558cda28cda6b5defafb9232d191be8c203
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
-4
View File
@@ -1,8 +1,4 @@
{ {
"sonarlint.connectedMode.project": {
"connectionId": "formbricks",
"projectKey": "formbricks_formbricks"
},
"typescript.preferences.importModuleSpecifier": "non-relative", "typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.tsdk": "node_modules/typescript/lib" "typescript.tsdk": "node_modules/typescript/lib"
} }
+4 -4
View File
@@ -27,7 +27,7 @@ const secondaryNavigation = [
export function Sidebar(): React.JSX.Element { export function Sidebar(): React.JSX.Element {
return ( return (
<div className="flex grow flex-col overflow-y-auto bg-cyan-700 pt-5 pb-4"> <div className="flex flex-grow flex-col overflow-y-auto bg-cyan-700 pb-4 pt-5">
<nav <nav
className="mt-5 flex flex-1 flex-col divide-y divide-cyan-800 overflow-y-auto" className="mt-5 flex flex-1 flex-col divide-y divide-cyan-800 overflow-y-auto"
aria-label="Sidebar"> aria-label="Sidebar">
@@ -38,10 +38,10 @@ export function Sidebar(): React.JSX.Element {
href={item.href} href={item.href}
className={classNames( className={classNames(
item.current ? "bg-cyan-800 text-white" : "text-cyan-100 hover:bg-cyan-600 hover:text-white", item.current ? "bg-cyan-800 text-white" : "text-cyan-100 hover:bg-cyan-600 hover:text-white",
"group flex items-center rounded-md px-2 py-2 text-sm leading-6 font-medium" "group flex items-center rounded-md px-2 py-2 text-sm font-medium leading-6"
)} )}
aria-current={item.current ? "page" : undefined}> aria-current={item.current ? "page" : undefined}>
<item.icon className="mr-4 h-6 w-6 shrink-0 text-cyan-200" aria-hidden="true" /> <item.icon className="mr-4 h-6 w-6 flex-shrink-0 text-cyan-200" aria-hidden="true" />
{item.name} {item.name}
</a> </a>
))} ))}
@@ -52,7 +52,7 @@ export function Sidebar(): React.JSX.Element {
<a <a
key={item.name} key={item.name}
href={item.href} href={item.href}
className="group flex items-center rounded-md px-2 py-2 text-sm leading-6 font-medium text-cyan-100 hover:bg-cyan-600 hover:text-white"> className="group flex items-center rounded-md px-2 py-2 text-sm font-medium leading-6 text-cyan-100 hover:bg-cyan-600 hover:text-white">
<item.icon className="mr-4 h-6 w-6 text-cyan-200" aria-hidden="true" /> <item.icon className="mr-4 h-6 w-6 text-cyan-200" aria-hidden="true" />
{item.name} {item.name}
</a> </a>
+3 -23
View File
@@ -1,23 +1,3 @@
@import 'tailwindcss'; @tailwind base;
@tailwind components;
@plugin '@tailwindcss/forms'; @tailwind utilities;
@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);
}
}
+5 -9
View File
@@ -1,6 +1,6 @@
{ {
"name": "@formbricks/demo", "name": "@formbricks/demo",
"version": "0.0.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"clean": "rimraf .turbo node_modules .next", "clean": "rimraf .turbo node_modules .next",
@@ -12,14 +12,10 @@
}, },
"dependencies": { "dependencies": {
"@formbricks/js": "workspace:*", "@formbricks/js": "workspace:*",
"@tailwindcss/forms": "0.5.9", "lucide-react": "0.468.0",
"@tailwindcss/postcss": "4.1.3", "next": "15.2.3",
"lucide-react": "0.486.0", "react": "19.0.0",
"next": "15.2.4", "react-dom": "19.0.0"
"postcss": "8.5.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"tailwindcss": "4.1.3"
}, },
"devDependencies": { "devDependencies": {
"@formbricks/config-typescript": "workspace:*", "@formbricks/config-typescript": "workspace:*",
+2 -2
View File
@@ -96,10 +96,10 @@ export default function AppPage(): React.JSX.Element {
<p className="text-slate-700 dark:text-slate-300"> <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 Copy the environment ID of your Formbricks app to the env variable in /apps/demo/.env
</p> </p>
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded-xs" priority /> <Image src={fbsetup} alt="fb setup" className="mt-4 rounded" 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"> <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:mr-2 sm:mb-0">You&apos;re connected with env:</p> <p className="mb-1 sm:mb-0 sm:mr-2">You&apos;re connected with env:</p>
<div className="flex items-center"> <div className="flex items-center">
<strong className="w-32 truncate sm:w-auto"> <strong className="w-32 truncate sm:w-auto">
{process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID} {process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID}
+2 -1
View File
@@ -1,5 +1,6 @@
module.exports = { module.exports = {
plugins: { plugins: {
"@tailwindcss/postcss": {}, tailwindcss: {},
autoprefixer: {},
}, },
}; };
+13
View File
@@ -0,0 +1,13 @@
/** @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")],
};
+20 -20
View File
@@ -11,30 +11,30 @@
"clean": "rimraf .turbo node_modules dist storybook-static" "clean": "rimraf .turbo node_modules dist storybook-static"
}, },
"dependencies": { "dependencies": {
"eslint-plugin-react-refresh": "0.4.19", "eslint-plugin-react-refresh": "0.4.16",
"react": "19.1.0", "react": "19.0.0",
"react-dom": "19.1.0" "react-dom": "19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "3.2.6", "@chromatic-com/storybook": "3.2.2",
"@formbricks/config-typescript": "workspace:*", "@formbricks/config-typescript": "workspace:*",
"@storybook/addon-a11y": "8.6.12", "@storybook/addon-a11y": "8.4.7",
"@storybook/addon-essentials": "8.6.12", "@storybook/addon-essentials": "8.4.7",
"@storybook/addon-interactions": "8.6.12", "@storybook/addon-interactions": "8.4.7",
"@storybook/addon-links": "8.6.12", "@storybook/addon-links": "8.4.7",
"@storybook/addon-onboarding": "8.6.12", "@storybook/addon-onboarding": "8.4.7",
"@storybook/blocks": "8.6.12", "@storybook/blocks": "8.4.7",
"@storybook/react": "8.6.12", "@storybook/react": "8.4.7",
"@storybook/react-vite": "8.6.12", "@storybook/react-vite": "8.4.7",
"@storybook/test": "8.6.12", "@storybook/test": "8.4.7",
"@typescript-eslint/eslint-plugin": "8.29.1", "@typescript-eslint/eslint-plugin": "8.18.0",
"@typescript-eslint/parser": "8.29.1", "@typescript-eslint/parser": "8.18.0",
"@vitejs/plugin-react": "4.3.4", "@vitejs/plugin-react": "4.3.4",
"esbuild": "0.25.2", "esbuild": "0.25.1",
"eslint-plugin-storybook": "0.12.0", "eslint-plugin-storybook": "0.11.1",
"prop-types": "15.8.1", "prop-types": "15.8.1",
"storybook": "8.6.12", "storybook": "8.4.7",
"tsup": "8.4.0", "tsup": "8.3.5",
"vite": "6.2.5" "vite": "6.0.12"
} }
} }
+7 -34
View File
@@ -22,7 +22,7 @@ RUN npm install -g corepack@latest
RUN corepack enable RUN corepack enable
# Install necessary build tools and compilers # Install necessary build tools and compilers
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3 RUN apk update && apk add --no-cache g++ cmake make gcc python3 openssl-dev jq
# BuildKit secret handling without hardcoded fallback values # BuildKit secret handling without hardcoded fallback values
# This approach relies entirely on secrets passed from GitHub Actions # This approach relies entirely on secrets passed from GitHub Actions
@@ -40,6 +40,8 @@ RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \
echo 'exec "$@"' >> /tmp/read-secrets.sh && \ echo 'exec "$@"' >> /tmp/read-secrets.sh && \
chmod +x /tmp/read-secrets.sh chmod +x /tmp/read-secrets.sh
ARG SENTRY_AUTH_TOKEN
# Increase Node.js memory limit as a regular build argument # Increase Node.js memory limit as a regular build argument
ARG NODE_OPTIONS="--max_old_space_size=4096" ARG NODE_OPTIONS="--max_old_space_size=4096"
ENV NODE_OPTIONS=${NODE_OPTIONS} ENV NODE_OPTIONS=${NODE_OPTIONS}
@@ -85,60 +87,31 @@ RUN apk add --no-cache curl \
WORKDIR /home/nextjs WORKDIR /home/nextjs
# Ensure no write permissions are assigned to the copied resources
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/standalone ./
RUN chmod -R 755 ./
COPY --from=installer /app/apps/web/next.config.mjs . COPY --from=installer /app/apps/web/next.config.mjs .
RUN chmod 644 ./next.config.mjs
COPY --from=installer /app/apps/web/package.json . COPY --from=installer /app/apps/web/package.json .
RUN chmod 644 ./package.json # Leverage output traces to reduce image size
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/.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 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 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 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 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 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 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 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 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 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 --from=installer --chown=nextjs:nextjs /prisma_version.txt .
RUN chmod 644 ./prisma_version.txt
COPY /docker/cronjobs /app/docker/cronjobs 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 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 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 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 RUN npm install -g tsx typescript prisma pino-pretty
@@ -101,17 +101,17 @@ export const OnboardingSetupInstructions = ({
<div> <div>
{activeTab === "npm" ? ( {activeTab === "npm" ? (
<div className="prose prose-slate w-full"> <div className="prose prose-slate w-full">
<CodeBlock customEditorClass="bg-white! border border-slate-200" language="sh"> <CodeBlock customEditorClass="!bg-white border border-slate-200" language="sh">
npm install @formbricks/js npm install @formbricks/js
</CodeBlock> </CodeBlock>
<p>{t("common.or")}</p> <p>{t("common.or")}</p>
<CodeBlock customEditorClass="bg-white! border border-slate-200" language="sh"> <CodeBlock customEditorClass="!bg-white border border-slate-200" language="sh">
yarn add @formbricks/js yarn add @formbricks/js
</CodeBlock> </CodeBlock>
<p className="text-sm text-slate-700"> <p className="text-sm text-slate-700">
{t("environments.connect.import_formbricks_and_initialize_the_widget_in_your_component")} {t("environments.connect.import_formbricks_and_initialize_the_widget_in_your_component")}
</p> </p>
<CodeBlock customEditorClass="bg-white! border border-slate-200" language="js"> <CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
{channel === "app" ? npmSnippetForAppSurveys : npmSnippetForWebsiteSurveys} {channel === "app" ? npmSnippetForAppSurveys : npmSnippetForWebsiteSurveys}
</CodeBlock> </CodeBlock>
<Button id="onboarding-inapp-connect-read-npm-docs" className="mt-3" variant="secondary" asChild> <Button id="onboarding-inapp-connect-read-npm-docs" className="mt-3" variant="secondary" asChild>
@@ -125,11 +125,11 @@ export const OnboardingSetupInstructions = ({
</div> </div>
) : activeTab === "html" ? ( ) : activeTab === "html" ? (
<div className="prose prose-slate"> <div className="prose prose-slate">
<p className="mt-6 -mb-1 text-sm text-slate-700"> <p className="-mb-1 mt-6 text-sm text-slate-700">
{t("environments.connect.insert_this_code_into_the_head_tag_of_your_website")} {t("environments.connect.insert_this_code_into_the_head_tag_of_your_website")}
</p> </p>
<div> <div>
<CodeBlock customEditorClass="bg-white! border border-slate-200" language="js"> <CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
{channel === "app" ? htmlSnippetForAppSurveys : htmlSnippetForWebsiteSurveys} {channel === "app" ? htmlSnippetForAppSurveys : htmlSnippetForWebsiteSurveys}
</CodeBlock> </CodeBlock>
</div> </div>
@@ -44,7 +44,7 @@ const Page = async (props: ConnectPageProps) => {
channel={channel} channel={channel}
/> />
<Button <Button
className="absolute top-5 right-5 mt-0! text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={`/environments/${environment.id}`}> <Link href={`/environments/${environment.id}`}>
@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
<XMTemplateList project={project} user={user} environmentId={environment.id} /> <XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && ( {projects.length >= 2 && (
<Button <Button
className="absolute top-5 right-5 mt-0! text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={`/environments/${environment.id}/surveys`}> <Link href={`/environments/${environment.id}/surveys`}>
@@ -80,7 +80,7 @@ export const LandingSidebar = ({
<DropdownMenuTrigger <DropdownMenuTrigger
asChild asChild
id="userDropdownTrigger" id="userDropdownTrigger"
className="w-full rounded-br-xl border-t py-4 pl-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-hidden"> className="w-full rounded-br-xl border-t py-4 pl-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center space-x-3")}> <div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center space-x-3")}>
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} /> <ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
<> <>
@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
<OnboardingOptionsContainer options={channelOptions} /> <OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute top-5 right-5 mt-0! text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={"/"}> <Link href={"/"}>
@@ -47,7 +47,7 @@ const Page = async (props: ModePageProps) => {
<OnboardingOptionsContainer options={channelOptions} /> <OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute top-5 right-5 mt-0! text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={"/"}> <Link href={"/"}>
@@ -218,14 +218,14 @@ export const ProjectSettings = ({
</FormProvider> </FormProvider>
</div> </div>
<div className="relative flex h-[30rem] w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 shadow-sm"> <div className="relative flex h-[30rem] w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 shadow">
{logoUrl && ( {logoUrl && (
<Image <Image
src={logoUrl} src={logoUrl}
alt="Logo" alt="Logo"
width={256} width={256}
height={56} height={56}
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1" className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
/> />
)} )}
<p className="text-sm text-slate-400">{t("common.preview")}</p> <p className="text-sm text-slate-400">{t("common.preview")}</p>
@@ -65,7 +65,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
/> />
{projects.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute top-5 right-5 mt-0! text-slate-500 hover:text-slate-700" className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost" variant="ghost"
asChild> asChild>
<Link href={"/"}> <Link href={"/"}>
@@ -1,120 +1,191 @@
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils"; import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react"; import { act, cleanup, render, screen } from "@testing-library/react";
import { Session } from "next-auth"; import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { afterEach, describe, expect, it, vi } from "vitest"; import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getEnvironment } from "@formbricks/lib/environment/service"; 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 { TEnvironment } from "@formbricks/types/environment";
import { AuthorizationError } from "@formbricks/types/errors";
import { TOrganization } from "@formbricks/types/organizations"; import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import SurveyEditorEnvironmentLayout from "./layout"; import SurveyEditorEnvironmentLayout from "./layout";
// Mock sub-components to render identifiable elements // mock all dependencies
vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
EnvironmentIdBaseLayout: ({ children, environmentId }: any) => ( vi.mock("@formbricks/lib/constants", () => ({
<div data-testid="EnvironmentIdBaseLayout"> IS_FORMBRICKS_CLOUD: false,
{environmentId} POSTHOG_API_KEY: "mock-posthog-api-key",
{children} POSTHOG_HOST: "mock-posthog-host",
</div> IS_POSTHOG_CONFIGURED: true,
), ENCRYPTION_KEY: "mock-encryption-key",
})); ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({ GITHUB_ID: "mock-github-id",
DevEnvironmentBanner: ({ environment }: any) => ( GITHUB_SECRET: "test-githubID",
<div data-testid="DevEnvironmentBanner">{environment.id}</div> 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,
})); }));
// Mocks for dependencies vi.mock("next-auth", () => ({
vi.mock("@/modules/environments/lib/utils", () => ({ getServerSession: vi.fn(),
environmentIdLayoutChecks: vi.fn(),
}));
vi.mock("@formbricks/lib/environment/service", () => ({
getEnvironment: vi.fn(),
})); }));
vi.mock("next/navigation", () => ({ vi.mock("next/navigation", () => ({
redirect: vi.fn(), redirect: vi.fn(),
})); }));
vi.mock("@formbricks/lib/environment/auth", () => ({
hasUserEnvironmentAccess: 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>
),
}));
describe("SurveyEditorEnvironmentLayout", () => { describe("SurveyEditorEnvironmentLayout", () => {
afterEach(() => { beforeEach(() => {
cleanup(); cleanup();
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("renders successfully when environment is found", async () => { it("redirects to /auth/login if there is no session", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({ // Mock no session
t: ((key: string) => key) as any, // Mock translation function, we don't need to implement it for the test vi.mocked(getServerSession).mockResolvedValueOnce(null);
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 result = await SurveyEditorEnvironmentLayout({ const layoutElement = await SurveyEditorEnvironmentLayout({
params: Promise.resolve({ environmentId: "env1" }), params: { environmentId: "env-123" },
children: <div data-testid="child">Survey Editor Content</div>, children: <div data-testid="child-content">Hello!</div>,
}); });
render(result); expect(redirect).toHaveBeenCalledWith("/auth/login");
// No JSX is returned after redirect
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1"); expect(layoutElement).toBeUndefined();
expect(screen.getByTestId("DevEnvironmentBanner")).toHaveTextContent("env1");
expect(screen.getByTestId("child")).toHaveTextContent("Survey Editor Content");
}); });
it("throws an error when environment is not found", async () => { it("throws error if user does not exist in DB", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({ vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
t: ((key: string) => key) as any, vi.mocked(getUser).mockResolvedValueOnce(null); // user not found
session: { user: { id: "user1" } } as Session,
user: { id: "user1", email: "user1@example.com" } as TUser, await expect(
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization, 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);
vi.mocked(getEnvironment).mockResolvedValueOnce(null); vi.mocked(getEnvironment).mockResolvedValueOnce(null);
await expect( await expect(
SurveyEditorEnvironmentLayout({ SurveyEditorEnvironmentLayout({
params: Promise.resolve({ environmentId: "env1" }), params: { environmentId: "env-123" },
children: <div>Content</div>, children: <div>Child</div>,
}) })
).rejects.toThrow("common.environment_not_found"); ).rejects.toThrow("common.environment_not_found");
}); });
it("calls redirect when session is null", async () => { it("renders environment layout if everything is valid", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({ // Provide all valid data
t: ((key: string) => key) as any, vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
session: undefined as unknown as Session, vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", email: "test@example.com" } as TUser);
user: undefined as unknown as TUser, vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization, vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
}); vi.mocked(getEnvironment).mockResolvedValueOnce({
vi.mocked(redirect).mockImplementationOnce(() => { id: "env-123",
throw new Error("Redirect called"); 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);
}); });
await expect( // Now confirm we got the child plus all the mocked sub-components
SurveyEditorEnvironmentLayout({ expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children!");
params: Promise.resolve({ environmentId: "env1" }), expect(screen.getByTestId("posthog-identify")).toBeInTheDocument();
children: <div>Content</div>, expect(screen.getByTestId("formbricks-client")).toBeInTheDocument();
}) expect(screen.getByTestId("mock-toaster")).toBeInTheDocument();
).rejects.toThrow("Redirect called"); expect(screen.getByTestId("mock-response-filter-provider")).toBeInTheDocument();
}); expect(screen.getByTestId("dev-environment-banner")).toHaveTextContent("env-123");
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");
}); });
}); });
@@ -1,24 +1,46 @@
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils"; 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 { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner"; import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout"; import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; 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 { 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 SurveyEditorEnvironmentLayout = async (props) => {
const params = await props.params; const params = await props.params;
const { children } = props; const { children } = props;
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId); const t = await getTranslate();
const session = await getServerSession(authOptions);
if (!session) { if (!session?.user) {
return redirect(`/auth/login`); return redirect(`/auth/login`);
} }
const user = await getUser(session.user.id);
if (!user) { if (!user) {
throw new Error(t("common.user_not_found")); 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); const environment = await getEnvironment(params.environmentId);
if (!environment) { if (!environment) {
@@ -26,16 +48,23 @@ const SurveyEditorEnvironmentLayout = async (props) => {
} }
return ( return (
<EnvironmentIdBaseLayout <ResponseFilterProvider>
environmentId={params.environmentId} <PosthogIdentify
session={session} session={session}
user={user} user={user}
organization={organization}> environmentId={params.environmentId}
organizationId={organization.id}
organizationName={organization.name}
organizationBilling={organization.billing}
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
/>
<FormbricksClient userId={user.id} email={user.email} />
<ToasterClient />
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
<DevEnvironmentBanner environment={environment} /> <DevEnvironmentBanner environment={environment} />
<div className="h-full overflow-y-auto bg-slate-50">{children}</div> <div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div> </div>
</EnvironmentIdBaseLayout> </ResponseFilterProvider>
); );
}; };
@@ -1,5 +1,5 @@
import { render } from "@testing-library/react"; import { render } from "@testing-library/react";
import { describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import formbricks from "@formbricks/js"; import formbricks from "@formbricks/js";
import { FormbricksClient } from "./FormbricksClient"; import { FormbricksClient } from "./FormbricksClient";
@@ -9,6 +9,14 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams("foo=bar"), 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. // Mock the flag that enables Formbricks.
vi.mock("@/app/lib/formbricks", () => ({ vi.mock("@/app/lib/formbricks", () => ({
formbricksEnabled: true, formbricksEnabled: true,
@@ -26,21 +34,17 @@ vi.mock("@formbricks/js", () => ({
})); }));
describe("FormbricksClient", () => { describe("FormbricksClient", () => {
afterEach(() => {
vi.clearAllMocks();
});
test("calls setup, setUserId, setEmail and registerRouteChange on mount when enabled", () => { test("calls setup, setUserId, setEmail and registerRouteChange on mount when enabled", () => {
const mockSetup = vi.spyOn(formbricks, "setup"); const mockSetup = vi.spyOn(formbricks, "setup");
const mockSetUserId = vi.spyOn(formbricks, "setUserId"); const mockSetUserId = vi.spyOn(formbricks, "setUserId");
const mockSetEmail = vi.spyOn(formbricks, "setEmail"); const mockSetEmail = vi.spyOn(formbricks, "setEmail");
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange"); const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
render( render(<FormbricksClient userId="user-123" email="test@example.com" />);
<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 the first effect to call setup and assign the provided user details.
expect(mockSetup).toHaveBeenCalledWith({ expect(mockSetup).toHaveBeenCalledWith({
@@ -60,15 +64,7 @@ describe("FormbricksClient", () => {
const mockSetEmail = vi.spyOn(formbricks, "setEmail"); const mockSetEmail = vi.spyOn(formbricks, "setEmail");
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange"); const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
render( render(<FormbricksClient userId="" email="test@example.com" />);
<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. // Since userId is falsy, the first effect should not call setup or assign user details.
expect(mockSetup).not.toHaveBeenCalled(); expect(mockSetup).not.toHaveBeenCalled();
@@ -1,44 +1,32 @@
"use client"; "use client";
import { formbricksEnabled } from "@/app/lib/formbricks";
import { usePathname, useSearchParams } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
import { useEffect } from "react"; import { useEffect } from "react";
import formbricks from "@formbricks/js"; import formbricks from "@formbricks/js";
import { env } from "@formbricks/lib/env";
interface FormbricksClientProps { export const FormbricksClient = ({ userId, email }: { userId: string; email: string }) => {
userId: string;
email: string;
formbricksEnvironmentId?: string;
formbricksApiHost?: string;
formbricksEnabled?: boolean;
}
export const FormbricksClient = ({
userId,
email,
formbricksEnvironmentId,
formbricksApiHost,
formbricksEnabled,
}: FormbricksClientProps) => {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
useEffect(() => { useEffect(() => {
if (formbricksEnabled && userId) { if (formbricksEnabled && userId) {
formbricks.setup({ formbricks.setup({
environmentId: formbricksEnvironmentId ?? "", environmentId: env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
appUrl: formbricksApiHost ?? "", appUrl: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
}); });
formbricks.setUserId(userId); formbricks.setUserId(userId);
formbricks.setEmail(email); formbricks.setEmail(email);
} }
}, [userId, email, formbricksEnvironmentId, formbricksApiHost, formbricksEnabled]); }, [userId, email]);
useEffect(() => { useEffect(() => {
if (formbricksEnabled) { if (formbricksEnabled) {
formbricks.registerRouteChange(); formbricks.registerRouteChange();
} }
}, [pathname, searchParams, formbricksEnabled]); }, [pathname, searchParams]);
return null; return null;
}; };
@@ -36,7 +36,7 @@ export const ActionClassesTable = ({
return ( return (
<> <>
<div className="rounded-xl border border-slate-200 bg-white shadow-xs"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
{TableHeading} {TableHeading}
<div id="actionClassesWrapper" className="flex flex-col"> <div id="actionClassesWrapper" className="flex flex-col">
{actionClasses.length > 0 ? ( {actionClasses.length > 0 ? (
@@ -14,14 +14,16 @@ export const ActionClassDataRow = ({
<div className="m-2 grid h-16 grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100"> <div className="m-2 grid h-16 grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
<div className="col-span-4 flex items-center pl-6 text-sm"> <div className="col-span-4 flex items-center pl-6 text-sm">
<div className="flex items-center"> <div className="flex items-center">
<div className="h-5 w-5 shrink-0 text-slate-500">{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}</div> <div className="h-5 w-5 flex-shrink-0 text-slate-500">
{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
</div>
<div className="ml-4 text-left"> <div className="ml-4 text-left">
<div className="font-medium text-slate-900">{actionClass.name}</div> <div className="font-medium text-slate-900">{actionClass.name}</div>
<div className="text-xs text-slate-400">{actionClass.description}</div> <div className="text-xs text-slate-400">{actionClass.description}</div>
</div> </div>
</div> </div>
</div> </div>
<div className="col-span-2 my-auto text-center text-sm whitespace-nowrap text-slate-500"> <div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
{timeSince(actionClass.createdAt.toString(), locale)} {timeSince(actionClass.createdAt.toString(), locale)}
</div> </div>
<div className="text-center"></div> <div className="text-center"></div>
@@ -10,7 +10,7 @@ const Loading = () => {
<> <>
<PageContentWrapper> <PageContentWrapper>
<PageHeader pageTitle={t("common.actions")} /> <PageHeader pageTitle={t("common.actions")} />
<div className="rounded-xl border border-slate-200 bg-white shadow-xs"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="grid h-12 grid-cols-6 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900"> <div className="grid h-12 grid-cols-6 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
<span className="sr-only">{t("common.edit")}</span> <span className="sr-only">{t("common.edit")}</span>
<div className="col-span-4 pl-6">{t("environments.actions.user_actions")}</div> <div className="col-span-4 pl-6">{t("environments.actions.user_actions")}</div>
@@ -22,7 +22,7 @@ const Loading = () => {
className="m-2 grid h-16 grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100"> className="m-2 grid h-16 grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
<div className="col-span-4 flex items-center pl-6 text-sm"> <div className="col-span-4 flex items-center pl-6 text-sm">
<div className="flex items-center"> <div className="flex items-center">
<div className="h-6 w-6 shrink-0 animate-pulse rounded-full bg-slate-200 text-slate-500" /> <div className="h-6 w-6 flex-shrink-0 animate-pulse rounded-full bg-slate-200 text-slate-500" />
<div className="ml-4 text-left"> <div className="ml-4 text-left">
<div className="font-medium text-slate-900"> <div className="font-medium text-slate-900">
<div className="mt-0 h-4 w-48 animate-pulse rounded-full bg-slate-200"></div> <div className="mt-0 h-4 w-48 animate-pulse rounded-full bg-slate-200"></div>
@@ -33,7 +33,7 @@ const Loading = () => {
</div> </div>
</div> </div>
</div> </div>
<div className="col-span-2 my-auto flex justify-center text-center text-sm whitespace-nowrap text-slate-500"> <div className="col-span-2 my-auto flex justify-center whitespace-nowrap text-center text-sm text-slate-500">
<div className="h-4 w-28 animate-pulse rounded-full bg-slate-200"></div> <div className="h-4 w-28 animate-pulse rounded-full bg-slate-200"></div>
</div> </div>
</div> </div>
@@ -7,7 +7,7 @@ import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-bann
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner"; import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
import type { Session } from "next-auth"; import type { Session } from "next-auth";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service"; import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils"; import { getAccessFlags } from "@formbricks/lib/membership/utils";
@@ -111,7 +111,6 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
organizationProjectsLimit={organizationProjectsLimit} organizationProjectsLimit={organizationProjectsLimit}
user={user} user={user}
isFormbricksCloud={IS_FORMBRICKS_CLOUD} isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isDevelopment={IS_DEVELOPMENT}
membershipRole={membershipRole} membershipRole={membershipRole}
isMultiOrgEnabled={isMultiOrgEnabled} isMultiOrgEnabled={isMultiOrgEnabled}
isLicenseActive={active} isLicenseActive={active}
@@ -63,7 +63,6 @@ interface NavigationProps {
projects: TProject[]; projects: TProject[];
isMultiOrgEnabled: boolean; isMultiOrgEnabled: boolean;
isFormbricksCloud: boolean; isFormbricksCloud: boolean;
isDevelopment: boolean;
membershipRole?: TOrganizationRole; membershipRole?: TOrganizationRole;
organizationProjectsLimit: number; organizationProjectsLimit: number;
isLicenseActive: boolean; isLicenseActive: boolean;
@@ -80,7 +79,6 @@ export const MainNavigation = ({
isFormbricksCloud, isFormbricksCloud,
organizationProjectsLimit, organizationProjectsLimit,
isLicenseActive, isLicenseActive,
isDevelopment,
}: NavigationProps) => { }: NavigationProps) => {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
@@ -265,7 +263,7 @@ export const MainNavigation = ({
size="icon" size="icon"
onClick={toggleSidebar} onClick={toggleSidebar}
className={cn( className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-hidden" "rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
)}> )}>
{isCollapsed ? ( {isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} /> <PanelLeftOpenIcon strokeWidth={1.5} />
@@ -298,7 +296,7 @@ export const MainNavigation = ({
<div> <div>
{/* New Version Available */} {/* New Version Available */}
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && !isDevelopment && ( {!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && (
<Link <Link
href="https://github.com/formbricks/formbricks/releases" href="https://github.com/formbricks/formbricks/releases"
target="_blank" target="_blank"
@@ -332,7 +330,7 @@ export const MainNavigation = ({
<DropdownMenuTrigger <DropdownMenuTrigger
asChild asChild
id="userDropdownTrigger" id="userDropdownTrigger"
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-hidden"> className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<div <div
tabIndex={0} tabIndex={0}
className={cn( className={cn(
@@ -1,7 +1,9 @@
// PosthogIdentify.test.tsx
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { cleanup, render } from "@testing-library/react"; import { cleanup, render } from "@testing-library/react";
import { Session } from "next-auth"; import { Session } from "next-auth";
import { usePostHog } from "posthog-js/react"; import { usePostHog } from "posthog-js/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { TOrganizationBilling } from "@formbricks/types/organizations"; import { TOrganizationBilling } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
@@ -18,7 +18,7 @@ export const TopControlBar = ({
}: SideBarProps) => { }: SideBarProps) => {
return ( return (
<div className="fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6"> <div className="fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6">
<div className="z-10 shadow-2xs"> <div className="shadow-xs z-10">
<div className="flex w-fit items-center space-x-2 py-2"> <div className="flex w-fit items-center space-x-2 py-2">
<TopControlButtons <TopControlButtons
environment={environment} environment={environment}
@@ -1,156 +1,250 @@
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils"; import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react"; import { act, cleanup, render, screen } from "@testing-library/react";
import { Session } from "next-auth"; import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { afterEach, describe, expect, it, vi } from "vitest"; import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/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 { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations"; import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project"; import { TProject } from "@formbricks/types/project";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import EnvLayout from "./layout"; import EnvLayout from "./layout";
// Mock sub-components to render identifiable elements // mock all the dependencies
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentLayout", () => ({
EnvironmentLayout: ({ children }: any) => <div data-testid="EnvironmentLayout">{children}</div>, vi.mock("@formbricks/lib/constants", () => ({
})); IS_FORMBRICKS_CLOUD: false,
vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({ POSTHOG_API_KEY: "mock-posthog-api-key",
EnvironmentIdBaseLayout: ({ children, environmentId }: any) => ( POSTHOG_HOST: "mock-posthog-host",
<div data-testid="EnvironmentIdBaseLayout"> IS_POSTHOG_CONFIGURED: true,
{environmentId} ENCRYPTION_KEY: "mock-encryption-key",
{children} ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
</div> GITHUB_ID: "mock-github-id",
), GITHUB_SECRET: "test-githubID",
})); GOOGLE_CLIENT_ID: "test-google-client-id",
vi.mock("@/modules/ui/components/toaster-client", () => ({ GOOGLE_CLIENT_SECRET: "test-google-client-secret",
ToasterClient: () => <div data-testid="ToasterClient" />, AZUREAD_CLIENT_ID: "test-azuread-client-id",
})); AZUREAD_CLIENT_SECRET: "test-azure",
vi.mock("../../components/FormbricksClient", () => ({ AZUREAD_TENANT_ID: "test-azuread-tenant-id",
FormbricksClient: ({ userId, email }: any) => ( OIDC_DISPLAY_NAME: "test-oidc-display-name",
<div data-testid="FormbricksClient"> OIDC_CLIENT_ID: "test-oidc-client-id",
{userId}-{email} OIDC_ISSUER: "test-oidc-issuer",
</div> OIDC_CLIENT_SECRET: "test-oidc-client-secret",
), OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
})); WEBAPP_URL: "test-webapp-url",
vi.mock("./components/EnvironmentStorageHandler", () => ({ IS_PRODUCTION: false,
default: ({ environmentId }: any) => <div data-testid="EnvironmentStorageHandler">{environmentId}</div>,
})); }));
// Mocks for dependencies vi.mock("@/tolgee/server", () => ({
vi.mock("@/modules/environments/lib/utils", () => ({ getTranslate: vi.fn(() => {
environmentIdLayoutChecks: vi.fn(), return (key: string) => {
return key;
};
}),
})); }));
vi.mock("@formbricks/lib/project/service", () => ({
getProjectByEnvironmentId: vi.fn(), vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@formbricks/lib/environment/auth", () => ({
hasUserEnvironmentAccess: vi.fn(),
})); }));
vi.mock("@formbricks/lib/membership/service", () => ({ vi.mock("@formbricks/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(), getMembershipByUserIdOrganizationId: vi.fn(),
})); }));
vi.mock("@formbricks/lib/organization/service", () => ({
getOrganizationByEnvironmentId: 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>
),
}));
describe("EnvLayout", () => { describe("EnvLayout", () => {
afterEach(() => { beforeEach(() => {
cleanup(); cleanup();
}); });
it("renders successfully when all dependencies return valid data", async () => { it("redirects to /auth/login if there is no session", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({ vi.mocked(getServerSession).mockResolvedValueOnce(null);
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);
const result = await EnvLayout({ // Since it's an async server component, call EnvLayout yourself:
params: Promise.resolve({ environmentId: "env1" }), const layoutElement = await EnvLayout({
children: <div data-testid="child">Content</div>, params: Promise.resolve({ environmentId: "env-123" }),
children: <div data-testid="child-content">Hello!</div>,
}); });
render(result);
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1"); // Because we have no session, we expect a redirect to "/auth/login"
expect(screen.getByTestId("EnvironmentStorageHandler")).toHaveTextContent("env1"); expect(redirect).toHaveBeenCalledWith("/auth/login");
expect(screen.getByTestId("EnvironmentLayout")).toBeDefined();
expect(screen.getByTestId("child")).toHaveTextContent("Content"); // If your code calls redirect() early and returns no JSX,
// layoutElement might be undefined or null.
expect(layoutElement).toBeUndefined();
}); });
it("throws error if project is not found", async () => { it("redirects to /auth/login if user does not exist in DB", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({ vi.mocked(getServerSession).mockResolvedValueOnce({
t: ((key: string) => key) as any, user: { id: "user-123" },
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(null);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({ vi.mocked(getUser).mockResolvedValueOnce(null); // user not found
id: "member1",
} as unknown as TMembership); 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( await expect(
EnvLayout({ EnvLayout({
params: Promise.resolve({ environmentId: "env1" }), params: Promise.resolve({ environmentId: "env-123" }),
children: <div>Content</div>, children: <div>Child</div>,
}) })
).rejects.toThrow("common.project_not_found"); ).rejects.toThrow(AuthorizationError);
}); });
it("throws error if membership is not found", async () => { it("throws if no organization is found", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({ vi.mocked(getServerSession).mockResolvedValueOnce({
t: ((key: string) => key) as any, user: { id: "user-123" },
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(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);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div>Child</div>,
})
).rejects.toThrow("project_not_found");
});
it("calls notFound if membership is missing", 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({ id: "proj-111" } as TProject);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(null); vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(null);
await expect( await expect(
EnvLayout({ EnvLayout({
params: Promise.resolve({ environmentId: "env1" }), params: Promise.resolve({ environmentId: "env-123" }),
children: <div>Content</div>, children: <div>Child</div>,
}) })
).rejects.toThrow("common.membership_not_found"); ).rejects.toThrow("membership_not_found");
}); });
it("calls redirect when session is null", async () => { it("renders environment layout if everything is valid", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({ vi.mocked(getServerSession).mockResolvedValueOnce({
t: ((key: string) => key) as any, user: { id: "user-123" },
session: undefined as unknown as Session,
user: undefined as unknown as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
}); });
vi.mocked(redirect).mockImplementationOnce(() => { vi.mocked(getUser).mockResolvedValueOnce({
throw new Error("Redirect called"); 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);
}); });
await expect( expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children!");
EnvLayout({ expect(screen.getByTestId("posthog-identify")).toBeInTheDocument();
params: Promise.resolve({ environmentId: "env1" }), expect(screen.getByTestId("formbricks-client")).toBeInTheDocument();
children: <div>Content</div>, expect(screen.getByTestId("mock-toaster")).toBeInTheDocument();
}) expect(screen.getByTestId("mock-storage-handler")).toBeInTheDocument();
).rejects.toThrow("Redirect called"); expect(screen.getByTestId("mock-response-filter-provider")).toBeInTheDocument();
}); expect(screen.getByTestId("mock-environment-result")).toBeInTheDocument();
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");
}); });
}); });
@@ -1,10 +1,20 @@
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"; import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils"; import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout"; 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 { redirect } from "next/navigation"; 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 { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/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 EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
import { PosthogIdentify } from "./components/PosthogIdentify";
const EnvLayout = async (props: { const EnvLayout = async (props: {
params: Promise<{ environmentId: string }>; params: Promise<{ environmentId: string }>;
@@ -14,16 +24,27 @@ const EnvLayout = async (props: {
const { children } = props; const { children } = props;
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId); const t = await getTranslate();
const session = await getServerSession(authOptions);
if (!session) { if (!session?.user) {
return redirect(`/auth/login`); return redirect(`/auth/login`);
} }
const user = await getUser(session.user.id);
if (!user) { if (!user) {
throw new Error(t("common.user_not_found")); return redirect(`/auth/login`);
} }
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); const project = await getProjectByEnvironmentId(params.environmentId);
if (!project) { if (!project) {
throw new Error(t("common.project_not_found")); throw new Error(t("common.project_not_found"));
@@ -36,16 +57,23 @@ const EnvLayout = async (props: {
} }
return ( return (
<EnvironmentIdBaseLayout <ResponseFilterProvider>
environmentId={params.environmentId} <PosthogIdentify
session={session} session={session}
user={user} user={user}
organization={organization}> environmentId={params.environmentId}
organizationId={organization.id}
organizationName={organization.name}
organizationBilling={organization.billing}
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
/>
<FormbricksClient userId={user.id} email={user.email} />
<ToasterClient />
<EnvironmentStorageHandler environmentId={params.environmentId} /> <EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentLayout environmentId={params.environmentId} session={session}> <EnvironmentLayout environmentId={params.environmentId} session={session}>
{children} {children}
</EnvironmentLayout> </EnvironmentLayout>
</EnvironmentIdBaseLayout> </ResponseFilterProvider>
); );
}; };
@@ -0,0 +1,3 @@
import { APIKeysLoading } from "@/modules/projects/settings/api-keys/loading";
export default APIKeysLoading;
@@ -0,0 +1,3 @@
import { APIKeysPage } from "@/modules/projects/settings/api-keys/page";
export default APIKeysPage;
@@ -56,7 +56,7 @@ export const EditAlerts = ({
<TooltipTrigger> <TooltipTrigger>
<div className="col-span-1 flex cursor-default items-center justify-center space-x-2"> <div className="col-span-1 flex cursor-default items-center justify-center space-x-2">
<span>{t("environments.settings.notifications.every_response")}</span> <span>{t("environments.settings.notifications.every_response")}</span>
<HelpCircleIcon className="h-4 w-4 shrink-0 text-slate-500" /> <HelpCircleIcon className="h-4 w-4 flex-shrink-0 text-slate-500" />
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
@@ -99,7 +99,7 @@ export const EditAlerts = ({
))} ))}
</div> </div>
) : ( ) : (
<div className="m-2 flex h-16 items-center justify-center rounded-sm bg-slate-50 text-sm text-slate-500"> <div className="m-2 flex h-16 items-center justify-center rounded bg-slate-50 text-sm text-slate-500">
<p>{t("common.no_surveys_found")}</p> <p>{t("common.no_surveys_found")}</p>
</div> </div>
)} )}
@@ -11,7 +11,7 @@ export const IntegrationsTip = ({ environmentId }: IntegrationsTipProps) => {
const { t } = useTranslate(); const { t } = useTranslate();
return ( return (
<div> <div>
<div className="flex max-w-4xl items-center space-y-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-xs md:space-y-0 md:text-base"> <div className="flex max-w-4xl items-center space-y-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm md:space-y-0 md:text-base">
<SlackIcon className="mr-3 h-4 w-4 text-blue-400" /> <SlackIcon className="mr-3 h-4 w-4 text-blue-400" />
<p className="text-sm"> <p className="text-sm">
{t("environments.settings.notifications.need_slack_or_discord_notifications")}? {t("environments.settings.notifications.need_slack_or_discord_notifications")}?
@@ -105,7 +105,7 @@ export const EditProfileAvatarForm = ({ session, environmentId, imageUrl }: Edit
<div> <div>
<div className="relative h-10 w-10 overflow-hidden rounded-full"> <div className="relative h-10 w-10 overflow-hidden rounded-full">
{isLoading && ( {isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/30"> <div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30">
<svg className="h-7 w-7 animate-spin text-slate-200" viewBox="0 0 24 24"> <svg className="h-7 w-7 animate-spin text-slate-200" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path <path
@@ -1,6 +0,0 @@
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} />;
}
@@ -1,3 +0,0 @@
import { APIKeysPage } from "@/modules/organization/settings/api-keys/page";
export default APIKeysPage;
@@ -54,12 +54,6 @@ export const OrganizationSettingsNavbar = ({
hidden: isFormbricksCloud || isPricingDisabled, hidden: isFormbricksCloud || isPricingDisabled,
current: pathname?.includes("/enterprise"), 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} />; return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
@@ -97,7 +97,7 @@ const Page = async (props) => {
</PageHeader> </PageHeader>
{isEnterpriseEdition ? ( {isEnterpriseEdition ? (
<div> <div>
<div className="mt-8 max-w-4xl rounded-lg border border-slate-300 bg-slate-100 shadow-xs"> <div className="mt-8 max-w-4xl rounded-lg border border-slate-300 bg-slate-100 shadow-sm">
<div className="space-y-4 p-8"> <div className="space-y-4 p-8">
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-2">
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800"> <div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800">
@@ -123,7 +123,7 @@ const Page = async (props) => {
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0"> <div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
<svg <svg
viewBox="0 0 1024 1024" viewBox="0 0 1024 1024"
className="absolute top-1/2 left-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0" className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
aria-hidden="true"> aria-hidden="true">
<circle <circle
cx={512} cx={512}
@@ -152,7 +152,7 @@ const Page = async (props) => {
</p> </p>
</div> </div>
</div> </div>
<div className="mt-8 rounded-lg border border-slate-300 bg-slate-100 shadow-xs"> <div className="mt-8 rounded-lg border border-slate-300 bg-slate-100 shadow-sm">
<div className="p-8"> <div className="p-8">
<h2 className="mr-2 inline-flex text-2xl font-bold text-slate-700"> <h2 className="mr-2 inline-flex text-2xl font-bold text-slate-700">
{t("environments.settings.enterprise.enterprise_features")} {t("environments.settings.enterprise.enterprise_features")}
@@ -33,16 +33,12 @@ vi.mock("@formbricks/lib/constants", () => ({
WEBAPP_URL: "mock-webapp-url", WEBAPP_URL: "mock-webapp-url",
SMTP_HOST: "mock-smtp-host", SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "mock-smtp-port", SMTP_PORT: "mock-smtp-port",
AI_AZURE_LLM_RESSOURCE_NAME: "mock-ai-azure-llm-ressource-name", AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name",
AI_AZURE_LLM_API_KEY: "mock-ai", AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key",
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-ai-azure-llm-deployment-id", AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id",
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-ai-azure-embeddings-ressource-name", AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name",
AI_AZURE_EMBEDDINGS_API_KEY: "mock-ai-azure-embeddings-api-key", AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key",
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-ai-azure-embeddings-deployment-id", AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id",
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
})); }));
vi.mock("@/tolgee/server", () => ({ vi.mock("@/tolgee/server", () => ({
@@ -25,13 +25,13 @@ export const SettingsCard = ({
return ( return (
<div <div
className={cn( className={cn(
"relative my-4 w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 text-left shadow-xs", "relative my-4 w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 text-left shadow-sm",
className className
)} )}
id={title}> id={title}>
<div className="border-b border-slate-200 px-4 pb-4"> <div className="border-b border-slate-200 px-4 pb-4">
<div className="flex"> <div className="flex">
<h3 className="text-lg leading-6 font-medium text-slate-900 capitalize">{title}</h3> <h3 className="text-lg font-medium capitalize leading-6 text-slate-900">{title}</h3>
<div className="ml-2"> <div className="ml-2">
{beta && <Badge size="normal" type="warning" text="Beta" />} {beta && <Badge size="normal" type="warning" text="Beta" />}
{soon && ( {soon && (
@@ -36,7 +36,7 @@ export const ResponseTableCell = ({
// Conditional rendering of maximize icon // Conditional rendering of maximize icon
const renderMaximizeIcon = cell.column.id === "createdAt" && ( const renderMaximizeIcon = cell.column.id === "createdAt" && (
<div <div
className="hidden shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 group-hover:flex hover:border-slate-300" className="hidden flex-shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 hover:border-slate-300 group-hover:flex"
onClick={handleCellClick}> onClick={handleCellClick}>
<Maximize2Icon className="h-4 w-4" /> <Maximize2Icon className="h-4 w-4" />
</div> </div>
@@ -71,11 +71,7 @@ const getQuestionColumnsData = (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-2 overflow-hidden"> <div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{QUESTIONS_ICON_MAP["matrix"]}</span> <span className="h-4 w-4">{QUESTIONS_ICON_MAP["matrix"]}</span>
<span className="truncate"> <span className="truncate">{getLocalizedValue(matrixRow, "default")}</span>
{getLocalizedValue(question.headline, "default") +
" - " +
getLocalizedValue(matrixRow, "default")}
</span>
</div> </div>
</div> </div>
); );
@@ -20,7 +20,7 @@ interface AddressSummaryProps {
export const AddressSummary = ({ questionSummary, environmentId, survey, locale }: AddressSummaryProps) => { export const AddressSummary = ({ questionSummary, environmentId, survey, locale }: AddressSummaryProps) => {
const { t } = useTranslate(); const { t } = useTranslate();
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} /> <QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div> <div>
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600"> <div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
@@ -16,7 +16,7 @@ export const CTASummary = ({ questionSummary, survey }: CTASummaryProps) => {
const { t } = useTranslate(); const { t } = useTranslate();
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader <QuestionSummaryHeader
survey={survey} survey={survey}
questionSummary={questionSummary} questionSummary={questionSummary}
@@ -40,7 +40,7 @@ export const CTASummary = ({ questionSummary, survey }: CTASummaryProps) => {
</> </>
} }
/> />
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base"> <div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div className="text flex justify-between px-2 pb-2"> <div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex space-x-1"> <div className="mr-8 flex space-x-1">
<p className="font-semibold text-slate-700">CTR</p> <p className="font-semibold text-slate-700">CTR</p>
@@ -16,9 +16,9 @@ export const CalSummary = ({ questionSummary, survey }: CalSummaryProps) => {
const { t } = useTranslate(); const { t } = useTranslate();
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} /> <QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base"> <div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div> <div>
<div className="text flex justify-between px-2 pb-2"> <div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex space-x-1"> <div className="mr-8 flex space-x-1">
@@ -39,9 +39,9 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu
}, },
]; ];
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} /> <QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base"> <div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{summaryItems.map((summaryItem) => { {summaryItems.map((summaryItem) => {
return ( return (
<div <div
@@ -25,7 +25,7 @@ export const ContactInfoSummary = ({
}: ContactInfoSummaryProps) => { }: ContactInfoSummaryProps) => {
const { t } = useTranslate(); const { t } = useTranslate();
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} /> <QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div> <div>
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600"> <div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
@@ -35,18 +35,8 @@ 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 ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} /> <QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className=""> <div className="">
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600"> <div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
@@ -80,8 +70,8 @@ export const DateQuestionSummary = ({
</div> </div>
)} )}
</div> </div>
<div className="ph-no-capture col-span-2 pl-6 font-semibold whitespace-pre-wrap"> <div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{renderResponseValue(response.value)} {formatDateWithOrdinal(new Date(response.value as string))}
</div> </div>
<div className="px-4 text-slate-500 md:px-6"> <div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)} {timeSince(new Date(response.updatedAt).toISOString(), locale)}
@@ -36,7 +36,7 @@ export const FileUploadSummary = ({
}; };
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} /> <QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className=""> <div className="">
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600"> <div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
@@ -80,7 +80,7 @@ export const FileUploadSummary = ({
return ( return (
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}> <div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
<a href={fileUrl} key={index} target="_blank" rel="noopener noreferrer"> <a href={fileUrl} key={index} target="_blank" rel="noopener noreferrer">
<div className="absolute top-0 right-0 m-2"> <div className="absolute right-0 top-0 m-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white"> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
<DownloadIcon className="h-6 text-slate-500" /> <DownloadIcon className="h-6 text-slate-500" />
</div> </div>
@@ -27,8 +27,8 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
); );
}; };
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="space-y-2 px-4 pt-6 pb-5 md:px-6"> <div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<div className={"align-center flex justify-between gap-4"}> <div className={"align-center flex justify-between gap-4"}>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{questionSummary.id}</h3> <h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{questionSummary.id}</h3>
</div> </div>
@@ -76,7 +76,7 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
</div> </div>
)} )}
</div> </div>
<div className="ph-no-capture col-span-2 pl-6 font-semibold whitespace-pre-wrap"> <div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{response.value} {response.value}
</div> </div>
<div className="px-4 text-slate-500 md:px-6"> <div className="px-4 text-slate-500 md:px-6">
@@ -45,14 +45,14 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
: []; : [];
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} /> <QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="overflow-x-auto p-6"> <div className="overflow-x-auto p-6">
{/* Summary Table */} {/* Summary Table */}
<table className="mx-auto border-collapse cursor-default text-left"> <table className="mx-auto border-collapse cursor-default text-left">
<thead> <thead>
<tr> <tr>
<th className="p-4 pt-0 pb-3 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th> <th className="p-4 pb-3 pt-0 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
{columns.map((column) => ( {columns.map((column) => (
<th key={column} className="text-center font-medium"> <th key={column} className="text-center font-medium">
<TooltipRenderer tooltipContent={getTooltipContent(column)} shouldRender={true}> <TooltipRenderer tooltipContent={getTooltipContent(column)} shouldRender={true}>
@@ -65,7 +65,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
<tbody> <tbody>
{questionSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => ( {questionSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => (
<tr key={rowLabel}> <tr key={rowLabel}>
<td className="max-w-60 overflow-hidden p-4 text-ellipsis whitespace-nowrap"> <td className="max-w-60 overflow-hidden text-ellipsis whitespace-nowrap p-4">
<TooltipRenderer tooltipContent={getTooltipContent(rowLabel)} shouldRender={true}> <TooltipRenderer tooltipContent={getTooltipContent(rowLabel)} shouldRender={true}>
<p className="max-w-40 overflow-hidden text-ellipsis whitespace-nowrap">{rowLabel}</p> <p className="max-w-40 overflow-hidden text-ellipsis whitespace-nowrap">{rowLabel}</p>
</TooltipRenderer> </TooltipRenderer>
@@ -83,7 +83,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
)}> )}>
<div <div
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }} style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded-sm p-4 text-sm text-slate-950 hover:outline" className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline"
onClick={() => onClick={() =>
setFilter( setFilter(
questionSummary.question.id, questionSummary.question.id,
@@ -65,7 +65,7 @@ export const MultipleChoiceSummary = ({
}; };
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader <QuestionSummaryHeader
questionSummary={questionSummary} questionSummary={questionSummary}
survey={survey} survey={survey}
@@ -78,7 +78,7 @@ export const MultipleChoiceSummary = ({
) : undefined ) : undefined
} }
/> />
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base"> <div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, resultsIdx) => ( {results.map((result, resultsIdx) => (
<div <div
key={result.value} key={result.value}
@@ -60,16 +60,16 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
}; };
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} /> <QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base"> <div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{["promoters", "passives", "detractors", "dismissed"].map((group) => ( {["promoters", "passives", "detractors", "dismissed"].map((group) => (
<div className="cursor-pointer hover:opacity-80" key={group} onClick={() => applyFilter(group)}> <div className="cursor-pointer hover:opacity-80" key={group} onClick={() => applyFilter(group)}>
<div <div
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}> className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
<div className="mr-8 flex space-x-1"> <div className="mr-8 flex space-x-1">
<p <p
className={`font-semibold text-slate-700 capitalize ${group === "dismissed" ? "" : "text-slate-700"}`}> className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
{group} {group}
</p> </p>
<div> <div>
@@ -91,7 +91,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
))} ))}
</div> </div>
<div className="flex justify-center pt-4 pb-4"> <div className="flex justify-center pb-4 pt-4">
<HalfCircle value={questionSummary.score} /> <HalfCircle value={questionSummary.score} />
</div> </div>
</div> </div>
@@ -60,7 +60,7 @@ export const OpenTextSummary = ({
]; ];
return ( return (
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-xs"> <div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader <QuestionSummaryHeader
questionSummary={questionSummary} questionSummary={questionSummary}
survey={survey} survey={survey}
@@ -30,7 +30,7 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
const results = questionSummary.choices; const results = questionSummary.choices;
const { t } = useTranslate(); const { t } = useTranslate();
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader <QuestionSummaryHeader
questionSummary={questionSummary} questionSummary={questionSummary}
survey={survey} survey={survey}
@@ -43,7 +43,7 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
) : undefined ) : undefined
} }
/> />
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base"> <div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, index) => ( {results.map((result, index) => (
<div <div
className="cursor-pointer hover:opacity-80" className="cursor-pointer hover:opacity-80"
@@ -17,16 +17,16 @@ export const RankingSummary = ({ questionSummary, surveyType, survey }: RankingS
}); });
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} /> <QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base"> <div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, resultsIdx) => ( {results.map((result, resultsIdx) => (
<div key={result.value} className="group cursor-pointer"> <div key={result.value} className="group cursor-pointer">
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row"> <div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal"> <div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
<div className="flex w-full items-center"> <div className="flex w-full items-center">
<span className="mr-2 text-slate-400">#{resultsIdx + 1}</span> <span className="mr-2 text-slate-400">#{resultsIdx + 1}</span>
<div className="rounded-sm bg-slate-100 px-2 py-1">{result.value}</div> <div className="rounded bg-slate-100 px-2 py-1">{result.value}</div>
<span className="ml-auto flex items-center space-x-1"> <span className="ml-auto flex items-center space-x-1">
<span className="font-bold text-slate-600"> <span className="font-bold text-slate-600">
#{convertFloatToNDecimal(result.avgRanking, 2)} #{convertFloatToNDecimal(result.avgRanking, 2)}
@@ -37,7 +37,7 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
}, [questionSummary]); }, [questionSummary]);
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader <QuestionSummaryHeader
questionSummary={questionSummary} questionSummary={questionSummary}
survey={survey} survey={survey}
@@ -50,7 +50,7 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
</div> </div>
} }
/> />
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base"> <div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{questionSummary.choices.map((result) => ( {questionSummary.choices.map((result) => (
<div <div
className="cursor-pointer hover:opacity-80" className="cursor-pointer hover:opacity-80"
@@ -43,7 +43,7 @@ const ScrollToTop: React.FC<ScrollToTopProps> = ({ containerId }) => {
return ( return (
<button <button
onClick={scrollToTop} onClick={scrollToTop}
className={`fixed right-4 bottom-4 z-1 flex h-10 w-10 justify-center rounded-md bg-slate-500 p-2 text-white transition-opacity ${ className={`fixed bottom-4 right-4 z-[1] flex h-10 w-10 justify-center rounded-md bg-slate-500 p-2 text-white transition-opacity ${
showButton ? "opacity-80" : "opacity-0" showButton ? "opacity-80" : "opacity-0"
}`}> }`}>
@@ -39,7 +39,7 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
}; };
return ( return (
<div className="rounded-xl border border-slate-200 bg-white shadow-xs"> <div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className=""> <div className="">
<div className="grid h-10 grid-cols-6 items-center border-y border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600"> <div className="grid h-10 grid-cols-6 items-center border-y border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
<div className="col-span-3 pl-4 md:pl-6">{t("common.questions")}</div> <div className="col-span-3 pl-4 md:pl-6">{t("common.questions")}</div>
@@ -77,10 +77,10 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
)} )}
</p> </p>
</div> </div>
<div className="text-center font-semibold whitespace-pre-wrap"> <div className="whitespace-pre-wrap text-center font-semibold">
{quesDropOff.ttc > 0 ? (quesDropOff.ttc / 1000).toFixed(2) + "s" : "N/A"} {quesDropOff.ttc > 0 ? (quesDropOff.ttc / 1000).toFixed(2) + "s" : "N/A"}
</div> </div>
<div className="text-center font-semibold whitespace-pre-wrap">{quesDropOff.impressions}</div> <div className="whitespace-pre-wrap text-center font-semibold">{quesDropOff.impressions}</div>
<div className="pl-6 text-center md:px-6"> <div className="pl-6 text-center md:px-6">
<span className="mr-1.5 font-semibold">{quesDropOff.dropOffCount}</span> <span className="mr-1.5 font-semibold">{quesDropOff.dropOffCount}</span>
<span>({Math.round(quesDropOff.dropOffPercentage)}%)</span> <span>({Math.round(quesDropOff.dropOffPercentage)}%)</span>
@@ -17,7 +17,7 @@ const StatCard = ({ label, percentage, value, tooltipText, isLoading }) => {
<TooltipProvider delayDuration={50}> <TooltipProvider delayDuration={50}>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-xs"> <div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
<p className="flex items-center gap-1 text-sm text-slate-600"> <p className="flex items-center gap-1 text-sm text-slate-600">
{label} {label}
{typeof percentage === "number" && !isNaN(percentage) && !isLoading && ( {typeof percentage === "number" && !isNaN(percentage) && !isLoading && (
@@ -101,7 +101,7 @@ export const SummaryMetadata = ({
<TooltipTrigger> <TooltipTrigger>
<div <div
onClick={() => setShowDropOffs(!showDropOffs)} onClick={() => setShowDropOffs(!showDropOffs)}
className="group flex h-full w-full cursor-pointer flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 text-left shadow-xs"> className="group flex h-full w-full cursor-pointer flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 text-left shadow-sm">
<span className="text-sm text-slate-600"> <span className="text-sm text-slate-600">
{t("environments.surveys.summary.drop_offs")} {t("environments.surveys.summary.drop_offs")}
{`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && ( {`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && (
@@ -99,7 +99,7 @@ export const EmbedView = ({
className={cn( className={cn(
"rounded-md px-4 py-2", "rounded-md px-4 py-2",
tab.id === activeId tab.id === activeId
? "bg-white text-slate-900 shadow-xs" ? "bg-white text-slate-900 shadow-sm"
: "border-transparent text-slate-700 hover:text-slate-900" : "border-transparent text-slate-700 hover:text-slate-900"
)}> )}>
{tab.label} {tab.label}
@@ -389,8 +389,8 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
onOpenChange={(value) => { onOpenChange={(value) => {
value && handleDatePickerClose(); value && handleDatePickerClose();
}}> }}>
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-hidden"> <DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
<div className="h-auto min-w-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3"> <div className="min-w-auto h-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
<div className="hidden w-full items-center justify-between sm:flex"> <div className="hidden w-full items-center justify-between sm:flex">
<span className="text-sm text-slate-700">{t("common.download")}</span> <span className="text-sm text-slate-700">{t("common.download")}</span>
<ArrowDownToLineIcon className="ml-2 h-4 w-4" /> <ArrowDownToLineIcon className="ml-2 h-4 w-4" />
@@ -416,14 +416,14 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
onClick={() => { onClick={() => {
handleDowndloadResponses(FilterDownload.FILTER, "csv"); handleDowndloadResponses(FilterDownload.FILTER, "csv");
}}> }}>
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p> <p className="text-slate-700">{t("environments.surveys.summary.current_selection_csv")}</p>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
handleDowndloadResponses(FilterDownload.FILTER, "xlsx"); handleDowndloadResponses(FilterDownload.FILTER, "xlsx");
}}> }}>
<p className="text-slate-700"> <p className="text-slate-700">
{t("environments.surveys.summary.filtered_responses_excel")} {t("environments.surveys.summary.current_selection_excel")}
</p> </p>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
@@ -96,7 +96,7 @@ export const QuestionFilterComboBox = ({
<DropdownMenuTrigger <DropdownMenuTrigger
disabled={disabled} disabled={disabled}
className={clsx( className={clsx(
"h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600 focus:ring-0 focus:outline-transparent", "h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600 focus:outline-transparent focus:ring-0",
!disabled ? "cursor-pointer" : "opacity-50" !disabled ? "cursor-pointer" : "opacity-50"
)}> )}>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -146,7 +146,7 @@ export const QuestionFilterComboBox = ({
key={`${o}-${index}`} key={`${o}-${index}`}
type="button" type="button"
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))} onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
className="flex w-30 items-center bg-slate-100 px-2 whitespace-nowrap text-slate-600"> className="w-30 flex items-center whitespace-nowrap bg-slate-100 px-2 text-slate-600">
{o} {o}
<X width={14} height={14} className="ml-2" /> <X width={14} height={14} className="ml-2" />
</button> </button>
@@ -166,7 +166,7 @@ export const QuestionFilterComboBox = ({
</div> </div>
<div className="relative mt-2 h-full"> <div className="relative mt-2 h-full">
{open && ( {open && (
<div className="animate-in bg-popover absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-hidden"> <div className="animate-in bg-popover absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
<CommandList> <CommandList>
<div className="p-2"> <div className="p-2">
<Input <Input
@@ -164,7 +164,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
value={inputValue} value={inputValue}
onValueChange={setInputValue} onValueChange={setInputValue}
placeholder={t("common.search") + "..."} placeholder={t("common.search") + "..."}
className="h-5 border-none border-transparent p-0 shadow-none ring-offset-transparent outline-0 focus:border-none focus:border-transparent focus:shadow-none focus:ring-offset-transparent focus:outline-0" className="h-5 border-none border-transparent p-0 shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
/> />
)} )}
<div> <div>
@@ -177,7 +177,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
</div> </div>
<div className="relative mt-2 h-full"> <div className="relative mt-2 h-full">
{open && ( {open && (
<div className="animate-in bg-popover absolute top-0 z-50 max-h-52 w-full overflow-auto rounded-md bg-white outline-hidden"> <div className="animate-in bg-popover absolute top-0 z-50 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
<CommandList> <CommandList>
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty> <CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
{options?.map((data) => ( {options?.map((data) => (
@@ -199,7 +199,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
return ( return (
<Popover open={isOpen} onOpenChange={handleOpenChange}> <Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger className="flex min-w-[8rem] items-center justify-between rounded-sm border border-slate-200 bg-white p-3 text-sm text-slate-600 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3"> <PopoverTrigger className="flex min-w-[8rem] items-center justify-between rounded border border-slate-200 bg-white p-3 text-sm text-slate-600 hover:border-slate-300 sm:min-w-[11rem] sm:px-6 sm:py-3">
<span> <span>
Filter <b>{filterValue.filter.length > 0 && `(${filterValue.filter.length})`}</b> Filter <b>{filterValue.filter.length > 0 && `(${filterValue.filter.length})`}</b>
</span> </span>
@@ -86,8 +86,8 @@ export const ResultsShareButton = ({ survey, webAppUrl }: ResultsShareButtonProp
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
asChild asChild
className="focus:bg-muted cursor-pointer border border-slate-200 outline-hidden hover:border-slate-300"> className="focus:bg-muted cursor-pointer border border-slate-200 outline-none hover:border-slate-300">
<div className="h-auto min-w-auto rounded-md border bg-white p-3 sm:flex sm:min-w-[7rem] sm:px-6 sm:py-3"> <div className="min-w-auto h-auto rounded-md border bg-white p-3 sm:flex sm:min-w-[7rem] sm:px-6 sm:py-3">
<div className="hidden w-full items-center justify-between sm:flex"> <div className="hidden w-full items-center justify-between sm:flex">
<span className="text-sm text-slate-700"> <span className="text-sm text-slate-700">
{t("environments.surveys.summary.share_results")} {t("environments.surveys.summary.share_results")}
+8 -3
View File
@@ -36,9 +36,6 @@ vi.mock("@formbricks/lib/constants", () => ({
IS_POSTHOG_CONFIGURED: true, IS_POSTHOG_CONFIGURED: true,
POSTHOG_API_HOST: "test-posthog-api-host", POSTHOG_API_HOST: "test-posthog-api-host",
POSTHOG_API_KEY: "test-posthog-api-key", 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", () => ({ vi.mock("@/app/(app)/components/FormbricksClient", () => ({
@@ -50,6 +47,12 @@ vi.mock("@/app/intercom/IntercomClientWrapper", () => ({
vi.mock("@/modules/ui/components/no-mobile-overlay", () => ({ vi.mock("@/modules/ui/components/no-mobile-overlay", () => ({
NoMobileOverlay: () => <div data-testid="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", () => ({ vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="toaster-client" />, ToasterClient: () => <div data-testid="toaster-client" />,
})); }));
@@ -71,6 +74,8 @@ describe("(app) AppLayout", () => {
render(element); render(element);
expect(screen.getByTestId("no-mobile-overlay")).toBeInTheDocument(); 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("mock-intercom-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("toaster-client")).toBeInTheDocument(); expect(screen.getByTestId("toaster-client")).toBeInTheDocument();
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children"); expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children");
+2 -23
View File
@@ -1,31 +1,18 @@
import { FormbricksClient } from "@/app/(app)/components/FormbricksClient"; import { FormbricksClient } from "@/app/(app)/components/FormbricksClient";
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper"; import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
import { authOptions } from "@/modules/auth/lib/authOptions"; 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 { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
import { PHProvider, PostHogPageview } from "@/modules/ui/components/post-hog-client"; import { PHProvider, PostHogPageview } from "@/modules/ui/components/post-hog-client";
import { ToasterClient } from "@/modules/ui/components/toaster-client"; import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { Suspense } from "react"; import { Suspense } from "react";
import { import { IS_POSTHOG_CONFIGURED, POSTHOG_API_HOST, POSTHOG_API_KEY } from "@formbricks/lib/constants";
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"; import { getUser } from "@formbricks/lib/user/service";
const AppLayout = async ({ children }) => { const AppLayout = async ({ children }) => {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
const user = session?.user?.id ? await getUser(session.user.id) : null; 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 ( return (
<> <>
<NoMobileOverlay /> <NoMobileOverlay />
@@ -38,15 +25,7 @@ const AppLayout = async ({ children }) => {
</Suspense> </Suspense>
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}> <PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
<> <>
{user ? ( {user ? <FormbricksClient userId={user.id} email={user.email} /> : null}
<FormbricksClient
userId={user.id}
email={user.email}
formbricksApiHost={FORMBRICKS_API_HOST}
formbricksEnvironmentId={FORMBRICKS_ENVIRONMENT_ID}
formbricksEnabled={IS_FORMBRICKS_ENABLED}
/>
) : null}
<IntercomClientWrapper user={user} /> <IntercomClientWrapper user={user} />
<ToasterClient /> <ToasterClient />
{children} {children}
-178
View File
@@ -1,178 +0,0 @@
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();
});
});
+15 -28
View File
@@ -1,38 +1,25 @@
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { hashApiKey } from "@/modules/api/v2/management/lib/utils"; 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 { TAuthenticationApiKey } from "@formbricks/types/auth";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
export const authenticateRequest = async (request: Request): Promise<TAuthenticationApiKey | null> => { export const authenticateRequest = async (request: Request): Promise<TAuthenticationApiKey | null> => {
const apiKey = request.headers.get("x-api-key"); const apiKey = request.headers.get("x-api-key");
if (!apiKey) return null; if (apiKey) {
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
// Get API key with permissions if (environmentId) {
const apiKeyData = await getApiKeyWithPermissions(apiKey); const hashedApiKey = hashApiKey(apiKey);
if (!apiKeyData) return null; const authentication: TAuthenticationApiKey = {
type: "apiKey",
// In the route handlers, we'll do more specific permission checks environmentId,
const environmentIds = apiKeyData.apiKeyEnvironments.map((env) => env.environmentId); hashedApiKey,
if (environmentIds.length === 0) return null; };
return authentication;
const hashedApiKey = hashApiKey(apiKey); }
const authentication: TAuthenticationApiKey = { return null;
type: "apiKey", }
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({ return null;
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 => { export const handleErrorResponse = (error: any): Response => {
+49
View File
@@ -0,0 +1,49 @@
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)],
}
)();
});
@@ -1,7 +1,6 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth"; import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; 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 { deleteActionClass, getActionClass, updateActionClass } from "@formbricks/lib/actionClass/service";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes"; import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
@@ -9,20 +8,15 @@ import { TAuthenticationApiKey } from "@formbricks/types/auth";
const fetchAndAuthorizeActionClass = async ( const fetchAndAuthorizeActionClass = async (
authentication: TAuthenticationApiKey, authentication: TAuthenticationApiKey,
actionClassId: string, actionClassId: string
method: "GET" | "POST" | "PUT" | "DELETE"
): Promise<TActionClass | null> => { ): Promise<TActionClass | null> => {
// Get the action class
const actionClass = await getActionClass(actionClassId); const actionClass = await getActionClass(actionClassId);
if (!actionClass) { if (!actionClass) {
return null; 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"); throw new Error("Unauthorized");
} }
return actionClass; return actionClass;
}; };
@@ -34,7 +28,7 @@ export const GET = async (
try { try {
const authentication = await authenticateRequest(request); const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse(); if (!authentication) return responses.notAuthenticatedResponse();
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "GET"); const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId);
if (actionClass) { if (actionClass) {
return responses.successResponse(actionClass); return responses.successResponse(actionClass);
} }
@@ -52,7 +46,7 @@ export const PUT = async (
try { try {
const authentication = await authenticateRequest(request); const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse(); if (!authentication) return responses.notAuthenticatedResponse();
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "PUT"); const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId);
if (!actionClass) { if (!actionClass) {
return responses.notFoundResponse("Action Class", params.actionClassId); return responses.notFoundResponse("Action Class", params.actionClassId);
} }
@@ -94,7 +88,7 @@ export const DELETE = async (
try { try {
const authentication = await authenticateRequest(request); const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse(); if (!authentication) return responses.notAuthenticatedResponse();
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "DELETE"); const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId);
if (!actionClass) { if (!actionClass) {
return responses.notFoundResponse("Action Class", params.actionClassId); return responses.notFoundResponse("Action Class", params.actionClassId);
} }
@@ -1,88 +0,0 @@
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",
},
});
});
});
@@ -1,51 +0,0 @@
"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)),
}
)()
);
@@ -1,24 +1,16 @@
import { authenticateRequest } from "@/app/api/v1/auth"; import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { createActionClass, getActionClasses } from "@formbricks/lib/actionClass/service";
import { createActionClass } from "@formbricks/lib/actionClass/service";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes"; import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
import { DatabaseError } from "@formbricks/types/errors"; import { DatabaseError } from "@formbricks/types/errors";
import { getActionClasses } from "./lib/action-classes";
export const GET = async (request: Request) => { export const GET = async (request: Request) => {
try { try {
const authentication = await authenticateRequest(request); const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse(); 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); return responses.successResponse(actionClasses);
} catch (error) { } catch (error) {
if (error instanceof DatabaseError) { if (error instanceof DatabaseError) {
@@ -43,12 +35,6 @@ export const POST = async (request: Request): Promise<Response> => {
const inputValidation = ZActionClassInput.safeParse(actionClassInput); const inputValidation = ZActionClassInput.safeParse(actionClassInput);
const environmentId = actionClassInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
if (!inputValidation.success) { if (!inputValidation.success) {
return responses.badRequestResponse( return responses.badRequestResponse(
"Fields are missing or incorrectly formatted", "Fields are missing or incorrectly formatted",
@@ -57,7 +43,10 @@ export const POST = async (request: Request): Promise<Response> => {
); );
} }
const actionClass: TActionClass = await createActionClass(environmentId, inputValidation.data); const actionClass: TActionClass = await createActionClass(
authentication.environmentId!,
inputValidation.data
);
return responses.successResponse(actionClass); return responses.successResponse(actionClass);
} catch (error) { } catch (error) {
if (error instanceof DatabaseError) { if (error instanceof DatabaseError) {
+9 -36
View File
@@ -12,56 +12,29 @@ export const GET = async () => {
hashedKey: hashApiKey(apiKey), hashedKey: hashApiKey(apiKey),
}, },
select: { select: {
apiKeyEnvironments: { environment: {
select: { select: {
environment: { id: true,
createdAt: true,
updatedAt: true,
type: true,
project: {
select: { select: {
id: true, id: true,
type: true, name: true,
createdAt: true,
updatedAt: true,
projectId: true,
widgetSetupCompleted: true,
project: {
select: {
id: true,
name: true,
},
},
}, },
}, },
permission: true, appSetupCompleted: true,
}, },
}, },
}, },
}); });
if (!apiKeyData) { if (!apiKeyData) {
return new Response("Not authenticated", { return new Response("Not authenticated", {
status: 401, 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 { } else {
const sessionUser = await getSessionUser(); const sessionUser = await getSessionUser();
if (!sessionUser) { if (!sessionUser) {
@@ -1,33 +1,32 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth"; import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { deleteResponse, getResponse, updateResponse } from "@formbricks/lib/response/service"; import { deleteResponse, getResponse, updateResponse } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service"; import { getSurvey } from "@formbricks/lib/survey/service";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { ZResponseUpdateInput } from "@formbricks/types/responses"; import { TResponse, ZResponseUpdateInput } from "@formbricks/types/responses";
async function fetchAndAuthorizeResponse( const fetchAndValidateResponse = async (authentication: any, responseId: string): Promise<TResponse> => {
responseId: string,
authentication: any,
requiredPermission: "GET" | "PUT" | "DELETE"
) {
const response = await getResponse(responseId); const response = await getResponse(responseId);
if (!response) { if (!response || !(await canUserAccessResponse(authentication, response))) {
return { error: responses.notFoundResponse("Response", responseId) }; throw new Error("Unauthorized");
} }
return response;
};
const canUserAccessResponse = async (authentication: any, response: TResponse): Promise<boolean> => {
const survey = await getSurvey(response.surveyId); const survey = await getSurvey(response.surveyId);
if (!survey) { if (!survey) return false;
return { error: responses.notFoundResponse("Survey", response.surveyId, true) };
}
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, requiredPermission)) { if (authentication.type === "session") {
return { error: responses.unauthorizedResponse() }; 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");
} }
};
return { response };
}
export const GET = async ( export const GET = async (
request: Request, request: Request,
@@ -37,11 +36,11 @@ export const GET = async (
try { try {
const authentication = await authenticateRequest(request); const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse(); if (!authentication) return responses.notAuthenticatedResponse();
const response = await fetchAndValidateResponse(authentication, params.responseId);
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "GET"); if (response) {
if (result.error) return result.error; return responses.successResponse(response);
}
return responses.successResponse(result.response); return responses.notFoundResponse("Response", params.responseId);
} catch (error) { } catch (error) {
return handleErrorResponse(error); return handleErrorResponse(error);
} }
@@ -55,10 +54,10 @@ export const DELETE = async (
try { try {
const authentication = await authenticateRequest(request); const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse(); if (!authentication) return responses.notAuthenticatedResponse();
const response = await fetchAndValidateResponse(authentication, params.responseId);
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "DELETE"); if (!response) {
if (result.error) return result.error; return responses.notFoundResponse("Response", params.responseId);
}
const deletedResponse = await deleteResponse(params.responseId); const deletedResponse = await deleteResponse(params.responseId);
return responses.successResponse(deletedResponse); return responses.successResponse(deletedResponse);
} catch (error) { } catch (error) {
@@ -74,10 +73,7 @@ export const PUT = async (
try { try {
const authentication = await authenticateRequest(request); const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse(); 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; let responseUpdate;
try { try {
responseUpdate = await request.json(); responseUpdate = await request.json();
@@ -1,8 +1,6 @@
import "server-only"; import "server-only";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { import {
getMonthlyOrganizationResponseCount, getMonthlyOrganizationResponseCount,
@@ -10,13 +8,11 @@ import {
} from "@formbricks/lib/organization/service"; } from "@formbricks/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer"; import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer";
import { responseCache } from "@formbricks/lib/response/cache"; import { responseCache } from "@formbricks/lib/response/cache";
import { getResponseContact } from "@formbricks/lib/response/service";
import { calculateTtcTotal } from "@formbricks/lib/response/utils"; import { calculateTtcTotal } from "@formbricks/lib/response/utils";
import { responseNoteCache } from "@formbricks/lib/responseNote/cache"; import { responseNoteCache } from "@formbricks/lib/responseNote/cache";
import { captureTelemetry } from "@formbricks/lib/telemetry"; import { captureTelemetry } from "@formbricks/lib/telemetry";
import { validateInputs } from "@formbricks/lib/utils/validate"; import { validateInputs } from "@formbricks/lib/utils/validate";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { TContactAttributes } from "@formbricks/types/contact-attribute"; import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses"; import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
@@ -29,7 +25,6 @@ export const responseSelection = {
updatedAt: true, updatedAt: true,
surveyId: true, surveyId: true,
finished: true, finished: true,
endingId: true,
data: true, data: true,
meta: true, meta: true,
ttc: true, ttc: true,
@@ -198,53 +193,3 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
throw error; 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)),
}
)()
);
@@ -1,14 +1,13 @@
import { authenticateRequest } from "@/app/api/v1/auth"; import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { getResponses } from "@formbricks/lib/response/service"; import { getResponses, getResponsesByEnvironmentId } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service"; import { getSurvey } from "@formbricks/lib/survey/service";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors"; import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
import { TResponse, ZResponseInput } from "@formbricks/types/responses"; import { TResponse, ZResponseInput } from "@formbricks/types/responses";
import { createResponse, getResponsesByEnvironmentIds } from "./lib/response"; import { createResponse } from "./lib/response";
export const GET = async (request: NextRequest) => { export const GET = async (request: NextRequest) => {
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
@@ -19,26 +18,14 @@ export const GET = async (request: NextRequest) => {
try { try {
const authentication = await authenticateRequest(request); const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse(); if (!authentication) return responses.notAuthenticatedResponse();
let allResponses: TResponse[] = []; let environmentResponses: TResponse[] = [];
if (surveyId) { if (surveyId) {
const survey = await getSurvey(surveyId); environmentResponses = await getResponses(surveyId, limit, offset);
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 { } else {
const environmentIds = authentication.environmentPermissions.map( environmentResponses = await getResponsesByEnvironmentId(authentication.environmentId, limit, offset);
(permission) => permission.environmentId
);
const environmentResponses = await getResponsesByEnvironmentIds(environmentIds, limit, offset);
allResponses.push(...environmentResponses);
} }
return responses.successResponse(allResponses); return responses.successResponse(environmentResponses);
} catch (error) { } catch (error) {
if (error instanceof DatabaseError) { if (error instanceof DatabaseError) {
return responses.badRequestResponse(error.message); return responses.badRequestResponse(error.message);
@@ -52,6 +39,8 @@ export const POST = async (request: Request): Promise<Response> => {
const authentication = await authenticateRequest(request); const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse(); if (!authentication) return responses.notAuthenticatedResponse();
const environmentId = authentication.environmentId;
let jsonInput; let jsonInput;
try { try {
@@ -61,6 +50,9 @@ export const POST = async (request: Request): Promise<Response> => {
return responses.badRequestResponse("Malformed JSON input, please check your request body"); return responses.badRequestResponse("Malformed JSON input, please check your request body");
} }
// add environmentId to response
jsonInput.environmentId = environmentId;
const inputValidation = ZResponseInput.safeParse(jsonInput); const inputValidation = ZResponseInput.safeParse(jsonInput);
if (!inputValidation.success) { if (!inputValidation.success) {
@@ -73,12 +65,6 @@ export const POST = async (request: Request): Promise<Response> => {
const responseInput = inputValidation.data; const responseInput = inputValidation.data;
const environmentId = responseInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return responses.unauthorizedResponse();
}
// get and check survey // get and check survey
const survey = await getSurvey(responseInput.surveyId); const survey = await getSurvey(responseInput.surveyId);
if (!survey) { if (!survey) {
@@ -3,28 +3,21 @@ import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/sur
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils"; 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 { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service"; import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth"; import { TSurvey, ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
import { ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
const fetchAndAuthorizeSurvey = async ( const fetchAndAuthorizeSurvey = async (authentication: any, surveyId: string): Promise<TSurvey | null> => {
surveyId: string,
authentication: TAuthenticationApiKey,
requiredPermission: "GET" | "PUT" | "DELETE"
) => {
const survey = await getSurvey(surveyId); const survey = await getSurvey(surveyId);
if (!survey) { if (!survey) {
return { error: responses.notFoundResponse("Survey", surveyId) }; return null;
} }
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, requiredPermission)) { if (survey.environmentId !== authentication.environmentId) {
return { error: responses.unauthorizedResponse() }; throw new Error("Unauthorized");
} }
return survey;
return { survey };
}; };
export const GET = async ( export const GET = async (
@@ -35,9 +28,11 @@ export const GET = async (
try { try {
const authentication = await authenticateRequest(request); const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse(); if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "GET"); const survey = await fetchAndAuthorizeSurvey(authentication, params.surveyId);
if (result.error) return result.error; if (survey) {
return responses.successResponse(result.survey); return responses.successResponse(survey);
}
return responses.notFoundResponse("Survey", params.surveyId);
} catch (error) { } catch (error) {
return handleErrorResponse(error); return handleErrorResponse(error);
} }
@@ -51,8 +46,10 @@ export const DELETE = async (
try { try {
const authentication = await authenticateRequest(request); const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse(); if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "DELETE"); const survey = await fetchAndAuthorizeSurvey(authentication, params.surveyId);
if (result.error) return result.error; if (!survey) {
return responses.notFoundResponse("Survey", params.surveyId);
}
const deletedSurvey = await deleteSurvey(params.surveyId); const deletedSurvey = await deleteSurvey(params.surveyId);
return responses.successResponse(deletedSurvey); return responses.successResponse(deletedSurvey);
} catch (error) { } catch (error) {
@@ -68,10 +65,13 @@ export const PUT = async (
try { try {
const authentication = await authenticateRequest(request); const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse(); if (!authentication) return responses.notAuthenticatedResponse();
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "PUT");
if (result.error) return result.error;
const organization = await getOrganizationByEnvironmentId(result.survey.environmentId); const survey = await fetchAndAuthorizeSurvey(authentication, params.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", params.surveyId);
}
const organization = await getOrganizationByEnvironmentId(authentication.environmentId);
if (!organization) { if (!organization) {
return responses.notFoundResponse("Organization", null); return responses.notFoundResponse("Organization", null);
} }
@@ -85,7 +85,7 @@ export const PUT = async (
} }
const inputValidation = ZSurveyUpdateInput.safeParse({ const inputValidation = ZSurveyUpdateInput.safeParse({
...result.survey, ...survey,
...surveyUpdate, ...surveyUpdate,
}); });
@@ -1,6 +1,5 @@
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth"; import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl"; import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
import { getSurvey } from "@formbricks/lib/survey/service"; import { getSurvey } from "@formbricks/lib/survey/service";
@@ -18,8 +17,8 @@ export const GET = async (
if (!survey) { if (!survey) {
return responses.notFoundResponse("Survey", params.surveyId); return responses.notFoundResponse("Survey", params.surveyId);
} }
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) { if (survey.environmentId !== authentication.environmentId) {
return responses.unauthorizedResponse(); throw new Error("Unauthorized");
} }
if (!survey.singleUse || !survey.singleUse.enabled) { if (!survey.singleUse || !survey.singleUse.enabled) {
@@ -1,48 +0,0 @@
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)),
}
)()
);
+13 -23
View File
@@ -2,14 +2,12 @@ import { authenticateRequest } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils"; 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 { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service"; import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { createSurvey } from "@formbricks/lib/survey/service"; import { createSurvey, getSurveys } from "@formbricks/lib/survey/service";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors"; import { DatabaseError } from "@formbricks/types/errors";
import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types"; import { ZSurveyCreateInput } from "@formbricks/types/surveys/types";
import { getSurveys } from "./lib/surveys";
export const GET = async (request: Request) => { export const GET = async (request: Request) => {
try { try {
@@ -20,11 +18,7 @@ export const GET = async (request: Request) => {
const limit = searchParams.has("limit") ? Number(searchParams.get("limit")) : undefined; const limit = searchParams.has("limit") ? Number(searchParams.get("limit")) : undefined;
const offset = searchParams.has("offset") ? Number(searchParams.get("offset")) : undefined; const offset = searchParams.has("offset") ? Number(searchParams.get("offset")) : undefined;
const environmentIds = authentication.environmentPermissions.map( const surveys = await getSurveys(authentication.environmentId!, limit, offset);
(permission) => permission.environmentId
);
const surveys = await getSurveys(environmentIds, limit, offset);
return responses.successResponse(surveys); return responses.successResponse(surveys);
} catch (error) { } catch (error) {
if (error instanceof DatabaseError) { if (error instanceof DatabaseError) {
@@ -39,6 +33,11 @@ export const POST = async (request: Request): Promise<Response> => {
const authentication = await authenticateRequest(request); const authentication = await authenticateRequest(request);
if (!authentication) return responses.notAuthenticatedResponse(); if (!authentication) return responses.notAuthenticatedResponse();
const organization = await getOrganizationByEnvironmentId(authentication.environmentId);
if (!organization) {
return responses.notFoundResponse("Organization", null);
}
let surveyInput; let surveyInput;
try { try {
surveyInput = await request.json(); surveyInput = await request.json();
@@ -46,7 +45,8 @@ export const POST = async (request: Request): Promise<Response> => {
logger.error({ error, url: request.url }, "Error parsing JSON"); logger.error({ error, url: request.url }, "Error parsing JSON");
return responses.badRequestResponse("Malformed JSON input, please check your request body"); return responses.badRequestResponse("Malformed JSON input, please check your request body");
} }
const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput);
const inputValidation = ZSurveyCreateInput.safeParse(surveyInput);
if (!inputValidation.success) { if (!inputValidation.success) {
return responses.badRequestResponse( return responses.badRequestResponse(
@@ -56,18 +56,8 @@ export const POST = async (request: Request): Promise<Response> => {
); );
} }
const environmentId = inputValidation.data.environmentId; const environmentId = authentication.environmentId;
const surveyData = { ...inputValidation.data, environmentId: undefined };
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) { if (surveyData.followUps?.length) {
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan); const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan);
@@ -83,7 +73,7 @@ export const POST = async (request: Request): Promise<Response> => {
} }
} }
const survey = await createSurvey(environmentId, { ...surveyData, environmentId: undefined }); const survey = await createSurvey(environmentId, surveyData);
return responses.successResponse(survey); return responses.successResponse(survey);
} catch (error) { } catch (error) {
if (error instanceof DatabaseError) { if (error instanceof DatabaseError) {
+1 -1
View File
@@ -35,7 +35,7 @@ export const GET = async (req: NextRequest) => {
<div tw="flex rounded-2xl absolute -right-2 mt-2"> <div tw="flex rounded-2xl absolute -right-2 mt-2">
<a tw={`rounded-xl border border-transparent bg-[${brandColor}] h-18 w-38 opacity-50`}></a> <a tw={`rounded-xl border border-transparent bg-[${brandColor}] h-18 w-38 opacity-50`}></a>
</div> </div>
<div tw="flex rounded-2xl shadow-sm "> <div tw="flex rounded-2xl shadow ">
<a <a
tw={`flex items-center justify-center rounded-xl border border-transparent bg-[${brandColor}] text-2xl text-white h-18 w-38`}> tw={`flex items-center justify-center rounded-xl border border-transparent bg-[${brandColor}] text-2xl text-white h-18 w-38`}>
Begin! Begin!
@@ -1,19 +1,18 @@
import { authenticateRequest } from "@/app/api/v1/auth"; import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
import { deleteWebhook, getWebhook } from "@/app/api/v1/webhooks/[webhookId]/lib/webhook"; import { deleteWebhook, getWebhook } from "@/app/api/v1/webhooks/[webhookId]/lib/webhook";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
export const GET = async (request: Request, props: { params: Promise<{ webhookId: string }> }) => { export const GET = async (_: Request, props: { params: Promise<{ webhookId: string }> }) => {
const params = await props.params; const params = await props.params;
const headersList = await headers(); const headersList = await headers();
const apiKey = headersList.get("x-api-key"); const apiKey = headersList.get("x-api-key");
if (!apiKey) { if (!apiKey) {
return responses.notAuthenticatedResponse(); return responses.notAuthenticatedResponse();
} }
const authentication = await authenticateRequest(request); const environmentId = await getEnvironmentIdFromApiKey(apiKey);
if (!authentication) { if (!environmentId) {
return responses.notAuthenticatedResponse(); return responses.notAuthenticatedResponse();
} }
@@ -22,7 +21,7 @@ export const GET = async (request: Request, props: { params: Promise<{ webhookId
if (!webhook) { if (!webhook) {
return responses.notFoundResponse("Webhook", params.webhookId); return responses.notFoundResponse("Webhook", params.webhookId);
} }
if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "GET")) { if (webhook.environmentId !== environmentId) {
return responses.unauthorizedResponse(); return responses.unauthorizedResponse();
} }
return responses.successResponse(webhook); return responses.successResponse(webhook);
@@ -35,8 +34,8 @@ export const DELETE = async (request: Request, props: { params: Promise<{ webhoo
if (!apiKey) { if (!apiKey) {
return responses.notAuthenticatedResponse(); return responses.notAuthenticatedResponse();
} }
const authentication = await authenticateRequest(request); const environmentId = await getEnvironmentIdFromApiKey(apiKey);
if (!authentication) { if (!environmentId) {
return responses.notAuthenticatedResponse(); return responses.notAuthenticatedResponse();
} }
@@ -45,7 +44,7 @@ export const DELETE = async (request: Request, props: { params: Promise<{ webhoo
if (!webhook) { if (!webhook) {
return responses.notFoundResponse("Webhook", params.webhookId); return responses.notFoundResponse("Webhook", params.webhookId);
} }
if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "DELETE")) { if (webhook.environmentId !== environmentId) {
return responses.unauthorizedResponse(); return responses.unauthorizedResponse();
} }
+10 -15
View File
@@ -8,20 +8,17 @@ import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId, ZOptionalNumber } from "@formbricks/types/common"; import { ZId, ZOptionalNumber } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors"; import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhook> => { export const createWebhook = async (environmentId: string, webhookInput: TWebhookInput): Promise<Webhook> => {
validateInputs([webhookInput, ZWebhookInput]); validateInputs([environmentId, ZId], [webhookInput, ZWebhookInput]);
try { try {
const createdWebhook = await prisma.webhook.create({ const createdWebhook = await prisma.webhook.create({
data: { data: {
url: webhookInput.url, ...webhookInput,
name: webhookInput.name,
source: webhookInput.source,
surveyIds: webhookInput.surveyIds || [], surveyIds: webhookInput.surveyIds || [],
triggers: webhookInput.triggers || [],
environment: { environment: {
connect: { connect: {
id: webhookInput.environmentId, id: environmentId,
}, },
}, },
}, },
@@ -40,24 +37,22 @@ export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhoo
} }
if (!(error instanceof InvalidInputError)) { if (!(error instanceof InvalidInputError)) {
throw new DatabaseError( throw new DatabaseError(`Database error when creating webhook for environment ${environmentId}`);
`Database error when creating webhook for environment ${webhookInput.environmentId}`
);
} }
throw error; throw error;
} }
}; };
export const getWebhooks = (environmentIds: string[], page?: number): Promise<Webhook[]> => export const getWebhooks = (environmentId: string, page?: number): Promise<Webhook[]> =>
cache( cache(
async () => { async () => {
validateInputs([environmentIds, ZId.array()], [page, ZOptionalNumber]); validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
try { try {
const webhooks = await prisma.webhook.findMany({ const webhooks = await prisma.webhook.findMany({
where: { where: {
environmentId: { in: environmentIds }, environmentId: environmentId,
}, },
take: page ? ITEMS_PER_PAGE : undefined, take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined, skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
@@ -71,8 +66,8 @@ export const getWebhooks = (environmentIds: string[], page?: number): Promise<We
throw error; throw error;
} }
}, },
environmentIds.map((environmentId) => `getWebhooks-${environmentId}-${page}`), [`getWebhooks-${environmentId}-${page}`],
{ {
tags: environmentIds.map((environmentId) => webhookCache.tag.byEnvironmentId(environmentId)), tags: [webhookCache.tag.byEnvironmentId(environmentId)],
} }
)(); )();
+24 -25
View File
@@ -1,33 +1,42 @@
import { authenticateRequest } from "@/app/api/v1/auth"; import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
import { createWebhook, getWebhooks } from "@/app/api/v1/webhooks/lib/webhook"; import { createWebhook, getWebhooks } from "@/app/api/v1/webhooks/lib/webhook";
import { ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks"; import { ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
import { responses } from "@/app/lib/api/response"; import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator"; import { transformErrorToDetails } from "@/app/lib/api/validator";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { headers } from "next/headers";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors"; import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
export const GET = async (request: Request) => { export const GET = async () => {
const authentication = await authenticateRequest(request); const headersList = await headers();
if (!authentication) { const apiKey = headersList.get("x-api-key");
if (!apiKey) {
return responses.notAuthenticatedResponse(); return responses.notAuthenticatedResponse();
} }
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
if (!environmentId) {
return responses.notAuthenticatedResponse();
}
// get webhooks from database
try { try {
const environmentIds = authentication.environmentPermissions.map( const webhooks = await getWebhooks(environmentId);
(permission) => permission.environmentId return Response.json({ data: webhooks });
);
const webhooks = await getWebhooks(environmentIds);
return responses.successResponse(webhooks);
} catch (error) { } catch (error) {
if (error instanceof DatabaseError) { if (error instanceof DatabaseError) {
return responses.internalServerErrorResponse(error.message); return responses.badRequestResponse(error.message);
} }
throw error; return responses.internalServerErrorResponse(error.message);
} }
}; };
export const POST = async (request: Request) => { export const POST = async (request: Request) => {
const authentication = await authenticateRequest(request); const headersList = await headers();
if (!authentication) { const apiKey = headersList.get("x-api-key");
if (!apiKey) {
return responses.notAuthenticatedResponse();
}
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
if (!environmentId) {
return responses.notAuthenticatedResponse(); return responses.notAuthenticatedResponse();
} }
const webhookInput = await request.json(); const webhookInput = await request.json();
@@ -41,19 +50,9 @@ 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 // add webhook to database
try { try {
const webhook = await createWebhook(inputValidation.data); const webhook = await createWebhook(environmentId, inputValidation.data);
return responses.successResponse(webhook); return responses.successResponse(webhook);
} catch (error) { } catch (error) {
if (error instanceof InvalidInputError) { if (error instanceof InvalidInputError) {
@@ -11,7 +11,6 @@ export const ZWebhookInput = ZWebhook.partial({
surveyIds: true, surveyIds: true,
triggers: true, triggers: true,
url: true, url: true,
environmentId: true,
}); });
export type TWebhookInput = z.infer<typeof ZWebhookInput>; export type TWebhookInput = z.infer<typeof ZWebhookInput>;
@@ -0,0 +1,3 @@
import { GET } from "@/modules/api/v2/management/roles/route";
export { GET };
@@ -1,3 +0,0 @@
import { GET } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/route";
export { GET };
-3
View File
@@ -1,3 +0,0 @@
import { GET } from "@/modules/api/v2/me/route";
export { GET };
@@ -1,3 +0,0 @@
import { DELETE, GET, POST, PUT } from "@/modules/api/v2/organizations/[organizationId]/project-teams/route";
export { GET, POST, PUT, DELETE };
@@ -1,3 +0,0 @@
import { DELETE, GET, PUT } from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route";
export { GET, PUT, DELETE };

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