Compare commits

..

5 Commits

Author SHA1 Message Date
Tiago Farto f89a83c199 chore: add more locale time "translations" 2026-03-20 10:33:51 +00:00
Tiago Farto 82236be8bb chore: add more locale time "translations" 2026-03-20 10:33:26 +00:00
Tiago Farto 759a0e31ef chore: add local to app 2026-03-20 09:49:58 +00:00
Tiago Farto 4359963eba fix: localize survey date recall rendering 2026-03-19 13:06:15 +00:00
Tiago Farto 58db9422ad chore: remove date type choice from block 2026-03-19 11:15:38 +00:00
2089 changed files with 58335 additions and 147448 deletions
-9
View File
@@ -1,9 +0,0 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "formbricks"
[setup]
script = '''
pnpm install
pnpm dev:setup
'''
+1 -108
View File
@@ -32,62 +32,12 @@ CRON_SECRET=
# Set the minimum log level(debug, info, warn, error, fatal) # Set the minimum log level(debug, info, warn, error, fatal)
LOG_LEVEL=info LOG_LEVEL=info
# BullMQ workers require REDIS_URL (for example `redis://localhost:6379`) to be set.
# BullMQ worker startup is enabled by default outside tests. Set to 0 to disable.
# BULLMQ_WORKER_ENABLED=1
# Set to 1 on web/API pods that only enqueue jobs while a separate BullMQ worker deployment consumes them.
# BULLMQ_EXTERNAL_WORKER_ENABLED=0
# Number of BullMQ worker instances started per Formbricks server process.
# BULLMQ_WORKER_COUNT=1
# Number of concurrent jobs each BullMQ worker can process.
# BULLMQ_WORKER_CONCURRENCY=1
# Survey publish/close scheduling is configured with public build-time env vars because the editor UI
# also needs to render the selected execution time and timezone.
# NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE=Europe/Berlin
# NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR=0
# NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE=0
############## ##############
# DATABASE # # DATABASE #
############## ##############
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public' DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public'
#################
# HUB (DEV) #
#################
# The dev stack (pnpm db:up / pnpm go) runs Formbricks Hub on port 8080.
# Set explicitly to avoid confusion; override as needed when using docker-compose.dev.yml.
HUB_API_KEY=dev-api-key
HUB_API_URL=http://localhost:8080
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/hub?sslmode=disable
# Hub image tag used by docker-compose.dev.yml (hub + hub-migrate). Leave unset to use the
# pinned default in the compose file; override here when testing a specific Hub release.
# HUB_IMAGE_TAG=0.3.0
# Hub embeddings are optional. Set a provider and model to enable semantic search embeddings in
# the Hub API and hub-worker. For provider-specific settings, see:
# https://hub.formbricks.com/reference/environment-variables/#embeddings
# Example with Google AI Studio:
# EMBEDDING_PROVIDER=google
# EMBEDDING_MODEL=gemini-embedding-001
# EMBEDDING_PROVIDER_API_KEY=
###########################
# CUBE ANALYTICS (XM V5) #
###########################
# XM Suite v5 analysis features require Cube.js. The optional xm dev profile exposes Cube on port 4000.
# Uncomment COMPOSE_PROFILES=xm to run the optional Cube analytics service.
# COMPOSE_PROFILES=xm
CUBEJS_API_URL=http://localhost:4000
# Generate with: openssl rand -hex 32. `pnpm dev:setup` will create/preserve this automatically.
CUBEJS_API_SECRET=
CUBEJS_JWT_ISSUER=formbricks-web
CUBEJS_JWT_AUDIENCE=formbricks-cube
################ ################
# MAIL SETUP # # MAIL SETUP #
################ ################
@@ -120,7 +70,7 @@ SMTP_PASSWORD=smtpPassword
# S3 STORAGE # # S3 STORAGE #
############## ##############
# S3 Storage is required for the file upload in serverless environments # S3 Storage is required for the file upload in serverless environments like Vercel
S3_ACCESS_KEY= S3_ACCESS_KEY=
S3_SECRET_KEY= S3_SECRET_KEY=
S3_REGION= S3_REGION=
@@ -144,25 +94,12 @@ EMAIL_VERIFICATION_DISABLED=1
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too. # Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
PASSWORD_RESET_DISABLED=1 PASSWORD_RESET_DISABLED=1
# Password reset token lifetime in minutes. Must be between 5 and 120 if set.
# PASSWORD_RESET_TOKEN_LIFETIME_MINUTES=30
# Development-only helper: log the password reset link to the server console instead of sending reset emails.
# DEBUG_SHOW_RESET_LINK=1
# Email login. Disable the ability for users to login with email. # Email login. Disable the ability for users to login with email.
# EMAIL_AUTH_DISABLED=1 # EMAIL_AUTH_DISABLED=1
# Organization Invite. Disable the ability for invited users to create an account. # Organization Invite. Disable the ability for invited users to create an account.
# INVITE_DISABLED=1 # INVITE_DISABLED=1
###########################################
# Account deletion reauthentication #
###########################################
# Danger: disables fresh SSO reauthentication for passwordless account deletion. Keep unset unless you accept the risk.
# DISABLE_ACCOUNT_DELETION_SSO_REAUTH=1
########## ##########
# Other # # Other #
@@ -189,41 +126,12 @@ GITHUB_SECRET=
# Configure Google Login # Configure Google Login
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
# Google only returns the auth_time proof after Auth Platform Security Bundle "Session age claims" is enabled.
# Keep this unset until that setting is active for the OAuth app.
# GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED=1
# Configure Azure Active Directory Login # Configure Azure Active Directory Login
AZUREAD_CLIENT_ID= AZUREAD_CLIENT_ID=
AZUREAD_CLIENT_SECRET= AZUREAD_CLIENT_SECRET=
AZUREAD_TENANT_ID= AZUREAD_TENANT_ID=
# Configure Formbricks AI at the instance level
# Set the provider used for AI features on this instance.
# Accepted values for AI_PROVIDER: aws, google, azure
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching provider settings below.
# AI_PROVIDER=google
# AI_MODEL=gemini-2.5-flash
# Google Cloud settings for Gemini models
# Credentials are optional when Application Default Credentials are available.
# AI_GOOGLE_CLOUD_PROJECT=
# AI_GOOGLE_CLOUD_LOCATION=
# AI_GOOGLE_CLOUD_CREDENTIALS_JSON=
# AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS=
# Amazon Bedrock credentials
# AI_AWS_REGION=
# AI_AWS_ACCESS_KEY_ID=
# AI_AWS_SECRET_ACCESS_KEY=
# AI_AWS_SESSION_TOKEN=
# Azure AI / Microsoft Foundry credentials
# AI_AZURE_BASE_URL=
# AI_AZURE_RESOURCE_NAME=
# AI_AZURE_API_KEY=
# AI_AZURE_API_VERSION=v1
# OpenID Connect (OIDC) configuration # OpenID Connect (OIDC) configuration
# OIDC_CLIENT_ID= # OIDC_CLIENT_ID=
# OIDC_CLIENT_SECRET= # OIDC_CLIENT_SECRET=
@@ -277,14 +185,6 @@ ENTERPRISE_LICENSE_KEY=
# Ignore Rate Limiting across the Formbricks app # Ignore Rate Limiting across the Formbricks app
# RATE_LIMITING_DISABLED=1 # RATE_LIMITING_DISABLED=1
# Disable telemetry reporting (usage stats sent to Formbricks). Ignored when an EE license is active.
# TELEMETRY_DISABLED=1
# Allow webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x)
# WARNING: Only enable this if you understand the SSRF risks. Useful for self-hosted instances
# that need to send webhooks to internal services.
# DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics) # OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 # OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf # OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
@@ -329,13 +229,6 @@ REDIS_URL=redis://localhost:6379
# If the ip should be added in the log or not. Default 0 # If the ip should be added in the log or not. Default 0
# AUDIT_LOG_GET_USER_IP=0 # AUDIT_LOG_GET_USER_IP=0
# Optional Cube.js database overrides. The official local docker-compose.dev.yml stack points Cube at the
# local `postgres` service automatically; set these only when running Cube yourself or changing bundled defaults.
# CUBEJS_DB_HOST=postgres
# CUBEJS_DB_PORT=5432
# CUBEJS_DB_NAME=postgres
# CUBEJS_DB_USER=postgres
# CUBEJS_DB_PASS=postgres
# Lingo.dev API key for translation generation # Lingo.dev API key for translation generation
LINGO_API_KEY=your_api_key_here LINGO_API_KEY=your_api_key_here
-78
View File
@@ -1,78 +0,0 @@
name: Accessibility issue
description: "Report an accessibility barrier in Formbricks (WCAG, screen reader, keyboard, contrast, etc.)"
type: bug
labels: ["accessibility", "bug"]
body:
- type: markdown
attributes:
value: |
Thanks for helping make Formbricks accessible to everyone. Please fill in as much as you can — see [ACCESSIBILITY.md](https://github.com/formbricks/formbricks/blob/main/ACCESSIBILITY.md) for context.
- type: textarea
id: summary
attributes:
label: Summary
description: What part of Formbricks is affected and what's wrong?
placeholder: "e.g. The language switcher in survey runtime can't be reached with Tab."
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
placeholder: |
1. Open a survey with multiple languages
2. Press Tab repeatedly
3. Focus never lands on the language switcher
validations:
required: true
- type: input
id: wcag
attributes:
label: Related WCAG criterion (if known)
placeholder: "e.g. 2.1.1 Keyboard"
- type: dropdown
id: severity
attributes:
label: Severity
options:
- "Critical — blocks a user from completing a core task"
- "High — significant barrier with no easy workaround"
- "Medium — barrier with a workaround"
- "Low — minor friction"
validations:
required: true
- type: input
id: at
attributes:
label: Assistive technology
placeholder: "e.g. NVDA 2026.1, VoiceOver on macOS 15, keyboard only"
- type: input
id: browser
attributes:
label: Browser and OS
placeholder: "e.g. Firefox 138 on Windows 11"
- type: dropdown
id: environment
attributes:
label: Your Environment
options:
- Formbricks Cloud (app.formbricks.com)
- Self-hosted Formbricks
validations:
required: true
- type: textarea
id: other
attributes:
label: Other information (screenshots, recordings, axe output)
@@ -284,10 +284,6 @@ runs:
database_url=${{ env.DUMMY_DATABASE_URL }} database_url=${{ env.DUMMY_DATABASE_URL }}
encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }} encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }}
redis_url=${{ env.DUMMY_REDIS_URL }} redis_url=${{ env.DUMMY_REDIS_URL }}
hub_api_url=${{ env.DUMMY_HUB_API_URL }}
hub_api_key=${{ env.DUMMY_HUB_API_KEY }}
cubejs_api_url=${{ env.DUMMY_CUBEJS_API_URL }}
cubejs_api_secret=${{ env.DUMMY_CUBEJS_API_SECRET }}
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }} sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
posthog_key=${{ env.POSTHOG_KEY }} posthog_key=${{ env.POSTHOG_KEY }}
env: env:
@@ -295,10 +291,6 @@ runs:
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }} DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }} DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }} DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
DUMMY_HUB_API_URL: ${{ env.DUMMY_HUB_API_URL }}
DUMMY_HUB_API_KEY: ${{ env.DUMMY_HUB_API_KEY }}
DUMMY_CUBEJS_API_URL: ${{ env.DUMMY_CUBEJS_API_URL }}
DUMMY_CUBEJS_API_SECRET: ${{ env.DUMMY_CUBEJS_API_SECRET }}
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
POSTHOG_KEY: ${{ env.POSTHOG_KEY }} POSTHOG_KEY: ${{ env.POSTHOG_KEY }}
+9 -7
View File
@@ -20,12 +20,12 @@ runs:
using: "composite" using: "composite"
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout - uses: ./.github/actions/dangerous-git-checkout
- name: Cache Build - name: Cache Build
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 uses: actions/cache@v3
id: cache-build id: cache-build
env: env:
cache-name: prod-build cache-name: prod-build
@@ -43,7 +43,7 @@ runs:
shell: bash shell: bash
- name: Setup Node.js 20.x - name: Setup Node.js 20.x
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@v3
with: with:
node-version: 20.x node-version: 20.x
if: steps.cache-build.outputs.cache-hit != 'true' if: steps.cache-build.outputs.cache-hit != 'true'
@@ -53,18 +53,20 @@ runs:
if: steps.cache-build.outputs.cache-hit != 'true' if: steps.cache-build.outputs.cache-hit != 'true'
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64 run: pnpm install --config.platform=linux --config.architecture=x64
if: steps.cache-build.outputs.cache-hit != 'true' if: steps.cache-build.outputs.cache-hit != 'true'
shell: bash shell: bash
- name: Create .env - name: create .env
run: pnpm dev:setup run: cp .env.example .env
shell: bash shell: bash
- name: Fill E2E_TESTING in .env - name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
env: env:
E2E_TESTING_MODE: ${{ inputs.e2e_testing_mode }} E2E_TESTING_MODE: ${{ inputs.e2e_testing_mode }}
run: | run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
echo "E2E_TESTING=$E2E_TESTING_MODE" >> .env echo "E2E_TESTING=$E2E_TESTING_MODE" >> .env
shell: bash shell: bash
@@ -4,7 +4,7 @@ runs:
using: "composite" using: "composite"
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@v3
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 2 fetch-depth: 2
-4
View File
@@ -91,9 +91,5 @@ jobs:
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }} DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }} DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }} DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
DUMMY_HUB_API_URL: ${{ secrets.DUMMY_HUB_API_URL }}
DUMMY_HUB_API_KEY: ${{ secrets.DUMMY_HUB_API_KEY }}
DUMMY_CUBEJS_API_URL: ${{ secrets.DUMMY_CUBEJS_API_URL }}
DUMMY_CUBEJS_API_SECRET: ${{ secrets.DUMMY_CUBEJS_API_SECRET }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }} POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
${{ runner.os }}-pnpm-store- ${{ runner.os }}-pnpm-store-
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64 run: pnpm install --config.platform=linux --config.architecture=x64
- name: Run Chromatic - name: Run Chromatic
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4 uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
@@ -73,10 +73,6 @@ jobs:
database_url=${{ secrets.DUMMY_DATABASE_URL }} database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }} encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
redis_url=redis://localhost:6379 redis_url=redis://localhost:6379
hub_api_url=http://localhost:4000
hub_api_key=build-time-placeholder
cubejs_api_url=http://localhost:4000
cubejs_api_secret=build-time-placeholder
- name: Verify and Initialize PostgreSQL - name: Verify and Initialize PostgreSQL
run: | run: |
@@ -147,10 +143,6 @@ jobs:
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \ -e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
-e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \ -e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \
-e REDIS_URL="redis://host.docker.internal:6379" \ -e REDIS_URL="redis://host.docker.internal:6379" \
-e HUB_API_URL="http://localhost:4000" \
-e HUB_API_KEY="build-time-placeholder" \
-e CUBEJS_API_URL="http://localhost:4000" \
-e CUBEJS_API_SECRET="build-time-placeholder" \
-d "formbricks-test:$GITHUB_SHA" -d "formbricks-test:$GITHUB_SHA"
# Start health check polling immediately (every 5 seconds for up to 5 minutes) # Start health check polling immediately (every 5 seconds for up to 5 minutes)
+55 -40
View File
@@ -57,7 +57,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout - uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 22.x - name: Setup Node.js 22.x
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
with: with:
node-version: 22.x node-version: 22.x
@@ -65,15 +65,19 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64 run: pnpm install --config.platform=linux --config.architecture=x64
shell: bash shell: bash
- name: Create .env - name: create .env
run: pnpm dev:setup run: cp .env.example .env
shell: bash shell: bash
- name: Fill ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env - name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
run: | run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env
echo "" >> .env echo "" >> .env
@@ -81,48 +85,65 @@ jobs:
echo "S3_REGION=us-east-1" >> .env echo "S3_REGION=us-east-1" >> .env
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
echo "S3_ACCESS_KEY=devrustfs-service" >> .env echo "S3_ACCESS_KEY=devminio" >> .env
echo "S3_SECRET_KEY=devrustfs-service123" >> .env echo "S3_SECRET_KEY=devminio123" >> .env
echo "S3_FORCE_PATH_STYLE=1" >> .env echo "S3_FORCE_PATH_STYLE=1" >> .env
shell: bash shell: bash
- name: Start RustFS Server - name: Install MinIO client (mc)
run: |
set -euo pipefail
MC_VERSION="RELEASE.2025-08-13T08-35-41Z"
MC_BASE="https://dl.min.io/client/mc/release/linux-amd64/archive"
MC_BIN="mc.${MC_VERSION}"
MC_SUM="${MC_BIN}.sha256sum"
curl -fsSL "${MC_BASE}/${MC_BIN}" -o "${MC_BIN}"
curl -fsSL "${MC_BASE}/${MC_SUM}" -o "${MC_SUM}"
sha256sum -c "${MC_SUM}"
chmod +x "${MC_BIN}"
sudo mv "${MC_BIN}" /usr/local/bin/mc
- name: Start MinIO Server
run: | run: |
set -euo pipefail set -euo pipefail
# Start RustFS server in background # Start MinIO server in background
docker run -d \ docker run -d \
--name rustfs-server \ --name minio-server \
-p 9000:9000 \ -p 9000:9000 \
-p 9001:9001 \ -p 9001:9001 \
-e RUSTFS_ACCESS_KEY=devrustfs \ -e MINIO_ROOT_USER=devminio \
-e RUSTFS_SECRET_KEY=devrustfs123 \ -e MINIO_ROOT_PASSWORD=devminio123 \
-e RUSTFS_ADDRESS=:9000 \ minio/minio:RELEASE.2025-09-07T16-13-09Z \
-e RUSTFS_CONSOLE_ENABLE=true \ server /data --console-address :9001
-e RUSTFS_CONSOLE_ADDRESS=:9001 \
rustfs/rustfs:1.0.0-alpha.93 \
/data
echo "RustFS server started" echo "MinIO server started"
- name: Bootstrap RustFS bucket and browser upload CORS - name: Wait for MinIO and create S3 bucket
run: | run: |
set -euo pipefail set -euo pipefail
docker run --rm \ echo "Waiting for MinIO to be ready..."
--network host \ ready=0
--entrypoint /bin/sh \ for i in {1..60}; do
-e RUSTFS_ENDPOINT_URL=http://127.0.0.1:9000 \ if curl -fsS http://localhost:9000/minio/health/live >/dev/null; then
-e RUSTFS_ADMIN_USER=devrustfs \ echo "MinIO is up after ${i} seconds"
-e RUSTFS_ADMIN_PASSWORD=devrustfs123 \ ready=1
-e RUSTFS_SERVICE_USER=devrustfs-service \ break
-e RUSTFS_SERVICE_PASSWORD=devrustfs-service123 \ fi
-e RUSTFS_BUCKET_NAME=formbricks-e2e \ sleep 1
-e RUSTFS_POLICY_NAME=formbricks-e2e-policy \ done
-e RUSTFS_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 \
-v "$PWD/docker/rustfs-init.sh:/tmp/rustfs-init.sh:ro" \ if [ "$ready" -ne 1 ]; then
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 \ echo "::error::MinIO did not become ready within 60 seconds"
/tmp/rustfs-init.sh exit 1
fi
mc alias set local http://localhost:9000 devminio devminio123
mc mb --ignore-existing local/formbricks-e2e
- name: Build App - name: Build App
run: | run: |
@@ -221,14 +242,8 @@ jobs:
if: failure() if: failure()
with: with:
name: app-logs name: app-logs
if-no-files-found: ignore
path: app.log path: app.log
- name: Output App Logs - name: Output App Logs
if: failure() if: failure()
run: | run: cat app.log
if [ -f app.log ]; then
cat app.log
else
echo "app.log not found because the Run App step did not execute or failed before log creation."
fi
-28
View File
@@ -155,31 +155,3 @@ jobs:
commit_sha: ${{ github.sha }} commit_sha: ${{ github.sha }}
is_prerelease: ${{ github.event.release.prerelease }} is_prerelease: ${{ github.event.release.prerelease }}
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }} make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
linear-release-complete:
name: Mark Linear release as complete
runs-on: ubuntu-latest
timeout-minutes: 5
needs:
- docker-build-community
- docker-build-cloud
- helm-chart-release
- move-stable-tag
if: ${{ !github.event.release.prerelease }}
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Complete Linear release
uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
command: complete
version: ${{ github.event.release.tag_name }}
-30
View File
@@ -1,30 +0,0 @@
name: Linear Release Sync
on:
push:
branches:
- main
permissions:
contents: read
jobs:
linear-release:
name: Sync release to Linear
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Sync Linear release
uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
+11 -4
View File
@@ -21,7 +21,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout - uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x - name: Setup Node.js 20.x
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with: with:
node-version: 20.x node-version: 20.x
@@ -29,10 +29,17 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64 run: pnpm install --config.platform=linux --config.architecture=x64
- name: Create .env - name: create .env
run: pnpm dev:setup run: cp .env.example .env
- name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
- name: Lint - name: Lint
run: pnpm lint run: pnpm lint
@@ -47,8 +47,4 @@ jobs:
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }} DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }} DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }} DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
DUMMY_HUB_API_URL: ${{ secrets.DUMMY_HUB_API_URL }}
DUMMY_HUB_API_KEY: ${{ secrets.DUMMY_HUB_API_KEY }}
DUMMY_CUBEJS_API_URL: ${{ secrets.DUMMY_CUBEJS_API_URL }}
DUMMY_CUBEJS_API_SECRET: ${{ secrets.DUMMY_CUBEJS_API_SECRET }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
@@ -105,8 +105,4 @@ jobs:
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }} DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }} DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }} DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
DUMMY_HUB_API_URL: ${{ secrets.DUMMY_HUB_API_URL }}
DUMMY_HUB_API_KEY: ${{ secrets.DUMMY_HUB_API_KEY }}
DUMMY_CUBEJS_API_URL: ${{ secrets.DUMMY_CUBEJS_API_URL }}
DUMMY_CUBEJS_API_SECRET: ${{ secrets.DUMMY_CUBEJS_API_SECRET }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
+9 -5
View File
@@ -25,7 +25,7 @@ jobs:
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 22.x
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with: with:
node-version: 22.x node-version: 22.x
@@ -33,13 +33,17 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64 run: pnpm install --config.platform=linux --config.architecture=x64
- name: Create .env - name: create .env
run: pnpm dev:setup run: cp .env.example .env
- name: Adjust CI-specific env values - name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
run: | run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
- name: Run tests with coverage - name: Run tests with coverage
+9 -5
View File
@@ -22,7 +22,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout - uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x - name: Setup Node.js 20.x
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
with: with:
node-version: 20.x node-version: 20.x
@@ -30,13 +30,17 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64 run: pnpm install --config.platform=linux --config.architecture=x64
- name: Create .env - name: create .env
run: pnpm dev:setup run: cp .env.example .env
- name: Adjust CI-specific env values - name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
run: | run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
- name: Test - name: Test
+2 -3
View File
@@ -2,7 +2,6 @@ name: Translation Validation
permissions: permissions:
contents: read contents: read
pull-requests: read
on: on:
pull_request: pull_request:
@@ -40,7 +39,7 @@ jobs:
- name: Setup Node.js 22.x - name: Setup Node.js 22.x
if: steps.changes.outputs.translations == 'true' if: steps.changes.outputs.translations == 'true'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with: with:
node-version: 22.x node-version: 22.x
@@ -50,7 +49,7 @@ jobs:
- name: Install dependencies - name: Install dependencies
if: steps.changes.outputs.translations == 'true' if: steps.changes.outputs.translations == 'true'
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64 run: pnpm install --config.platform=linux --config.architecture=x64
- name: Validate translation keys - name: Validate translation keys
if: steps.changes.outputs.translations == 'true' if: steps.changes.outputs.translations == 'true'
+1 -1
View File
@@ -45,7 +45,7 @@ yarn-error.log*
.direnv .direnv
# Playwright # Playwright
**/test-results/ /test-results/
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
+1 -13
View File
@@ -1,13 +1 @@
#!/usr/bin/env sh pnpm lint-staged
if command -v pnpm >/dev/null 2>&1; then
pnpm lint-staged
elif command -v npm >/dev/null 2>&1; then
npm exec --yes pnpm@10.32.1 lint-staged
elif command -v corepack >/dev/null 2>&1; then
corepack pnpm lint-staged
else
echo "Error: pnpm, npm, and corepack are unavailable in this Git hook PATH."
echo "Install Node.js tooling or update your PATH, then retry the commit."
exit 127
fi
+1
View File
@@ -0,0 +1 @@
apps/web/.env
-48
View File
@@ -1,48 +0,0 @@
# Accessibility
Formbricks is committed to making our platform usable by everyone, including people who rely on assistive technologies.
## Standards
We aim to conform to:
- **[WCAG 2.1 Level AA](https://www.w3.org/TR/WCAG21/)** — the web content baseline.
- **[EN 301 549](https://www.etsi.org/deliver/etsi_en/301500_301599/301549/)** — the European harmonised standard referenced by the **European Accessibility Act (EAA)**, applicable to us as a Germany-based company.
- **Section 508** — for users in US public-sector contexts.
## Priorities
1. **End-user surveys** (`packages/surveys`) — everything respondents see and interact with. This is our highest priority because survey takers don't choose Formbricks; the organisations running surveys choose for them.
2. **Admin app** (`apps/web`) — survey creation, response analysis, and team management used by Formbricks customers.
In both areas we focus on:
- Keyboard navigation with a clearly visible focus indicator
- Screen reader support through semantic HTML and correctly scoped ARIA
- Sufficient color and contrast
- Programmatically associated labels and announced status messages
## Supported Environments
- Latest two versions of Chrome, Firefox, Safari, and Edge
- VoiceOver (macOS/iOS), NVDA (Windows), and TalkBack (Android)
## Contributing
When contributing UI changes:
- Prefer semantic HTML over ARIA.
- Tab through your change end-to-end and confirm focus is visible at every stop.
- Label every control. Don't convey meaning by color alone.
- Run [axe DevTools](https://www.deque.com/axe/devtools/) or Lighthouse on the page you changed.
## Reporting Accessibility Issues
If you encounter an accessibility barrier, please [open an issue](https://github.com/formbricks/formbricks/issues/new?labels=accessibility&template=accessibility.yml) using the accessibility template. For blocking issues in a procurement or compliance context, email **[hola@formbricks.com](mailto:hola@formbricks.com)**.
## Resources
- [WCAG 2.1 Quick Reference](https://www.w3.org/WAI/WCAG21/quickref/)
- [EN 301 549](https://www.etsi.org/deliver/etsi_en/301500_301599/301549/)
- [European Accessibility Act overview](https://ec.europa.eu/social/main.jsp?catId=1202)
- [MDN Accessibility Reference](https://developer.mozilla.org/en-US/docs/Web/Accessibility)
-1
View File
@@ -32,7 +32,6 @@ The `@formbricks/surveys` package is pre-compiled (Vite → UMD + ESM) and the b
TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable. TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable.
We are using SonarQube to identify code smells and security hotspots. We are using SonarQube to identify code smells and security hotspots.
Always mark React component props as `Readonly<>` (e.g., `({ children }: Readonly<MyProps>)`).
## Architecture & Patterns ## Architecture & Patterns
+25 -1
View File
@@ -127,10 +127,34 @@ Formbricks has a hosted cloud offering with a generous free plan to get you up a
Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription. Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription.
If you opt for self-hosting Formbricks, here are a few options to consider:
#### Docker #### Docker
To get started with self-hosting with Docker, take a look at our [self-hosting docs](https://formbricks.com/docs/self-hosting/deployment). To get started with self-hosting with Docker, take a look at our [self-hosting docs](https://formbricks.com/docs/self-hosting/deployment).
#### Community-managed One Click Hosting
##### Railway
You can deploy Formbricks on [Railway](https://railway.app) using the button below.
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template/PPDzCd)
##### RepoCloud
Or you can also deploy Formbricks on [RepoCloud](https://repocloud.io) using the button below.
[![Deploy on RepoCloud](https://d16t0pc4846x52.cloudfront.net/deploy.png)](https://repocloud.io/details/?app_id=254)
##### Zeabur
Or you can also deploy Formbricks on [Zeabur](https://zeabur.com) using the button below.
[![Deploy to Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/G4TUJL)
<a id="development"></a>
## 👨‍💻 Development ## 👨‍💻 Development
### Prerequisites ### Prerequisites
@@ -223,4 +247,4 @@ We currently do not offer Formbricks white-labeled. That means that we don't sel
The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come. The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come.
<a id="readme-de"></a> <p align="right"><a href="#top">🔼 Back to top</a></p>
+12 -12
View File
@@ -11,19 +11,19 @@
"clean": "rimraf .turbo node_modules dist storybook-static" "clean": "rimraf .turbo node_modules dist storybook-static"
}, },
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "5.0.2", "@chromatic-com/storybook": "^5.0.1",
"@storybook/addon-a11y": "10.3.6", "@storybook/addon-a11y": "10.2.17",
"@storybook/addon-docs": "10.3.6", "@storybook/addon-links": "10.2.17",
"@storybook/addon-links": "10.3.6", "@storybook/addon-onboarding": "10.2.17",
"@storybook/addon-onboarding": "10.3.6", "@storybook/react-vite": "10.2.17",
"@storybook/react-vite": "10.3.6", "@typescript-eslint/eslint-plugin": "8.57.0",
"@tailwindcss/vite": "4.2.4", "@tailwindcss/vite": "4.2.1",
"@typescript-eslint/eslint-plugin": "8.57.2", "@typescript-eslint/parser": "8.57.0",
"@typescript-eslint/parser": "8.57.2",
"@vitejs/plugin-react": "5.1.4", "@vitejs/plugin-react": "5.1.4",
"eslint-plugin-react-refresh": "0.4.26", "eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.3.6", "eslint-plugin-storybook": "10.2.17",
"storybook": "10.3.6", "storybook": "10.2.17",
"vite": "7.3.3" "vite": "7.3.1",
"@storybook/addon-docs": "10.2.17"
} }
} }
-4
View File
@@ -66,10 +66,6 @@ RUN pnpm build --filter=@formbricks/database
RUN --mount=type=secret,id=database_url \ RUN --mount=type=secret,id=database_url \
--mount=type=secret,id=encryption_key \ --mount=type=secret,id=encryption_key \
--mount=type=secret,id=redis_url \ --mount=type=secret,id=redis_url \
--mount=type=secret,id=hub_api_url \
--mount=type=secret,id=hub_api_key \
--mount=type=secret,id=cubejs_api_url \
--mount=type=secret,id=cubejs_api_secret \
--mount=type=secret,id=sentry_auth_token \ --mount=type=secret,id=sentry_auth_token \
--mount=type=secret,id=posthog_key \ --mount=type=secret,id=posthog_key \
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web... /tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
@@ -4,20 +4,21 @@ import { ArrowRight } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect } from "react"; import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TWorkspaceConfigChannel } from "@formbricks/types/workspace"; import { TEnvironment } from "@formbricks/types/environment";
import { TProjectConfigChannel } from "@formbricks/types/project";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions"; import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
interface ConnectWithFormbricksProps { interface ConnectWithFormbricksProps {
workspaceId: string; environment: TEnvironment;
publicDomain: string; publicDomain: string;
appSetupCompleted: boolean; appSetupCompleted: boolean;
channel: TWorkspaceConfigChannel; channel: TProjectConfigChannel;
} }
export const ConnectWithFormbricks = ({ export const ConnectWithFormbricks = ({
workspaceId, environment,
publicDomain, publicDomain,
appSetupCompleted, appSetupCompleted,
channel, channel,
@@ -25,7 +26,7 @@ export const ConnectWithFormbricks = ({
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const handleFinishOnboarding = async () => { const handleFinishOnboarding = async () => {
router.push(`/workspaces/${workspaceId}/surveys`); router.push(`/environments/${environment.id}/surveys`);
}; };
useEffect(() => { useEffect(() => {
@@ -47,7 +48,7 @@ export const ConnectWithFormbricks = ({
<div className="flex w-full space-x-10"> <div className="flex w-full space-x-10">
<div className="flex w-1/2 flex-col space-y-4"> <div className="flex w-1/2 flex-col space-y-4">
<OnboardingSetupInstructions <OnboardingSetupInstructions
workspaceId={workspaceId} environmentId={environment.id}
publicDomain={publicDomain} publicDomain={publicDomain}
channel={channel} channel={channel}
appSetupCompleted={appSetupCompleted} appSetupCompleted={appSetupCompleted}
@@ -60,9 +61,9 @@ export const ConnectWithFormbricks = ({
)}> )}>
{appSetupCompleted ? ( {appSetupCompleted ? (
<div> <div>
<p className="text-3xl">{t("workspace.connect.congrats")}</p> <p className="text-3xl">{t("environments.connect.congrats")}</p>
<p className="pt-4 text-sm font-medium text-slate-600"> <p className="pt-4 text-sm font-medium text-slate-600">
{t("workspace.connect.connection_successful_message")} {t("environments.connect.connection_successful_message")}
</p> </p>
</div> </div>
) : ( ) : (
@@ -72,7 +73,7 @@ export const ConnectWithFormbricks = ({
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span> <span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
</span> </span>
<p className="pt-4 text-sm font-medium text-slate-600"> <p className="pt-4 text-sm font-medium text-slate-600">
{t("workspace.connect.waiting_for_your_signal")} {t("environments.connect.waiting_for_your_signal")}
</p> </p>
</div> </div>
)} )}
@@ -82,7 +83,9 @@ export const ConnectWithFormbricks = ({
id="finishOnboarding" id="finishOnboarding"
variant={appSetupCompleted ? "default" : "ghost"} variant={appSetupCompleted ? "default" : "ghost"}
onClick={handleFinishOnboarding}> onClick={handleFinishOnboarding}>
{appSetupCompleted ? t("workspace.connect.finish_onboarding") : t("workspace.connect.do_it_later")} {appSetupCompleted
? t("environments.connect.finish_onboarding")
: t("environments.connect.do_it_later")}
<ArrowRight /> <ArrowRight />
</Button> </Button>
</div> </div>
@@ -5,7 +5,7 @@ import "prismjs/themes/prism.css";
import { useState } from "react"; import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TWorkspaceConfigChannel } from "@formbricks/types/workspace"; import { TProjectConfigChannel } from "@formbricks/types/project";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { CodeBlock } from "@/modules/ui/components/code-block"; import { CodeBlock } from "@/modules/ui/components/code-block";
import { Html5Icon, NpmIcon } from "@/modules/ui/components/icons"; import { Html5Icon, NpmIcon } from "@/modules/ui/components/icons";
@@ -17,14 +17,14 @@ const tabs = [
]; ];
interface OnboardingSetupInstructionsProps { interface OnboardingSetupInstructionsProps {
workspaceId: string; environmentId: string;
publicDomain: string; publicDomain: string;
channel: TWorkspaceConfigChannel; channel: TProjectConfigChannel;
appSetupCompleted: boolean; appSetupCompleted: boolean;
} }
export const OnboardingSetupInstructions = ({ export const OnboardingSetupInstructions = ({
workspaceId, environmentId,
publicDomain, publicDomain,
channel, channel,
appSetupCompleted, appSetupCompleted,
@@ -35,8 +35,8 @@ export const OnboardingSetupInstructions = ({
<script type="text/javascript"> <script type="text/javascript">
!function(){ !function(){
var appUrl = "${publicDomain}"; var appUrl = "${publicDomain}";
var workspaceId = "${workspaceId}"; var environmentId = "${environmentId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({workspaceId:workspaceId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}(); var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
</script> </script>
<!-- END Formbricks Surveys --> <!-- END Formbricks Surveys -->
`; `;
@@ -45,46 +45,46 @@ export const OnboardingSetupInstructions = ({
<script type="text/javascript"> <script type="text/javascript">
!function(){ !function(){
var appUrl = "${publicDomain}"; var appUrl = "${publicDomain}";
var workspaceId = "${workspaceId}"; var environmentId = "${environmentId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({workspaceId:workspaceId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}(); var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
</script> </script>
<!-- END Formbricks Surveys --> <!-- END Formbricks Surveys -->
`; `;
const npmSnippetForAppSurveys = ` const npmSnippetForAppSurveys = `
import formbricks from "@formbricks/js"; import formbricks from "@formbricks/js";
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
formbricks.setup({ formbricks.setup({
workspaceId: "${workspaceId}", environmentId: "${environmentId}",
appUrl: "${publicDomain}", appUrl: "${publicDomain}",
}); });
} }
function App() { function App() {
// your own app // your own app
} }
export default App; export default App;
`; `;
const npmSnippetForWebsiteSurveys = ` const npmSnippetForWebsiteSurveys = `
// other imports // other imports
import formbricks from "@formbricks/js"; import formbricks from "@formbricks/js";
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
formbricks.setup({ formbricks.setup({
workspaceId: "${workspaceId}", environmentId: "${environmentId}",
appUrl: "${publicDomain}", appUrl: "${publicDomain}",
}); });
} }
function App() { function App() {
// your own app // your own app
} }
export default App; export default App;
`; `;
return ( return (
@@ -109,7 +109,7 @@ export const OnboardingSetupInstructions = ({
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("workspace.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}
@@ -126,7 +126,7 @@ export const OnboardingSetupInstructions = ({
) : activeTab === "html" ? ( ) : activeTab === "html" ? (
<div className="prose prose-slate"> <div className="prose prose-slate">
<p className="-mb-1 mt-6 text-sm text-slate-700"> <p className="-mb-1 mt-6 text-sm text-slate-700">
{t("workspace.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">
@@ -1,50 +1,55 @@
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { ResourceNotFoundError } from "@formbricks/types/errors"; import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/connect/components/ConnectWithFormbricks"; import { getEnvironment } from "@/lib/environment/service";
import { getPublicDomain } from "@/lib/getPublicUrl"; import { getPublicDomain } from "@/lib/getPublicUrl";
import { getWorkspace } from "@/lib/workspace/service"; import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
interface ConnectPageProps { interface ConnectPageProps {
params: Promise<{ params: Promise<{
workspaceId: string; environmentId: string;
}>; }>;
} }
const Page = async (props: ConnectPageProps) => { const Page = async (props: ConnectPageProps) => {
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const environment = await getEnvironment(params.environmentId);
const workspace = await getWorkspace(params.workspaceId); if (!environment) {
if (!workspace) { throw new Error(t("common.environment_not_found"));
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
} }
const channel = workspace.config.channel || null; const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new Error(t("common.workspace_not_found"));
}
const channel = project.config.channel || null;
const publicDomain = getPublicDomain(); const publicDomain = getPublicDomain();
return ( return (
<div className="flex min-h-full flex-col items-center justify-center py-10"> <div className="flex min-h-full flex-col items-center justify-center py-10">
<Header title={t("workspace.connect.headline")} subtitle={t("workspace.connect.subtitle")} /> <Header title={t("environments.connect.headline")} subtitle={t("environments.connect.subtitle")} />
<div className="space-y-4 text-center"> <div className="space-y-4 text-center">
<p className="text-4xl font-medium text-slate-800"></p> <p className="text-4xl font-medium text-slate-800"></p>
<p className="text-sm text-slate-500"></p> <p className="text-sm text-slate-500"></p>
</div> </div>
<ConnectWithFormbricks <ConnectWithFormbricks
workspaceId={params.workspaceId} environment={environment}
publicDomain={publicDomain} publicDomain={publicDomain}
appSetupCompleted={workspace.appSetupCompleted} appSetupCompleted={environment.appSetupCompleted}
channel={channel} channel={channel}
/> />
<Button <Button
className="absolute right-5 top-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={`/workspaces/${params.workspaceId}`}> <Link href={`/environments/${environment.id}`}>
<XIcon className="h-7 w-7" strokeWidth={1.5} /> <XIcon className="h-7 w-7" strokeWidth={1.5} />
</Link> </Link>
</Button> </Button>
@@ -1,11 +1,11 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AuthorizationError } from "@formbricks/types/errors"; import { AuthorizationError } from "@formbricks/types/errors";
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth"; import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
const OnboardingLayout = async (props: { const OnboardingLayout = async (props: {
params: Promise<{ workspaceId: string }>; params: Promise<{ environmentId: string }>;
children: React.ReactNode; children: React.ReactNode;
}) => { }) => {
const params = await props.params; const params = await props.params;
@@ -17,9 +17,9 @@ const OnboardingLayout = async (props: {
return redirect(`/auth/login`); return redirect(`/auth/login`);
} }
const isAuthorized = await hasUserWorkspaceAccess(session.user.id, params.workspaceId); const isAuthorized = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
if (!isAuthorized) { if (!isAuthorized) {
throw new AuthorizationError("User is not authorized to access this workspace"); throw new AuthorizationError("User is not authorized to access this environment");
} }
return <div className="flex-1 bg-slate-50">{children}</div>; return <div className="flex-1 bg-slate-50">{children}</div>;
@@ -5,23 +5,23 @@ import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TProject } from "@formbricks/types/project";
import { TSurveyCreateInput } from "@formbricks/types/surveys/types"; import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
import { TXMTemplate } from "@formbricks/types/templates"; import { TXMTemplate } from "@formbricks/types/templates";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import { TWorkspace } from "@formbricks/types/workspace"; import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils";
import { getXMTemplates } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer"; import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/xm-templates/lib/utils";
import { getXMTemplates } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/xm-templates/lib/xm-templates";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createSurveyAction } from "@/modules/survey/components/template-list/actions"; import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
interface XMTemplateListProps { interface XMTemplateListProps {
workspace: TWorkspace; project: TProject;
user: TUser; user: TUser;
workspaceId: string; environmentId: string;
} }
export const XMTemplateList = ({ workspace, user, workspaceId }: XMTemplateListProps) => { export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListProps) => {
const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null); const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null);
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
@@ -33,12 +33,12 @@ export const XMTemplateList = ({ workspace, user, workspaceId }: XMTemplateListP
createdBy: user.id, createdBy: user.id,
}; };
const createSurveyResponse = await createSurveyAction({ const createSurveyResponse = await createSurveyAction({
workspaceId: workspaceId, environmentId: environmentId,
surveyBody: augmentedTemplate, surveyBody: augmentedTemplate,
}); });
if (createSurveyResponse?.data) { if (createSurveyResponse?.data) {
router.push(`/workspaces/${workspaceId}/surveys/${createSurveyResponse.data.id}/edit?mode=cx`); router.push(`/environments/${environmentId}/surveys/${createSurveyResponse.data.id}/edit?mode=cx`);
} else { } else {
const errorMessage = getFormattedErrorMessage(createSurveyResponse); const errorMessage = getFormattedErrorMessage(createSurveyResponse);
toast.error(errorMessage); toast.error(errorMessage);
@@ -48,49 +48,49 @@ export const XMTemplateList = ({ workspace, user, workspaceId }: XMTemplateListP
const handleTemplateClick = (templateIdx: number) => { const handleTemplateClick = (templateIdx: number) => {
setActiveTemplateId(templateIdx); setActiveTemplateId(templateIdx);
const template = getXMTemplates(t)[templateIdx]; const template = getXMTemplates(t)[templateIdx];
const newTemplate = replacePresetPlaceholders(template, workspace); const newTemplate = replacePresetPlaceholders(template, project);
createSurvey(newTemplate); createSurvey(newTemplate);
}; };
const XMTemplateOptions = [ const XMTemplateOptions = [
{ {
title: t("workspace.xm-templates.nps"), title: t("environments.xm-templates.nps"),
description: t("workspace.xm-templates.nps_description"), description: t("environments.xm-templates.nps_description"),
icon: ShoppingCartIcon, icon: ShoppingCartIcon,
onClick: () => handleTemplateClick(0), onClick: () => handleTemplateClick(0),
isLoading: activeTemplateId === 0, isLoading: activeTemplateId === 0,
}, },
{ {
title: t("workspace.xm-templates.five_star_rating"), title: t("environments.xm-templates.five_star_rating"),
description: t("workspace.xm-templates.five_star_rating_description"), description: t("environments.xm-templates.five_star_rating_description"),
icon: StarIcon, icon: StarIcon,
onClick: () => handleTemplateClick(1), onClick: () => handleTemplateClick(1),
isLoading: activeTemplateId === 1, isLoading: activeTemplateId === 1,
}, },
{ {
title: t("workspace.xm-templates.csat"), title: t("environments.xm-templates.csat"),
description: t("workspace.xm-templates.csat_description"), description: t("environments.xm-templates.csat_description"),
icon: ThumbsUpIcon, icon: ThumbsUpIcon,
onClick: () => handleTemplateClick(2), onClick: () => handleTemplateClick(2),
isLoading: activeTemplateId === 2, isLoading: activeTemplateId === 2,
}, },
{ {
title: t("workspace.xm-templates.ces"), title: t("environments.xm-templates.ces"),
description: t("workspace.xm-templates.ces_description"), description: t("environments.xm-templates.ces_description"),
icon: ActivityIcon, icon: ActivityIcon,
onClick: () => handleTemplateClick(3), onClick: () => handleTemplateClick(3),
isLoading: activeTemplateId === 3, isLoading: activeTemplateId === 3,
}, },
{ {
title: t("workspace.xm-templates.smileys"), title: t("environments.xm-templates.smileys"),
description: t("workspace.xm-templates.smileys_description"), description: t("environments.xm-templates.smileys_description"),
icon: SmileIcon, icon: SmileIcon,
onClick: () => handleTemplateClick(4), onClick: () => handleTemplateClick(4),
isLoading: activeTemplateId === 4, isLoading: activeTemplateId === 4,
}, },
{ {
title: t("workspace.xm-templates.enps"), title: t("environments.xm-templates.enps"),
description: t("workspace.xm-templates.enps_description"), description: t("environments.xm-templates.enps_description"),
icon: UsersIcon, icon: UsersIcon,
onClick: () => handleTemplateClick(5), onClick: () => handleTemplateClick(5),
isLoading: activeTemplateId === 5, isLoading: activeTemplateId === 5,
@@ -1,17 +1,17 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react"; import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest"; import { afterEach, describe, expect, test } from "vitest";
import { TProject } from "@formbricks/types/project";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants"; import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { TXMTemplate } from "@formbricks/types/templates"; import { TXMTemplate } from "@formbricks/types/templates";
import { TWorkspace } from "@formbricks/types/workspace";
import { replacePresetPlaceholders } from "./utils"; import { replacePresetPlaceholders } from "./utils";
// Mock data // Mock data
const mockWorkspace: TWorkspace = { const mockProject: TProject = {
id: "workspace1", id: "project1",
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
name: "Test Workspace", name: "Test Project",
organizationId: "org1", organizationId: "org1",
styling: { styling: {
allowStyleOverwrite: true, allowStyleOverwrite: true,
@@ -27,12 +27,12 @@ const mockWorkspace: TWorkspace = {
placement: "bottomRight", placement: "bottomRight",
clickOutsideClose: true, clickOutsideClose: true,
overlay: "none", overlay: "none",
appSetupCompleted: false, environments: [],
languages: [], languages: [],
logo: null, logo: null,
}; };
const mockTemplate: TXMTemplate = { const mockTemplate: TXMTemplate = {
name: "$[workspaceName] Survey", name: "$[projectName] Survey",
blocks: [ blocks: [
{ {
id: "block1", id: "block1",
@@ -42,7 +42,7 @@ const mockTemplate: TXMTemplate = {
id: "q1", id: "q1",
type: "openText" as TSurveyElementTypeEnum.OpenText, type: "openText" as TSurveyElementTypeEnum.OpenText,
inputType: "text" as const, inputType: "text" as const,
headline: { default: "$[workspaceName] Question" }, headline: { default: "$[projectName] Question" },
subheader: { default: "" }, subheader: { default: "" },
required: false, required: false,
placeholder: { default: "" }, placeholder: { default: "" },
@@ -60,8 +60,8 @@ const mockTemplate: TXMTemplate = {
], ],
styling: { styling: {
brandColor: { light: "#0000FF" }, brandColor: { light: "#0000FF" },
elementHeadlineColor: { light: "#00FF00" }, questionColor: { light: "#00FF00" },
inputBgColor: { light: "#FF0000" }, inputColor: { light: "#FF0000" },
}, },
}; };
@@ -70,19 +70,19 @@ describe("replacePresetPlaceholders", () => {
cleanup(); cleanup();
}); });
test("replaces workspaceName placeholder in template name", () => { test("replaces projectName placeholder in template name", () => {
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace); const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.name).toBe("Test Workspace Survey"); expect(result.name).toBe("Test Project Survey");
}); });
test("replaces workspaceName placeholder in element headline", () => { test("replaces projectName placeholder in element headline", () => {
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace); const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.blocks[0].elements[0].headline.default).toBe("Test Workspace Question"); expect(result.blocks[0].elements[0].headline.default).toBe("Test Project Question");
}); });
test("returns a new object without mutating the original template", () => { test("returns a new object without mutating the original template", () => {
const originalTemplate = structuredClone(mockTemplate); const originalTemplate = structuredClone(mockTemplate);
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace); const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result).not.toBe(mockTemplate); expect(result).not.toBe(mockTemplate);
expect(mockTemplate).toEqual(originalTemplate); expect(mockTemplate).toEqual(originalTemplate);
}); });
@@ -1,16 +1,16 @@
import { TProject } from "@formbricks/types/project";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks"; import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TXMTemplate } from "@formbricks/types/templates"; import { TXMTemplate } from "@formbricks/types/templates";
import { TWorkspace } from "@formbricks/types/workspace";
import { replaceElementPresetPlaceholders } from "@/lib/utils/templates"; import { replaceElementPresetPlaceholders } from "@/lib/utils/templates";
// replace all occurences of workspaceName with the actual workspace name in the current template // replace all occurences of projectName with the actual project name in the current template
export const replacePresetPlaceholders = (template: TXMTemplate, workspace: TWorkspace): TXMTemplate => { export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject): TXMTemplate => {
const survey = structuredClone(template); const survey = structuredClone(template);
const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({ const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({
...block, ...block,
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, workspace)), elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, project)),
})); }));
return { ...survey, name: survey.name.replace("$[workspaceName]", workspace.name), blocks: modifiedBlocks }; return { ...survey, name: survey.name.replace("$[projectName]", project.name), blocks: modifiedBlocks };
}; };
@@ -0,0 +1,64 @@
import { XIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import Link from "next/link";
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
import { getEnvironment } from "@/lib/environment/service";
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
interface XMTemplatePageProps {
params: Promise<{
environmentId: string;
}>;
}
const Page = async (props: XMTemplatePageProps) => {
const params = await props.params;
const session = await getServerSession(authOptions);
const environment = await getEnvironment(params.environmentId);
const t = await getTranslate();
if (!session) {
throw new Error(t("common.session_not_found"));
}
const user = await getUser(session.user.id);
if (!user) {
throw new Error(t("common.user_not_found"));
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new Error(t("common.workspace_not_found"));
}
const projects = await getUserProjects(session.user.id, organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title={t("environments.xm-templates.headline")} />
<XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}/surveys`}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Link>
</Button>
)}
</div>
);
};
export default Page;
@@ -1,4 +1,4 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors"; import { DatabaseError } from "@formbricks/types/errors";
@@ -32,7 +32,7 @@ describe("getTeamsByOrganizationId", () => {
test("throws DatabaseError on Prisma error", async () => { test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.team.findMany).mockRejectedValueOnce( vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
new PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" }) new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
); );
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError); await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
}); });
@@ -1,6 +1,6 @@
"use server"; "use server";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react"; import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
@@ -27,7 +27,7 @@ export const getTeamsByOrganizationId = reactCache(
name: team.name, name: team.name,
})); }));
} catch (error) { } catch (error) {
if (error instanceof PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message); throw new DatabaseError(error.message);
} }
@@ -1,32 +1,20 @@
"use client"; "use client";
import { import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon } from "lucide-react";
ArrowUpRightIcon,
Building2Icon,
ChevronRightIcon,
Loader2,
LogOutIcon,
PlusIcon,
} from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useState } from "react";
import { useCallback, useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
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 { getOrganizationsForSwitcherAction } from "@/app/(app)/workspaces/[workspaceId]/actions";
import FBLogo from "@/images/formbricks-wordmark.svg"; import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal"; import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProfileAvatar } from "@/modules/ui/components/avatars"; import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu"; } from "@/modules/ui/components/dropdown-menu";
@@ -34,65 +22,14 @@ import {
interface LandingSidebarProps { interface LandingSidebarProps {
user: TUser; user: TUser;
organization: TOrganization; organization: TOrganization;
isMultiOrgEnabled: boolean;
} }
export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: LandingSidebarProps) => { export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false); const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
const [organizationLoadError, setOrganizationLoadError] = useState<string | null>(null);
const [isOrgDropdownOpen, setIsOrgDropdownOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email }); const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const loadOrganizations = useCallback(async () => {
setIsLoadingOrganizations(true);
setOrganizationLoadError(null);
try {
const result = await getOrganizationsForSwitcherAction({ organizationId: organization.id });
if (result?.data) {
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setOrganizations(sorted);
} else {
setOrganizationLoadError(
getFormattedErrorMessage(result) || t("common.failed_to_load_organizations")
);
}
} catch {
setOrganizationLoadError(t("common.failed_to_load_organizations"));
} finally {
setIsLoadingOrganizations(false);
}
}, [organization.id, t]);
useEffect(() => {
if (
isOrgDropdownOpen &&
organizations.length === 0 &&
!isLoadingOrganizations &&
!organizationLoadError
) {
loadOrganizations();
}
}, [
isOrgDropdownOpen,
organizations.length,
isLoadingOrganizations,
organizationLoadError,
loadOrganizations,
]);
const handleOrganizationChange = (orgId: string) => {
startTransition(() => {
setIsOrgDropdownOpen(false);
router.push(`/organizations/${orgId}/`);
});
};
const dropdownNavigation = [ const dropdownNavigation = [
{ {
label: t("common.documentation"), label: t("common.documentation"),
@@ -102,109 +39,52 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
}, },
]; ];
const switcherTriggerClasses =
"w-full border-t px-3 py-3 text-left transition-colors duration-200 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-inset";
const switcherIconClasses =
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600";
return ( return (
<aside <aside
className={cn( className={cn(
"z-40 flex w-sidebar-collapsed flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100" "z-40 flex w-sidebar-collapsed flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
)}> )}>
<Image src={FBLogo} width={160} height={30} alt={t("workspace.formbricks_logo")} /> <Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
<div className="flex flex-col"> <div className="flex items-center">
{/* Organization Switcher */}
<DropdownMenu onOpenChange={setIsOrgDropdownOpen}>
<DropdownMenuTrigger asChild className={switcherTriggerClasses}>
<button type="button" className="flex w-full items-center gap-3">
<span className={switcherIconClasses}>
<Building2Icon className="h-4 w-4" strokeWidth={1.5} />
</span>
<div className="grow overflow-hidden">
<p className="truncate text-sm font-bold text-slate-700">{organization.name}</p>
<p className="text-sm text-slate-500">{t("common.organization")}</p>
</div>
{isPending && <Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />}
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Building2Icon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.change_organization")}
</div>
{isLoadingOrganizations && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingOrganizations && organizationLoadError && (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{organizationLoadError}</p>
<button
onClick={() => {
setOrganizationLoadError(null);
setOrganizations([]);
}}
className="text-xs text-slate-600 underline hover:text-slate-800">
{t("common.try_again")}
</button>
</div>
)}
{!isLoadingOrganizations && !organizationLoadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{organizations.map((org) => (
<DropdownMenuCheckboxItem
key={org.id}
checked={org.id === organization.id}
onClick={() => handleOrganizationChange(org.id)}
className="cursor-pointer">
{org.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isMultiOrgEnabled && (
<DropdownMenuCheckboxItem
onClick={() => setOpenCreateOrganizationModal(true)}
className="w-full cursor-pointer justify-between">
<span>{t("common.create_new_organization")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* User Dropdown */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
asChild asChild
id="userDropdownTrigger" id="userDropdownTrigger"
className={cn(switcherTriggerClasses, "rounded-br-xl")}> className="w-full rounded-br-xl border-t p-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<button type="button" className="flex w-full items-center gap-3"> <button
<span className={switcherIconClasses}> type="button"
<ProfileAvatar userId={user.id} /> className={cn("flex w-full cursor-pointer flex-row items-center gap-3 text-left")}
</span> aria-haspopup="menu">
<ProfileAvatar userId={user.id} />
<div className="grow overflow-hidden"> <div className="grow overflow-hidden">
<p <p
title={user?.email} title={user?.email}
className="ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"> className={cn(
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
)}>
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>} {user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p> </p>
<p className="text-sm text-slate-500">{t("common.account")}</p> <p title={organization?.name} className="truncate text-sm text-slate-500">
{organization?.name}
</p>
</div> </div>
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} /> <ChevronRightIcon className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")} />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end"> <DropdownMenuContent
id="userDropdownInnerContentWrapper"
side="right"
sideOffset={10}
alignOffset={5}
align="end">
{/* Dropdown Items */}
{dropdownNavigation.map((link) => ( {dropdownNavigation.map((link) => (
<Link <Link
key={link.href} key={link.href}
id={link.href}
href={link.href} href={link.href}
target={link.target} target={link.target}
rel={link.target === "_blank" ? "noopener noreferrer" : undefined} rel={link.target === "_blank" ? "noopener noreferrer" : undefined}
@@ -215,6 +95,8 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
</DropdownMenuItem> </DropdownMenuItem>
</Link> </Link>
))} ))}
{/* Logout */}
<DropdownMenuItem <DropdownMenuItem
onClick={async () => { onClick={async () => {
await signOutWithAudit({ await signOutWithAudit({
@@ -223,6 +105,7 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
organizationId: organization.id, organizationId: organization.id,
redirect: true, redirect: true,
callbackUrl: "/auth/login", callbackUrl: "/auth/login",
clearEnvironmentId: true,
}); });
}} }}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}> icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
@@ -231,7 +114,6 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
<CreateOrganizationModal open={openCreateOrganizationModal} setOpen={setOpenCreateOrganizationModal} /> <CreateOrganizationModal open={openCreateOrganizationModal} setOpen={setOpenCreateOrganizationModal} />
</aside> </aside>
); );
@@ -1,7 +1,8 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getUserWorkspaces } from "@/lib/workspace/service"; import { getUserProjects } from "@/lib/project/service";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
const LandingLayout = async (props: { const LandingLayout = async (props: {
@@ -23,11 +24,16 @@ const LandingLayout = async (props: {
return notFound(); return notFound();
} }
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId); const projects = await getUserProjects(session.user.id, params.organizationId);
if (workspaces.length !== 0) { if (projects.length !== 0) {
const firstWorkspace = workspaces[0]; const firstProject = projects[0];
return redirect(`/workspaces/${firstWorkspace.id}/`); const environments = await getEnvironments(firstProject.id);
const prodEnvironment = environments.find((e) => e.type === "production");
if (prodEnvironment) {
return redirect(`/environments/${prodEnvironment.id}/`);
}
} }
return <>{children}</>; return <>{children}</>;
@@ -1,8 +1,9 @@
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar"; import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
import { WorkspaceAndOrgSwitch } from "@/app/(app)/workspaces/[workspaceId]/components/workspace-and-org-switch"; import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils"; import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
@@ -25,25 +26,26 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled(); const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id); const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
const isMembershipPending = membership?.role === undefined; const { isMember } = getAccessFlags(membership?.role);
return ( return (
<div className="flex min-h-full min-w-full flex-row"> <div className="flex min-h-full min-w-full flex-row">
<LandingSidebar user={user} organization={organization} isMultiOrgEnabled={isMultiOrgEnabled} /> <LandingSidebar user={user} organization={organization} />
<div className="flex-1"> <div className="flex-1">
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="p-6"> <div className="p-6">
{/* we only need to render organization breadcrumb on this page, organizations/workspaces are lazy-loaded */} {/* we only need to render organization breadcrumb on this page, organizations/projects are lazy-loaded */}
<WorkspaceAndOrgSwitch <ProjectAndOrgSwitch
currentOrganizationId={organization.id} currentOrganizationId={organization.id}
currentOrganizationName={organization.name} currentOrganizationName={organization.name}
isMultiOrgEnabled={isMultiOrgEnabled} isMultiOrgEnabled={isMultiOrgEnabled}
organizationWorkspacesLimit={0} organizationProjectsLimit={0}
isFormbricksCloud={IS_FORMBRICKS_CLOUD} isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isLicenseActive={false} isLicenseActive={false}
isOwnerOrManager={false} isOwnerOrManager={false}
isAccessControlAllowed={false} isAccessControlAllowed={false}
isMembershipPending={isMembershipPending} isMember={isMember}
environments={[]}
/> />
</div> </div>
<div className="flex h-full flex-col items-center justify-center space-y-12"> <div className="flex h-full flex-col items-center justify-center space-y-12">
@@ -1,6 +1,6 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors"; import { AuthorizationError } from "@formbricks/types/errors";
import { canUserAccessOrganization } from "@/lib/organization/auth"; import { canUserAccessOrganization } from "@/lib/organization/auth";
import { getOrganization } from "@/lib/organization/service"; import { getOrganization } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
@@ -8,7 +8,7 @@ import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { ToasterClient } from "@/modules/ui/components/toaster-client"; import { ToasterClient } from "@/modules/ui/components/toaster-client";
const WorkspaceOnboardingLayout = async (props: { const ProjectOnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>; params: Promise<{ organizationId: string }>;
children: React.ReactNode; children: React.ReactNode;
}) => { }) => {
@@ -25,7 +25,7 @@ const WorkspaceOnboardingLayout = async (props: {
const user = await getUser(session.user.id); const user = await getUser(session.user.id);
if (!user) { if (!user) {
throw new AuthenticationError(t("common.not_authenticated")); throw new Error(t("common.user_not_found"));
} }
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId); const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
@@ -36,7 +36,7 @@ const WorkspaceOnboardingLayout = async (props: {
const organization = await getOrganization(params.organizationId); const organization = await getOrganization(params.organizationId);
if (!organization) { if (!organization) {
throw new ResourceNotFoundError(t("common.organization"), params.organizationId); throw new Error(t("common.organization_not_found"));
} }
return ( return (
@@ -47,4 +47,4 @@ const WorkspaceOnboardingLayout = async (props: {
); );
}; };
export default WorkspaceOnboardingLayout; export default ProjectOnboardingLayout;
@@ -2,8 +2,7 @@ import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer"; import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { capturePostHogEvent } from "@/lib/posthog"; import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -40,17 +39,7 @@ const Page = async (props: ChannelPageProps) => {
}, },
]; ];
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId); const projects = await getUserProjects(session.user.id, params.organizationId);
capturePostHogEvent(
session.user.id,
"organization_mode_selected",
{
organization_id: params.organizationId,
mode: "surveys",
},
{ organizationId: params.organizationId }
);
return ( return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12"> <div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
@@ -59,7 +48,7 @@ const Page = async (props: ChannelPageProps) => {
subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")} subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")}
/> />
<OnboardingOptionsContainer options={channelOptions} /> <OnboardingOptionsContainer options={channelOptions} />
{workspaces.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute right-5 top-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"
@@ -1,13 +1,12 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganization } from "@/lib/organization/service"; import { getOrganization } from "@/lib/organization/service";
import { getOrganizationWorkspacesCount } from "@/lib/workspace/service"; import { getOrganizationProjectsCount } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { getOrganizationWorkspacesLimit } from "@/modules/ee/license-check/lib/utils"; import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
const OnboardingLayout = async (props: { const OnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>; params: Promise<{ organizationId: string }>;
@@ -29,15 +28,15 @@ const OnboardingLayout = async (props: {
const organization = await getOrganization(params.organizationId); const organization = await getOrganization(params.organizationId);
if (!organization) { if (!organization) {
throw new ResourceNotFoundError(t("common.organization"), params.organizationId); throw new Error(t("common.organization_not_found"));
} }
const [organizationWorkspacesLimit, organizationWorkspacesCount] = await Promise.all([ const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([
getOrganizationWorkspacesLimit(organization.id), getOrganizationProjectsLimit(organization.id),
getOrganizationWorkspacesCount(organization.id), getOrganizationProjectsCount(organization.id),
]); ]);
if (organizationWorkspacesCount >= organizationWorkspacesLimit) { if (organizationProjectsCount >= organizationProjectsLimit) {
return redirect(`/`); return redirect(`/`);
} }
@@ -2,7 +2,7 @@ import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer"; import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { getUserWorkspaces } from "@/lib/workspace/service"; import { getUserProjects } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
@@ -39,13 +39,13 @@ const Page = async (props: ModePageProps) => {
}, },
]; ];
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId); const projects = await getUserProjects(session.user.id, params.organizationId);
return ( return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12"> <div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title={t("organizations.workspaces.new.mode.what_are_you_here_for")} /> <Header title={t("organizations.workspaces.new.mode.what_are_you_here_for")} />
<OnboardingOptionsContainer options={channelOptions} /> <OnboardingOptionsContainer options={channelOptions} />
{workspaces.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute right-5 top-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"
@@ -13,8 +13,8 @@ export const SelectPlanOnboarding = async ({ organizationId }: SelectPlanOnboard
return ( return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-8"> <div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-8">
<Header <Header
title={t("workspace.settings.billing.select_plan_header_title")} title={t("environments.settings.billing.select_plan_header_title")}
subtitle={t("workspace.settings.billing.select_plan_header_subtitle")} subtitle={t("environments.settings.billing.select_plan_header_subtitle")}
/> />
<SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} /> <SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} />
</div> </div>
@@ -8,20 +8,19 @@ import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
TWorkspaceConfigChannel, TProjectConfigChannel,
TWorkspaceConfigIndustry, TProjectConfigIndustry,
TWorkspaceMode, TProjectMode,
TWorkspaceUpdateInput, TProjectUpdateInput,
ZWorkspaceUpdateInput, ZProjectUpdateInput,
} from "@formbricks/types/workspace"; } from "@formbricks/types/project";
import { createWorkspaceAction } from "@/app/(app)/workspaces/[workspaceId]/actions"; import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
import { previewSurvey } from "@/app/lib/templates"; import { previewSurvey } from "@/app/lib/templates";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage"; import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { buildStylingFromBrandColor } from "@/lib/styling/constants"; import { buildStylingFromBrandColor } from "@/lib/styling/constants";
import { toJsWorkspaceStateSurvey } from "@/lib/survey/client-utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal"; import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
import { TOrganizationTeam } from "@/modules/ee/teams/workspace-teams/types/team";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { ColorPicker } from "@/modules/ui/components/color-picker"; import { ColorPicker } from "@/modules/ui/components/color-picker";
import { import {
@@ -37,34 +36,34 @@ import { Input } from "@/modules/ui/components/input";
import { MultiSelect } from "@/modules/ui/components/multi-select"; import { MultiSelect } from "@/modules/ui/components/multi-select";
import { SurveyInline } from "@/modules/ui/components/survey"; import { SurveyInline } from "@/modules/ui/components/survey";
interface WorkspaceSettingsProps { interface ProjectSettingsProps {
organizationId: string; organizationId: string;
workspaceMode: TWorkspaceMode; projectMode: TProjectMode;
channel: TWorkspaceConfigChannel; channel: TProjectConfigChannel;
industry: TWorkspaceConfigIndustry; industry: TProjectConfigIndustry;
defaultBrandColor: string; defaultBrandColor: string;
organizationTeams: TOrganizationTeam[]; organizationTeams: TOrganizationTeam[];
isAccessControlAllowed: boolean; isAccessControlAllowed: boolean;
userWorkspacesCount: number; userProjectsCount: number;
publicDomain: string; publicDomain: string;
} }
export const WorkspaceSettings = ({ export const ProjectSettings = ({
organizationId, organizationId,
workspaceMode, projectMode,
channel, channel,
industry, industry,
defaultBrandColor, defaultBrandColor,
organizationTeams, organizationTeams,
isAccessControlAllowed = false, isAccessControlAllowed = false,
userWorkspacesCount, userProjectsCount,
publicDomain, publicDomain,
}: WorkspaceSettingsProps) => { }: ProjectSettingsProps) => {
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false); const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
const addWorkspace = async (data: TWorkspaceUpdateInput) => { const addProject = async (data: TProjectUpdateInput) => {
try { try {
// Build the full styling from the chosen brand color so all derived // Build the full styling from the chosen brand color so all derived
// colours (question, button, input, option, progress, etc.) are persisted. // colours (question, button, input, option, progress, etc.) are persisted.
@@ -72,7 +71,7 @@ export const WorkspaceSettings = ({
// back to STYLE_DEFAULTS computed from the default brand (#64748b). // back to STYLE_DEFAULTS computed from the default brand (#64748b).
const fullStyling = buildStylingFromBrandColor(data.styling?.brandColor?.light); const fullStyling = buildStylingFromBrandColor(data.styling?.brandColor?.light);
const createWorkspaceResponse = await createWorkspaceAction({ const createProjectResponse = await createProjectAction({
organizationId, organizationId,
data: { data: {
...data, ...data,
@@ -82,21 +81,26 @@ export const WorkspaceSettings = ({
}, },
}); });
if (createWorkspaceResponse?.data) { if (createProjectResponse?.data) {
if (globalThis.window !== undefined) { // get production environment
// Remove filters when creating a new workspace const productionEnvironment = createProjectResponse.data.environments.find(
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS); (environment) => environment.type === "production"
);
if (productionEnvironment) {
if (globalThis.window !== undefined) {
// Rmove filters when creating a new project
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
}
} }
const workspaceId = createWorkspaceResponse.data.id;
if (channel === "app" || channel === "website") { if (channel === "app" || channel === "website") {
router.push(`/workspaces/${workspaceId}/connect`); router.push(`/environments/${productionEnvironment?.id}/connect`);
} else if (channel === "link") { } else if (channel === "link") {
router.push(`/workspaces/${workspaceId}/surveys`); router.push(`/environments/${productionEnvironment?.id}/surveys`);
} else if (workspaceMode === "cx") { } else if (projectMode === "cx") {
router.push(`/workspaces/${workspaceId}/xm-templates`); router.push(`/environments/${productionEnvironment?.id}/xm-templates`);
} }
} else { } else {
const errorMessage = getFormattedErrorMessage(createWorkspaceResponse); const errorMessage = getFormattedErrorMessage(createProjectResponse);
toast.error(errorMessage); toast.error(errorMessage);
} }
} catch (error) { } catch (error) {
@@ -105,15 +109,15 @@ export const WorkspaceSettings = ({
} }
}; };
const form = useForm<TWorkspaceUpdateInput>({ const form = useForm<TProjectUpdateInput>({
defaultValues: { defaultValues: {
name: "", name: "",
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } }, styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
teamIds: [], teamIds: [],
}, },
resolver: zodResolver(ZWorkspaceUpdateInput), resolver: zodResolver(ZProjectUpdateInput),
}); });
const workspaceName = form.watch("name"); const projectName = form.watch("name");
const logoUrl = form.watch("logo.url"); const logoUrl = form.watch("logo.url");
const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor; const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
const previewStyling = useMemo(() => buildStylingFromBrandColor(brandColor), [brandColor]); const previewStyling = useMemo(() => buildStylingFromBrandColor(brandColor), [brandColor]);
@@ -128,7 +132,7 @@ export const WorkspaceSettings = ({
<div className="mt-6 flex w-5/6 space-x-10 lg:w-2/3 2xl:w-1/2"> <div className="mt-6 flex w-5/6 space-x-10 lg:w-2/3 2xl:w-1/2">
<div className="flex w-1/2 flex-col space-y-4"> <div className="flex w-1/2 flex-col space-y-4">
<FormProvider {...form}> <FormProvider {...form}>
<form onSubmit={form.handleSubmit(addWorkspace)} className="w-full space-y-4"> <form onSubmit={form.handleSubmit(addProject)} className="w-full space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="styling.brandColor.light" name="styling.brandColor.light"
@@ -180,7 +184,7 @@ export const WorkspaceSettings = ({
)} )}
/> />
{isAccessControlAllowed && userWorkspacesCount > 0 && ( {isAccessControlAllowed && userProjectsCount > 0 && (
<FormField <FormField
control={form.control} control={form.control}
name="teamIds" name="teamIds"
@@ -238,7 +242,7 @@ export const WorkspaceSettings = ({
<SurveyInline <SurveyInline
appUrl={publicDomain} appUrl={publicDomain}
isPreviewMode={true} isPreviewMode={true}
survey={toJsWorkspaceStateSurvey(previewSurvey(workspaceName || t("common.my_product"), t))} survey={previewSurvey(projectName || t("common.my_product"), t)}
styling={previewStyling} styling={previewStyling}
isBrandingEnabled={false} isBrandingEnabled={false}
languageCode="default" languageCode="default"
@@ -1,36 +1,30 @@
import { XIcon } from "lucide-react"; import { XIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors"; import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
import {
TWorkspaceConfigChannel,
TWorkspaceConfigIndustry,
TWorkspaceMode,
} from "@formbricks/types/workspace";
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding"; import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { WorkspaceSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/WorkspaceSettings"; import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants"; import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl"; import { getPublicDomain } from "@/lib/getPublicUrl";
import { capturePostHogEvent } from "@/lib/posthog"; import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils"; import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
interface WorkspaceSettingsPageProps { interface ProjectSettingsPageProps {
params: Promise<{ params: Promise<{
organizationId: string; organizationId: string;
}>; }>;
searchParams: Promise<{ searchParams: Promise<{
channel?: TWorkspaceConfigChannel; channel?: TProjectConfigChannel;
industry?: TWorkspaceConfigIndustry; industry?: TProjectConfigIndustry;
mode?: TWorkspaceMode; mode?: TProjectMode;
}>; }>;
} }
const Page = async (props: WorkspaceSettingsPageProps) => { const Page = async (props: ProjectSettingsPageProps) => {
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const params = await props.params; const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
@@ -44,48 +38,36 @@ const Page = async (props: WorkspaceSettingsPageProps) => {
const channel = searchParams.channel ?? null; const channel = searchParams.channel ?? null;
const industry = searchParams.industry ?? null; const industry = searchParams.industry ?? null;
const mode = searchParams.mode ?? "surveys"; const mode = searchParams.mode ?? "surveys";
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId); const projects = await getUserProjects(session.user.id, params.organizationId);
const organizationTeams = await getTeamsByOrganizationId(params.organizationId); const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
const isAccessControlAllowed = await getAccessControlPermission(organization.id); const isAccessControlAllowed = await getAccessControlPermission(organization.id);
if (!organizationTeams) { if (!organizationTeams) {
throw new ResourceNotFoundError(t("common.team"), null); throw new Error(t("common.organization_teams_not_found"));
} }
const publicDomain = getPublicDomain(); const publicDomain = getPublicDomain();
if (searchParams.mode === "cx") {
capturePostHogEvent(
session.user.id,
"organization_mode_selected",
{
organization_id: params.organizationId,
mode: "cx",
},
{ organizationId: params.organizationId }
);
}
return ( return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12"> <div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header <Header
title={t("organizations.workspaces.new.settings.workspace_settings_title")} title={t("organizations.workspaces.new.settings.workspace_settings_title")}
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")} subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
/> />
<WorkspaceSettings <ProjectSettings
organizationId={params.organizationId} organizationId={params.organizationId}
workspaceMode={mode} projectMode={mode}
channel={channel} channel={channel}
industry={industry} industry={industry}
defaultBrandColor={DEFAULT_BRAND_COLOR} defaultBrandColor={DEFAULT_BRAND_COLOR}
organizationTeams={organizationTeams} organizationTeams={organizationTeams}
isAccessControlAllowed={isAccessControlAllowed} isAccessControlAllowed={isAccessControlAllowed}
userWorkspacesCount={workspaces.length} userProjectsCount={projects.length}
publicDomain={publicDomain} publicDomain={publicDomain}
/> />
{workspaces.length >= 1 && ( {projects.length >= 1 && (
<Button <Button
className="absolute right-5 top-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"
@@ -1,58 +0,0 @@
import { XIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import Link from "next/link";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { XMTemplateList } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/xm-templates/components/XMTemplateList";
import { getUser } from "@/lib/user/service";
import { getUserWorkspaces, getWorkspace } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
interface XMTemplatePageProps {
params: Promise<{
workspaceId: string;
}>;
}
const Page = async (props: XMTemplatePageProps) => {
const params = await props.params;
const session = await getServerSession(authOptions);
const t = await getTranslate();
if (!session) {
throw new AuthenticationError(t("common.not_authenticated"));
}
const user = await getUser(session.user.id);
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
}
const workspace = await getWorkspace(params.workspaceId);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
}
const workspaces = await getUserWorkspaces(session.user.id, workspace.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title={t("workspace.xm-templates.headline")} />
<XMTemplateList workspace={workspace} user={user} workspaceId={params.workspaceId} />
{workspaces.length >= 2 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/workspaces/${params.workspaceId}/surveys`}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Link>
</Button>
)}
</div>
);
};
export default Page;
@@ -0,0 +1,36 @@
import { redirect } from "next/navigation";
import { getEnvironment } from "@/lib/environment/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
const SurveyEditorEnvironmentLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
const { t, session, user } = await environmentIdLayoutChecks(params.environmentId);
if (!session) {
return redirect(`/auth/login`);
}
if (!user) {
throw new Error(t("common.user_not_found"));
}
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
return (
<div className="flex h-screen flex-col">
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div>
);
};
export default SurveyEditorEnvironmentLayout;
@@ -1,37 +0,0 @@
import { redirect } from "next/navigation";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getWorkspace } from "@/lib/workspace/service";
import { workspaceIdLayoutChecks } from "@/modules/workspaces/lib/utils";
const SurveyEditorWorkspaceLayout = async (props: {
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
const { t, session, user } = await workspaceIdLayoutChecks(params.workspaceId);
if (!session) {
return redirect(`/auth/login`);
}
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
}
const workspace = await getWorkspace(params.workspaceId);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
}
return (
<div className="flex h-screen flex-col">
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div>
);
};
export default SurveyEditorWorkspaceLayout;
@@ -6,12 +6,12 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Confetti } from "@/modules/ui/components/confetti"; import { Confetti } from "@/modules/ui/components/confetti";
const BILLING_CONFIRMATION_WORKSPACE_ID_KEY = "billingConfirmationWorkspaceId"; const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId";
export const ConfirmationPage = () => { export const ConfirmationPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [showConfetti, setShowConfetti] = useState(false); const [showConfetti, setShowConfetti] = useState(false);
const [resolvedWorkspaceId, setResolvedWorkspaceId] = useState<string | null>(null); const [resolvedEnvironmentId, setResolvedEnvironmentId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
setShowConfetti(true); setShowConfetti(true);
@@ -20,9 +20,11 @@ export const ConfirmationPage = () => {
return; return;
} }
const storedWorkspaceId = globalThis.window.sessionStorage.getItem(BILLING_CONFIRMATION_WORKSPACE_ID_KEY); const storedEnvironmentId = globalThis.window.sessionStorage.getItem(
if (storedWorkspaceId) { BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY
setResolvedWorkspaceId(storedWorkspaceId); );
if (storedEnvironmentId) {
setResolvedEnvironmentId(storedEnvironmentId);
} }
}, []); }, []);
@@ -41,7 +43,9 @@ export const ConfirmationPage = () => {
<Button asChild className="w-full justify-center"> <Button asChild className="w-full justify-center">
<Link <Link
href={ href={
resolvedWorkspaceId ? `/workspaces/${resolvedWorkspaceId}/settings/organization/billing` : "/" resolvedEnvironmentId
? `/environments/${resolvedEnvironmentId}/settings/billing`
: "/environments"
}> }>
{t("billing_confirmation.back_to_billing_overview")} {t("billing_confirmation.back_to_billing_overview")}
</Link> </Link>
@@ -1,4 +1,4 @@
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard"; import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
export const LoadingCard = ({ export const LoadingCard = ({
@@ -2,35 +2,30 @@
import { z } from "zod"; import { z } from "zod";
import { ZId } from "@formbricks/types/common"; import { ZId } from "@formbricks/types/common";
import { import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
AuthorizationError, import { ZProjectUpdateInput } from "@formbricks/types/project";
OperationNotAllowedError,
ResourceNotFoundError,
} from "@formbricks/types/errors";
import { ZWorkspaceUpdateInput } from "@formbricks/types/workspace";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service"; import { getOrganization } from "@/lib/organization/service";
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog"; import { getOrganizationProjectsCount } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service"; import { updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationWorkspacesCount } from "@/lib/workspace/service";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { import {
getAccessControlPermission, getAccessControlPermission,
getOrganizationWorkspacesLimit, getOrganizationProjectsLimit,
} from "@/modules/ee/license-check/lib/utils"; } from "@/modules/ee/license-check/lib/utils";
import { createWorkspace } from "@/modules/workspaces/settings/lib/workspace"; import { createProject } from "@/modules/projects/settings/lib/project";
import { getOrganizationsByUserId } from "./lib/organization"; import { getOrganizationsByUserId } from "./lib/organization";
import { getWorkspacesByUserId } from "./lib/workspace"; import { getProjectsByUserId } from "./lib/project";
const ZCreateWorkspaceAction = z.object({ const ZCreateProjectAction = z.object({
organizationId: ZId, organizationId: ZId,
data: ZWorkspaceUpdateInput, data: ZProjectUpdateInput,
}); });
export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCreateWorkspaceAction).action( export const createProjectAction = authenticatedActionClient.inputSchema(ZCreateProjectAction).action(
withAuditLogging("created", "workspace", async ({ ctx, parsedInput }) => { withAuditLogging("created", "project", async ({ ctx, parsedInput }) => {
const { user } = ctx; const { user } = ctx;
const organizationId = parsedInput.organizationId; const organizationId = parsedInput.organizationId;
@@ -41,7 +36,7 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
access: [ access: [
{ {
data: parsedInput.data, data: parsedInput.data,
schema: ZWorkspaceUpdateInput, schema: ZProjectUpdateInput,
type: "organization", type: "organization",
roles: ["owner", "manager"], roles: ["owner", "manager"],
}, },
@@ -51,13 +46,13 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
const organization = await getOrganization(organizationId); const organization = await getOrganization(organizationId);
if (!organization) { if (!organization) {
throw new ResourceNotFoundError("Organization", organizationId); throw new Error("Organization not found");
} }
const organizationWorkspacesLimit = await getOrganizationWorkspacesLimit(organization.id); const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
const organizationWorkspacesCount = await getOrganizationWorkspacesCount(organization.id); const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
if (organizationWorkspacesCount >= organizationWorkspacesLimit) { if (organizationProjectsCount >= organizationProjectsLimit) {
throw new OperationNotAllowedError("Organization workspace limit reached"); throw new OperationNotAllowedError("Organization workspace limit reached");
} }
@@ -69,7 +64,7 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
} }
} }
const workspace = await createWorkspace(parsedInput.organizationId, parsedInput.data); const project = await createProject(parsedInput.organizationId, parsedInput.data);
const updatedNotificationSettings = { const updatedNotificationSettings = {
...user.notificationSettings, ...user.notificationSettings,
alert: { alert: {
@@ -81,28 +76,15 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
notificationSettings: updatedNotificationSettings, notificationSettings: updatedNotificationSettings,
}); });
groupIdentifyPostHog("workspace", workspace.id, { name: workspace.name });
capturePostHogEvent(
user.id,
"workspace_created",
{
organization_id: organizationId,
workspace_id: workspace.id,
name: workspace.name,
},
{ organizationId, workspaceId: workspace.id }
);
ctx.auditLoggingCtx.organizationId = organizationId; ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.workspaceId = workspace.id; ctx.auditLoggingCtx.projectId = project.id;
ctx.auditLoggingCtx.newObject = workspace; ctx.auditLoggingCtx.newObject = project;
return workspace; return project;
}) })
); );
const ZGetOrganizationsForSwitcherAction = z.object({ const ZGetOrganizationsForSwitcherAction = z.object({
organizationId: ZId, // Changed from workspaceId to avoid extra query organizationId: ZId, // Changed from environmentId to avoid extra query
}); });
/** /**
@@ -126,16 +108,16 @@ export const getOrganizationsForSwitcherAction = authenticatedActionClient
return await getOrganizationsByUserId(ctx.user.id); return await getOrganizationsByUserId(ctx.user.id);
}); });
const ZGetWorkspacesForSwitcherAction = z.object({ const ZGetProjectsForSwitcherAction = z.object({
organizationId: ZId, // Changed from workspaceId to avoid extra query organizationId: ZId, // Changed from environmentId to avoid extra query
}); });
/** /**
* Fetches workspaces list for switcher dropdown. * Fetches projects list for switcher dropdown.
* Called on-demand when user opens the workspace switcher. * Called on-demand when user opens the project switcher.
*/ */
export const getWorkspacesForSwitcherAction = authenticatedActionClient export const getProjectsForSwitcherAction = authenticatedActionClient
.inputSchema(ZGetWorkspacesForSwitcherAction) .inputSchema(ZGetProjectsForSwitcherAction)
.action(async ({ ctx, parsedInput }) => { .action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({ await checkAuthorizationUpdated({
userId: ctx.user.id, userId: ctx.user.id,
@@ -148,11 +130,11 @@ export const getWorkspacesForSwitcherAction = authenticatedActionClient
], ],
}); });
// Need membership for getWorkspacesByUserId (1 DB query) // Need membership for getProjectsByUserId (1 DB query)
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId); const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
if (!membership) { if (!membership) {
throw new AuthorizationError("Membership not found"); throw new AuthorizationError("Membership not found");
} }
return await getWorkspacesByUserId(ctx.user.id, membership); return await getProjectsByUserId(ctx.user.id, membership);
}); });
@@ -1,32 +1,33 @@
import { ResourceNotFoundError } from "@formbricks/types/errors"; import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
import { MainNavigation } from "@/app/(app)/workspaces/[workspaceId]/components/MainNavigation"; import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
import { TopControlBar } from "@/app/(app)/workspaces/[workspaceId]/components/TopControlBar";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants"; import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl"; import { getPublicDomain } from "@/lib/getPublicUrl";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationWorkspacesLimit } from "@/modules/ee/license-check/lib/utils"; import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { TEnvironmentLayoutData } from "@/modules/environments/types/environment-auth";
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner"; import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner"; import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
import { TWorkspaceLayoutData } from "@/modules/workspaces/types/workspace-auth";
interface WorkspaceLayoutProps { interface EnvironmentLayoutProps {
layoutData: TWorkspaceLayoutData; layoutData: TEnvironmentLayoutData;
children?: React.ReactNode; children?: React.ReactNode;
} }
export const WorkspaceLayout = async ({ layoutData, children }: WorkspaceLayoutProps) => { export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => {
const t = await getTranslate(); const t = await getTranslate();
const publicDomain = getPublicDomain(); const publicDomain = getPublicDomain();
// Destructure all data from props (NO database queries) // Destructure all data from props (NO database queries)
const { const {
user, user,
environment,
organization, organization,
membership, membership,
workspace, // Current workspace details project, // Current project details
environments, // All project environments (for environment switcher)
isAccessControlAllowed, isAccessControlAllowed,
workspacePermission, projectPermission,
license, license,
responseCount, responseCount,
} = layoutData; } = layoutData;
@@ -36,47 +37,51 @@ export const WorkspaceLayout = async ({ layoutData, children }: WorkspaceLayoutP
const { features, lastChecked, isPendingDowngrade, active, status } = license; const { features, lastChecked, isPendingDowngrade, active, status } = license;
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false; const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
const organizationWorkspacesLimit = await getOrganizationWorkspacesLimit(organization.id); const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
const isOwnerOrManager = isOwner || isManager; const isOwnerOrManager = isOwner || isManager;
// Validate that workspace permission exists for members // Validate that project permission exists for members
if (isMember && !workspacePermission) { if (isMember && !projectPermission) {
throw new ResourceNotFoundError(t("common.workspace"), null); throw new Error(t("common.workspace_permission_not_found"));
} }
return ( return (
<div className="flex h-screen min-h-screen flex-col overflow-hidden"> <div className="flex h-screen min-h-screen flex-col overflow-hidden">
{IS_FORMBRICKS_CLOUD && ( {IS_FORMBRICKS_CLOUD && (
<LimitsReachedBanner organization={organization} responseCount={responseCount} /> <LimitsReachedBanner
organization={organization}
environmentId={environment.id}
responseCount={responseCount}
/>
)} )}
<PendingDowngradeBanner <PendingDowngradeBanner
lastChecked={lastChecked} lastChecked={lastChecked}
isPendingDowngrade={isPendingDowngrade ?? false} isPendingDowngrade={isPendingDowngrade ?? false}
active={active} active={active}
environmentId={environment.id}
locale={user.locale} locale={user.locale}
status={status} status={status}
/> />
<div className="flex h-full"> <div className="flex h-full">
<MainNavigation <MainNavigation
environment={environment}
organization={organization} organization={organization}
user={user} user={user}
workspace={{ id: workspace.id, name: workspace.name }} project={{ id: project.id, name: project.name }}
isFormbricksCloud={IS_FORMBRICKS_CLOUD} isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isDevelopment={IS_DEVELOPMENT} isDevelopment={IS_DEVELOPMENT}
membershipRole={membership.role} membershipRole={membership.role}
publicDomain={publicDomain} publicDomain={publicDomain}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationWorkspacesLimit={organizationWorkspacesLimit}
isLicenseActive={active}
isAccessControlAllowed={isAccessControlAllowed}
/> />
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50"> <div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<TopControlBar <TopControlBar
environments={environments}
currentOrganizationId={organization.id} currentOrganizationId={organization.id}
currentProjectId={project.id}
isMultiOrgEnabled={isMultiOrgEnabled} isMultiOrgEnabled={isMultiOrgEnabled}
organizationWorkspacesLimit={organizationWorkspacesLimit} organizationProjectsLimit={organizationProjectsLimit}
isFormbricksCloud={IS_FORMBRICKS_CLOUD} isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isLicenseActive={active} isLicenseActive={active}
isOwnerOrManager={isOwnerOrManager} isOwnerOrManager={isOwnerOrManager}
@@ -0,0 +1,18 @@
"use client";
import { useEffect } from "react";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
interface EnvironmentStorageHandlerProps {
environmentId: string;
}
const EnvironmentStorageHandler = ({ environmentId }: EnvironmentStorageHandlerProps) => {
useEffect(() => {
localStorage.setItem(FORMBRICKS_ENVIRONMENT_ID_LS, environmentId);
}, [environmentId]);
return null;
};
export default EnvironmentStorageHandler;
@@ -0,0 +1,56 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { cn } from "@/lib/cn";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
interface EnvironmentSwitchProps {
environment: TEnvironment;
environments: TEnvironment[];
}
export const EnvironmentSwitch = ({ environment, environments }: EnvironmentSwitchProps) => {
const { t } = useTranslation();
const router = useRouter();
const [isEnvSwitchChecked, setIsEnvSwitchChecked] = useState(environment?.type === "development");
const [isLoading, setIsLoading] = useState(false);
const handleEnvironmentChange = (environmentType: "production" | "development") => {
const newEnvironmentId = environments.find((e) => e.type === environmentType)?.id;
if (newEnvironmentId) {
router.push(`/environments/${newEnvironmentId}/`);
}
};
const toggleEnvSwitch = () => {
const newEnvironmentType = isEnvSwitchChecked ? "production" : "development";
setIsLoading(true);
setIsEnvSwitchChecked(!isEnvSwitchChecked);
handleEnvironmentChange(newEnvironmentType);
};
return (
<div
className={cn(
"flex items-center space-x-2 rounded-lg p-2",
isEnvSwitchChecked ? "bg-slate-100 text-orange-800" : "hover:bg-slate-100"
)}>
<Label
htmlFor="development-mode"
className={cn("hover:cursor-pointer", isEnvSwitchChecked && "text-orange-800")}>
{t("common.dev_env")}
</Label>
<Switch
className="focus:ring-orange-800 data-[state=checked]:bg-orange-800"
id="development-mode"
disabled={isLoading}
checked={isEnvSwitchChecked}
onCheckedChange={toggleEnvSwitch}
/>
</div>
);
};
@@ -0,0 +1,346 @@
"use client";
import {
ArrowUpRightIcon,
ChevronRightIcon,
Cog,
LogOutIcon,
MessageCircle,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
RocketIcon,
UserCircleIcon,
UserIcon,
WorkflowIcon,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import packageJson from "../../../../../package.json";
interface NavigationProps {
environment: TEnvironment;
user: TUser;
organization: TOrganization;
project: { id: string; name: string };
isFormbricksCloud: boolean;
isDevelopment: boolean;
membershipRole?: TOrganizationRole;
publicDomain: string;
}
export const MainNavigation = ({
environment,
organization,
user,
project,
membershipRole,
isFormbricksCloud,
isDevelopment,
publicDomain,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
const { t } = useTranslation();
const [isCollapsed, setIsCollapsed] = useState(false);
const [isTextVisible, setIsTextVisible] = useState(true);
const [latestVersion, setLatestVersion] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
const isOwnerOrManager = isManager || isOwner;
const toggleSidebar = () => {
setIsCollapsed(!isCollapsed);
localStorage.setItem("isMainNavCollapsed", isCollapsed ? "false" : "true");
};
useEffect(() => {
const isCollapsedValueFromLocalStorage = localStorage.getItem("isMainNavCollapsed") === "true";
setIsCollapsed(isCollapsedValueFromLocalStorage);
}, []);
useEffect(() => {
const toggleTextOpacity = () => {
setIsTextVisible(isCollapsed);
};
const timeoutId = setTimeout(toggleTextOpacity, 150);
return () => clearTimeout(timeoutId);
}, [isCollapsed]);
useEffect(() => {
// Auto collapse project navbar on org and account settings
if (pathname?.includes("/settings")) {
setIsCollapsed(true);
}
}, [pathname]);
const mainNavigation = useMemo(
() => [
{
name: t("common.surveys"),
href: `/environments/${environment.id}/surveys`,
icon: MessageCircle,
isActive: pathname?.includes("/surveys"),
isHidden: false,
},
{
href: `/environments/${environment.id}/contacts`,
name: t("common.contacts"),
icon: UserIcon,
isActive:
pathname?.includes("/contacts") ||
pathname?.includes("/segments") ||
pathname?.includes("/attributes"),
},
{
name: t("common.workflows"),
href: `/environments/${environment.id}/workflows`,
icon: WorkflowIcon,
isActive: pathname?.includes("/workflows"),
isHidden: !isFormbricksCloud,
},
{
name: t("common.configuration"),
href: `/environments/${environment.id}/workspace/general`,
icon: Cog,
isActive: pathname?.includes("/workspace"),
},
],
[t, environment.id, pathname, isFormbricksCloud]
);
const dropdownNavigation = [
{
label: t("common.account"),
href: `/environments/${environment.id}/settings/profile`,
icon: UserCircleIcon,
},
{
label: t("common.documentation"),
href: "https://formbricks.com/docs",
target: "_blank",
icon: ArrowUpRightIcon,
},
{
label: t("common.share_feedback"),
href: "https://github.com/formbricks/formbricks/issues",
target: "_blank",
icon: ArrowUpRightIcon,
},
];
useEffect(() => {
async function loadReleases() {
const res = await getLatestStableFbReleaseAction();
if (res?.data) {
const latestVersionTag = res.data;
const currentVersionTag = `v${packageJson.version}`;
if (isNewerVersion(currentVersionTag, latestVersionTag)) {
setLatestVersion(latestVersionTag);
}
}
}
if (isOwnerOrManager) loadReleases();
}, [isOwnerOrManager]);
const trialDaysRemaining = useMemo(() => {
if (!isFormbricksCloud || organization.billing?.stripe?.subscriptionStatus !== "trialing") return null;
const trialEnd = organization.billing.stripe.trialEnd;
if (!trialEnd) return null;
const ts = new Date(trialEnd).getTime();
if (!Number.isFinite(ts)) return null;
const msPerDay = 86_400_000;
return Math.ceil((ts - Date.now()) / msPerDay);
}, [
isFormbricksCloud,
organization.billing?.stripe?.subscriptionStatus,
organization.billing?.stripe?.trialEnd,
]);
const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`;
return (
<>
{project && (
<aside
className={cn(
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
isCollapsed ? "w-sidebar-expanded" : "w-sidebar-collapsed"
)}>
<div>
{/* Logo and Toggle */}
<div className="flex items-center justify-between px-3 pb-4">
{!isCollapsed && (
<Link
href={mainNavigationLink}
className={cn(
"flex items-center justify-center transition-opacity duration-100",
isTextVisible ? "opacity-0" : "opacity-100"
)}>
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
</Link>
)}
<Button
variant="ghost"
size="icon"
onClick={toggleSidebar}
className={cn(
"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 ? (
<PanelLeftOpenIcon strokeWidth={1.5} />
) : (
<PanelLeftCloseIcon strokeWidth={1.5} />
)}
</Button>
</div>
{/* Main Nav Switch */}
{!isBilling && (
<ul>
{mainNavigation.map(
(item) =>
!item.isHidden && (
<NavigationLink
key={item.name}
href={item.href}
isActive={item.isActive}
isCollapsed={isCollapsed}
isTextVisible={isTextVisible}
linkText={item.name}>
<item.icon strokeWidth={1.5} />
</NavigationLink>
)
)}
</ul>
)}
</div>
<div>
{/* New Version Available */}
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && !isDevelopment && (
<Link
href="https://github.com/formbricks/formbricks/releases"
target="_blank"
className="m-2 flex items-center space-x-4 rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-800 hover:border-slate-300 hover:bg-slate-200">
<p className="flex items-center justify-center gap-x-2 text-xs">
<RocketIcon strokeWidth={1.5} className="mx-1 h-6 w-6 text-slate-900" />
{t("common.new_version_available", { version: latestVersion })}
</p>
</Link>
)}
{/* Trial Days Remaining */}
{!isCollapsed && isFormbricksCloud && trialDaysRemaining !== null && (
<Link href={`/environments/${environment.id}/settings/billing`} className="m-2 block">
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
</Link>
)}
{/* User Switch */}
<div className="flex items-center">
<DropdownMenu>
<DropdownMenuTrigger
asChild
id="userDropdownTrigger"
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<div
className={cn(
"flex cursor-pointer flex-row items-center gap-3",
isCollapsed ? "justify-center px-2" : "px-4"
)}>
<ProfileAvatar userId={user.id} />
{!isCollapsed && !isTextVisible && (
<>
<div
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
<p
title={user?.email}
className={cn(
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
)}>
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p>
<p className="text-sm text-slate-700">{t("common.account")}</p>
</div>
<ChevronRightIcon
className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")}
/>
</>
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
id="userDropdownInnerContentWrapper"
side="right"
sideOffset={10}
alignOffset={5}
align="end">
{/* Dropdown Items */}
{dropdownNavigation.map((link) => (
<Link
href={link.href}
target={link.target}
className="flex w-full items-center"
key={link.label}
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}>
<DropdownMenuItem>
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
{link.label}
</DropdownMenuItem>
</Link>
))}
{/* Logout */}
<DropdownMenuItem
onClick={async () => {
const loginUrl = `${publicDomain}/auth/login`;
const route = await signOutWithAudit({
reason: "user_initiated",
redirectUrl: loginUrl,
organizationId: organization.id,
redirect: false,
callbackUrl: loginUrl,
clearEnvironmentId: true,
});
router.push(route?.url || loginUrl); // NOSONAR // We want to check for empty strings
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</aside>
)}
</>
);
};
@@ -0,0 +1,66 @@
import Link from "next/link";
import React from "react";
import { cn } from "@/lib/cn";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface NavigationLinkProps {
href: string;
isActive: boolean;
isCollapsed: boolean;
children: React.ReactNode;
linkText: string;
isTextVisible: boolean;
}
export const NavigationLink = ({
href,
isActive,
isCollapsed = false,
children,
linkText,
isTextVisible = true,
}: NavigationLinkProps) => {
const activeClass = "bg-slate-50 border-r-4 border-brand-dark font-semibold text-slate-900";
const inactiveClass =
"hover:bg-slate-50 border-r-4 border-transparent hover:border-slate-300 transition-all duration-150 ease-in-out";
return (
<>
{isCollapsed ? (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<li
className={cn(
"mb-1 ml-2 rounded-l-md py-2 pl-2 text-sm text-slate-700 hover:text-slate-900",
isActive ? activeClass : inactiveClass
)}>
<Link href={href} className="flex items-center">
{children}
</Link>
</li>
</TooltipTrigger>
<TooltipContent side="right">{linkText}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<li
className={cn(
"mb-1 rounded-l-md py-2 pl-5 text-sm text-slate-600 hover:text-slate-900",
isActive ? activeClass : inactiveClass
)}>
<Link href={href} className="flex items-center">
{children}
<span
className={cn(
"ml-2 flex transition-opacity duration-100",
isTextVisible ? "opacity-0" : "opacity-100"
)}>
{linkText}
</span>
</Link>
</li>
)}
</>
);
};
@@ -1,13 +1,13 @@
import Link from "next/link"; import Link from "next/link";
import { ReactNode } from "react"; import { ReactNode } from "react";
interface WorkspaceNavItemProps { interface ProjectNavItemProps {
href: string; href: string;
children: ReactNode; children: ReactNode;
isActive: boolean; isActive: boolean;
} }
export const WorkspaceNavItem = ({ href, children, isActive }: WorkspaceNavItemProps) => { export const ProjectNavItem = ({ href, children, isActive }: ProjectNavItemProps) => {
const activeClass = "bg-slate-50 font-semibold"; const activeClass = "bg-slate-50 font-semibold";
const inactiveClass = "hover:bg-slate-50"; const inactiveClass = "hover:bg-slate-50";
@@ -1,13 +1,17 @@
"use client"; "use client";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships"; import { TOrganizationRole } from "@formbricks/types/memberships";
import { WorkspaceAndOrgSwitch } from "@/app/(app)/workspaces/[workspaceId]/components/workspace-and-org-switch"; import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
import { useWorkspaceContext } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context"; import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { getAccessFlags } from "@/lib/membership/utils";
interface TopControlBarProps { interface TopControlBarProps {
environments: TEnvironment[];
currentOrganizationId: string; currentOrganizationId: string;
currentProjectId: string;
isMultiOrgEnabled: boolean; isMultiOrgEnabled: boolean;
organizationWorkspacesLimit: number; organizationProjectsLimit: number;
isFormbricksCloud: boolean; isFormbricksCloud: boolean;
isLicenseActive: boolean; isLicenseActive: boolean;
isOwnerOrManager: boolean; isOwnerOrManager: boolean;
@@ -16,31 +20,35 @@ interface TopControlBarProps {
} }
export const TopControlBar = ({ export const TopControlBar = ({
environments,
currentOrganizationId, currentOrganizationId,
currentProjectId,
isMultiOrgEnabled, isMultiOrgEnabled,
organizationWorkspacesLimit, organizationProjectsLimit,
isFormbricksCloud, isFormbricksCloud,
isLicenseActive, isLicenseActive,
isOwnerOrManager, isOwnerOrManager,
isAccessControlAllowed, isAccessControlAllowed,
membershipRole, membershipRole,
}: TopControlBarProps) => { }: TopControlBarProps) => {
const { workspace } = useWorkspaceContext(); const { isMember } = getAccessFlags(membershipRole);
const isMembershipPending = membershipRole === undefined; const { environment } = useEnvironment();
return ( return (
<div <div
className="flex h-14 w-full items-center justify-between bg-slate-50 px-6" className="flex h-14 w-full items-center justify-between bg-slate-50 px-6"
data-testid="fb__global-top-control-bar"> data-testid="fb__global-top-control-bar">
<WorkspaceAndOrgSwitch <ProjectAndOrgSwitch
currentWorkspaceId={workspace.id} currentEnvironmentId={environment.id}
environments={environments}
currentOrganizationId={currentOrganizationId} currentOrganizationId={currentOrganizationId}
currentProjectId={currentProjectId}
isMultiOrgEnabled={isMultiOrgEnabled} isMultiOrgEnabled={isMultiOrgEnabled}
organizationWorkspacesLimit={organizationWorkspacesLimit} organizationProjectsLimit={organizationProjectsLimit}
isFormbricksCloud={isFormbricksCloud} isFormbricksCloud={isFormbricksCloud}
isLicenseActive={isLicenseActive} isLicenseActive={isLicenseActive}
isOwnerOrManager={isOwnerOrManager} isOwnerOrManager={isOwnerOrManager}
isMembershipPending={isMembershipPending} isMember={isMember}
isAccessControlAllowed={isAccessControlAllowed} isAccessControlAllowed={isAccessControlAllowed}
/> />
</div> </div>
@@ -3,32 +3,33 @@
import { AlertTriangleIcon, CheckIcon, RotateCcwIcon } from "lucide-react"; import { AlertTriangleIcon, CheckIcon, RotateCcwIcon } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
interface WidgetStatusIndicatorProps { interface WidgetStatusIndicatorProps {
workspace: { appSetupCompleted: boolean }; environment: TEnvironment;
} }
export const WidgetStatusIndicator = ({ workspace }: WidgetStatusIndicatorProps) => { export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const stati = { const stati = {
notImplemented: { notImplemented: {
icon: AlertTriangleIcon, icon: AlertTriangleIcon,
title: t("workspace.app-connection.formbricks_sdk_not_connected"), title: t("environments.workspace.app-connection.formbricks_sdk_not_connected"),
subtitle: t("workspace.app-connection.formbricks_sdk_not_connected_description"), subtitle: t("environments.workspace.app-connection.formbricks_sdk_not_connected_description"),
}, },
running: { running: {
icon: CheckIcon, icon: CheckIcon,
title: t("workspace.app-connection.receiving_data"), title: t("environments.workspace.app-connection.receiving_data"),
subtitle: t("workspace.app-connection.formbricks_sdk_connected"), subtitle: t("environments.workspace.app-connection.formbricks_sdk_connected"),
}, },
}; };
let status: "notImplemented" | "running"; let status: "notImplemented" | "running";
if (workspace.appSetupCompleted) { if (environment.appSetupCompleted) {
status = "running"; status = "running";
} else { } else {
status = "notImplemented"; status = "notImplemented";
@@ -56,7 +57,7 @@ export const WidgetStatusIndicator = ({ workspace }: WidgetStatusIndicatorProps)
{status === "notImplemented" && ( {status === "notImplemented" && (
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}> <Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
<RotateCcwIcon /> <RotateCcwIcon />
{t("workspace.app-connection.recheck")} {t("environments.workspace.app-connection.recheck")}
</Button> </Button>
)} )}
</div> </div>
@@ -0,0 +1,89 @@
"use client";
import { ChevronDownIcon, CircleHelpIcon, Code2Icon, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
export const EnvironmentBreadcrumb = ({
environments,
currentEnvironment,
}: {
environments: { id: string; type: string }[];
currentEnvironment: { id: string; type: string };
}) => {
const { t } = useTranslation();
const [isEnvironmentDropdownOpen, setIsEnvironmentDropdownOpen] = useState(false);
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const handleEnvironmentChange = (environmentId: string) => {
if (environmentId === currentEnvironment.id) return;
setIsLoading(true);
router.push(`/environments/${environmentId}/`);
};
const developmentTooltip = () => {
return (
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<CircleHelpIcon className="h-3 w-3" />
</TooltipTrigger>
<TooltipContent className="mt-2 border-none bg-red-800 text-white">
{t("common.development_environment_banner")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
return (
<BreadcrumbItem
isActive={isEnvironmentDropdownOpen}
isHighlighted={currentEnvironment.type === "development"}>
<DropdownMenu onOpenChange={setIsEnvironmentDropdownOpen}>
<DropdownMenuTrigger
className="flex cursor-pointer items-center gap-1 outline-none"
id="environmentDropdownTrigger"
asChild>
<div className="flex items-center gap-1">
<Code2Icon className="h-3 w-3" strokeWidth={1.5} />
<span className="capitalize">{currentEnvironment.type}</span>
{isLoading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{currentEnvironment.type === "development" && developmentTooltip()}
{isEnvironmentDropdownOpen && <ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="mt-2" align="start">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Code2Icon className="mr-2 inline h-4 w-4" />
{t("common.choose_environment")}
</div>
<DropdownMenuGroup>
{environments.map((env) => (
<DropdownMenuCheckboxItem
key={env.id}
checked={env.type === currentEnvironment.type}
onClick={() => handleEnvironmentChange(env.id)}
className="cursor-pointer">
<div className="flex items-center gap-2 capitalize">
<span>{env.type}</span>
</div>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</BreadcrumbItem>
);
};
@@ -9,11 +9,11 @@ import {
PlusIcon, PlusIcon,
SettingsIcon, SettingsIcon,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react"; import { useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger"; import { logger } from "@formbricks/logger";
import { getOrganizationsForSwitcherAction } from "@/app/(app)/workspaces/[workspaceId]/actions"; import { getOrganizationsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal"; import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb"; import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
@@ -25,24 +25,42 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu"; } from "@/modules/ui/components/dropdown-menu";
import { useOrganization, useWorkspace } from "../context/workspace-context"; import { useOrganization } from "../context/environment-context";
interface OrganizationBreadcrumbProps { interface OrganizationBreadcrumbProps {
currentOrganizationId: string; currentOrganizationId: string;
currentOrganizationName?: string; // Optional: pass directly if context not available currentOrganizationName?: string; // Optional: pass directly if context not available
isMultiOrgEnabled: boolean; isMultiOrgEnabled: boolean;
currentWorkspaceId?: string; currentEnvironmentId?: string;
isFormbricksCloud: boolean;
isMember: boolean;
isOwnerOrManager: boolean;
} }
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
// Match /settings/{settingId} or /settings/{settingId}/... but exclude account settings
// Exclude paths with /(account)/
if (pathname.includes("/(account)/")) {
return false;
}
// Check if path matches /settings/{settingId} (with optional trailing path)
const pattern = new RegExp(`/settings/${settingId}(?:/|$)`);
return pattern.test(pathname);
};
export const OrganizationBreadcrumb = ({ export const OrganizationBreadcrumb = ({
currentOrganizationId, currentOrganizationId,
currentOrganizationName, currentOrganizationName,
isMultiOrgEnabled, isMultiOrgEnabled,
currentWorkspaceId, currentEnvironmentId,
isFormbricksCloud,
isMember,
isOwnerOrManager,
}: OrganizationBreadcrumbProps) => { }: OrganizationBreadcrumbProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false); const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false); const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false); const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
@@ -52,7 +70,6 @@ export const OrganizationBreadcrumb = ({
// Get current organization name from context OR prop // Get current organization name from context OR prop
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper // Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
const { organization: currentOrganization } = useOrganization(); const { organization: currentOrganization } = useOrganization();
const { workspace } = useWorkspace();
const organizationName = currentOrganization?.name || currentOrganizationName || ""; const organizationName = currentOrganization?.name || currentOrganizationName || "";
// Lazy-load organizations when dropdown opens // Lazy-load organizations when dropdown opens
@@ -93,15 +110,9 @@ export const OrganizationBreadcrumb = ({
return; return;
} }
const workspaceBasePath = `/workspaces/${workspace?.id}`;
const handleOrganizationChange = (organizationId: string) => { const handleOrganizationChange = (organizationId: string) => {
if (organizationId === currentOrganizationId) return;
startTransition(() => { startTransition(() => {
setIsOrganizationDropdownOpen(false);
if (organizationId === currentOrganizationId && currentWorkspaceId) {
router.push(`/workspaces/${currentWorkspaceId}/settings/organization/general`);
return;
}
router.push(`/organizations/${organizationId}/`); router.push(`/organizations/${organizationId}/`);
}); });
}; };
@@ -116,6 +127,43 @@ export const OrganizationBreadcrumb = ({
}); });
}; };
const organizationSettings = [
{
id: "general",
label: t("common.general"),
href: `/environments/${currentEnvironmentId}/settings/general`,
},
{
id: "teams",
label: t("common.members_and_teams"),
href: `/environments/${currentEnvironmentId}/settings/teams`,
},
{
id: "api-keys",
label: t("common.api_keys"),
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
hidden: !isOwnerOrManager,
},
{
id: "domain",
label: t("common.domain"),
href: `/environments/${currentEnvironmentId}/settings/domain`,
hidden: isFormbricksCloud,
},
{
id: "billing",
label: t("common.billing"),
href: `/environments/${currentEnvironmentId}/settings/billing`,
hidden: !isFormbricksCloud,
},
{
id: "enterprise",
label: t("common.enterprise_license"),
href: `/environments/${currentEnvironmentId}/settings/enterprise`,
hidden: isFormbricksCloud || isMember,
},
];
return ( return (
<BreadcrumbItem isActive={isOrganizationDropdownOpen}> <BreadcrumbItem isActive={isOrganizationDropdownOpen}>
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}> <DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
@@ -184,16 +232,27 @@ export const OrganizationBreadcrumb = ({
)} )}
</> </>
)} )}
{currentWorkspaceId && ( {currentEnvironmentId && (
<> <div>
{showOrganizationDropdown && <DropdownMenuSeparator />} {showOrganizationDropdown && <DropdownMenuSeparator />}
<DropdownMenuCheckboxItem <div className="px-2 py-1.5 text-sm font-medium text-slate-500">
onClick={() => handleSettingChange(`${workspaceBasePath}/settings/organization/general`)} <SettingsIcon className="mr-2 inline h-4 w-4" />
className="cursor-pointer"> {t("common.organization_settings")}
<SettingsIcon className="mr-2 h-4 w-4" /> </div>
{t("common.settings")}
</DropdownMenuCheckboxItem> {organizationSettings.map((setting) => {
</> return setting.hidden ? null : (
<DropdownMenuCheckboxItem
key={setting.id}
checked={isActiveOrganizationSetting(pathname, setting.id)}
hidden={setting.hidden}
onClick={() => handleSettingChange(setting.href)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
);
})}
</div>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -0,0 +1,74 @@
"use client";
import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb";
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
import { ProjectBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/project-breadcrumb";
import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
interface ProjectAndOrgSwitchProps {
currentOrganizationId: string;
currentOrganizationName?: string; // Optional: for pages without context
currentProjectId?: string;
currentProjectName?: string; // Optional: for pages without context
currentEnvironmentId?: string;
environments: { id: string; type: string }[];
isMultiOrgEnabled: boolean;
organizationProjectsLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
isOwnerOrManager: boolean;
isMember: boolean;
isAccessControlAllowed: boolean;
}
export const ProjectAndOrgSwitch = ({
currentOrganizationId,
currentOrganizationName,
currentProjectId,
currentProjectName,
currentEnvironmentId,
environments,
isMultiOrgEnabled,
organizationProjectsLimit,
isFormbricksCloud,
isLicenseActive,
isOwnerOrManager,
isAccessControlAllowed,
isMember,
}: ProjectAndOrgSwitchProps) => {
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
return (
<Breadcrumb>
<BreadcrumbList className="gap-0">
<OrganizationBreadcrumb
currentOrganizationId={currentOrganizationId}
currentOrganizationName={currentOrganizationName}
currentEnvironmentId={currentEnvironmentId}
isMultiOrgEnabled={isMultiOrgEnabled}
isFormbricksCloud={isFormbricksCloud}
isMember={isMember}
isOwnerOrManager={isOwnerOrManager}
/>
{currentProjectId && currentEnvironmentId && (
<ProjectBreadcrumb
currentProjectId={currentProjectId}
currentProjectName={currentProjectName}
currentOrganizationId={currentOrganizationId}
currentEnvironmentId={currentEnvironmentId}
isOwnerOrManager={isOwnerOrManager}
organizationProjectsLimit={organizationProjectsLimit}
isFormbricksCloud={isFormbricksCloud}
isLicenseActive={isLicenseActive}
isAccessControlAllowed={isAccessControlAllowed}
isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb}
/>
)}
{showEnvironmentBreadcrumb && (
<EnvironmentBreadcrumb environments={environments} currentEnvironment={currentEnvironment} />
)}
</BreadcrumbList>
</Breadcrumb>
);
};
@@ -0,0 +1,297 @@
"use client";
import * as Sentry from "@sentry/nextjs";
import { ChevronDownIcon, ChevronRightIcon, CogIcon, HotelIcon, Loader2, PlusIcon } from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { getProjectsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import { useProject } from "../context/environment-context";
interface ProjectBreadcrumbProps {
currentProjectId: string;
currentProjectName?: string; // Optional: pass directly if context not available
isOwnerOrManager: boolean;
organizationProjectsLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
currentOrganizationId: string;
currentEnvironmentId: string;
isAccessControlAllowed: boolean;
isEnvironmentBreadcrumbVisible: boolean;
}
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
// Match /workspace/{settingId} or /workspace/{settingId}/... but exclude settings paths
if (pathname.includes("/settings/")) {
return false;
}
// Check if path matches /workspace/{settingId} (with optional trailing path)
const pattern = new RegExp(`/workspace/${settingId}(?:/|$)`);
return pattern.test(pathname);
};
export const ProjectBreadcrumb = ({
currentProjectId,
currentProjectName,
isOwnerOrManager,
organizationProjectsLimit,
isFormbricksCloud,
isLicenseActive,
currentOrganizationId,
currentEnvironmentId,
isAccessControlAllowed,
isEnvironmentBreadcrumbVisible,
}: ProjectBreadcrumbProps) => {
const { t } = useTranslation();
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
const [openLimitModal, setOpenLimitModal] = useState(false);
const router = useRouter();
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const pathname = usePathname();
// Get current project name from context OR prop
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
const { project: currentProject } = useProject();
const projectName = currentProject?.name || currentProjectName || "";
// Lazy-load projects when dropdown opens
useEffect(() => {
// Only fetch when dropdown opened for first time (and no error state)
if (isProjectDropdownOpen && projects.length === 0 && !isLoadingProjects && !loadError) {
setIsLoadingProjects(true);
setLoadError(null); // Clear any previous errors
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) {
// Sort projects by name
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
setProjects(sorted);
} else {
// Handle server errors or validation errors
const errorMessage = getFormattedErrorMessage(result);
const error = new Error(errorMessage);
logger.error(error, "Failed to load projects");
Sentry.captureException(error);
setLoadError(errorMessage || t("common.failed_to_load_workspaces"));
}
setIsLoadingProjects(false);
});
}
}, [isProjectDropdownOpen, currentOrganizationId, projects.length, isLoadingProjects, loadError, t]);
const projectSettings = [
{
id: "general",
label: t("common.general"),
href: `/environments/${currentEnvironmentId}/workspace/general`,
},
{
id: "look",
label: t("common.look_and_feel"),
href: `/environments/${currentEnvironmentId}/workspace/look`,
},
{
id: "app-connection",
label: t("common.website_and_app_connection"),
href: `/environments/${currentEnvironmentId}/workspace/app-connection`,
},
{
id: "integrations",
label: t("common.integrations"),
href: `/environments/${currentEnvironmentId}/workspace/integrations`,
},
{
id: "teams",
label: t("common.team_access"),
href: `/environments/${currentEnvironmentId}/workspace/teams`,
},
{
id: "languages",
label: t("common.survey_languages"),
href: `/environments/${currentEnvironmentId}/workspace/languages`,
},
{
id: "tags",
label: t("common.tags"),
href: `/environments/${currentEnvironmentId}/workspace/tags`,
},
];
if (!currentProject) {
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
logger.error(errorMessage);
Sentry.captureException(new Error(errorMessage));
return;
}
const handleProjectChange = (projectId: string) => {
if (projectId === currentProjectId) return;
startTransition(() => {
router.push(`/workspaces/${projectId}/`);
});
};
const handleAddProject = () => {
if (projects.length >= organizationProjectsLimit) {
setOpenLimitModal(true);
return;
}
setOpenCreateProjectModal(true);
};
const handleProjectSettingsNavigation = (settingId: string) => {
startTransition(() => {
router.push(`/environments/${currentEnvironmentId}/workspace/${settingId}`);
});
};
const LimitModalButtons = (): [ModalButton, ModalButton] => {
if (isFormbricksCloud) {
return [
{
text: t("environments.settings.billing.upgrade"),
href: `/environments/${currentEnvironmentId}/settings/billing`,
},
{
text: t("common.cancel"),
onClick: () => setOpenLimitModal(false),
},
];
}
return [
{
text: t("environments.settings.billing.upgrade"),
href: isLicenseActive
? `/environments/${currentEnvironmentId}/settings/enterprise`
: "https://formbricks.com/upgrade-self-hosted-license",
},
{
text: t("common.cancel"),
onClick: () => setOpenLimitModal(false),
},
];
};
return (
<BreadcrumbItem isActive={isProjectDropdownOpen}>
<DropdownMenu onOpenChange={setIsProjectDropdownOpen}>
<DropdownMenuTrigger
className="flex cursor-pointer items-center gap-1 outline-none"
id="projectDropdownTrigger"
asChild>
<div className="flex items-center gap-1">
<HotelIcon className="h-3 w-3" strokeWidth={1.5} />
<span>{projectName}</span>
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
) : (
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="mt-2">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.choose_workspace")}
</div>
{isLoadingProjects && (
<div className="flex items-center justify-center py-2">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
{!isLoadingProjects && loadError && (
<div className="px-2 py-4">
<p className="mb-2 text-sm text-red-600">{loadError}</p>
<button
onClick={() => {
setLoadError(null);
setProjects([]);
}}
className="text-xs text-slate-600 underline hover:text-slate-800">
{t("common.try_again")}
</button>
</div>
)}
{!isLoadingProjects && !loadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
{projects.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}
checked={proj.id === currentProjectId}
onClick={() => handleProjectChange(proj.id)}
className="cursor-pointer">
<div className="flex items-center gap-2">
<span>{proj.name}</span>
</div>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isOwnerOrManager && (
<DropdownMenuCheckboxItem
onClick={handleAddProject}
className="w-full cursor-pointer justify-between">
<span>{t("common.add_new_workspace")}</span>
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuCheckboxItem>
)}
</>
)}
<DropdownMenuGroup>
<DropdownMenuSeparator />
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
{t("common.workspace_configuration")}
</div>
{projectSettings.map((setting) => (
<DropdownMenuCheckboxItem
key={setting.id}
checked={isActiveProjectSetting(pathname, setting.id)}
onClick={() => handleProjectSettingsNavigation(setting.id)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* Modals */}
{openLimitModal && (
<ProjectLimitModal
open={openLimitModal}
setOpen={setOpenLimitModal}
buttons={LimitModalButtons()}
projectLimit={organizationProjectsLimit}
/>
)}
{openCreateProjectModal && (
<CreateProjectModal
open={openCreateProjectModal}
setOpen={setOpenCreateProjectModal}
organizationId={currentOrganizationId}
isAccessControlAllowed={isAccessControlAllowed}
/>
)}
</BreadcrumbItem>
);
};
@@ -0,0 +1,68 @@
"use client";
import { createContext, useContext, useMemo } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
export interface EnvironmentContextType {
environment: TEnvironment;
project: TProject;
organization: TOrganization;
organizationId: string;
}
const EnvironmentContext = createContext<EnvironmentContextType | null>(null);
export const useEnvironment = () => {
const context = useContext(EnvironmentContext);
if (!context) {
throw new Error("useEnvironment must be used within an EnvironmentProvider");
}
return context;
};
export const useProject = () => {
const context = useContext(EnvironmentContext);
if (!context) {
return { project: null };
}
return { project: context.project };
};
export const useOrganization = () => {
const context = useContext(EnvironmentContext);
if (!context) {
return { organization: null };
}
return { organization: context.organization };
};
// Client wrapper component to be used in server components
interface EnvironmentContextWrapperProps {
environment: TEnvironment;
project: TProject;
organization: TOrganization;
children: React.ReactNode;
}
export const EnvironmentContextWrapper = ({
environment,
project,
organization,
children,
}: EnvironmentContextWrapperProps) => {
const environmentContextValue = useMemo(
() => ({
environment,
project,
organization,
organizationId: project.organizationId,
}),
[environment, project, organization]
);
return (
<EnvironmentContext.Provider value={environmentContextValue}>{children}</EnvironmentContext.Provider>
);
};
@@ -0,0 +1,38 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
const EnvLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
// Check session first (required for userId)
const session = await getServerSession(authOptions);
if (!session?.user) {
return redirect(`/auth/login`);
}
// Single consolidated data fetch (replaces ~12 individual fetches)
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
return (
<>
<EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentContextWrapper
environment={layoutData.environment}
project={layoutData.project}
organization={layoutData.organization}>
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
</EnvironmentContextWrapper>
</>
);
};
export default EnvLayout;
@@ -3,18 +3,18 @@ import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors"; import { DatabaseError } from "@formbricks/types/errors";
import { TMembership } from "@formbricks/types/memberships"; import { TMembership } from "@formbricks/types/memberships";
import { getWorkspacesByUserId } from "./workspace"; import { getProjectsByUserId } from "./project";
vi.mock("@formbricks/database", () => ({ vi.mock("@formbricks/database", () => ({
prisma: { prisma: {
workspace: { project: {
findMany: vi.fn(), findMany: vi.fn(),
}, },
}, },
})); }));
describe("Workspace", () => { describe("Project", () => {
describe("getUserWorkspaces", () => { describe("getUserProjects", () => {
const mockAdminMembership: TMembership = { const mockAdminMembership: TMembership = {
role: "manager", role: "manager",
organizationId: "org1", organizationId: "org1",
@@ -29,17 +29,17 @@ describe("Workspace", () => {
accepted: true, accepted: true,
}; };
test("should return workspaces for admin role", async () => { test("should return projects for admin role", async () => {
const mockWorkspaces = [ const mockProjects = [
{ id: "workspace1", name: "Workspace 1" }, { id: "project1", name: "Project 1" },
{ id: "workspace2", name: "Workspace 2" }, { id: "project2", name: "Project 2" },
]; ];
vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any); vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
const result = await getWorkspacesByUserId("user1", mockAdminMembership); const result = await getProjectsByUserId("user1", mockAdminMembership);
expect(prisma.workspace.findMany).toHaveBeenCalledWith({ expect(prisma.project.findMany).toHaveBeenCalledWith({
where: { where: {
organizationId: "org1", organizationId: "org1",
}, },
@@ -48,20 +48,20 @@ describe("Workspace", () => {
name: true, name: true,
}, },
}); });
expect(result).toEqual(mockWorkspaces); expect(result).toEqual(mockProjects);
}); });
test("should return workspaces for member role with team restrictions", async () => { test("should return projects for member role with team restrictions", async () => {
const mockWorkspaces = [{ id: "workspace1", name: "Workspace 1" }]; const mockProjects = [{ id: "project1", name: "Project 1" }];
vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any); vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
const result = await getWorkspacesByUserId("user1", mockMemberMembership); const result = await getProjectsByUserId("user1", mockMemberMembership);
expect(prisma.workspace.findMany).toHaveBeenCalledWith({ expect(prisma.project.findMany).toHaveBeenCalledWith({
where: { where: {
organizationId: "org1", organizationId: "org1",
workspaceTeams: { projectTeams: {
some: { some: {
team: { team: {
teamUsers: { teamUsers: {
@@ -78,13 +78,13 @@ describe("Workspace", () => {
name: true, name: true,
}, },
}); });
expect(result).toEqual(mockWorkspaces); expect(result).toEqual(mockProjects);
}); });
test("should return empty array when no workspaces found", async () => { test("should return empty array when no projects found", async () => {
vi.mocked(prisma.workspace.findMany).mockResolvedValue([]); vi.mocked(prisma.project.findMany).mockResolvedValue([]);
const result = await getWorkspacesByUserId("user1", mockAdminMembership); const result = await getProjectsByUserId("user1", mockAdminMembership);
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
@@ -95,27 +95,27 @@ describe("Workspace", () => {
clientVersion: "5.0.0", clientVersion: "5.0.0",
}); });
vi.mocked(prisma.workspace.findMany).mockRejectedValue(prismaError); vi.mocked(prisma.project.findMany).mockRejectedValue(prismaError);
await expect(getWorkspacesByUserId("user1", mockAdminMembership)).rejects.toThrow( await expect(getProjectsByUserId("user1", mockAdminMembership)).rejects.toThrow(
new DatabaseError("Database error") new DatabaseError("Database error")
); );
}); });
test("should re-throw unknown errors", async () => { test("should re-throw unknown errors", async () => {
const unknownError = new Error("Unknown error"); const unknownError = new Error("Unknown error");
vi.mocked(prisma.workspace.findMany).mockRejectedValue(unknownError); vi.mocked(prisma.project.findMany).mockRejectedValue(unknownError);
await expect(getWorkspacesByUserId("user1", mockAdminMembership)).rejects.toThrow(unknownError); await expect(getProjectsByUserId("user1", mockAdminMembership)).rejects.toThrow(unknownError);
}); });
test("should validate inputs correctly", async () => { test("should validate inputs correctly", async () => {
await expect(getWorkspacesByUserId(123 as any, mockAdminMembership)).rejects.toThrow(); await expect(getProjectsByUserId(123 as any, mockAdminMembership)).rejects.toThrow();
}); });
test("should validate membership input correctly", async () => { test("should validate membership input correctly", async () => {
const invalidMembership = {} as TMembership; const invalidMembership = {} as TMembership;
await expect(getWorkspacesByUserId("user1", invalidMembership)).rejects.toThrow(); await expect(getProjectsByUserId("user1", invalidMembership)).rejects.toThrow();
}); });
test("should handle owner role like manager", async () => { test("should handle owner role like manager", async () => {
@@ -126,12 +126,12 @@ describe("Workspace", () => {
accepted: true, accepted: true,
}; };
const mockWorkspaces = [{ id: "workspace1", name: "Workspace 1" }]; const mockProjects = [{ id: "project1", name: "Project 1" }];
vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any); vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
const result = await getWorkspacesByUserId("user1", mockOwnerMembership); const result = await getProjectsByUserId("user1", mockOwnerMembership);
expect(prisma.workspace.findMany).toHaveBeenCalledWith({ expect(prisma.project.findMany).toHaveBeenCalledWith({
where: { where: {
organizationId: "org1", organizationId: "org1",
}, },
@@ -140,7 +140,7 @@ describe("Workspace", () => {
name: true, name: true,
}, },
}); });
expect(result).toEqual(mockWorkspaces); expect(result).toEqual(mockProjects);
}); });
}); });
}); });
@@ -6,15 +6,15 @@ import { DatabaseError } from "@formbricks/types/errors";
import { TMembership, ZMembership } from "@formbricks/types/memberships"; import { TMembership, ZMembership } from "@formbricks/types/memberships";
import { validateInputs } from "@/lib/utils/validate"; import { validateInputs } from "@/lib/utils/validate";
export const getWorkspacesByUserId = reactCache( export const getProjectsByUserId = reactCache(
async (userId: string, orgMembership: TMembership): Promise<{ id: string; name: string }[]> => { async (userId: string, orgMembership: TMembership): Promise<{ id: string; name: string }[]> => {
validateInputs([userId, ZString], [orgMembership, ZMembership]); validateInputs([userId, ZString], [orgMembership, ZMembership]);
let workspaceWhereClause: Prisma.WorkspaceWhereInput = {}; let projectWhereClause: Prisma.ProjectWhereInput = {};
if (orgMembership.role === "member") { if (orgMembership.role === "member") {
workspaceWhereClause = { projectWhereClause = {
workspaceTeams: { projectTeams: {
some: { some: {
team: { team: {
teamUsers: { teamUsers: {
@@ -29,17 +29,17 @@ export const getWorkspacesByUserId = reactCache(
} }
try { try {
const workspaces = await prisma.workspace.findMany({ const projects = await prisma.project.findMany({
where: { where: {
organizationId: orgMembership.organizationId, organizationId: orgMembership.organizationId,
...workspaceWhereClause, ...projectWhereClause,
}, },
select: { select: {
id: true, id: true,
name: true, name: true,
}, },
}); });
return workspaces; return projects;
} catch (error) { } catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message); throw new DatabaseError(error.message);
@@ -0,0 +1,25 @@
import { redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
const EnvironmentPage = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const { session, organization } = await getEnvironmentAuth(params.environmentId);
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isBilling } = getAccessFlags(currentUserMembership?.role);
if (isBilling) {
if (IS_FORMBRICKS_CLOUD) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
} else {
return redirect(`/environments/${params.environmentId}/settings/enterprise`);
}
}
return redirect(`/environments/${params.environmentId}/surveys`);
};
export default EnvironmentPage;
@@ -2,30 +2,28 @@
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation"; import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
interface AccountSettingsNavbarProps { interface AccountSettingsNavbarProps {
environmentId?: string;
activeId: string; activeId: string;
loading?: boolean; loading?: boolean;
} }
export const AccountSettingsNavbar = ({ activeId, loading }: AccountSettingsNavbarProps) => { export const AccountSettingsNavbar = ({ environmentId, activeId, loading }: AccountSettingsNavbarProps) => {
const pathname = usePathname(); const pathname = usePathname();
const { t } = useTranslation(); const { t } = useTranslation();
const { workspace } = useWorkspace();
const workspaceBasePath = `/workspaces/${workspace?.id}`;
const navigation = [ const navigation = [
{ {
id: "profile", id: "profile",
label: t("common.profile"), label: t("common.profile"),
href: `${workspaceBasePath}/settings/account/profile`, href: `/environments/${environmentId}/settings/profile`,
current: pathname?.includes("/profile"), current: pathname?.includes("/profile"),
}, },
{ {
id: "notifications", id: "notifications",
label: t("common.notifications"), label: t("common.notifications"),
href: `${workspaceBasePath}/settings/account/notifications`, href: `/environments/${environmentId}/settings/notifications`,
current: pathname?.includes("/notifications"), current: pathname?.includes("/notifications"),
}, },
]; ];
@@ -0,0 +1,37 @@
import { getServerSession } from "next-auth";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
const AccountSettingsLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
const t = await getTranslate();
const [organization, project, session] = await Promise.all([
getOrganizationByEnvironmentId(params.environmentId),
getProjectByEnvironmentId(params.environmentId),
getServerSession(authOptions),
]);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
if (!project) {
throw new Error(t("common.workspace_not_found"));
}
if (!session) {
throw new Error(t("common.session_not_found"));
}
return <>{children}</>;
};
export default AccountSettingsLayout;
@@ -4,7 +4,6 @@ import { HelpCircleIcon, UsersIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { Membership } from "../types"; import { Membership } from "../types";
import { NotificationSwitch } from "./NotificationSwitch"; import { NotificationSwitch } from "./NotificationSwitch";
@@ -12,6 +11,7 @@ import { NotificationSwitch } from "./NotificationSwitch";
interface EditAlertsProps { interface EditAlertsProps {
memberships: Membership[]; memberships: Membership[];
user: TUser; user: TUser;
environmentId: string;
autoDisableNotificationType: string; autoDisableNotificationType: string;
autoDisableNotificationElementId: string; autoDisableNotificationElementId: string;
} }
@@ -19,11 +19,11 @@ interface EditAlertsProps {
export const EditAlerts = ({ export const EditAlerts = ({
memberships, memberships,
user, user,
environmentId,
autoDisableNotificationType, autoDisableNotificationType,
autoDisableNotificationElementId, autoDisableNotificationElementId,
}: EditAlertsProps) => { }: EditAlertsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { workspace: currentWorkspace } = useWorkspace();
return ( return (
<> <>
{memberships.map((membership) => ( {memberships.map((membership) => (
@@ -37,10 +37,10 @@ export const EditAlerts = ({
<div className="col-span-3 flex items-center justify-end pr-2"> <div className="col-span-3 flex items-center justify-end pr-2">
<p className="pr-4 text-sm text-slate-600"> <p className="pr-4 text-sm text-slate-600">
{t("workspace.settings.notifications.auto_subscribe_to_new_surveys")} {t("environments.settings.notifications.auto_subscribe_to_new_surveys")}
</p> </p>
<NotificationSwitch <NotificationSwitch
surveyOrWorkspaceOrOrganizationId={membership.organization.id} surveyOrProjectOrOrganizationId={membership.organization.id}
notificationSettings={user.notificationSettings!} notificationSettings={user.notificationSettings!}
notificationType={"unsubscribedOrganizationIds"} notificationType={"unsubscribedOrganizationIds"}
autoDisableNotificationType={autoDisableNotificationType} autoDisableNotificationType={autoDisableNotificationType}
@@ -55,38 +55,44 @@ export const EditAlerts = ({
<Tooltip> <Tooltip>
<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("workspace.settings.notifications.every_response")}</span> <span>{t("environments.settings.notifications.every_response")}</span>
<HelpCircleIcon className="h-4 w-4 flex-shrink-0 text-slate-500" /> <HelpCircleIcon className="h-4 w-4 flex-shrink-0 text-slate-500" />
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
{t("workspace.settings.notifications.every_response_tooltip")} {t("environments.settings.notifications.every_response_tooltip")}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
</div> </div>
{membership.organization.workspaces.some((workspace) => workspace.surveys.length > 0) ? ( {membership.organization.projects.some((project) =>
project.environments.some((environment) => environment.surveys.length > 0)
) ? (
<div className="grid-cols-8 space-y-1 p-2"> <div className="grid-cols-8 space-y-1 p-2">
{membership.organization.workspaces.map((workspace) => ( {membership.organization.projects.map((project) => (
<div key={workspace.id}> <div key={project.id}>
{workspace.surveys.map((survey) => ( {project.environments.map((environment) => (
<div <div key={environment.id}>
className="grid h-auto w-full cursor-pointer grid-cols-3 place-content-center rounded-lg px-2 py-2 text-left text-sm text-slate-900 hover:bg-slate-50" {environment.surveys.map((survey) => (
key={survey.name}> <div
<div className="col-span-2 text-left"> className="grid h-auto w-full cursor-pointer grid-cols-3 place-content-center rounded-lg px-2 py-2 text-left text-sm text-slate-900 hover:bg-slate-50"
<div className="font-medium text-slate-900">{survey.name}</div> key={survey.name}>
<div className="text-xs text-slate-400">{workspace.name}</div> <div className="col-span-2 text-left">
</div> <div className="font-medium text-slate-900">{survey.name}</div>
<div className="col-span-1 text-center"> <div className="text-xs text-slate-400">{project.name}</div>
<NotificationSwitch </div>
surveyOrWorkspaceOrOrganizationId={survey.id} <div className="col-span-1 text-center">
notificationSettings={user.notificationSettings!} <NotificationSwitch
notificationType={"alert"} surveyOrProjectOrOrganizationId={survey.id}
autoDisableNotificationType={autoDisableNotificationType} notificationSettings={user.notificationSettings!}
autoDisableNotificationElementId={autoDisableNotificationElementId} notificationType={"alert"}
/> autoDisableNotificationType={autoDisableNotificationType}
</div> autoDisableNotificationElementId={autoDisableNotificationElementId}
/>
</div>
</div>
))}
</div> </div>
))} ))}
</div> </div>
@@ -98,10 +104,8 @@ export const EditAlerts = ({
</div> </div>
)} )}
<p className="pb-3 pl-4 text-xs text-slate-400"> <p className="pb-3 pl-4 text-xs text-slate-400">
{t("workspace.settings.notifications.want_to_loop_in_organization_mates")}{" "} {t("environments.settings.notifications.want_to_loop_in_organization_mates")}{" "}
<Link <Link className="font-semibold" href={`/environments/${environmentId}/settings/general`}>
className="font-semibold"
href={`/workspaces/${currentWorkspace?.id}/settings/organization/general`}>
{t("common.invite_them")} {t("common.invite_them")}
</Link> </Link>
</p> </p>
@@ -1,22 +1,24 @@
"use client"; "use client";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
import { SlackIcon } from "@/modules/ui/components/icons"; import { SlackIcon } from "@/modules/ui/components/icons";
export const IntegrationsTip = () => { interface IntegrationsTipProps {
environmentId: string;
}
export const IntegrationsTip = ({ environmentId }: IntegrationsTipProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { workspace } = useWorkspace();
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-sm 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("workspace.settings.notifications.need_slack_or_discord_notifications")}? {t("environments.settings.notifications.need_slack_or_discord_notifications")}?
<a <a
href={`/workspaces/${workspace?.id}/settings/workspace/integrations`} href={`/environments/${environmentId}/workspace/integrations`}
className="ml-1 cursor-pointer text-sm underline"> className="ml-1 cursor-pointer text-sm underline">
{t("workspace.settings.notifications.use_the_integration")} {t("environments.settings.notifications.use_the_integration")}
</a> </a>
</p> </p>
</div> </div>
@@ -10,7 +10,7 @@ import { Switch } from "@/modules/ui/components/switch";
import { updateNotificationSettingsAction } from "../actions"; import { updateNotificationSettingsAction } from "../actions";
interface NotificationSwitchProps { interface NotificationSwitchProps {
surveyOrWorkspaceOrOrganizationId: string; surveyOrProjectOrOrganizationId: string;
notificationSettings: TUserNotificationSettings; notificationSettings: TUserNotificationSettings;
notificationType: "alert" | "unsubscribedOrganizationIds"; notificationType: "alert" | "unsubscribedOrganizationIds";
autoDisableNotificationType?: string; autoDisableNotificationType?: string;
@@ -18,7 +18,7 @@ interface NotificationSwitchProps {
} }
export const NotificationSwitch = ({ export const NotificationSwitch = ({
surveyOrWorkspaceOrOrganizationId, surveyOrProjectOrOrganizationId,
notificationSettings, notificationSettings,
notificationType, notificationType,
autoDisableNotificationType, autoDisableNotificationType,
@@ -29,8 +29,8 @@ export const NotificationSwitch = ({
const router = useRouter(); const router = useRouter();
const isChecked = const isChecked =
notificationType === "unsubscribedOrganizationIds" notificationType === "unsubscribedOrganizationIds"
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrWorkspaceOrOrganizationId) ? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
: notificationSettings[notificationType]?.[surveyOrWorkspaceOrOrganizationId] === true; : notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true;
const handleSwitchChange = async () => { const handleSwitchChange = async () => {
setIsLoading(true); setIsLoading(true);
@@ -38,21 +38,21 @@ export const NotificationSwitch = ({
let updatedNotificationSettings = { ...notificationSettings }; let updatedNotificationSettings = { ...notificationSettings };
if (notificationType === "unsubscribedOrganizationIds") { if (notificationType === "unsubscribedOrganizationIds") {
const unsubscribedOrganizationIds = updatedNotificationSettings.unsubscribedOrganizationIds ?? []; const unsubscribedOrganizationIds = updatedNotificationSettings.unsubscribedOrganizationIds ?? [];
if (unsubscribedOrganizationIds.includes(surveyOrWorkspaceOrOrganizationId)) { if (unsubscribedOrganizationIds.includes(surveyOrProjectOrOrganizationId)) {
updatedNotificationSettings.unsubscribedOrganizationIds = unsubscribedOrganizationIds.filter( updatedNotificationSettings.unsubscribedOrganizationIds = unsubscribedOrganizationIds.filter(
(id) => id !== surveyOrWorkspaceOrOrganizationId (id) => id !== surveyOrProjectOrOrganizationId
); );
} else { } else {
updatedNotificationSettings.unsubscribedOrganizationIds = [ updatedNotificationSettings.unsubscribedOrganizationIds = [
...unsubscribedOrganizationIds, ...unsubscribedOrganizationIds,
surveyOrWorkspaceOrOrganizationId, surveyOrProjectOrOrganizationId,
]; ];
} }
} else { } else {
updatedNotificationSettings[notificationType] = { updatedNotificationSettings[notificationType] = {
...updatedNotificationSettings[notificationType], ...updatedNotificationSettings[notificationType],
[surveyOrWorkspaceOrOrganizationId]: [surveyOrProjectOrOrganizationId]:
!updatedNotificationSettings[notificationType]?.[surveyOrWorkspaceOrOrganizationId], !updatedNotificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId],
}; };
} }
@@ -60,7 +60,7 @@ export const NotificationSwitch = ({
notificationSettings: updatedNotificationSettings, notificationSettings: updatedNotificationSettings,
}); });
if (updatedNotificationSettingsActionResponse?.data) { if (updatedNotificationSettingsActionResponse?.data) {
toast.success(t("workspace.settings.notifications.notification_settings_updated"), { toast.success(t("environments.settings.notifications.notification_settings_updated"), {
id: "notification-switch", id: "notification-switch",
}); });
router.refresh(); router.refresh();
@@ -76,16 +76,16 @@ export const NotificationSwitch = ({
useEffect(() => { useEffect(() => {
if ( if (
autoDisableNotificationType && autoDisableNotificationType &&
autoDisableNotificationElementId === surveyOrWorkspaceOrOrganizationId && autoDisableNotificationElementId === surveyOrProjectOrOrganizationId &&
isChecked isChecked
) { ) {
switch (notificationType) { switch (notificationType) {
case "alert": case "alert":
if (notificationSettings[notificationType]?.[surveyOrWorkspaceOrOrganizationId] === true) { if (notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true) {
handleSwitchChange(); handleSwitchChange();
toast.success( toast.success(
t( t(
"workspace.settings.notifications.you_will_not_receive_any_more_emails_for_responses_on_this_survey" "environments.settings.notifications.you_will_not_receive_any_more_emails_for_responses_on_this_survey"
), ),
{ {
id: "notification-switch", id: "notification-switch",
@@ -95,13 +95,11 @@ export const NotificationSwitch = ({
break; break;
case "unsubscribedOrganizationIds": case "unsubscribedOrganizationIds":
if ( if (!notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)) {
!notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrWorkspaceOrOrganizationId)
) {
handleSwitchChange(); handleSwitchChange();
toast.success( toast.success(
t( t(
"workspace.settings.notifications.you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore" "environments.settings.notifications.you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore"
), ),
{ {
id: "notification-switch", id: "notification-switch",
@@ -2,6 +2,7 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { LoadingCard } from "@/app/(app)/components/LoadingCard"; import { LoadingCard } from "@/app/(app)/components/LoadingCard";
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
@@ -9,15 +10,17 @@ const Loading = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const cards = [ const cards = [
{ {
title: t("workspace.settings.notifications.email_alerts_surveys"), title: t("environments.settings.notifications.email_alerts_surveys"),
description: t("workspace.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses"), description: t("environments.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses"),
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }], skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }],
}, },
]; ];
return ( return (
<PageContentWrapper> <PageContentWrapper>
<PageHeader pageTitle={t("common.notifications")} /> <PageHeader pageTitle={t("common.account_settings")}>
<AccountSettingsNavbar activeId="notifications" loading />
</PageHeader>
{cards.map((card, index) => ( {cards.map((card, index) => (
<LoadingCard key={index} {...card} /> <LoadingCard key={index} {...card} />
))} ))}
@@ -1,16 +1,16 @@
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TUserNotificationSettings } from "@formbricks/types/user"; import { TUserNotificationSettings } from "@formbricks/types/user";
import { EditAlerts } from "@/app/(app)/workspaces/[workspaceId]/settings/account/notifications/components/EditAlerts"; import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
import { IntegrationsTip } from "@/app/(app)/workspaces/[workspaceId]/settings/account/notifications/components/IntegrationsTip"; import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import type { Membership } from "@/app/(app)/workspaces/[workspaceId]/settings/account/notifications/types";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server"; import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions"; import { authOptions } from "@/modules/auth/lib/authOptions";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header"; import { PageHeader } from "@/modules/ui/components/page-header";
import { EditAlerts } from "./components/EditAlerts";
import { IntegrationsTip } from "./components/IntegrationsTip";
import type { Membership } from "./types";
const setCompleteNotificationSettings = ( const setCompleteNotificationSettings = (
notificationSettings: TUserNotificationSettings, notificationSettings: TUserNotificationSettings,
@@ -21,14 +21,16 @@ const setCompleteNotificationSettings = (
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [], unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
}; };
for (const membership of memberships) { for (const membership of memberships) {
for (const workspace of membership.organization.workspaces) { for (const project of membership.organization.projects) {
// set default values for alerts // set default values for alerts
for (const survey of workspace.surveys) { for (const environment of project.environments) {
newNotificationSettings.alert[survey.id] = for (const survey of environment.surveys) {
(notificationSettings as unknown as Record<string, Record<string, boolean>>)[survey.id] newNotificationSettings.alert[survey.id] =
?.responseFinished || (notificationSettings as unknown as Record<string, Record<string, boolean>>)[survey.id]
(notificationSettings.alert && notificationSettings.alert[survey.id]) || ?.responseFinished ||
false; // check for legacy notification settings w/o "alerts" key (notificationSettings.alert && notificationSettings.alert[survey.id]) ||
false; // check for legacy notification settings w/o "alerts" key
}
} }
} }
} }
@@ -44,17 +46,17 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
}, },
OR: [ OR: [
{ {
// Fetch all workspaces if user role is owner or manager // Fetch all projects if user role is owner or manager
role: { role: {
in: ["owner", "manager"], in: ["owner", "manager"],
}, },
}, },
{ {
// Filter workspaces based on team membership if user is not owner or manager // Filter projects based on team membership if user is not owner or manager
organization: { organization: {
workspaces: { projects: {
some: { some: {
workspaceTeams: { projectTeams: {
some: { some: {
team: { team: {
teamUsers: { teamUsers: {
@@ -76,12 +78,12 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
select: { select: {
id: true, id: true,
name: true, name: true,
workspaces: { projects: {
// Apply conditional filtering based on user's role // Apply conditional filtering based on user's role
where: { where: {
OR: [ OR: [
{ {
// Fetch all workspaces if user is owner or manager // Fetch all projects if user is owner or manager
organization: { organization: {
memberships: { memberships: {
some: { some: {
@@ -94,8 +96,8 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
}, },
}, },
{ {
// Only include workspaces accessible through teams if user is not owner or manager // Only include projects accessible through teams if user is not owner or manager
workspaceTeams: { projectTeams: {
some: { some: {
team: { team: {
teamUsers: { teamUsers: {
@@ -112,10 +114,18 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
select: { select: {
id: true, id: true,
name: true, name: true,
surveys: { environments: {
where: {
type: "production",
},
select: { select: {
id: true, id: true,
name: true, surveys: {
select: {
id: true,
name: true,
},
},
}, },
}, },
}, },
@@ -127,23 +137,27 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
return memberships; return memberships;
}; };
const Page = async (props: { searchParams: Promise<Record<string, string>> }) => { const Page = async (props: {
params: Promise<{ environmentId: string }>;
searchParams: Promise<Record<string, string>>;
}) => {
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const params = await props.params;
const t = await getTranslate(); const t = await getTranslate();
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session) { if (!session) {
throw new AuthenticationError(t("common.not_authenticated")); throw new Error(t("common.session_not_found"));
} }
const autoDisableNotificationType = searchParams["type"]; const autoDisableNotificationType = searchParams["type"];
const autoDisableNotificationElementId = searchParams["elementId"]; const autoDisableNotificationElementId = searchParams["elementId"];
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]); const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
if (!user) { if (!user) {
throw new AuthenticationError(t("common.not_authenticated")); throw new Error(t("common.user_not_found"));
} }
if (!memberships) { if (!memberships) {
throw new ResourceNotFoundError(t("common.membership"), null); throw new Error(t("common.membership_not_found"));
} }
if (user?.notificationSettings) { if (user?.notificationSettings) {
@@ -151,18 +165,23 @@ const Page = async (props: { searchParams: Promise<Record<string, string>> }) =>
} }
return ( return (
<PageContentWrapper> <PageContentWrapper>
<PageHeader pageTitle={t("common.notifications")} /> <PageHeader pageTitle={t("common.account_settings")}>
<AccountSettingsNavbar environmentId={params.environmentId} activeId="notifications" />
</PageHeader>
<SettingsCard <SettingsCard
title={t("workspace.settings.notifications.email_alerts_surveys")} title={t("environments.settings.notifications.email_alerts_surveys")}
description={t("workspace.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses")}> description={t(
"environments.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses"
)}>
<EditAlerts <EditAlerts
memberships={memberships} memberships={memberships}
user={user} user={user}
environmentId={params.environmentId}
autoDisableNotificationType={autoDisableNotificationType} autoDisableNotificationType={autoDisableNotificationType}
autoDisableNotificationElementId={autoDisableNotificationElementId} autoDisableNotificationElementId={autoDisableNotificationElementId}
/> />
</SettingsCard> </SettingsCard>
<IntegrationsTip /> <IntegrationsTip environmentId={params.environmentId} />
</PageContentWrapper> </PageContentWrapper>
); );
}; };
@@ -4,12 +4,15 @@ export interface Membership {
organization: { organization: {
id: string; id: string;
name: string; name: string;
workspaces: { projects: {
id: string; id: string;
name: string; name: string;
surveys: { environments: {
id: string; id: string;
name: string; surveys: {
id: string;
name: string;
}[];
}[]; }[];
}[]; }[];
}; };
@@ -6,18 +6,19 @@ import {
TUserUpdateInput, TUserUpdateInput,
ZUserPersonalInfoUpdateInput, ZUserPersonalInfoUpdateInput,
} from "@formbricks/types/user"; } from "@formbricks/types/user";
import { getIsEmailUnique } from "@/app/(app)/workspaces/[workspaceId]/settings/account/profile/lib/user"; import {
import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants"; getIsEmailUnique,
import { verifyUserPassword } from "@/lib/user/password"; verifyUserPassword,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
import { getUser, updateUser } from "@/lib/user/service"; import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client"; import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { requestPasswordReset } from "@/modules/auth/forgot-password/lib/password-reset-service";
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo"; import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers"; import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendVerificationNewEmail } from "@/modules/email"; import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
function buildUserUpdatePayload(parsedInput: TUserPersonalInfoUpdateInput): TUserUpdateInput { function buildUserUpdatePayload(parsedInput: TUserPersonalInfoUpdateInput): TUserUpdateInput {
return { return {
@@ -84,15 +85,11 @@ export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPerso
export const resetPasswordAction = authenticatedActionClient.action( export const resetPasswordAction = authenticatedActionClient.action(
withAuditLogging("passwordReset", "user", async ({ ctx }) => { withAuditLogging("passwordReset", "user", async ({ ctx }) => {
if (PASSWORD_RESET_DISABLED) {
throw new OperationNotAllowedError("Password reset is disabled");
}
if (ctx.user.identityProvider !== "email") { if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Password reset is not allowed for this user."); throw new OperationNotAllowedError("Password reset is not allowed for this user.");
} }
await requestPasswordReset(ctx.user, "profile"); await sendForgotPasswordEmail(ctx.user);
ctx.auditLoggingCtx.userId = ctx.user.id; ctx.auditLoggingCtx.userId = ctx.user.id;
@@ -31,11 +31,11 @@ export const AccountSecurity = ({ user }: AccountSecurityProps) => {
/> />
<div className="flex flex-col"> <div className="flex flex-col">
<h1 className="text-sm font-semibold text-slate-800"> <h1 className="text-sm font-semibold text-slate-800">
{t("workspace.settings.profile.two_factor_authentication")} {t("environments.settings.profile.two_factor_authentication")}
</h1> </h1>
<p className="text-xs text-slate-600"> <p className="text-xs text-slate-600">
{t("workspace.settings.profile.two_factor_authentication_description")} {t("environments.settings.profile.two_factor_authentication_description")}
</p> </p>
</div> </div>
</div> </div>
@@ -0,0 +1,58 @@
"use client";
import type { Session } from "next-auth";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountModal";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
export const DeleteAccount = ({
session,
IS_FORMBRICKS_CLOUD,
user,
organizationsWithSingleOwner,
isMultiOrgEnabled,
}: {
session: Session | null;
IS_FORMBRICKS_CLOUD: boolean;
user: TUser;
organizationsWithSingleOwner: TOrganization[];
isMultiOrgEnabled: boolean;
}) => {
const [isModalOpen, setModalOpen] = useState(false);
const isDeleteDisabled = !isMultiOrgEnabled && organizationsWithSingleOwner.length > 0;
const { t } = useTranslation();
if (!session) {
return null;
}
return (
<div>
<DeleteAccountModal
open={isModalOpen}
setOpen={setModalOpen}
user={user}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
organizationsWithSingleOwner={organizationsWithSingleOwner}
/>
<p className="text-sm text-slate-700">
<strong>{t("environments.settings.profile.warning_cannot_undo")}</strong>
</p>
<TooltipRenderer
shouldRender={isDeleteDisabled}
tooltipContent={t("environments.settings.profile.warning_cannot_delete_account")}>
<Button
className="mt-4"
variant="destructive"
size="sm"
onClick={() => setModalOpen(!isModalOpen)}
disabled={isDeleteDisabled}>
{t("environments.settings.profile.confirm_delete_my_account")}
</Button>
</TooltipRenderer>
</div>
);
};
@@ -8,7 +8,7 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { z } from "zod"; import { z } from "zod";
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user"; import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
import { PasswordConfirmationModal } from "@/app/(app)/workspaces/[workspaceId]/settings/account/profile/components/password-confirmation-modal"; import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
import { appLanguages, sortedAppLanguages } from "@/lib/i18n/utils"; import { appLanguages, sortedAppLanguages } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
@@ -91,13 +91,13 @@ export const EditProfileDetailsForm = ({
if (!emailVerificationDisabled) { if (!emailVerificationDisabled) {
toast.success(t("auth.verification-requested.new_email_verification_success")); toast.success(t("auth.verification-requested.new_email_verification_success"));
} else { } else {
toast.success(t("workspace.settings.profile.email_change_initiated")); toast.success(t("environments.settings.profile.email_change_initiated"));
await signOutWithAudit({ await signOutWithAudit({
reason: "email_change", reason: "email_change",
redirectUrl: "/email-change-without-verification-success", redirectUrl: "/email-change-without-verification-success",
redirect: true, redirect: true,
callbackUrl: "/email-change-without-verification-success", callbackUrl: "/email-change-without-verification-success",
clearWorkspaceId: true, clearEnvironmentId: true,
}); });
return; return;
} }
@@ -116,15 +116,11 @@ export const EditProfileDetailsForm = ({
setShowModal(true); setShowModal(true);
} else { } else {
try { try {
const result = await updateUserAction({ await updateUserAction({
...data, ...data,
name: data.name.trim(), name: data.name.trim(),
}); });
if (result?.serverError) { toast.success(t("environments.settings.profile.profile_updated_successfully"));
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("workspace.settings.profile.profile_updated_successfully"));
window.location.reload(); window.location.reload();
form.reset(data); form.reset(data);
} catch (error: any) { } catch (error: any) {
@@ -145,7 +141,7 @@ export const EditProfileDetailsForm = ({
redirectUrl: "/auth/login", redirectUrl: "/auth/login",
redirect: true, redirect: true,
callbackUrl: "/auth/login", callbackUrl: "/auth/login",
clearWorkspaceId: true, clearEnvironmentId: true,
}); });
} else { } else {
const errorMessage = getFormattedErrorMessage(result); const errorMessage = getFormattedErrorMessage(result);
@@ -0,0 +1,133 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib/utils";
import { getIsEmailUnique, verifyUserPassword } from "./user";
vi.mock("@/modules/auth/lib/utils", () => ({
verifyPassword: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
findUnique: vi.fn(),
},
},
}));
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
const mockVerifyPasswordUtil = vi.mocked(mockVerifyPasswordImported);
describe("User Library Tests", () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe("verifyUserPassword", () => {
const userId = "test-user-id";
const password = "test-password";
test("should return true for correct password", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "email",
} as any);
mockVerifyPasswordUtil.mockResolvedValue(true);
const result = await verifyUserPassword(userId, password);
expect(result).toBe(true);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
});
test("should return false for incorrect password", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "email",
} as any);
mockVerifyPasswordUtil.mockResolvedValue(false);
const result = await verifyUserPassword(userId, password);
expect(result).toBe(false);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
});
test("should throw ResourceNotFoundError if user not found", async () => {
mockPrismaUserFindUnique.mockResolvedValue(null);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(ResourceNotFoundError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(`user with ID ${userId} not found`);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
test("should throw InvalidInputError if identityProvider is not email", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "google", // Not 'email'
} as any);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
test("should throw InvalidInputError if password is not set for email provider", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: null, // Password not set
identityProvider: "email",
} as any);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
});
describe("getIsEmailUnique", () => {
const email = "test@example.com";
test("should return false if user exists", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
id: "some-user-id",
} as any);
const result = await getIsEmailUnique(email);
expect(result).toBe(false);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { email },
select: { id: true },
});
});
test("should return true if user does not exist", async () => {
mockPrismaUserFindUnique.mockResolvedValue(null);
const result = await getIsEmailUnique(email);
expect(result).toBe(true);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { email },
select: { id: true },
});
});
});
});
@@ -1,40 +1,52 @@
import "server-only";
import { User } from "@prisma/client"; import { User } from "@prisma/client";
import { cache as reactCache } from "react"; import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database"; import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors"; import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword } from "@/modules/auth/lib/utils"; import { verifyPassword } from "@/modules/auth/lib/utils";
export const getUserAuthenticationData = reactCache( export const getUserById = reactCache(
async ( async (userId: string): Promise<Pick<User, "password" | "identityProvider">> => {
userId: string
): Promise<Pick<User, "email" | "password" | "identityProvider" | "identityProviderAccountId">> => {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { where: {
id: userId, id: userId,
}, },
select: { select: {
email: true,
password: true, password: true,
identityProvider: true, identityProvider: true,
identityProviderAccountId: true,
}, },
}); });
if (!user) { if (!user) {
throw new ResourceNotFoundError("user", userId); throw new ResourceNotFoundError("user", userId);
} }
return user; return user;
} }
); );
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => { export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
const user = await getUserAuthenticationData(userId); const user = await getUserById(userId);
if (!user.password) { if (user.identityProvider !== "email" || !user.password) {
throw new InvalidInputError("Password is not set for this user"); throw new InvalidInputError("Password is not set for this user");
} }
return await verifyPassword(password, user.password); const isCorrectPassword = await verifyPassword(password, user.password);
if (!isCorrectPassword) {
return false;
}
return true;
}; };
export const getIsEmailUnique = reactCache(async (email: string): Promise<boolean> => {
const user = await prisma.user.findUnique({
where: {
email: email.toLowerCase(),
},
select: {
id: true,
},
});
return !user;
});

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