mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-12 11:28:58 -05:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a10404ba1d | |||
| 39788ce0e1 |
@@ -1,9 +0,0 @@
|
|||||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
|
||||||
version = 1
|
|
||||||
name = "formbricks"
|
|
||||||
|
|
||||||
[setup]
|
|
||||||
script = '''
|
|
||||||
pnpm install
|
|
||||||
pnpm dev:setup
|
|
||||||
'''
|
|
||||||
+1
-50
@@ -94,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 #
|
||||||
@@ -139,40 +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, gcp, azure
|
|
||||||
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching credentials below.
|
|
||||||
# AI_PROVIDER=gcp
|
|
||||||
# AI_MODEL=gemini-2.5-flash
|
|
||||||
|
|
||||||
# Google Vertex AI credentials
|
|
||||||
# AI_GCP_PROJECT=
|
|
||||||
# AI_GCP_LOCATION=
|
|
||||||
# AI_GCP_CREDENTIALS_JSON=
|
|
||||||
# AI_GCP_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=
|
||||||
@@ -226,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
|
||||||
@@ -280,4 +231,4 @@ REDIS_URL=redis://localhost:6379
|
|||||||
|
|
||||||
|
|
||||||
# Lingo.dev API key for translation generation
|
# Lingo.dev API key for translation generation
|
||||||
LINGO_API_KEY=your_api_key_here
|
LINGODOTDEV_API_KEY=your_api_key_here
|
||||||
|
|||||||
@@ -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,7 +53,7 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+48
-37
@@ -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,7 +65,7 @@ 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
|
||||||
@@ -85,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: |
|
||||||
@@ -225,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
|
|
||||||
|
|||||||
@@ -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 }}
|
|
||||||
|
|||||||
@@ -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 }}
|
|
||||||
@@ -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,7 +29,7 @@ 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: cp .env.example .env
|
run: cp .env.example .env
|
||||||
|
|||||||
@@ -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,7 +33,7 @@ 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: cp .env.example .env
|
run: cp .env.example .env
|
||||||
|
|||||||
@@ -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,7 +30,7 @@ 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: cp .env.example .env
|
run: cp .env.example .env
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
|
||||||
@@ -52,14 +52,6 @@ We are using SonarQube to identify code smells and security hotspots.
|
|||||||
- Translations are in `apps/web/locales/`. Default is `en-US.json`.
|
- Translations are in `apps/web/locales/`. Default is `en-US.json`.
|
||||||
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
|
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
|
||||||
|
|
||||||
## Date and Time Rendering
|
|
||||||
|
|
||||||
- All user-facing dates and times must use shared formatting helpers instead of ad hoc `date-fns`, `Intl`, or `toLocale*` calls in components.
|
|
||||||
- Locale for display must come from the app language source of truth (`user.locale`, `getLocale()`, or `i18n.resolvedLanguage`), not browser defaults or implicit `undefined` locale behavior.
|
|
||||||
- Locale and time zone are different concerns: locale controls formatting, time zone controls the represented clock/calendar moment.
|
|
||||||
- Never infer a time zone from locale. If a product-level time zone source of truth exists, use it explicitly; otherwise preserve the existing semantic meaning of the stored value and avoid introducing browser-dependent conversions.
|
|
||||||
- Machine-facing values for storage, APIs, exports, integrations, and logs must remain stable and non-localized (`ISO 8601` / UTC where applicable).
|
|
||||||
|
|
||||||
## Database & Prisma Performance
|
## Database & Prisma Performance
|
||||||
|
|
||||||
- Multi-tenancy: All data must be scoped by Organization or Environment.
|
- Multi-tenancy: All data must be scoped by Organization or Environment.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
[](https://railway.app/new/template/PPDzCd)
|
||||||
|
|
||||||
|
##### RepoCloud
|
||||||
|
|
||||||
|
Or you can also deploy Formbricks on [RepoCloud](https://repocloud.io) using the button below.
|
||||||
|
|
||||||
|
[](https://repocloud.io/details/?app_id=254)
|
||||||
|
|
||||||
|
##### Zeabur
|
||||||
|
|
||||||
|
Or you can also deploy Formbricks on [Zeabur](https://zeabur.com) using the button below.
|
||||||
|
|
||||||
|
[](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
@@ -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.5",
|
"@storybook/addon-a11y": "10.2.17",
|
||||||
"@storybook/addon-docs": "10.3.5",
|
"@storybook/addon-links": "10.2.17",
|
||||||
"@storybook/addon-links": "10.3.5",
|
"@storybook/addon-onboarding": "10.2.17",
|
||||||
"@storybook/addon-onboarding": "10.3.5",
|
"@storybook/react-vite": "10.2.17",
|
||||||
"@storybook/react-vite": "10.3.5",
|
"@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.5",
|
"eslint-plugin-storybook": "10.2.17",
|
||||||
"storybook": "10.3.5",
|
"storybook": "10.2.17",
|
||||||
"vite": "7.3.2"
|
"vite": "7.3.1",
|
||||||
|
"@storybook/addon-docs": "10.2.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
||||||
import { getEnvironment } from "@/lib/environment/service";
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||||
@@ -21,12 +20,12 @@ const Page = async (props: ConnectPageProps) => {
|
|||||||
const environment = await getEnvironment(params.environmentId);
|
const environment = await getEnvironment(params.environmentId);
|
||||||
|
|
||||||
if (!environment) {
|
if (!environment) {
|
||||||
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
throw new Error(t("common.environment_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const project = await getProjectByEnvironmentId(environment.id);
|
const project = await getProjectByEnvironmentId(environment.id);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
throw new Error(t("common.workspace_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = project.config.channel || null;
|
const channel = project.config.channel || null;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
|
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
|
||||||
import { getEnvironment } from "@/lib/environment/service";
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
|
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
|
||||||
@@ -24,22 +23,22 @@ const Page = async (props: XMTemplatePageProps) => {
|
|||||||
const environment = await getEnvironment(params.environmentId);
|
const environment = await getEnvironment(params.environmentId);
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
throw new Error(t("common.session_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
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"));
|
||||||
}
|
}
|
||||||
if (!environment) {
|
if (!environment) {
|
||||||
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
throw new Error(t("common.environment_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||||
|
|
||||||
const project = await getProjectByEnvironmentId(environment.id);
|
const project = await getProjectByEnvironmentId(environment.id);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
throw new Error(t("common.workspace_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const projects = await getUserProjects(session.user.id, organizationId);
|
const projects = await getUserProjects(session.user.id, organizationId);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ 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 { isMember, isBilling } = getAccessFlags(membership?.role);
|
const { isMember } = getAccessFlags(membership?.role);
|
||||||
const isMembershipPending = membership?.role === undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-row">
|
<div className="flex min-h-full min-w-full flex-row">
|
||||||
@@ -46,8 +45,6 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
|
|||||||
isOwnerOrManager={false}
|
isOwnerOrManager={false}
|
||||||
isAccessControlAllowed={false}
|
isAccessControlAllowed={false}
|
||||||
isMember={isMember}
|
isMember={isMember}
|
||||||
isBilling={isBilling}
|
|
||||||
isMembershipPending={isMembershipPending}
|
|
||||||
environments={[]}
|
environments={[]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -25,7 +25,7 @@ const ProjectOnboardingLayout = 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 ProjectOnboardingLayout = 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 (
|
||||||
|
|||||||
-11
@@ -2,7 +2,6 @@ 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 { 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";
|
||||||
@@ -42,16 +41,6 @@ const Page = async (props: ChannelPageProps) => {
|
|||||||
|
|
||||||
const projects = await getUserProjects(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">
|
||||||
<Header
|
<Header
|
||||||
|
|||||||
+1
-2
@@ -1,6 +1,5 @@
|
|||||||
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";
|
||||||
@@ -29,7 +28,7 @@ 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 [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([
|
const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([
|
||||||
|
|||||||
+1
-2
@@ -18,7 +18,6 @@ import { createProjectAction } from "@/app/(app)/environments/[environmentId]/ac
|
|||||||
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 { toJsEnvironmentStateSurvey } 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 { 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";
|
||||||
@@ -243,7 +242,7 @@ export const ProjectSettings = ({
|
|||||||
<SurveyInline
|
<SurveyInline
|
||||||
appUrl={publicDomain}
|
appUrl={publicDomain}
|
||||||
isPreviewMode={true}
|
isPreviewMode={true}
|
||||||
survey={toJsEnvironmentStateSurvey(previewSurvey(projectName || 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
-15
@@ -1,13 +1,11 @@
|
|||||||
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 { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
|
||||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||||
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
|
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 { getUserProjects } from "@/lib/project/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";
|
||||||
@@ -47,23 +45,11 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
|||||||
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
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { getEnvironment } from "@/lib/environment/service";
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||||
|
|
||||||
@@ -18,13 +17,13 @@ const SurveyEditorEnvironmentLayout = async (props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
throw new Error(t("common.user_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const environment = await getEnvironment(params.environmentId);
|
const environment = await getEnvironment(params.environmentId);
|
||||||
|
|
||||||
if (!environment) {
|
if (!environment) {
|
||||||
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
throw new Error(t("common.environment_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,15 +2,10 @@
|
|||||||
|
|
||||||
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,
|
|
||||||
OperationNotAllowedError,
|
|
||||||
ResourceNotFoundError,
|
|
||||||
} from "@formbricks/types/errors";
|
|
||||||
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
||||||
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 { 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";
|
||||||
@@ -51,7 +46,7 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
|
|||||||
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 organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
|
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
|
||||||
@@ -81,19 +76,6 @@ export const createProjectAction = authenticatedActionClient.inputSchema(ZCreate
|
|||||||
notificationSettings: updatedNotificationSettings,
|
notificationSettings: updatedNotificationSettings,
|
||||||
});
|
});
|
||||||
|
|
||||||
groupIdentifyPostHog("workspace", project.id, { name: project.name });
|
|
||||||
|
|
||||||
capturePostHogEvent(
|
|
||||||
user.id,
|
|
||||||
"workspace_created",
|
|
||||||
{
|
|
||||||
organization_id: organizationId,
|
|
||||||
workspace_id: project.id,
|
|
||||||
name: project.name,
|
|
||||||
},
|
|
||||||
{ organizationId, workspaceId: project.id }
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||||
ctx.auditLoggingCtx.projectId = project.id;
|
ctx.auditLoggingCtx.projectId = project.id;
|
||||||
ctx.auditLoggingCtx.newObject = project;
|
ctx.auditLoggingCtx.newObject = project;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
||||||
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
||||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
@@ -43,7 +42,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
|||||||
|
|
||||||
// Validate that project permission exists for members
|
// Validate that project permission exists for members
|
||||||
if (isMember && !projectPermission) {
|
if (isMember && !projectPermission) {
|
||||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
throw new Error(t("common.workspace_permission_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -75,10 +74,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
|||||||
isDevelopment={IS_DEVELOPMENT}
|
isDevelopment={IS_DEVELOPMENT}
|
||||||
membershipRole={membership.role}
|
membershipRole={membership.role}
|
||||||
publicDomain={publicDomain}
|
publicDomain={publicDomain}
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
|
||||||
organizationProjectsLimit={organizationProjectsLimit}
|
|
||||||
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
|
||||||
|
|||||||
@@ -2,59 +2,42 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ArrowUpRightIcon,
|
ArrowUpRightIcon,
|
||||||
Building2Icon,
|
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
Cog,
|
Cog,
|
||||||
FoldersIcon,
|
|
||||||
Loader2,
|
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
PanelLeftCloseIcon,
|
PanelLeftCloseIcon,
|
||||||
PanelLeftOpenIcon,
|
PanelLeftOpenIcon,
|
||||||
PlusIcon,
|
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
SettingsIcon,
|
|
||||||
UserCircleIcon,
|
UserCircleIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
|
WorkflowIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useState, useTransition } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||||
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,
|
|
||||||
getProjectsForSwitcherAction,
|
|
||||||
} from "@/app/(app)/environments/[environmentId]/actions";
|
|
||||||
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
||||||
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
|
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
|
||||||
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 { getBillingFallbackPath } from "@/lib/membership/navigation";
|
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
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 { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
|
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
|
||||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
|
||||||
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
|
|
||||||
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
|
|
||||||
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
||||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
|
||||||
import packageJson from "../../../../../package.json";
|
import packageJson from "../../../../../package.json";
|
||||||
|
|
||||||
interface NavigationProps {
|
interface NavigationProps {
|
||||||
@@ -66,31 +49,8 @@ interface NavigationProps {
|
|||||||
isDevelopment: boolean;
|
isDevelopment: boolean;
|
||||||
membershipRole?: TOrganizationRole;
|
membershipRole?: TOrganizationRole;
|
||||||
publicDomain: string;
|
publicDomain: string;
|
||||||
isMultiOrgEnabled: boolean;
|
|
||||||
organizationProjectsLimit: number;
|
|
||||||
isLicenseActive: boolean;
|
|
||||||
isAccessControlAllowed: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
|
||||||
if (pathname.includes("/settings/")) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pattern = new RegExp(`/workspace/${settingId}(?:/|$)`);
|
|
||||||
return pattern.test(pathname);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
|
|
||||||
const accountSettingsPattern = /\/settings\/(profile|account|notifications|security|appearance)(?:\/|$)/;
|
|
||||||
if (accountSettingsPattern.test(pathname)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pattern = new RegExp(`/settings/${settingId}(?:/|$)`);
|
|
||||||
return pattern.test(pathname);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MainNavigation = ({
|
export const MainNavigation = ({
|
||||||
environment,
|
environment,
|
||||||
organization,
|
organization,
|
||||||
@@ -100,10 +60,6 @@ export const MainNavigation = ({
|
|||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
isDevelopment,
|
isDevelopment,
|
||||||
publicDomain,
|
publicDomain,
|
||||||
isMultiOrgEnabled,
|
|
||||||
organizationProjectsLimit,
|
|
||||||
isLicenseActive,
|
|
||||||
isAccessControlAllowed,
|
|
||||||
}: NavigationProps) => {
|
}: NavigationProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -113,12 +69,7 @@ export const MainNavigation = ({
|
|||||||
const [latestVersion, setLatestVersion] = useState("");
|
const [latestVersion, setLatestVersion] = useState("");
|
||||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
|
||||||
const { isManager, isOwner, isBilling, isMember } = getAccessFlags(membershipRole);
|
|
||||||
const isMembershipPending = membershipRole === undefined;
|
|
||||||
const disabledNavigationMessage = isMembershipPending
|
|
||||||
? t("common.loading")
|
|
||||||
: t("common.you_are_not_authorized_to_perform_this_action");
|
|
||||||
|
|
||||||
const isOwnerOrManager = isManager || isOwner;
|
const isOwnerOrManager = isManager || isOwner;
|
||||||
|
|
||||||
@@ -155,7 +106,6 @@ export const MainNavigation = ({
|
|||||||
icon: MessageCircle,
|
icon: MessageCircle,
|
||||||
isActive: pathname?.includes("/surveys"),
|
isActive: pathname?.includes("/surveys"),
|
||||||
isHidden: false,
|
isHidden: false,
|
||||||
disabled: isMembershipPending || isBilling,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: `/environments/${environment.id}/contacts`,
|
href: `/environments/${environment.id}/contacts`,
|
||||||
@@ -165,17 +115,22 @@ export const MainNavigation = ({
|
|||||||
pathname?.includes("/contacts") ||
|
pathname?.includes("/contacts") ||
|
||||||
pathname?.includes("/segments") ||
|
pathname?.includes("/segments") ||
|
||||||
pathname?.includes("/attributes"),
|
pathname?.includes("/attributes"),
|
||||||
disabled: isMembershipPending || isBilling,
|
},
|
||||||
|
{
|
||||||
|
name: t("common.workflows"),
|
||||||
|
href: `/environments/${environment.id}/workflows`,
|
||||||
|
icon: WorkflowIcon,
|
||||||
|
isActive: pathname?.includes("/workflows"),
|
||||||
|
isHidden: !isFormbricksCloud,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t("common.configuration"),
|
name: t("common.configuration"),
|
||||||
href: `/environments/${environment.id}/workspace/general`,
|
href: `/environments/${environment.id}/workspace/general`,
|
||||||
icon: Cog,
|
icon: Cog,
|
||||||
isActive: pathname?.includes("/workspace"),
|
isActive: pathname?.includes("/workspace"),
|
||||||
disabled: isMembershipPending || isBilling,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t, environment.id, pathname, isMembershipPending, isBilling]
|
[t, environment.id, pathname, isFormbricksCloud]
|
||||||
);
|
);
|
||||||
|
|
||||||
const dropdownNavigation = [
|
const dropdownNavigation = [
|
||||||
@@ -198,183 +153,6 @@ export const MainNavigation = ({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = useState(false);
|
|
||||||
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
|
|
||||||
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
|
|
||||||
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
|
|
||||||
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
|
||||||
const [hasInitializedProjects, setHasInitializedProjects] = useState(false);
|
|
||||||
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
|
|
||||||
const [workspaceLoadError, setWorkspaceLoadError] = useState<string | null>(null);
|
|
||||||
const [organizationLoadError, setOrganizationLoadError] = useState<string | null>(null);
|
|
||||||
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
|
|
||||||
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
|
|
||||||
const [openProjectLimitModal, setOpenProjectLimitModal] = useState(false);
|
|
||||||
|
|
||||||
const renderSwitcherError = (error: string, onRetry: () => void, retryLabel: string) => (
|
|
||||||
<div className="px-2 py-4">
|
|
||||||
<p className="mb-2 text-sm text-red-600">{error}</p>
|
|
||||||
<button onClick={onRetry} className="text-xs text-slate-600 underline hover:text-slate-800">
|
|
||||||
{retryLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const projectSettings = [
|
|
||||||
{
|
|
||||||
id: "general",
|
|
||||||
label: t("common.general"),
|
|
||||||
href: `/environments/${environment.id}/workspace/general`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "look",
|
|
||||||
label: t("common.look_and_feel"),
|
|
||||||
href: `/environments/${environment.id}/workspace/look`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "app-connection",
|
|
||||||
label: t("common.website_and_app_connection"),
|
|
||||||
href: `/environments/${environment.id}/workspace/app-connection`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "integrations",
|
|
||||||
label: t("common.integrations"),
|
|
||||||
href: `/environments/${environment.id}/workspace/integrations`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "teams",
|
|
||||||
label: t("common.team_access"),
|
|
||||||
href: `/environments/${environment.id}/workspace/teams`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "languages",
|
|
||||||
label: t("common.survey_languages"),
|
|
||||||
href: `/environments/${environment.id}/workspace/languages`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "tags",
|
|
||||||
label: t("common.tags"),
|
|
||||||
href: `/environments/${environment.id}/workspace/tags`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const organizationSettings = [
|
|
||||||
{
|
|
||||||
id: "general",
|
|
||||||
label: t("common.general"),
|
|
||||||
href: `/environments/${environment.id}/settings/general`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "teams",
|
|
||||||
label: t("common.members_and_teams"),
|
|
||||||
href: `/environments/${environment.id}/settings/teams`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "api-keys",
|
|
||||||
label: t("common.api_keys"),
|
|
||||||
href: `/environments/${environment.id}/settings/api-keys`,
|
|
||||||
hidden: !isOwnerOrManager,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "domain",
|
|
||||||
label: t("common.domain"),
|
|
||||||
href: `/environments/${environment.id}/settings/domain`,
|
|
||||||
hidden: isFormbricksCloud,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "billing",
|
|
||||||
label: t("common.billing"),
|
|
||||||
href: `/environments/${environment.id}/settings/billing`,
|
|
||||||
hidden: !isFormbricksCloud,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "enterprise",
|
|
||||||
label: t("common.enterprise_license"),
|
|
||||||
href: `/environments/${environment.id}/settings/enterprise`,
|
|
||||||
hidden: isFormbricksCloud || isMember,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const loadProjects = useCallback(async () => {
|
|
||||||
setIsLoadingProjects(true);
|
|
||||||
setWorkspaceLoadError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await getProjectsForSwitcherAction({ organizationId: organization.id });
|
|
||||||
if (result?.data) {
|
|
||||||
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
setProjects(sorted);
|
|
||||||
} else {
|
|
||||||
setWorkspaceLoadError(getFormattedErrorMessage(result) || t("common.failed_to_load_workspaces"));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const formattedError =
|
|
||||||
typeof error === "object" && error !== null
|
|
||||||
? getFormattedErrorMessage(error as { serverError?: string; validationErrors?: unknown })
|
|
||||||
: "";
|
|
||||||
setWorkspaceLoadError(
|
|
||||||
formattedError || (error instanceof Error ? error.message : t("common.failed_to_load_workspaces"))
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingProjects(false);
|
|
||||||
setHasInitializedProjects(true);
|
|
||||||
}
|
|
||||||
}, [organization.id, t]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isWorkspaceDropdownOpen || projects.length > 0 || isLoadingProjects || workspaceLoadError) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadProjects();
|
|
||||||
}, [isWorkspaceDropdownOpen, projects.length, isLoadingProjects, workspaceLoadError, loadProjects]);
|
|
||||||
|
|
||||||
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 (error) {
|
|
||||||
const formattedError =
|
|
||||||
typeof error === "object" && error !== null
|
|
||||||
? getFormattedErrorMessage(error as { serverError?: string; validationErrors?: unknown })
|
|
||||||
: "";
|
|
||||||
setOrganizationLoadError(
|
|
||||||
formattedError || (error instanceof Error ? error.message : t("common.failed_to_load_organizations"))
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingOrganizations(false);
|
|
||||||
}
|
|
||||||
}, [organization.id, t]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!isOrganizationDropdownOpen ||
|
|
||||||
organizations.length > 0 ||
|
|
||||||
isLoadingOrganizations ||
|
|
||||||
organizationLoadError
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadOrganizations();
|
|
||||||
}, [
|
|
||||||
isOrganizationDropdownOpen,
|
|
||||||
organizations.length,
|
|
||||||
isLoadingOrganizations,
|
|
||||||
organizationLoadError,
|
|
||||||
loadOrganizations,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadReleases() {
|
async function loadReleases() {
|
||||||
const res = await getLatestStableFbReleaseAction();
|
const res = await getLatestStableFbReleaseAction();
|
||||||
@@ -404,85 +182,7 @@ export const MainNavigation = ({
|
|||||||
organization.billing?.stripe?.trialEnd,
|
organization.billing?.stripe?.trialEnd,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const mainNavigationLink = isBilling
|
const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`;
|
||||||
? getBillingFallbackPath(environment.id, isFormbricksCloud)
|
|
||||||
: `/environments/${environment.id}/surveys/`;
|
|
||||||
|
|
||||||
const handleProjectChange = (projectId: string) => {
|
|
||||||
const targetPath =
|
|
||||||
projectId === project.id ? `/environments/${environment.id}/surveys` : `/workspaces/${projectId}/`;
|
|
||||||
startTransition(() => {
|
|
||||||
setIsWorkspaceDropdownOpen(false);
|
|
||||||
router.push(targetPath);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOrganizationChange = (organizationId: string) => {
|
|
||||||
const targetPath =
|
|
||||||
organizationId === organization.id
|
|
||||||
? `/environments/${environment.id}/settings/general`
|
|
||||||
: `/organizations/${organizationId}/`;
|
|
||||||
startTransition(() => {
|
|
||||||
setIsOrganizationDropdownOpen(false);
|
|
||||||
router.push(targetPath);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSettingNavigation = (href: string) => {
|
|
||||||
startTransition(() => {
|
|
||||||
router.push(href);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleProjectCreate = () => {
|
|
||||||
if (!hasInitializedProjects || isLoadingProjects) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (projects.length >= organizationProjectsLimit) {
|
|
||||||
setOpenProjectLimitModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpenCreateProjectModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const projectLimitModalButtons = (): [ModalButton, ModalButton] => {
|
|
||||||
if (isFormbricksCloud) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
text: t("environments.settings.billing.upgrade"),
|
|
||||||
href: `/environments/${environment.id}/settings/billing`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("common.cancel"),
|
|
||||||
onClick: () => setOpenProjectLimitModal(false),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
text: t("environments.settings.billing.upgrade"),
|
|
||||||
href: isLicenseActive
|
|
||||||
? `/environments/${environment.id}/settings/enterprise`
|
|
||||||
: "https://formbricks.com/upgrade-self-hosted-license",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("common.cancel"),
|
|
||||||
onClick: () => setOpenProjectLimitModal(false),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
const switcherTriggerClasses = cn(
|
|
||||||
"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",
|
|
||||||
isCollapsed ? "flex items-center justify-center" : ""
|
|
||||||
);
|
|
||||||
|
|
||||||
const switcherIconClasses =
|
|
||||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600";
|
|
||||||
const isInitialProjectsLoading = isWorkspaceDropdownOpen && !hasInitializedProjects && !workspaceLoadError;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -522,24 +222,24 @@ export const MainNavigation = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Nav Switch */}
|
{/* Main Nav Switch */}
|
||||||
<ul>
|
{!isBilling && (
|
||||||
{mainNavigation.map(
|
<ul>
|
||||||
(item) =>
|
{mainNavigation.map(
|
||||||
!item.isHidden && (
|
(item) =>
|
||||||
<NavigationLink
|
!item.isHidden && (
|
||||||
key={item.name}
|
<NavigationLink
|
||||||
href={item.href}
|
key={item.name}
|
||||||
isActive={item.isActive}
|
href={item.href}
|
||||||
isCollapsed={isCollapsed}
|
isActive={item.isActive}
|
||||||
isTextVisible={isTextVisible}
|
isCollapsed={isCollapsed}
|
||||||
disabled={item.disabled}
|
isTextVisible={isTextVisible}
|
||||||
disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
|
linkText={item.name}>
|
||||||
linkText={item.name}>
|
<item.icon strokeWidth={1.5} />
|
||||||
<item.icon strokeWidth={1.5} />
|
</NavigationLink>
|
||||||
</NavigationLink>
|
)
|
||||||
)
|
)}
|
||||||
)}
|
</ul>
|
||||||
</ul>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -563,210 +263,38 @@ export const MainNavigation = ({
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col">
|
{/* User Switch */}
|
||||||
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
|
<div className="flex items-center">
|
||||||
<DropdownMenuTrigger asChild id="workspaceDropdownTrigger" className={switcherTriggerClasses}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={isCollapsed ? t("common.change_workspace") : undefined}
|
|
||||||
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
|
|
||||||
<span className={switcherIconClasses}>
|
|
||||||
<FoldersIcon className="h-4 w-4" strokeWidth={1.5} />
|
|
||||||
</span>
|
|
||||||
{!isCollapsed && !isTextVisible && (
|
|
||||||
<>
|
|
||||||
<div className="grow overflow-hidden">
|
|
||||||
<p className="truncate text-sm font-bold text-slate-700">{project.name}</p>
|
|
||||||
<p className="text-sm text-slate-500">{t("common.workspace")}</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">
|
|
||||||
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
|
||||||
{t("common.change_workspace")}
|
|
||||||
</div>
|
|
||||||
{(isLoadingProjects || isInitialProjectsLoading) && (
|
|
||||||
<div className="flex items-center justify-center py-2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isLoadingProjects &&
|
|
||||||
!isInitialProjectsLoading &&
|
|
||||||
workspaceLoadError &&
|
|
||||||
renderSwitcherError(
|
|
||||||
workspaceLoadError,
|
|
||||||
() => {
|
|
||||||
setWorkspaceLoadError(null);
|
|
||||||
setProjects([]);
|
|
||||||
},
|
|
||||||
t("common.try_again")
|
|
||||||
)}
|
|
||||||
{!isLoadingProjects && !isInitialProjectsLoading && !workspaceLoadError && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
|
||||||
{projects.map((proj) => (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={proj.id}
|
|
||||||
checked={proj.id === project.id}
|
|
||||||
onClick={() => handleProjectChange(proj.id)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
{proj.name}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
{isOwnerOrManager && (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
onClick={handleProjectCreate}
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
|
||||||
<Cog 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={() => handleSettingNavigation(setting.href)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
{setting.label}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
asChild
|
|
||||||
id="organizationDropdownTriggerSidebar"
|
|
||||||
className={switcherTriggerClasses}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={isCollapsed ? t("common.change_organization") : undefined}
|
|
||||||
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
|
|
||||||
<span className={switcherIconClasses}>
|
|
||||||
<Building2Icon className="h-4 w-4" strokeWidth={1.5} />
|
|
||||||
</span>
|
|
||||||
{!isCollapsed && !isTextVisible && (
|
|
||||||
<>
|
|
||||||
<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 &&
|
|
||||||
renderSwitcherError(
|
|
||||||
organizationLoadError,
|
|
||||||
() => {
|
|
||||||
setOrganizationLoadError(null);
|
|
||||||
setOrganizations([]);
|
|
||||||
},
|
|
||||||
t("common.try_again")
|
|
||||||
)}
|
|
||||||
{!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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
|
||||||
<SettingsIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
|
||||||
{t("common.organization_settings")}
|
|
||||||
</div>
|
|
||||||
{organizationSettings.map((setting) => {
|
|
||||||
if (setting.hidden) return null;
|
|
||||||
return (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={setting.id}
|
|
||||||
checked={isActiveOrganizationSetting(pathname, setting.id)}
|
|
||||||
onClick={() => handleSettingNavigation(setting.href)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
{setting.label}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
asChild
|
asChild
|
||||||
id="userDropdownTrigger"
|
id="userDropdownTrigger"
|
||||||
className={cn(switcherTriggerClasses, "rounded-br-xl")}>
|
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
||||||
<button
|
<div
|
||||||
type="button"
|
className={cn(
|
||||||
aria-label={isCollapsed ? t("common.account_settings") : undefined}
|
"flex cursor-pointer flex-row items-center gap-3",
|
||||||
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
|
isCollapsed ? "justify-center px-2" : "px-4"
|
||||||
<span className={switcherIconClasses}>
|
)}>
|
||||||
<ProfileAvatar userId={user.id} />
|
<ProfileAvatar userId={user.id} />
|
||||||
</span>
|
|
||||||
{!isCollapsed && !isTextVisible && (
|
{!isCollapsed && !isTextVisible && (
|
||||||
<>
|
<>
|
||||||
<div className="grow overflow-hidden">
|
<div
|
||||||
|
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
|
||||||
<p
|
<p
|
||||||
title={user?.email}
|
title={user?.email}
|
||||||
className="ph-no-capture 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 className="text-sm text-slate-700">{t("common.account")}</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>
|
</div>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
@@ -775,6 +303,8 @@ export const MainNavigation = ({
|
|||||||
sideOffset={10}
|
sideOffset={10}
|
||||||
alignOffset={5}
|
alignOffset={5}
|
||||||
align="end">
|
align="end">
|
||||||
|
{/* Dropdown Items */}
|
||||||
|
|
||||||
{dropdownNavigation.map((link) => (
|
{dropdownNavigation.map((link) => (
|
||||||
<Link
|
<Link
|
||||||
href={link.href}
|
href={link.href}
|
||||||
@@ -788,6 +318,7 @@ export const MainNavigation = ({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
{/* Logout */}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const loginUrl = `${publicDomain}/auth/login`;
|
const loginUrl = `${publicDomain}/auth/login`;
|
||||||
@@ -810,28 +341,6 @@ export const MainNavigation = ({
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
{openProjectLimitModal && (
|
|
||||||
<ProjectLimitModal
|
|
||||||
open={openProjectLimitModal}
|
|
||||||
setOpen={setOpenProjectLimitModal}
|
|
||||||
buttons={projectLimitModalButtons()}
|
|
||||||
projectLimit={organizationProjectsLimit}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{openCreateProjectModal && (
|
|
||||||
<CreateProjectModal
|
|
||||||
open={openCreateProjectModal}
|
|
||||||
setOpen={setOpenCreateProjectModal}
|
|
||||||
organizationId={organization.id}
|
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{openCreateOrganizationModal && (
|
|
||||||
<CreateOrganizationModal
|
|
||||||
open={openCreateOrganizationModal}
|
|
||||||
setOpen={setOpenCreateOrganizationModal}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||||
|
|
||||||
interface NavigationLinkProps {
|
interface NavigationLinkProps {
|
||||||
@@ -11,8 +10,6 @@ interface NavigationLinkProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
linkText: string;
|
linkText: string;
|
||||||
isTextVisible: boolean;
|
isTextVisible: boolean;
|
||||||
disabled?: boolean;
|
|
||||||
disabledMessage?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NavigationLink = ({
|
export const NavigationLink = ({
|
||||||
@@ -22,34 +19,10 @@ export const NavigationLink = ({
|
|||||||
children,
|
children,
|
||||||
linkText,
|
linkText,
|
||||||
isTextVisible = true,
|
isTextVisible = true,
|
||||||
disabled = false,
|
|
||||||
disabledMessage,
|
|
||||||
}: NavigationLinkProps) => {
|
}: NavigationLinkProps) => {
|
||||||
const tooltipText = disabled ? disabledMessage || linkText : linkText;
|
|
||||||
const activeClass = "bg-slate-50 border-r-4 border-brand-dark font-semibold text-slate-900";
|
const activeClass = "bg-slate-50 border-r-4 border-brand-dark font-semibold text-slate-900";
|
||||||
const inactiveClass =
|
const inactiveClass =
|
||||||
"hover:bg-slate-50 border-r-4 border-transparent hover:border-slate-300 transition-all duration-150 ease-in-out";
|
"hover:bg-slate-50 border-r-4 border-transparent hover:border-slate-300 transition-all duration-150 ease-in-out";
|
||||||
const disabledClass = "cursor-not-allowed border-r-4 border-transparent text-slate-400";
|
|
||||||
const getColorClass = (baseClass: string) => {
|
|
||||||
if (disabled) {
|
|
||||||
return disabledClass;
|
|
||||||
}
|
|
||||||
|
|
||||||
return cn(baseClass, isActive ? activeClass : inactiveClass);
|
|
||||||
};
|
|
||||||
|
|
||||||
const collapsedColorClass = getColorClass("text-slate-700 hover:text-slate-900");
|
|
||||||
const expandedColorClass = getColorClass("text-slate-600 hover:text-slate-900");
|
|
||||||
|
|
||||||
const label = (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"ml-2 flex transition-opacity duration-100",
|
|
||||||
isTextVisible ? "opacity-0" : "opacity-100"
|
|
||||||
)}>
|
|
||||||
{linkText}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -57,37 +30,35 @@ export const NavigationLink = ({
|
|||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<li className={cn("mb-1 ml-2 rounded-l-md py-2 pl-2 text-sm", collapsedColorClass)}>
|
<li
|
||||||
{disabled ? (
|
className={cn(
|
||||||
<div className="flex items-center">{children}</div>
|
"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}>{children}</Link>
|
)}>
|
||||||
)}
|
<Link href={href} className="flex items-center">
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">{tooltipText}</TooltipContent>
|
<TooltipContent side="right">{linkText}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
) : (
|
) : (
|
||||||
<li className={cn("mb-1 rounded-l-md py-2 pl-5 text-sm", expandedColorClass)}>
|
<li
|
||||||
{disabled ? (
|
className={cn(
|
||||||
<Popover>
|
"mb-1 rounded-l-md py-2 pl-5 text-sm text-slate-600 hover:text-slate-900",
|
||||||
<PopoverTrigger asChild>
|
isActive ? activeClass : inactiveClass
|
||||||
<div className="flex items-center">
|
)}>
|
||||||
{children}
|
<Link href={href} className="flex items-center">
|
||||||
{label}
|
{children}
|
||||||
</div>
|
<span
|
||||||
</PopoverTrigger>
|
className={cn(
|
||||||
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
|
"ml-2 flex transition-opacity duration-100",
|
||||||
{disabledMessage || linkText}
|
isTextVisible ? "opacity-0" : "opacity-100"
|
||||||
</PopoverContent>
|
)}>
|
||||||
</Popover>
|
{linkText}
|
||||||
) : (
|
</span>
|
||||||
<Link href={href} className="flex items-center">
|
</Link>
|
||||||
{children}
|
|
||||||
{label}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -31,8 +31,7 @@ export const TopControlBar = ({
|
|||||||
isAccessControlAllowed,
|
isAccessControlAllowed,
|
||||||
membershipRole,
|
membershipRole,
|
||||||
}: TopControlBarProps) => {
|
}: TopControlBarProps) => {
|
||||||
const { isMember, isBilling } = getAccessFlags(membershipRole);
|
const { isMember } = getAccessFlags(membershipRole);
|
||||||
const isMembershipPending = membershipRole === undefined;
|
|
||||||
const { environment } = useEnvironment();
|
const { environment } = useEnvironment();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -50,8 +49,6 @@ export const TopControlBar = ({
|
|||||||
isLicenseActive={isLicenseActive}
|
isLicenseActive={isLicenseActive}
|
||||||
isOwnerOrManager={isOwnerOrManager}
|
isOwnerOrManager={isOwnerOrManager}
|
||||||
isMember={isMember}
|
isMember={isMember}
|
||||||
isBilling={isBilling}
|
|
||||||
isMembershipPending={isMembershipPending}
|
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
isAccessControlAllowed={isAccessControlAllowed}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+11
-41
@@ -25,7 +25,6 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
|
||||||
import { useOrganization } from "../context/environment-context";
|
import { useOrganization } from "../context/environment-context";
|
||||||
|
|
||||||
interface OrganizationBreadcrumbProps {
|
interface OrganizationBreadcrumbProps {
|
||||||
@@ -36,7 +35,6 @@ interface OrganizationBreadcrumbProps {
|
|||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
isMember: boolean;
|
isMember: boolean;
|
||||||
isOwnerOrManager: boolean;
|
isOwnerOrManager: boolean;
|
||||||
isMembershipPending: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
|
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
|
||||||
@@ -58,7 +56,6 @@ export const OrganizationBreadcrumb = ({
|
|||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
isMember,
|
isMember,
|
||||||
isOwnerOrManager,
|
isOwnerOrManager,
|
||||||
isMembershipPending,
|
|
||||||
}: OrganizationBreadcrumbProps) => {
|
}: OrganizationBreadcrumbProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
|
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
|
||||||
@@ -114,12 +111,8 @@ export const OrganizationBreadcrumb = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleOrganizationChange = (organizationId: string) => {
|
const handleOrganizationChange = (organizationId: string) => {
|
||||||
|
if (organizationId === currentOrganizationId) return;
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setIsOrganizationDropdownOpen(false);
|
|
||||||
if (organizationId === currentOrganizationId && currentEnvironmentId) {
|
|
||||||
router.push(`/environments/${currentEnvironmentId}/settings/general`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(`/organizations/${organizationId}/`);
|
router.push(`/organizations/${organizationId}/`);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -149,10 +142,7 @@ export const OrganizationBreadcrumb = ({
|
|||||||
id: "api-keys",
|
id: "api-keys",
|
||||||
label: t("common.api_keys"),
|
label: t("common.api_keys"),
|
||||||
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
|
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
|
||||||
disabled: isMembershipPending || !isOwnerOrManager,
|
hidden: !isOwnerOrManager,
|
||||||
disabledMessage: isMembershipPending
|
|
||||||
? t("common.loading")
|
|
||||||
: t("common.you_are_not_authorized_to_perform_this_action"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "domain",
|
id: "domain",
|
||||||
@@ -170,11 +160,7 @@ export const OrganizationBreadcrumb = ({
|
|||||||
id: "enterprise",
|
id: "enterprise",
|
||||||
label: t("common.enterprise_license"),
|
label: t("common.enterprise_license"),
|
||||||
href: `/environments/${currentEnvironmentId}/settings/enterprise`,
|
href: `/environments/${currentEnvironmentId}/settings/enterprise`,
|
||||||
hidden: isFormbricksCloud,
|
hidden: isFormbricksCloud || isMember,
|
||||||
disabled: isMembershipPending || isMember,
|
|
||||||
disabledMessage: isMembershipPending
|
|
||||||
? t("common.loading")
|
|
||||||
: t("common.you_are_not_authorized_to_perform_this_action"),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -256,30 +242,14 @@ export const OrganizationBreadcrumb = ({
|
|||||||
|
|
||||||
{organizationSettings.map((setting) => {
|
{organizationSettings.map((setting) => {
|
||||||
return setting.hidden ? null : (
|
return setting.hidden ? null : (
|
||||||
<div key={setting.id}>
|
<DropdownMenuCheckboxItem
|
||||||
{setting.disabled ? (
|
key={setting.id}
|
||||||
<Popover>
|
checked={isActiveOrganizationSetting(pathname, setting.id)}
|
||||||
<PopoverTrigger asChild>
|
hidden={setting.hidden}
|
||||||
<button
|
onClick={() => handleSettingChange(setting.href)}
|
||||||
type="button"
|
className="cursor-pointer">
|
||||||
aria-disabled="true"
|
{setting.label}
|
||||||
className="relative flex w-full cursor-not-allowed select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
|
</DropdownMenuCheckboxItem>
|
||||||
{setting.label}
|
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
|
|
||||||
{setting.disabledMessage}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
) : (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
checked={isActiveOrganizationSetting(pathname, setting.id)}
|
|
||||||
onClick={() => handleSettingChange(setting.href)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
{setting.label}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ interface ProjectAndOrgSwitchProps {
|
|||||||
isLicenseActive: boolean;
|
isLicenseActive: boolean;
|
||||||
isOwnerOrManager: boolean;
|
isOwnerOrManager: boolean;
|
||||||
isMember: boolean;
|
isMember: boolean;
|
||||||
isBilling: boolean;
|
|
||||||
isMembershipPending: boolean;
|
|
||||||
isAccessControlAllowed: boolean;
|
isAccessControlAllowed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,8 +35,6 @@ export const ProjectAndOrgSwitch = ({
|
|||||||
isOwnerOrManager,
|
isOwnerOrManager,
|
||||||
isAccessControlAllowed,
|
isAccessControlAllowed,
|
||||||
isMember,
|
isMember,
|
||||||
isBilling,
|
|
||||||
isMembershipPending,
|
|
||||||
}: ProjectAndOrgSwitchProps) => {
|
}: ProjectAndOrgSwitchProps) => {
|
||||||
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
||||||
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
|
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
|
||||||
@@ -54,7 +50,6 @@ export const ProjectAndOrgSwitch = ({
|
|||||||
isFormbricksCloud={isFormbricksCloud}
|
isFormbricksCloud={isFormbricksCloud}
|
||||||
isMember={isMember}
|
isMember={isMember}
|
||||||
isOwnerOrManager={isOwnerOrManager}
|
isOwnerOrManager={isOwnerOrManager}
|
||||||
isMembershipPending={isMembershipPending}
|
|
||||||
/>
|
/>
|
||||||
{currentProjectId && currentEnvironmentId && (
|
{currentProjectId && currentEnvironmentId && (
|
||||||
<ProjectBreadcrumb
|
<ProjectBreadcrumb
|
||||||
@@ -68,8 +63,6 @@ export const ProjectAndOrgSwitch = ({
|
|||||||
isLicenseActive={isLicenseActive}
|
isLicenseActive={isLicenseActive}
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
isAccessControlAllowed={isAccessControlAllowed}
|
||||||
isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb}
|
isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb}
|
||||||
isBilling={isBilling}
|
|
||||||
isMembershipPending={isMembershipPending}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showEnvironmentBreadcrumb && (
|
{showEnvironmentBreadcrumb && (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FoldersIcon, Loader2, PlusIcon } from "lucide-react";
|
import { ChevronDownIcon, ChevronRightIcon, CogIcon, HotelIcon, Loader2, PlusIcon } from "lucide-react";
|
||||||
import { usePathname, 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";
|
||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
|
||||||
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
||||||
import { useProject } from "../context/environment-context";
|
import { useProject } from "../context/environment-context";
|
||||||
|
|
||||||
@@ -34,8 +33,6 @@ interface ProjectBreadcrumbProps {
|
|||||||
currentEnvironmentId: string;
|
currentEnvironmentId: string;
|
||||||
isAccessControlAllowed: boolean;
|
isAccessControlAllowed: boolean;
|
||||||
isEnvironmentBreadcrumbVisible: boolean;
|
isEnvironmentBreadcrumbVisible: boolean;
|
||||||
isBilling: boolean;
|
|
||||||
isMembershipPending: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
||||||
@@ -59,8 +56,6 @@ export const ProjectBreadcrumb = ({
|
|||||||
currentEnvironmentId,
|
currentEnvironmentId,
|
||||||
isAccessControlAllowed,
|
isAccessControlAllowed,
|
||||||
isEnvironmentBreadcrumbVisible,
|
isEnvironmentBreadcrumbVisible,
|
||||||
isBilling,
|
|
||||||
isMembershipPending,
|
|
||||||
}: ProjectBreadcrumbProps) => {
|
}: ProjectBreadcrumbProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
|
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
|
||||||
@@ -139,10 +134,6 @@ export const ProjectBreadcrumb = ({
|
|||||||
href: `/environments/${currentEnvironmentId}/workspace/tags`,
|
href: `/environments/${currentEnvironmentId}/workspace/tags`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const areProjectSettingsDisabled = isMembershipPending || isBilling;
|
|
||||||
const projectSettingsDisabledMessage = isMembershipPending
|
|
||||||
? t("common.loading")
|
|
||||||
: t("common.you_are_not_authorized_to_perform_this_action");
|
|
||||||
|
|
||||||
if (!currentProject) {
|
if (!currentProject) {
|
||||||
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
|
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
|
||||||
@@ -152,13 +143,9 @@ export const ProjectBreadcrumb = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleProjectChange = (projectId: string) => {
|
const handleProjectChange = (projectId: string) => {
|
||||||
const targetPath =
|
if (projectId === currentProjectId) return;
|
||||||
projectId === currentProjectId
|
|
||||||
? `/environments/${currentEnvironmentId}/surveys`
|
|
||||||
: `/workspaces/${projectId}/`;
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setIsProjectDropdownOpen(false);
|
router.push(`/workspaces/${projectId}/`);
|
||||||
router.push(targetPath);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -211,7 +198,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
id="projectDropdownTrigger"
|
id="projectDropdownTrigger"
|
||||||
asChild>
|
asChild>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<FoldersIcon className="h-3 w-3" strokeWidth={1.5} />
|
<HotelIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
<span>{projectName}</span>
|
<span>{projectName}</span>
|
||||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||||
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
|
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
|
||||||
@@ -224,7 +211,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
|
|
||||||
<DropdownMenuContent align="start" className="mt-2">
|
<DropdownMenuContent align="start" className="mt-2">
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
<HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||||
{t("common.choose_workspace")}
|
{t("common.choose_workspace")}
|
||||||
</div>
|
</div>
|
||||||
{isLoadingProjects && (
|
{isLoadingProjects && (
|
||||||
@@ -260,24 +247,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
</DropdownMenuCheckboxItem>
|
</DropdownMenuCheckboxItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
{isMembershipPending || !isOwnerOrManager ? (
|
{isOwnerOrManager && (
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-disabled="true"
|
|
||||||
className="relative flex w-full cursor-not-allowed select-none items-center justify-between rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
|
|
||||||
<span>{t("common.add_new_workspace")}</span>
|
|
||||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
|
|
||||||
{isMembershipPending
|
|
||||||
? t("common.loading")
|
|
||||||
: t("common.you_are_not_authorized_to_perform_this_action")}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
) : (
|
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
onClick={handleAddProject}
|
onClick={handleAddProject}
|
||||||
className="w-full cursor-pointer justify-between">
|
className="w-full cursor-pointer justify-between">
|
||||||
@@ -294,30 +264,13 @@ export const ProjectBreadcrumb = ({
|
|||||||
{t("common.workspace_configuration")}
|
{t("common.workspace_configuration")}
|
||||||
</div>
|
</div>
|
||||||
{projectSettings.map((setting) => (
|
{projectSettings.map((setting) => (
|
||||||
<div key={setting.id}>
|
<DropdownMenuCheckboxItem
|
||||||
{areProjectSettingsDisabled ? (
|
key={setting.id}
|
||||||
<Popover>
|
checked={isActiveProjectSetting(pathname, setting.id)}
|
||||||
<PopoverTrigger asChild>
|
onClick={() => handleProjectSettingsNavigation(setting.id)}
|
||||||
<button
|
className="cursor-pointer">
|
||||||
type="button"
|
{setting.label}
|
||||||
aria-disabled="true"
|
</DropdownMenuCheckboxItem>
|
||||||
className="relative flex w-full cursor-not-allowed select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
|
|
||||||
{setting.label}
|
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
|
|
||||||
{projectSettingsDisabledMessage}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
) : (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
checked={isActiveProjectSetting(pathname, setting.id)}
|
|
||||||
onClick={() => handleProjectSettingsNavigation(setting.id)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
{setting.label}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { getServerSession } from "next-auth";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
||||||
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||||
import { PostHogGroupIdentify } from "@/app/posthog/PostHogGroupIdentify";
|
|
||||||
import { POSTHOG_KEY } from "@/lib/constants";
|
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
|
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
|
||||||
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
|
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
|
||||||
@@ -27,14 +25,6 @@ const EnvLayout = async (props: {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
||||||
{POSTHOG_KEY && (
|
|
||||||
<PostHogGroupIdentify
|
|
||||||
organizationId={layoutData.organization.id}
|
|
||||||
organizationName={layoutData.organization.name}
|
|
||||||
workspaceId={layoutData.project.id}
|
|
||||||
workspaceName={layoutData.project.name}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<EnvironmentContextWrapper
|
<EnvironmentContextWrapper
|
||||||
environment={layoutData.environment}
|
environment={layoutData.environment}
|
||||||
project={layoutData.project}
|
project={layoutData.project}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
|
||||||
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 { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
@@ -13,7 +12,11 @@ const EnvironmentPage = async (props: { params: Promise<{ environmentId: string
|
|||||||
const { isBilling } = getAccessFlags(currentUserMembership?.role);
|
const { isBilling } = getAccessFlags(currentUserMembership?.role);
|
||||||
|
|
||||||
if (isBilling) {
|
if (isBilling) {
|
||||||
return redirect(getBillingFallbackPath(params.environmentId, IS_FORMBRICKS_CLOUD));
|
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`);
|
return redirect(`/environments/${params.environmentId}/surveys`);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
@@ -21,15 +20,15 @@ const AccountSettingsLayout = async (props: {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new ResourceNotFoundError(t("common.organization"), null);
|
throw new Error(t("common.organization_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
throw new Error(t("common.workspace_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
throw new Error(t("common.session_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
+3
-4
@@ -1,6 +1,5 @@
|
|||||||
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 { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||||
@@ -147,18 +146,18 @@ const Page = async (props: {
|
|||||||
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) {
|
||||||
|
|||||||
+7
-10
@@ -6,18 +6,19 @@ import {
|
|||||||
TUserUpdateInput,
|
TUserUpdateInput,
|
||||||
ZUserPersonalInfoUpdateInput,
|
ZUserPersonalInfoUpdateInput,
|
||||||
} from "@formbricks/types/user";
|
} from "@formbricks/types/user";
|
||||||
import { getIsEmailUnique } from "@/app/(app)/environments/[environmentId]/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;
|
||||||
|
|
||||||
|
|||||||
+8
-47
@@ -1,68 +1,30 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { Session } from "next-auth";
|
import type { Session } from "next-auth";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
|
||||||
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 { DeleteAccountModal } from "@/modules/account/components/DeleteAccountModal";
|
import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountModal";
|
||||||
import {
|
|
||||||
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
|
|
||||||
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
|
|
||||||
} from "@/modules/account/constants";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||||
|
|
||||||
interface DeleteAccountProps {
|
|
||||||
session: Session | null;
|
|
||||||
IS_FORMBRICKS_CLOUD: boolean;
|
|
||||||
user: TUser;
|
|
||||||
organizationsWithSingleOwner: TOrganization[];
|
|
||||||
accountDeletionError?: string | string[];
|
|
||||||
isMultiOrgEnabled: boolean;
|
|
||||||
requiresPasswordConfirmation: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DeleteAccount = ({
|
export const DeleteAccount = ({
|
||||||
session,
|
session,
|
||||||
IS_FORMBRICKS_CLOUD,
|
IS_FORMBRICKS_CLOUD,
|
||||||
user,
|
user,
|
||||||
organizationsWithSingleOwner,
|
organizationsWithSingleOwner,
|
||||||
accountDeletionError,
|
|
||||||
isMultiOrgEnabled,
|
isMultiOrgEnabled,
|
||||||
requiresPasswordConfirmation,
|
}: {
|
||||||
}: Readonly<DeleteAccountProps>) => {
|
session: Session | null;
|
||||||
|
IS_FORMBRICKS_CLOUD: boolean;
|
||||||
|
user: TUser;
|
||||||
|
organizationsWithSingleOwner: TOrganization[];
|
||||||
|
isMultiOrgEnabled: boolean;
|
||||||
|
}) => {
|
||||||
const [isModalOpen, setModalOpen] = useState(false);
|
const [isModalOpen, setModalOpen] = useState(false);
|
||||||
const isDeleteDisabled = !isMultiOrgEnabled && organizationsWithSingleOwner.length > 0;
|
const isDeleteDisabled = !isMultiOrgEnabled && organizationsWithSingleOwner.length > 0;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const accountDeletionErrorCode = Array.isArray(accountDeletionError)
|
|
||||||
? accountDeletionError[0]
|
|
||||||
: accountDeletionError;
|
|
||||||
const hasShownAccountDeletionError = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!accountDeletionErrorCode || hasShownAccountDeletionError.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
hasShownAccountDeletionError.current = true;
|
|
||||||
|
|
||||||
if (accountDeletionErrorCode === ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE) {
|
|
||||||
toast.error(t("environments.settings.profile.google_sso_account_deletion_requires_setup"), {
|
|
||||||
id: "account-deletion-sso-reauth-error",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast.error(t("environments.settings.profile.sso_reauthentication_failed"), {
|
|
||||||
id: "account-deletion-sso-reauth-error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(globalThis.location.href);
|
|
||||||
url.searchParams.delete(ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM);
|
|
||||||
globalThis.history.replaceState(null, "", url.toString());
|
|
||||||
}, [accountDeletionErrorCode, t]);
|
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -70,7 +32,6 @@ export const DeleteAccount = ({
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<DeleteAccountModal
|
<DeleteAccountModal
|
||||||
requiresPasswordConfirmation={requiresPasswordConfirmation}
|
|
||||||
open={isModalOpen}
|
open={isModalOpen}
|
||||||
setOpen={setModalOpen}
|
setOpen={setModalOpen}
|
||||||
user={user}
|
user={user}
|
||||||
|
|||||||
+1
-5
@@ -116,14 +116,10 @@ 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.error(getFormattedErrorMessage(result));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toast.success(t("environments.settings.profile.profile_updated_successfully"));
|
toast.success(t("environments.settings.profile.profile_updated_successfully"));
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
form.reset(data);
|
form.reset(data);
|
||||||
|
|||||||
+87
-1
@@ -1,6 +1,12 @@
|
|||||||
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 { getIsEmailUnique } from "./user";
|
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", () => ({
|
vi.mock("@formbricks/database", () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
@@ -11,12 +17,92 @@ vi.mock("@formbricks/database", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
|
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
|
||||||
|
const mockVerifyPasswordUtil = vi.mocked(mockVerifyPasswordImported);
|
||||||
|
|
||||||
describe("User Library Tests", () => {
|
describe("User Library Tests", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
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", () => {
|
describe("getIsEmailUnique", () => {
|
||||||
const email = "test@example.com";
|
const email = "test@example.com";
|
||||||
|
|
||||||
|
|||||||
+37
@@ -1,5 +1,42 @@
|
|||||||
|
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 { verifyPassword } from "@/modules/auth/lib/utils";
|
||||||
|
|
||||||
|
export const getUserById = reactCache(
|
||||||
|
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> => {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
password: true,
|
||||||
|
identityProvider: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
throw new ResourceNotFoundError("user", userId);
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
|
||||||
|
const user = await getUserById(userId);
|
||||||
|
|
||||||
|
if (user.identityProvider !== "email" || !user.password) {
|
||||||
|
throw new InvalidInputError("Password is not set for this user");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCorrectPassword = await verifyPassword(password, user.password);
|
||||||
|
|
||||||
|
if (!isCorrectPassword) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
export const getIsEmailUnique = reactCache(async (email: string): Promise<boolean> => {
|
export const getIsEmailUnique = reactCache(async (email: string): Promise<boolean> => {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
|
|||||||
+2
-11
@@ -1,11 +1,9 @@
|
|||||||
import { AuthenticationError } from "@formbricks/types/errors";
|
|
||||||
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||||
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
|
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
|
||||||
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||||
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { requiresPasswordConfirmationForAccountDeletion } from "@/modules/account/lib/account-deletion-auth";
|
|
||||||
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||||
@@ -16,14 +14,10 @@ import { SettingsCard } from "../../components/SettingsCard";
|
|||||||
import { DeleteAccount } from "./components/DeleteAccount";
|
import { DeleteAccount } from "./components/DeleteAccount";
|
||||||
import { EditProfileDetailsForm } from "./components/EditProfileDetailsForm";
|
import { EditProfileDetailsForm } from "./components/EditProfileDetailsForm";
|
||||||
|
|
||||||
const Page = async (props: {
|
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||||
params: Promise<{ environmentId: string }>;
|
|
||||||
searchParams: Promise<{ accountDeletionError?: string | string[] }>;
|
|
||||||
}) => {
|
|
||||||
const isTwoFactorAuthEnabled = await getIsTwoFactorAuthEnabled();
|
const isTwoFactorAuthEnabled = await getIsTwoFactorAuthEnabled();
|
||||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const searchParams = await props.searchParams;
|
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const { environmentId } = params;
|
const { environmentId } = params;
|
||||||
|
|
||||||
@@ -34,11 +28,10 @@ const Page = async (props: {
|
|||||||
const user = session?.user ? await getUser(session.user.id) : null;
|
const user = session?.user ? await getUser(session.user.id) : null;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
throw new Error(t("common.user_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
|
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
|
||||||
const requiresPasswordConfirmation = requiresPasswordConfirmationForAccountDeletion(user);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
@@ -96,8 +89,6 @@ const Page = async (props: {
|
|||||||
user={user}
|
user={user}
|
||||||
organizationsWithSingleOwner={organizationsWithSingleOwner}
|
organizationsWithSingleOwner={organizationsWithSingleOwner}
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
accountDeletionError={searchParams.accountDeletionError}
|
|
||||||
requiresPasswordConfirmation={requiresPasswordConfirmation}
|
|
||||||
/>
|
/>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
|
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
|
||||||
|
|||||||
+5
-13
@@ -22,9 +22,8 @@ export const OrganizationSettingsNavbar = ({
|
|||||||
loading,
|
loading,
|
||||||
}: OrganizationSettingsNavbarProps) => {
|
}: OrganizationSettingsNavbarProps) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { isMember, isOwner, isManager } = getAccessFlags(membershipRole);
|
const { isMember, isOwner } = getAccessFlags(membershipRole);
|
||||||
const isOwnerOrManager = isOwner || isManager;
|
const isPricingDisabled = isMember;
|
||||||
const isMembershipPending = membershipRole === undefined || loading;
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
@@ -46,10 +45,7 @@ export const OrganizationSettingsNavbar = ({
|
|||||||
label: t("common.api_keys"),
|
label: t("common.api_keys"),
|
||||||
href: `/environments/${environmentId}/settings/api-keys`,
|
href: `/environments/${environmentId}/settings/api-keys`,
|
||||||
current: pathname?.includes("/api-keys"),
|
current: pathname?.includes("/api-keys"),
|
||||||
disabled: isMembershipPending || !isOwnerOrManager,
|
hidden: !isOwner,
|
||||||
disabledMessage: isMembershipPending
|
|
||||||
? t("common.loading")
|
|
||||||
: t("common.you_are_not_authorized_to_perform_this_action"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "domain",
|
id: "domain",
|
||||||
@@ -62,18 +58,14 @@ export const OrganizationSettingsNavbar = ({
|
|||||||
id: "billing",
|
id: "billing",
|
||||||
label: t("common.billing"),
|
label: t("common.billing"),
|
||||||
href: `/environments/${environmentId}/settings/billing`,
|
href: `/environments/${environmentId}/settings/billing`,
|
||||||
hidden: !isFormbricksCloud,
|
hidden: !isFormbricksCloud || loading,
|
||||||
current: pathname?.includes("/billing"),
|
current: pathname?.includes("/billing"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "enterprise",
|
id: "enterprise",
|
||||||
label: t("common.enterprise_license"),
|
label: t("common.enterprise_license"),
|
||||||
href: `/environments/${environmentId}/settings/enterprise`,
|
href: `/environments/${environmentId}/settings/enterprise`,
|
||||||
hidden: isFormbricksCloud,
|
hidden: isFormbricksCloud || isPricingDisabled,
|
||||||
disabled: isMembershipPending || isMember,
|
|
||||||
disabledMessage: isMembershipPending
|
|
||||||
? t("common.loading")
|
|
||||||
: t("common.you_are_not_authorized_to_perform_this_action"),
|
|
||||||
current: pathname?.includes("/enterprise"),
|
current: pathname?.includes("/enterprise"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+1
-2
@@ -1,5 +1,4 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { AuthenticationError } from "@formbricks/types/errors";
|
|
||||||
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
|
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
|
||||||
@@ -26,7 +25,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
throw new Error(t("common.session_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
|
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
|
||||||
|
|||||||
-146
@@ -1,146 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import type { TFunction } from "i18next";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
|
||||||
import type { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
|
|
||||||
import { Badge } from "@/modules/ui/components/badge";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
|
||||||
|
|
||||||
type TPublicLicenseFeatureKey = Exclude<keyof TEnterpriseLicenseFeatures, "isMultiOrgEnabled" | "ai">;
|
|
||||||
|
|
||||||
type TFeatureDefinition = {
|
|
||||||
key: TPublicLicenseFeatureKey;
|
|
||||||
labelKey: string;
|
|
||||||
docsUrl: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
key: "contacts",
|
|
||||||
labelKey: t("environments.settings.enterprise.license_feature_contacts"),
|
|
||||||
docsUrl:
|
|
||||||
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/contact-management-segments",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "projects",
|
|
||||||
labelKey: t("environments.settings.enterprise.license_feature_projects"),
|
|
||||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/license",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "whitelabel",
|
|
||||||
labelKey: t("environments.settings.enterprise.license_feature_whitelabel"),
|
|
||||||
docsUrl:
|
|
||||||
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/whitelabel-email-follow-ups",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "removeBranding",
|
|
||||||
labelKey: t("environments.settings.enterprise.license_feature_remove_branding"),
|
|
||||||
docsUrl:
|
|
||||||
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/hide-powered-by-formbricks",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "twoFactorAuth",
|
|
||||||
labelKey: t("environments.settings.enterprise.license_feature_two_factor_auth"),
|
|
||||||
docsUrl: "https://formbricks.com/docs/xm-and-surveys/core-features/user-management/two-factor-auth",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "sso",
|
|
||||||
labelKey: t("environments.settings.enterprise.license_feature_sso"),
|
|
||||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/oidc-sso",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "saml",
|
|
||||||
labelKey: t("environments.settings.enterprise.license_feature_saml"),
|
|
||||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/saml-sso",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "spamProtection",
|
|
||||||
labelKey: t("environments.settings.enterprise.license_feature_spam_protection"),
|
|
||||||
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/spam-protection",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "auditLogs",
|
|
||||||
labelKey: t("environments.settings.enterprise.license_feature_audit_logs"),
|
|
||||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/audit-logging",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "accessControl",
|
|
||||||
labelKey: t("environments.settings.enterprise.license_feature_access_control"),
|
|
||||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/team-access",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "quotas",
|
|
||||||
labelKey: t("environments.settings.enterprise.license_feature_quotas"),
|
|
||||||
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/quota-management",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
interface EnterpriseLicenseFeaturesTableProps {
|
|
||||||
features: TEnterpriseLicenseFeatures;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EnterpriseLicenseFeaturesTable = ({ features }: EnterpriseLicenseFeaturesTableProps) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsCard
|
|
||||||
title={t("environments.settings.enterprise.license_features_table_title")}
|
|
||||||
description={t("environments.settings.enterprise.license_features_table_description")}
|
|
||||||
noPadding>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow className="hover:bg-white">
|
|
||||||
<TableHead>{t("environments.settings.enterprise.license_features_table_feature")}</TableHead>
|
|
||||||
<TableHead>{t("environments.settings.enterprise.license_features_table_access")}</TableHead>
|
|
||||||
<TableHead>{t("environments.settings.enterprise.license_features_table_value")}</TableHead>
|
|
||||||
<TableHead>{t("common.documentation")}</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{getFeatureDefinitions(t).map((feature) => {
|
|
||||||
const value = features[feature.key];
|
|
||||||
const isEnabled = typeof value === "boolean" ? value : value === null || value > 0;
|
|
||||||
let displayValue: number | string = "—";
|
|
||||||
|
|
||||||
if (typeof value === "number") {
|
|
||||||
displayValue = value;
|
|
||||||
} else if (value === null) {
|
|
||||||
displayValue = t("environments.settings.enterprise.license_features_table_unlimited");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRow key={feature.key} className="hover:bg-white">
|
|
||||||
<TableCell className="font-medium text-slate-900">{t(feature.labelKey)}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge
|
|
||||||
type={isEnabled ? "success" : "gray"}
|
|
||||||
size="normal"
|
|
||||||
text={
|
|
||||||
isEnabled
|
|
||||||
? t("environments.settings.enterprise.license_features_table_enabled")
|
|
||||||
: t("environments.settings.enterprise.license_features_table_disabled")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-slate-600">{displayValue}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Link
|
|
||||||
href={feature.docsUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-sm font-medium text-slate-700 underline underline-offset-2 hover:text-slate-900">
|
|
||||||
{t("common.read_docs")}
|
|
||||||
</Link>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</SettingsCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
+3
-12
@@ -6,7 +6,6 @@ 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 { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
|
||||||
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
|
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
|
||||||
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
|
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
|
||||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||||
@@ -16,7 +15,6 @@ import { SettingsCard } from "../../../components/SettingsCard";
|
|||||||
|
|
||||||
interface EnterpriseLicenseStatusProps {
|
interface EnterpriseLicenseStatusProps {
|
||||||
status: TLicenseStatus;
|
status: TLicenseStatus;
|
||||||
lastChecked: Date;
|
|
||||||
gracePeriodEnd?: Date;
|
gracePeriodEnd?: Date;
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
}
|
}
|
||||||
@@ -46,12 +44,10 @@ const getBadgeConfig = (
|
|||||||
|
|
||||||
export const EnterpriseLicenseStatus = ({
|
export const EnterpriseLicenseStatus = ({
|
||||||
status,
|
status,
|
||||||
lastChecked,
|
|
||||||
gracePeriodEnd,
|
gracePeriodEnd,
|
||||||
environmentId,
|
environmentId,
|
||||||
}: EnterpriseLicenseStatusProps) => {
|
}: EnterpriseLicenseStatusProps) => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isRechecking, setIsRechecking] = useState(false);
|
const [isRechecking, setIsRechecking] = useState(false);
|
||||||
|
|
||||||
@@ -96,12 +92,7 @@ export const EnterpriseLicenseStatus = ({
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
|
||||||
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
|
|
||||||
<span className="text-sm text-slate-500">
|
|
||||||
{t("common.updated_at")} {formatDateTimeForDisplay(new Date(lastChecked), locale)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -127,7 +118,7 @@ export const EnterpriseLicenseStatus = ({
|
|||||||
<Alert variant="warning" size="small">
|
<Alert variant="warning" size="small">
|
||||||
<AlertDescription className="overflow-visible whitespace-normal">
|
<AlertDescription className="overflow-visible whitespace-normal">
|
||||||
{t("environments.settings.enterprise.license_unreachable_grace_period", {
|
{t("environments.settings.enterprise.license_unreachable_grace_period", {
|
||||||
gracePeriodEnd: formatDateForDisplay(new Date(gracePeriodEnd), locale, {
|
gracePeriodEnd: new Date(gracePeriodEnd).toLocaleDateString(undefined, {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
|
|||||||
+9
-14
@@ -10,7 +10,6 @@ import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
|||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
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 { EnterpriseLicenseFeaturesTable } from "./components/EnterpriseLicenseFeaturesTable";
|
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
@@ -94,19 +93,15 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
/>
|
/>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
{hasLicense ? (
|
{hasLicense ? (
|
||||||
<>
|
<EnterpriseLicenseStatus
|
||||||
<EnterpriseLicenseStatus
|
status={licenseState.status}
|
||||||
status={licenseState.status}
|
gracePeriodEnd={
|
||||||
lastChecked={licenseState.lastChecked}
|
licenseState.status === "unreachable"
|
||||||
gracePeriodEnd={
|
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
|
||||||
licenseState.status === "unreachable"
|
: undefined
|
||||||
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
|
}
|
||||||
: undefined
|
environmentId={params.environmentId}
|
||||||
}
|
/>
|
||||||
environmentId={params.environmentId}
|
|
||||||
/>
|
|
||||||
{licenseState.features && <EnterpriseLicenseFeaturesTable features={licenseState.features} />}
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
|
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
|
||||||
|
|||||||
-218
@@ -1,218 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
|
||||||
import { updateOrganizationAISettingsAction } from "./actions";
|
|
||||||
import { ZOrganizationAISettingsInput } from "./schemas";
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
isInstanceAIConfigured: vi.fn(),
|
|
||||||
checkAuthorizationUpdated: vi.fn(),
|
|
||||||
deleteOrganization: vi.fn(),
|
|
||||||
getOrganization: vi.fn(),
|
|
||||||
getIsMultiOrgEnabled: vi.fn(),
|
|
||||||
getTranslate: vi.fn(),
|
|
||||||
updateOrganization: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/utils/action-client", () => ({
|
|
||||||
authenticatedActionClient: {
|
|
||||||
inputSchema: vi.fn(() => ({
|
|
||||||
action: vi.fn((fn) => fn),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
|
|
||||||
checkAuthorizationUpdated: mocks.checkAuthorizationUpdated,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/organization/service", () => ({
|
|
||||||
deleteOrganization: mocks.deleteOrganization,
|
|
||||||
getOrganization: mocks.getOrganization,
|
|
||||||
updateOrganization: mocks.updateOrganization,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/ai/service", () => ({
|
|
||||||
isInstanceAIConfigured: mocks.isInstanceAIConfigured,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lingodotdev/server", () => ({
|
|
||||||
getTranslate: mocks.getTranslate,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
|
||||||
withAuditLogging: vi.fn((_eventName, _objectType, fn) => fn),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
|
||||||
getIsMultiOrgEnabled: mocks.getIsMultiOrgEnabled,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const organizationId = "cm9gptbhg0000192zceq9ayuc";
|
|
||||||
|
|
||||||
describe("organization AI settings actions", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
|
|
||||||
mocks.checkAuthorizationUpdated.mockResolvedValue(undefined);
|
|
||||||
mocks.getOrganization.mockResolvedValue({
|
|
||||||
id: organizationId,
|
|
||||||
isAISmartToolsEnabled: false,
|
|
||||||
isAIDataAnalysisEnabled: false,
|
|
||||||
});
|
|
||||||
mocks.isInstanceAIConfigured.mockReturnValue(true);
|
|
||||||
mocks.getTranslate.mockResolvedValue((key: string, values?: Record<string, string>) =>
|
|
||||||
values ? `${key}:${JSON.stringify(values)}` : key
|
|
||||||
);
|
|
||||||
mocks.updateOrganization.mockResolvedValue({
|
|
||||||
id: organizationId,
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
isAIDataAnalysisEnabled: false,
|
|
||||||
});
|
|
||||||
mocks.getIsMultiOrgEnabled.mockResolvedValue(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accepts AI toggle updates", () => {
|
|
||||||
expect(
|
|
||||||
ZOrganizationAISettingsInput.parse({
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
})
|
|
||||||
).toEqual({
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("passes owner and manager roles to the authorization check and updates organization settings", async () => {
|
|
||||||
const ctx = {
|
|
||||||
user: { id: "user_1", locale: "en-US" },
|
|
||||||
auditLoggingCtx: {},
|
|
||||||
};
|
|
||||||
const parsedInput = {
|
|
||||||
organizationId,
|
|
||||||
data: {
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await updateOrganizationAISettingsAction({ ctx, parsedInput } as any);
|
|
||||||
|
|
||||||
expect(mocks.checkAuthorizationUpdated).toHaveBeenCalledWith({
|
|
||||||
userId: "user_1",
|
|
||||||
organizationId,
|
|
||||||
access: [
|
|
||||||
{
|
|
||||||
type: "organization",
|
|
||||||
schema: ZOrganizationAISettingsInput,
|
|
||||||
data: parsedInput.data,
|
|
||||||
roles: ["owner", "manager"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(mocks.getOrganization).toHaveBeenCalledWith(organizationId);
|
|
||||||
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, parsedInput.data);
|
|
||||||
expect(ctx.auditLoggingCtx).toMatchObject({
|
|
||||||
organizationId,
|
|
||||||
oldObject: {
|
|
||||||
id: organizationId,
|
|
||||||
isAISmartToolsEnabled: false,
|
|
||||||
isAIDataAnalysisEnabled: false,
|
|
||||||
},
|
|
||||||
newObject: {
|
|
||||||
id: organizationId,
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
isAIDataAnalysisEnabled: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(result).toEqual({
|
|
||||||
id: organizationId,
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
isAIDataAnalysisEnabled: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("propagates authorization failures so members cannot update AI settings", async () => {
|
|
||||||
mocks.checkAuthorizationUpdated.mockRejectedValueOnce(new AuthorizationError("Not authorized"));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
updateOrganizationAISettingsAction({
|
|
||||||
ctx: {
|
|
||||||
user: { id: "user_member", locale: "en-US" },
|
|
||||||
auditLoggingCtx: {},
|
|
||||||
},
|
|
||||||
parsedInput: {
|
|
||||||
organizationId,
|
|
||||||
data: {
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as any)
|
|
||||||
).rejects.toThrow(AuthorizationError);
|
|
||||||
|
|
||||||
expect(mocks.updateOrganization).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects enabling AI when the instance AI provider is not configured", async () => {
|
|
||||||
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
updateOrganizationAISettingsAction({
|
|
||||||
ctx: {
|
|
||||||
user: { id: "user_owner", locale: "en-US" },
|
|
||||||
auditLoggingCtx: {},
|
|
||||||
},
|
|
||||||
parsedInput: {
|
|
||||||
organizationId,
|
|
||||||
data: {
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as any)
|
|
||||||
).rejects.toThrow(OperationNotAllowedError);
|
|
||||||
|
|
||||||
expect(mocks.updateOrganization).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("allows enabling AI when the instance configuration is valid", async () => {
|
|
||||||
await updateOrganizationAISettingsAction({
|
|
||||||
ctx: {
|
|
||||||
user: { id: "user_owner", locale: "en-US" },
|
|
||||||
auditLoggingCtx: {},
|
|
||||||
},
|
|
||||||
parsedInput: {
|
|
||||||
organizationId,
|
|
||||||
data: {
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, {
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("allows disabling AI when the instance configuration later becomes invalid", async () => {
|
|
||||||
mocks.getOrganization.mockResolvedValueOnce({
|
|
||||||
id: organizationId,
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
isAIDataAnalysisEnabled: false,
|
|
||||||
});
|
|
||||||
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
|
|
||||||
|
|
||||||
await updateOrganizationAISettingsAction({
|
|
||||||
ctx: {
|
|
||||||
user: { id: "user_owner", locale: "en-US" },
|
|
||||||
auditLoggingCtx: {},
|
|
||||||
},
|
|
||||||
parsedInput: {
|
|
||||||
organizationId,
|
|
||||||
data: {
|
|
||||||
isAISmartToolsEnabled: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, {
|
|
||||||
isAISmartToolsEnabled: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+22
-144
@@ -2,44 +2,13 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||||
import type { TOrganizationRole } from "@formbricks/types/memberships";
|
|
||||||
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
|
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
|
||||||
import { isInstanceAIConfigured } from "@/lib/ai/service";
|
|
||||||
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
|
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/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 { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
|
||||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { ZOrganizationAISettingsInput, ZUpdateOrganizationAISettingsAction } from "./schemas";
|
|
||||||
|
|
||||||
async function updateOrganizationAction<T extends z.ZodRawShape>({
|
|
||||||
ctx,
|
|
||||||
organizationId,
|
|
||||||
schema,
|
|
||||||
data,
|
|
||||||
roles,
|
|
||||||
}: {
|
|
||||||
ctx: AuthenticatedActionClientCtx;
|
|
||||||
organizationId: string;
|
|
||||||
schema: z.ZodObject<T>;
|
|
||||||
data: z.infer<z.ZodObject<T>>;
|
|
||||||
roles: TOrganizationRole[];
|
|
||||||
}) {
|
|
||||||
await checkAuthorizationUpdated({
|
|
||||||
userId: ctx.user.id,
|
|
||||||
organizationId,
|
|
||||||
access: [{ type: "organization", schema, data, roles }],
|
|
||||||
});
|
|
||||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
|
||||||
const oldObject = await getOrganization(organizationId);
|
|
||||||
const result = await updateOrganization(organizationId, data);
|
|
||||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
|
||||||
ctx.auditLoggingCtx.newObject = result;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ZUpdateOrganizationNameAction = z.object({
|
const ZUpdateOrganizationNameAction = z.object({
|
||||||
organizationId: ZId,
|
organizationId: ZId,
|
||||||
@@ -49,114 +18,26 @@ const ZUpdateOrganizationNameAction = z.object({
|
|||||||
export const updateOrganizationNameAction = authenticatedActionClient
|
export const updateOrganizationNameAction = authenticatedActionClient
|
||||||
.inputSchema(ZUpdateOrganizationNameAction)
|
.inputSchema(ZUpdateOrganizationNameAction)
|
||||||
.action(
|
.action(
|
||||||
withAuditLogging(
|
withAuditLogging("updated", "organization", async ({ ctx, parsedInput }) => {
|
||||||
"updated",
|
await checkAuthorizationUpdated({
|
||||||
"organization",
|
userId: ctx.user.id,
|
||||||
async ({
|
organizationId: parsedInput.organizationId,
|
||||||
ctx,
|
access: [
|
||||||
parsedInput,
|
{
|
||||||
}: {
|
type: "organization",
|
||||||
ctx: AuthenticatedActionClientCtx;
|
schema: ZOrganizationUpdateInput.pick({ name: true }),
|
||||||
parsedInput: z.infer<typeof ZUpdateOrganizationNameAction>;
|
data: parsedInput.data,
|
||||||
}) =>
|
roles: ["owner"],
|
||||||
updateOrganizationAction({
|
},
|
||||||
ctx,
|
],
|
||||||
organizationId: parsedInput.organizationId,
|
});
|
||||||
schema: ZOrganizationUpdateInput.pick({ name: true }),
|
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||||
data: parsedInput.data,
|
const oldObject = await getOrganization(parsedInput.organizationId);
|
||||||
roles: ["owner"],
|
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
|
||||||
})
|
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||||
)
|
ctx.auditLoggingCtx.newObject = result;
|
||||||
);
|
return result;
|
||||||
|
})
|
||||||
type TOrganizationAISettings = Pick<
|
|
||||||
NonNullable<Awaited<ReturnType<typeof getOrganization>>>,
|
|
||||||
"isAISmartToolsEnabled" | "isAIDataAnalysisEnabled"
|
|
||||||
>;
|
|
||||||
|
|
||||||
type TResolvedOrganizationAISettings = {
|
|
||||||
smartToolsEnabled: boolean;
|
|
||||||
dataAnalysisEnabled: boolean;
|
|
||||||
isEnablingAnyAISetting: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveOrganizationAISettings = ({
|
|
||||||
data,
|
|
||||||
organization,
|
|
||||||
}: {
|
|
||||||
data: z.infer<typeof ZOrganizationAISettingsInput>;
|
|
||||||
organization: TOrganizationAISettings;
|
|
||||||
}): TResolvedOrganizationAISettings => {
|
|
||||||
const smartToolsEnabled = Object.hasOwn(data, "isAISmartToolsEnabled")
|
|
||||||
? (data.isAISmartToolsEnabled ?? organization.isAISmartToolsEnabled)
|
|
||||||
: organization.isAISmartToolsEnabled;
|
|
||||||
const dataAnalysisEnabled = Object.hasOwn(data, "isAIDataAnalysisEnabled")
|
|
||||||
? (data.isAIDataAnalysisEnabled ?? organization.isAIDataAnalysisEnabled)
|
|
||||||
: organization.isAIDataAnalysisEnabled;
|
|
||||||
|
|
||||||
return {
|
|
||||||
smartToolsEnabled,
|
|
||||||
dataAnalysisEnabled,
|
|
||||||
isEnablingAnyAISetting:
|
|
||||||
(smartToolsEnabled && !organization.isAISmartToolsEnabled) ||
|
|
||||||
(dataAnalysisEnabled && !organization.isAIDataAnalysisEnabled),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const assertOrganizationAISettingsUpdateAllowed = ({
|
|
||||||
isInstanceAIConfigured,
|
|
||||||
resolvedSettings,
|
|
||||||
t,
|
|
||||||
}: {
|
|
||||||
isInstanceAIConfigured: boolean;
|
|
||||||
resolvedSettings: TResolvedOrganizationAISettings;
|
|
||||||
t: Awaited<ReturnType<typeof getTranslate>>;
|
|
||||||
}) => {
|
|
||||||
if (resolvedSettings.isEnablingAnyAISetting && !isInstanceAIConfigured) {
|
|
||||||
throw new OperationNotAllowedError(t("environments.settings.general.ai_instance_not_configured"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateOrganizationAISettingsAction = authenticatedActionClient
|
|
||||||
.inputSchema(ZUpdateOrganizationAISettingsAction)
|
|
||||||
.action(
|
|
||||||
withAuditLogging(
|
|
||||||
"updated",
|
|
||||||
"organization",
|
|
||||||
async ({
|
|
||||||
ctx,
|
|
||||||
parsedInput,
|
|
||||||
}: {
|
|
||||||
ctx: AuthenticatedActionClientCtx;
|
|
||||||
parsedInput: z.infer<typeof ZUpdateOrganizationAISettingsAction>;
|
|
||||||
}) => {
|
|
||||||
const t = await getTranslate(ctx.user.locale);
|
|
||||||
const organization = await getOrganization(parsedInput.organizationId);
|
|
||||||
|
|
||||||
if (!organization) {
|
|
||||||
throw new ResourceNotFoundError("Organization", parsedInput.organizationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedSettings = resolveOrganizationAISettings({
|
|
||||||
data: parsedInput.data,
|
|
||||||
organization,
|
|
||||||
});
|
|
||||||
|
|
||||||
assertOrganizationAISettingsUpdateAllowed({
|
|
||||||
isInstanceAIConfigured: isInstanceAIConfigured(),
|
|
||||||
resolvedSettings,
|
|
||||||
t,
|
|
||||||
});
|
|
||||||
|
|
||||||
return updateOrganizationAction({
|
|
||||||
ctx,
|
|
||||||
organizationId: parsedInput.organizationId,
|
|
||||||
schema: ZOrganizationAISettingsInput,
|
|
||||||
data: parsedInput.data,
|
|
||||||
roles: ["owner", "manager"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const ZDeleteOrganizationAction = z.object({
|
const ZDeleteOrganizationAction = z.object({
|
||||||
@@ -168,10 +49,7 @@ export const deleteOrganizationAction = authenticatedActionClient
|
|||||||
.action(
|
.action(
|
||||||
withAuditLogging("deleted", "organization", async ({ ctx, parsedInput }) => {
|
withAuditLogging("deleted", "organization", async ({ ctx, parsedInput }) => {
|
||||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||||
if (!isMultiOrgEnabled) {
|
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
|
||||||
const t = await getTranslate(ctx.user.locale);
|
|
||||||
throw new OperationNotAllowedError(t("environments.settings.general.organization_deletion_disabled"));
|
|
||||||
}
|
|
||||||
|
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
|
|||||||
-118
@@ -1,118 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
|
||||||
import { updateOrganizationAISettingsAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
|
|
||||||
import { getDisplayedOrganizationAISettingValue, getOrganizationAIEnablementState } from "@/lib/ai/utils";
|
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
|
||||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
|
||||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
|
||||||
|
|
||||||
interface AISettingsToggleProps {
|
|
||||||
organization: TOrganization;
|
|
||||||
membershipRole?: TOrganizationRole;
|
|
||||||
isInstanceAIConfigured: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AISettingsToggle = ({
|
|
||||||
organization,
|
|
||||||
membershipRole,
|
|
||||||
isInstanceAIConfigured,
|
|
||||||
}: Readonly<AISettingsToggleProps>) => {
|
|
||||||
const [loadingField, setLoadingField] = useState<string | null>(null);
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { isOwner, isManager } = getAccessFlags(membershipRole);
|
|
||||||
const canEdit = isOwner || isManager;
|
|
||||||
const aiEnablementState = getOrganizationAIEnablementState({
|
|
||||||
isInstanceConfigured: isInstanceAIConfigured,
|
|
||||||
});
|
|
||||||
const showInstanceConfigWarning = aiEnablementState.blockReason === "instanceNotConfigured";
|
|
||||||
const isToggleDisabled = loadingField !== null || !canEdit || !aiEnablementState.canEnableFeatures;
|
|
||||||
const aiEnablementBlockedMessage = t("environments.settings.general.ai_instance_not_configured");
|
|
||||||
const displayedSmartToolsValue = getDisplayedOrganizationAISettingValue({
|
|
||||||
currentValue: organization.isAISmartToolsEnabled,
|
|
||||||
isInstanceConfigured: isInstanceAIConfigured,
|
|
||||||
});
|
|
||||||
const displayedDataAnalysisValue = getDisplayedOrganizationAISettingValue({
|
|
||||||
currentValue: organization.isAIDataAnalysisEnabled,
|
|
||||||
isInstanceConfigured: isInstanceAIConfigured,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleToggle = async (
|
|
||||||
field: "isAISmartToolsEnabled" | "isAIDataAnalysisEnabled",
|
|
||||||
checked: boolean
|
|
||||||
) => {
|
|
||||||
if (checked && !aiEnablementState.canEnableFeatures) {
|
|
||||||
toast.error(aiEnablementBlockedMessage);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoadingField(field);
|
|
||||||
try {
|
|
||||||
const data =
|
|
||||||
field === "isAISmartToolsEnabled"
|
|
||||||
? { isAISmartToolsEnabled: checked }
|
|
||||||
: { isAIDataAnalysisEnabled: checked };
|
|
||||||
const response = await updateOrganizationAISettingsAction({
|
|
||||||
organizationId: organization.id,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response?.data) {
|
|
||||||
toast.success(t("environments.settings.general.ai_settings_updated_successfully"));
|
|
||||||
router.refresh();
|
|
||||||
} else {
|
|
||||||
toast.error(getFormattedErrorMessage(response));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : t("common.something_went_wrong_please_try_again"));
|
|
||||||
} finally {
|
|
||||||
setLoadingField(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{showInstanceConfigWarning && (
|
|
||||||
<Alert variant="warning">
|
|
||||||
<AlertDescription>{aiEnablementBlockedMessage}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AdvancedOptionToggle
|
|
||||||
isChecked={displayedSmartToolsValue}
|
|
||||||
onToggle={(checked) => handleToggle("isAISmartToolsEnabled", checked)}
|
|
||||||
htmlId="ai-smart-tools-toggle"
|
|
||||||
title={t("environments.settings.general.ai_smart_tools_enabled")}
|
|
||||||
description={t("environments.settings.general.ai_smart_tools_enabled_description")}
|
|
||||||
disabled={isToggleDisabled}
|
|
||||||
customContainerClass="px-0"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AdvancedOptionToggle
|
|
||||||
isChecked={displayedDataAnalysisValue}
|
|
||||||
onToggle={(checked) => handleToggle("isAIDataAnalysisEnabled", checked)}
|
|
||||||
htmlId="ai-data-analysis-toggle"
|
|
||||||
title={t("environments.settings.general.ai_data_analysis_enabled")}
|
|
||||||
description={t("environments.settings.general.ai_data_analysis_enabled_description")}
|
|
||||||
disabled={isToggleDisabled}
|
|
||||||
customContainerClass="px-0"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!canEdit && (
|
|
||||||
<Alert variant="warning">
|
|
||||||
<AlertDescription>
|
|
||||||
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-11
@@ -1,5 +1,4 @@
|
|||||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||||
import { isInstanceAIConfigured } from "@/lib/ai/service";
|
|
||||||
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
@@ -12,7 +11,6 @@ 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 packageJson from "@/package.json";
|
import packageJson from "@/package.json";
|
||||||
import { SettingsCard } from "../../components/SettingsCard";
|
import { SettingsCard } from "../../components/SettingsCard";
|
||||||
import { AISettingsToggle } from "./components/AISettingsToggle";
|
|
||||||
import { DeleteOrganization } from "./components/DeleteOrganization";
|
import { DeleteOrganization } from "./components/DeleteOrganization";
|
||||||
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
||||||
import { SecurityListTip } from "./components/SecurityListTip";
|
import { SecurityListTip } from "./components/SecurityListTip";
|
||||||
@@ -62,15 +60,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
membershipRole={currentUserMembership?.role}
|
membershipRole={currentUserMembership?.role}
|
||||||
/>
|
/>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
<SettingsCard
|
|
||||||
title={t("environments.settings.general.ai_enabled")}
|
|
||||||
description={t("environments.settings.general.ai_enabled_description")}>
|
|
||||||
<AISettingsToggle
|
|
||||||
organization={organization}
|
|
||||||
membershipRole={currentUserMembership?.role}
|
|
||||||
isInstanceAIConfigured={isInstanceAIConfigured()}
|
|
||||||
/>
|
|
||||||
</SettingsCard>
|
|
||||||
<EmailCustomizationSettings
|
<EmailCustomizationSettings
|
||||||
organization={organization}
|
organization={organization}
|
||||||
hasWhiteLabelPermission={hasWhiteLabelPermission}
|
hasWhiteLabelPermission={hasWhiteLabelPermission}
|
||||||
|
|||||||
-13
@@ -1,13 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
import { ZId } from "@formbricks/types/common";
|
|
||||||
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
|
|
||||||
|
|
||||||
export const ZOrganizationAISettingsInput = ZOrganizationUpdateInput.pick({
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
isAIDataAnalysisEnabled: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ZUpdateOrganizationAISettingsAction = z.object({
|
|
||||||
organizationId: ZId,
|
|
||||||
data: ZOrganizationAISettingsInput,
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
@@ -18,15 +17,15 @@ const Layout = async (props: { params: Promise<{ environmentId: string }>; child
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new ResourceNotFoundError(t("common.organization"), null);
|
throw new Error(t("common.organization_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
throw new Error(t("common.workspace_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
throw new Error(t("common.session_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
+11
-8
@@ -3,22 +3,25 @@
|
|||||||
import { InboxIcon, PresentationIcon } from "lucide-react";
|
import { InboxIcon, PresentationIcon } from "lucide-react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { revalidateSurveyIdPath } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
import { revalidateSurveyIdPath } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||||
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
|
|
||||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||||
|
|
||||||
interface SurveyAnalysisNavigationProps {
|
interface SurveyAnalysisNavigationProps {
|
||||||
|
environmentId: string;
|
||||||
|
survey: TSurvey;
|
||||||
activeId: string;
|
activeId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SurveyAnalysisNavigation = ({ activeId }: SurveyAnalysisNavigationProps) => {
|
export const SurveyAnalysisNavigation = ({
|
||||||
|
environmentId,
|
||||||
|
survey,
|
||||||
|
activeId,
|
||||||
|
}: SurveyAnalysisNavigationProps) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { environment } = useEnvironment();
|
|
||||||
const { survey } = useSurvey();
|
|
||||||
|
|
||||||
const url = `/environments/${environment.id}/surveys/${survey.id}`;
|
const url = `/environments/${environmentId}/surveys/${survey.id}`;
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{
|
{
|
||||||
@@ -28,7 +31,7 @@ export const SurveyAnalysisNavigation = ({ activeId }: SurveyAnalysisNavigationP
|
|||||||
href: `${url}/summary?referer=true`,
|
href: `${url}/summary?referer=true`,
|
||||||
current: pathname?.includes("/summary"),
|
current: pathname?.includes("/summary"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
revalidateSurveyIdPath(environment.id, survey.id);
|
revalidateSurveyIdPath(environmentId, survey.id);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -38,7 +41,7 @@ export const SurveyAnalysisNavigation = ({ activeId }: SurveyAnalysisNavigationP
|
|||||||
href: `${url}/responses?referer=true`,
|
href: `${url}/responses?referer=true`,
|
||||||
current: pathname?.includes("/responses"),
|
current: pathname?.includes("/responses"),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
revalidateSurveyIdPath(environment.id, survey.id);
|
revalidateSurveyIdPath(environmentId, survey.id);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+15
-41
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
import React, { createContext, useCallback, useContext, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ElementOption,
|
ElementOption,
|
||||||
ElementOptions,
|
ElementOptions,
|
||||||
@@ -30,7 +30,7 @@ interface SelectedFilterOptions {
|
|||||||
|
|
||||||
export interface DateRange {
|
export interface DateRange {
|
||||||
from: Date | undefined;
|
from: Date | undefined;
|
||||||
to?: Date;
|
to?: Date | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FilterDateContextProps {
|
interface FilterDateContextProps {
|
||||||
@@ -41,8 +41,6 @@ interface FilterDateContextProps {
|
|||||||
dateRange: DateRange;
|
dateRange: DateRange;
|
||||||
setDateRange: React.Dispatch<React.SetStateAction<DateRange>>;
|
setDateRange: React.Dispatch<React.SetStateAction<DateRange>>;
|
||||||
resetState: () => void;
|
resetState: () => void;
|
||||||
refreshAnalysisData: () => Promise<void>;
|
|
||||||
registerAnalysisRefreshHandler: (handler: () => Promise<void>) => () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ResponseFilterContext = createContext<FilterDateContextProps | undefined>(undefined);
|
const ResponseFilterContext = createContext<FilterDateContextProps | undefined>(undefined);
|
||||||
@@ -63,7 +61,6 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
|
|||||||
from: undefined,
|
from: undefined,
|
||||||
to: getTodayDate(),
|
to: getTodayDate(),
|
||||||
});
|
});
|
||||||
const refreshHandlerRef = useRef<(() => Promise<void>) | null>(null);
|
|
||||||
|
|
||||||
const resetState = useCallback(() => {
|
const resetState = useCallback(() => {
|
||||||
setDateRange({
|
setDateRange({
|
||||||
@@ -76,43 +73,20 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const refreshAnalysisData = useCallback(async () => {
|
return (
|
||||||
await refreshHandlerRef.current?.();
|
<ResponseFilterContext.Provider
|
||||||
}, []);
|
value={{
|
||||||
|
setSelectedFilter,
|
||||||
const registerAnalysisRefreshHandler = useCallback((handler: () => Promise<void>) => {
|
selectedFilter,
|
||||||
refreshHandlerRef.current = handler;
|
selectedOptions,
|
||||||
|
setSelectedOptions,
|
||||||
return () => {
|
dateRange,
|
||||||
if (refreshHandlerRef.current === handler) {
|
setDateRange,
|
||||||
refreshHandlerRef.current = null;
|
resetState,
|
||||||
}
|
}}>
|
||||||
};
|
{children}
|
||||||
}, []);
|
</ResponseFilterContext.Provider>
|
||||||
|
|
||||||
const contextValue = useMemo(
|
|
||||||
() => ({
|
|
||||||
setSelectedFilter,
|
|
||||||
selectedFilter,
|
|
||||||
selectedOptions,
|
|
||||||
setSelectedOptions,
|
|
||||||
dateRange,
|
|
||||||
setDateRange,
|
|
||||||
resetState,
|
|
||||||
refreshAnalysisData,
|
|
||||||
registerAnalysisRefreshHandler,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
dateRange,
|
|
||||||
refreshAnalysisData,
|
|
||||||
registerAnalysisRefreshHandler,
|
|
||||||
resetState,
|
|
||||||
selectedFilter,
|
|
||||||
selectedOptions,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return <ResponseFilterContext.Provider value={contextValue}>{children}</ResponseFilterContext.Provider>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const useResponseFilter = () => {
|
const useResponseFilter = () => {
|
||||||
|
|||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
|
||||||
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
|
|
||||||
|
|
||||||
const Loading = () => {
|
|
||||||
return (
|
|
||||||
<PageContentWrapper>
|
|
||||||
<PageHeader pageTitle="" />
|
|
||||||
<div className="flex h-9 animate-pulse gap-2">
|
|
||||||
<div className="h-9 w-36 rounded-full bg-slate-200" />
|
|
||||||
<div className="h-9 w-36 rounded-full bg-slate-200" />
|
|
||||||
<div className="h-9 w-36 rounded-full bg-slate-200" />
|
|
||||||
<div className="h-9 w-36 rounded-full bg-slate-200" />
|
|
||||||
</div>
|
|
||||||
<SkeletonLoader type="summary" />
|
|
||||||
</PageContentWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Loading;
|
|
||||||
+2
-35
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
import { TSurveyQuota } from "@formbricks/types/quota";
|
||||||
import { TResponseWithQuotas } from "@formbricks/types/responses";
|
import { TResponseWithQuotas } from "@formbricks/types/responses";
|
||||||
@@ -15,7 +13,6 @@ import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surv
|
|||||||
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
|
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
|
||||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
|
||||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||||
|
|
||||||
interface ResponsePageProps {
|
interface ResponsePageProps {
|
||||||
@@ -49,8 +46,8 @@ export const ResponsePage = ({
|
|||||||
const [page, setPage] = useState<number | null>(null);
|
const [page, setPage] = useState<number | null>(null);
|
||||||
const [hasMore, setHasMore] = useState<boolean>(initialResponses.length >= responsesPerPage);
|
const [hasMore, setHasMore] = useState<boolean>(initialResponses.length >= responsesPerPage);
|
||||||
const [isFetchingFirstPage, setIsFetchingFirstPage] = useState<boolean>(false);
|
const [isFetchingFirstPage, setIsFetchingFirstPage] = useState<boolean>(false);
|
||||||
const { selectedFilter, dateRange, resetState, registerAnalysisRefreshHandler } = useResponseFilter();
|
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||||
const { t } = useTranslation();
|
|
||||||
const filters = useMemo(
|
const filters = useMemo(
|
||||||
() => getFormattedFilters(survey, selectedFilter, dateRange),
|
() => getFormattedFilters(survey, selectedFilter, dateRange),
|
||||||
|
|
||||||
@@ -89,34 +86,6 @@ export const ResponsePage = ({
|
|||||||
setResponses((prev) => prev.map((r) => (r.id === responseId ? updatedResponse : r)));
|
setResponses((prev) => prev.map((r) => (r.id === responseId ? updatedResponse : r)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const refetchResponses = useCallback(async () => {
|
|
||||||
setIsFetchingFirstPage(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const getResponsesActionResponse = await getResponsesAction({
|
|
||||||
surveyId,
|
|
||||||
limit: responsesPerPage,
|
|
||||||
offset: 0,
|
|
||||||
filterCriteria: filters,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (getResponsesActionResponse?.serverError) {
|
|
||||||
toast.error(getFormattedErrorMessage(getResponsesActionResponse) ?? t("common.something_went_wrong"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const freshResponses = getResponsesActionResponse?.data ?? [];
|
|
||||||
setResponses(freshResponses);
|
|
||||||
setPage(1);
|
|
||||||
setHasMore(freshResponses.length >= responsesPerPage);
|
|
||||||
} finally {
|
|
||||||
setIsFetchingFirstPage(false);
|
|
||||||
}
|
|
||||||
}, [filters, responsesPerPage, surveyId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return registerAnalysisRefreshHandler(refetchResponses);
|
|
||||||
}, [refetchResponses, registerAnalysisRefreshHandler]);
|
|
||||||
|
|
||||||
const surveyMemoized = useMemo(() => {
|
const surveyMemoized = useMemo(() => {
|
||||||
return replaceHeadlineRecall(survey, "default");
|
return replaceHeadlineRecall(survey, "default");
|
||||||
}, [survey]);
|
}, [survey]);
|
||||||
@@ -165,8 +134,6 @@ export const ResponsePage = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchFilteredResponses();
|
fetchFilteredResponses();
|
||||||
// page is intentionally omitted to avoid refetching after the initial page setup.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]);
|
}, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+4
-10
@@ -29,7 +29,6 @@ import { ResponseTableCell } from "@/app/(app)/environments/[environmentId]/surv
|
|||||||
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
|
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
|
||||||
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||||
import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils";
|
import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
|
||||||
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
|
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import {
|
import {
|
||||||
@@ -97,8 +96,8 @@ export const ResponseTable = ({
|
|||||||
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
|
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
|
||||||
// Generate columns
|
// Generate columns
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, locale, t, showQuotasColumn),
|
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
|
||||||
[survey, isExpanded, isReadOnly, locale, t, showQuotasColumn]
|
[survey, isExpanded, isReadOnly, t, showQuotasColumn]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save settings to localStorage when they change
|
// Save settings to localStorage when they change
|
||||||
@@ -202,13 +201,7 @@ export const ResponseTable = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteResponse = async (responseId: string, params?: { decrementQuotas?: boolean }) => {
|
const deleteResponse = async (responseId: string, params?: { decrementQuotas?: boolean }) => {
|
||||||
const result = await deleteResponseAction({
|
await deleteResponseAction({ responseId, decrementQuotas: params?.decrementQuotas ?? false });
|
||||||
responseId,
|
|
||||||
decrementQuotas: params?.decrementQuotas ?? false,
|
|
||||||
});
|
|
||||||
if (result?.serverError) {
|
|
||||||
throw new Error(getFormattedErrorMessage(result));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle downloading selected responses
|
// Handle downloading selected responses
|
||||||
@@ -307,6 +300,7 @@ export const ResponseTable = ({
|
|||||||
<DataTableSettingsModal
|
<DataTableSettingsModal
|
||||||
open={isTableSettingsModalOpen}
|
open={isTableSettingsModalOpen}
|
||||||
setOpen={setIsTableSettingsModalOpen}
|
setOpen={setIsTableSettingsModalOpen}
|
||||||
|
survey={survey}
|
||||||
table={table}
|
table={table}
|
||||||
columnOrder={columnOrder}
|
columnOrder={columnOrder}
|
||||||
handleDragEnd={handleDragEnd}
|
handleDragEnd={handleDragEnd}
|
||||||
|
|||||||
+3
-10
@@ -8,11 +8,10 @@ import { TResponseTableData } from "@formbricks/types/responses";
|
|||||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
|
||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
|
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
|
||||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||||
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
|
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||||
@@ -35,7 +34,6 @@ const getElementColumnsData = (
|
|||||||
element: TSurveyElement,
|
element: TSurveyElement,
|
||||||
survey: TSurvey,
|
survey: TSurvey,
|
||||||
isExpanded: boolean,
|
isExpanded: boolean,
|
||||||
locale: TUserLocale,
|
|
||||||
t: TFunction
|
t: TFunction
|
||||||
): ColumnDef<TResponseTableData>[] => {
|
): ColumnDef<TResponseTableData>[] => {
|
||||||
const ELEMENTS_ICON_MAP = getElementIconMap(t);
|
const ELEMENTS_ICON_MAP = getElementIconMap(t);
|
||||||
@@ -169,7 +167,6 @@ const getElementColumnsData = (
|
|||||||
survey={survey}
|
survey={survey}
|
||||||
responseData={responseValue}
|
responseData={responseValue}
|
||||||
language={language}
|
language={language}
|
||||||
locale={locale}
|
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
showId={false}
|
showId={false}
|
||||||
/>
|
/>
|
||||||
@@ -221,7 +218,6 @@ const getElementColumnsData = (
|
|||||||
survey={survey}
|
survey={survey}
|
||||||
responseData={responseValue}
|
responseData={responseValue}
|
||||||
language={language}
|
language={language}
|
||||||
locale={locale}
|
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
showId={false}
|
showId={false}
|
||||||
/>
|
/>
|
||||||
@@ -263,14 +259,11 @@ export const generateResponseTableColumns = (
|
|||||||
survey: TSurvey,
|
survey: TSurvey,
|
||||||
isExpanded: boolean,
|
isExpanded: boolean,
|
||||||
isReadOnly: boolean,
|
isReadOnly: boolean,
|
||||||
locale: TUserLocale,
|
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
showQuotasColumn: boolean
|
showQuotasColumn: boolean
|
||||||
): ColumnDef<TResponseTableData>[] => {
|
): ColumnDef<TResponseTableData>[] => {
|
||||||
const elements = getElementsFromBlocks(survey.blocks);
|
const elements = getElementsFromBlocks(survey.blocks);
|
||||||
const elementColumns = elements.flatMap((element) =>
|
const elementColumns = elements.flatMap((element) => getElementColumnsData(element, survey, isExpanded, t));
|
||||||
getElementColumnsData(element, survey, isExpanded, locale, t)
|
|
||||||
);
|
|
||||||
|
|
||||||
const dateColumn: ColumnDef<TResponseTableData> = {
|
const dateColumn: ColumnDef<TResponseTableData> = {
|
||||||
accessorKey: "createdAt",
|
accessorKey: "createdAt",
|
||||||
@@ -278,7 +271,7 @@ export const generateResponseTableColumns = (
|
|||||||
size: 200,
|
size: 200,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const date = new Date(row.original.createdAt);
|
const date = new Date(row.original.createdAt);
|
||||||
return <p className="text-slate-900">{formatDateTimeForDisplay(date, locale)}</p>;
|
return <p className="text-slate-900">{getFormattedDateTimeString(date)}</p>;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,5 @@
|
|||||||
import { TFunction } from "i18next";
|
import { TFunction } from "i18next";
|
||||||
|
import { capitalize } from "lodash";
|
||||||
import {
|
import {
|
||||||
AirplayIcon,
|
AirplayIcon,
|
||||||
ArrowUpFromDotIcon,
|
ArrowUpFromDotIcon,
|
||||||
@@ -8,7 +9,6 @@ import {
|
|||||||
SmartphoneIcon,
|
SmartphoneIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { TResponseMeta } from "@formbricks/types/responses";
|
import { TResponseMeta } from "@formbricks/types/responses";
|
||||||
import { capitalize } from "@/lib/utils/object";
|
|
||||||
|
|
||||||
export const getAddressFieldLabel = (field: string, t: TFunction) => {
|
export const getAddressFieldLabel = (field: string, t: TFunction) => {
|
||||||
switch (field) {
|
switch (field) {
|
||||||
|
|||||||
-23
@@ -1,23 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
|
||||||
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
|
|
||||||
|
|
||||||
const Loading = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContentWrapper>
|
|
||||||
<PageHeader pageTitle={t("common.responses")} />
|
|
||||||
<div className="flex h-9 animate-pulse gap-1.5">
|
|
||||||
<div className="h-9 w-36 rounded-full bg-slate-200" />
|
|
||||||
<div className="h-9 w-36 rounded-full bg-slate-200" />
|
|
||||||
</div>
|
|
||||||
<SkeletonLoader type="responseTable" />
|
|
||||||
</PageContentWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Loading;
|
|
||||||
+11
-8
@@ -1,4 +1,3 @@
|
|||||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||||
@@ -8,6 +7,7 @@ import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service
|
|||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
|
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
||||||
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
@@ -23,24 +23,25 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
|
|||||||
|
|
||||||
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
const [survey, user, tags, isContactsEnabled, responseCount] = await Promise.all([
|
const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([
|
||||||
getSurvey(params.surveyId),
|
getSurvey(params.surveyId),
|
||||||
getUser(session.user.id),
|
getUser(session.user.id),
|
||||||
getTagsByEnvironmentId(params.environmentId),
|
getTagsByEnvironmentId(params.environmentId),
|
||||||
getIsContactsEnabled(organization.id),
|
getIsContactsEnabled(organization.id),
|
||||||
getResponseCountBySurveyId(params.surveyId),
|
getResponseCountBySurveyId(params.surveyId),
|
||||||
|
findMatchingLocale(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!survey) {
|
if (!survey) {
|
||||||
throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
|
throw new Error(t("common.survey_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
throw new Error(t("common.user_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new ResourceNotFoundError(t("common.organization"), null);
|
throw new Error(t("common.organization_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
|
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
|
||||||
@@ -49,7 +50,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
|
|||||||
|
|
||||||
const organizationBilling = await getOrganizationBilling(organization.id);
|
const organizationBilling = await getOrganizationBilling(organization.id);
|
||||||
if (!organizationBilling) {
|
if (!organizationBilling) {
|
||||||
throw new ResourceNotFoundError(t("common.organization"), organization.id);
|
throw new Error(t("common.organization_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const isQuotasAllowed = await getIsQuotasEnabled(organization.id);
|
const isQuotasAllowed = await getIsQuotasEnabled(organization.id);
|
||||||
@@ -64,6 +65,8 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
|
|||||||
pageTitle={survey.name}
|
pageTitle={survey.name}
|
||||||
cta={
|
cta={
|
||||||
<SurveyAnalysisCTA
|
<SurveyAnalysisCTA
|
||||||
|
environment={environment}
|
||||||
|
survey={survey}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
user={user}
|
user={user}
|
||||||
publicDomain={publicDomain}
|
publicDomain={publicDomain}
|
||||||
@@ -74,7 +77,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
|
|||||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||||
/>
|
/>
|
||||||
}>
|
}>
|
||||||
<SurveyAnalysisNavigation activeId="responses" />
|
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<ResponsePage
|
<ResponsePage
|
||||||
environment={environment}
|
environment={environment}
|
||||||
@@ -83,7 +86,7 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId:
|
|||||||
environmentTags={tags}
|
environmentTags={tags}
|
||||||
user={user}
|
user={user}
|
||||||
responsesPerPage={RESPONSES_PER_PAGE}
|
responsesPerPage={RESPONSES_PER_PAGE}
|
||||||
locale={user.locale}
|
locale={locale}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
isQuotasAllowed={isQuotasAllowed}
|
isQuotasAllowed={isQuotasAllowed}
|
||||||
quotas={quotas}
|
quotas={quotas}
|
||||||
|
|||||||
+6
-22
@@ -4,7 +4,6 @@ import { z } from "zod";
|
|||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
|
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
|
||||||
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
|
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
|
||||||
import { capturePostHogEvent } from "@/lib/posthog";
|
|
||||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
import { getSurvey, updateSurvey } from "@/lib/survey/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";
|
||||||
@@ -65,17 +64,15 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
|
|||||||
|
|
||||||
const ZResetSurveyAction = z.object({
|
const ZResetSurveyAction = z.object({
|
||||||
surveyId: ZId,
|
surveyId: ZId,
|
||||||
|
organizationId: ZId,
|
||||||
projectId: ZId,
|
projectId: ZId,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
|
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
|
||||||
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
|
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
|
||||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
|
||||||
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
|
|
||||||
|
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId,
|
organizationId: parsedInput.organizationId,
|
||||||
access: [
|
access: [
|
||||||
{
|
{
|
||||||
type: "organization",
|
type: "organization",
|
||||||
@@ -84,12 +81,12 @@ export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSur
|
|||||||
{
|
{
|
||||||
type: "projectTeam",
|
type: "projectTeam",
|
||||||
minPermission: "readWrite",
|
minPermission: "readWrite",
|
||||||
projectId,
|
projectId: parsedInput.projectId,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
||||||
ctx.auditLoggingCtx.oldObject = null;
|
ctx.auditLoggingCtx.oldObject = null;
|
||||||
|
|
||||||
@@ -147,7 +144,6 @@ export const generatePersonalLinksAction = authenticatedActionClient
|
|||||||
.inputSchema(ZGeneratePersonalLinksAction)
|
.inputSchema(ZGeneratePersonalLinksAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||||
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
|
|
||||||
const isContactsEnabled = await getIsContactsEnabled(organizationId);
|
const isContactsEnabled = await getIsContactsEnabled(organizationId);
|
||||||
if (!isContactsEnabled) {
|
if (!isContactsEnabled) {
|
||||||
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
|
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
|
||||||
@@ -155,7 +151,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
|
|||||||
|
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId,
|
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||||
access: [
|
access: [
|
||||||
{
|
{
|
||||||
type: "organization",
|
type: "organization",
|
||||||
@@ -163,7 +159,7 @@ export const generatePersonalLinksAction = authenticatedActionClient
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "projectTeam",
|
type: "projectTeam",
|
||||||
projectId,
|
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||||
minPermission: "readWrite",
|
minPermission: "readWrite",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -180,18 +176,6 @@ export const generatePersonalLinksAction = authenticatedActionClient
|
|||||||
throw new UnknownError("No contacts found for the selected segment");
|
throw new UnknownError("No contacts found for the selected segment");
|
||||||
}
|
}
|
||||||
|
|
||||||
capturePostHogEvent(
|
|
||||||
ctx.user.id,
|
|
||||||
"personal_link_created",
|
|
||||||
{
|
|
||||||
organization_id: organizationId,
|
|
||||||
workspace_id: projectId,
|
|
||||||
survey_id: parsedInput.surveyId,
|
|
||||||
link_count: contactsResult.length,
|
|
||||||
},
|
|
||||||
{ organizationId, workspaceId: projectId }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Prepare CSV data with the specified headers and order
|
// Prepare CSV data with the specified headers and order
|
||||||
const csvHeaders = [
|
const csvHeaders = [
|
||||||
"Formbricks Contact ID",
|
"Formbricks Contact ID",
|
||||||
|
|||||||
+9
-10
@@ -7,7 +7,7 @@ import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/ty
|
|||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { timeSince } from "@/lib/time";
|
import { timeSince } from "@/lib/time";
|
||||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||||
import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
|
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
|
||||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||||
@@ -32,14 +32,13 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderResponseValue = (value: string) => {
|
const renderResponseValue = (value: string) => {
|
||||||
const formattedDate = formatStoredDateForDisplay(value, elementSummary.element.format, locale);
|
const parsedDate = new Date(value);
|
||||||
|
|
||||||
return (
|
const formattedDate = isNaN(parsedDate.getTime())
|
||||||
formattedDate ??
|
? `${t("common.invalid_date")}(${value})`
|
||||||
t("common.invalid_date_with_value", {
|
: formatDateWithOrdinal(parsedDate);
|
||||||
value,
|
|
||||||
})
|
return formattedDate;
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -60,7 +59,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
|
|||||||
elementSummary.samples.slice(0, visibleResponses).map((response) => (
|
elementSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||||
<div
|
<div
|
||||||
key={response.id}
|
key={response.id}
|
||||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent">
|
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
|
||||||
<div className="pl-4 md:pl-6">
|
<div className="pl-4 md:pl-6">
|
||||||
{response.contact ? (
|
{response.contact ? (
|
||||||
<Link
|
<Link
|
||||||
@@ -85,7 +84,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
|
|||||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||||
{renderResponseValue(response.value)}
|
{renderResponseValue(response.value)}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 md:px-6">
|
<div className="px-4 text-slate-500 md:px-6">
|
||||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+8
-5
@@ -4,13 +4,16 @@ import { useSearchParams } from "next/navigation";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, 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 { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { Confetti } from "@/modules/ui/components/confetti";
|
import { Confetti } from "@/modules/ui/components/confetti";
|
||||||
|
|
||||||
export const SuccessMessage = () => {
|
interface SummaryMetadataProps {
|
||||||
const { environment } = useEnvironment();
|
environment: TEnvironment;
|
||||||
const { survey } = useSurvey();
|
survey: TSurvey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SuccessMessage = ({ environment, survey }: SummaryMetadataProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [confetti, setConfetti] = useState(false);
|
const [confetti, setConfetti] = useState(false);
|
||||||
|
|||||||
+1
-3
@@ -107,9 +107,7 @@ export const SummaryMetadata = ({
|
|||||||
label={t("environments.surveys.summary.time_to_complete")}
|
label={t("environments.surveys.summary.time_to_complete")}
|
||||||
percentage={null}
|
percentage={null}
|
||||||
value={ttcAverage === 0 ? <span>-</span> : `${formatTime(ttcAverage)}`}
|
value={ttcAverage === 0 ? <span>-</span> : `${formatTime(ttcAverage)}`}
|
||||||
tooltipText={t("environments.surveys.summary.ttc_survey_tooltip", {
|
tooltipText={t("environments.surveys.summary.ttc_tooltip")}
|
||||||
defaultValue: "Average time to complete the survey.",
|
|
||||||
})}
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
+19
-35
@@ -71,7 +71,7 @@ export const SummaryPage = ({
|
|||||||
const [tab, setTab] = useState<"dropOffs" | "quotas" | "impressions" | undefined>(undefined);
|
const [tab, setTab] = useState<"dropOffs" | "quotas" | "impressions" | undefined>(undefined);
|
||||||
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
|
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
|
||||||
|
|
||||||
const { selectedFilter, dateRange, resetState, registerAnalysisRefreshHandler } = useResponseFilter();
|
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||||
|
|
||||||
const [displays, setDisplays] = useState<TDisplayWithContact[]>([]);
|
const [displays, setDisplays] = useState<TDisplayWithContact[]>([]);
|
||||||
const [isDisplaysLoading, setIsDisplaysLoading] = useState(false);
|
const [isDisplaysLoading, setIsDisplaysLoading] = useState(false);
|
||||||
@@ -111,7 +111,7 @@ export const SummaryPage = ({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsDisplaysLoading(false);
|
setIsDisplaysLoading(false);
|
||||||
}
|
}
|
||||||
}, [fetchDisplays]);
|
}, [fetchDisplays, t]);
|
||||||
|
|
||||||
const handleLoadMoreDisplays = useCallback(async () => {
|
const handleLoadMoreDisplays = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -131,39 +131,13 @@ export const SummaryPage = ({
|
|||||||
}
|
}
|
||||||
}, [tab, loadInitialDisplays]);
|
}, [tab, loadInitialDisplays]);
|
||||||
|
|
||||||
const fetchSummary = useCallback(async () => {
|
|
||||||
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
|
|
||||||
const updatedSurveySummary = await getSurveySummaryAction({
|
|
||||||
surveyId,
|
|
||||||
filterCriteria: currentFilters,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (updatedSurveySummary?.serverError) {
|
|
||||||
throw new Error(getFormattedErrorMessage(updatedSurveySummary));
|
|
||||||
}
|
|
||||||
|
|
||||||
setSurveySummary(updatedSurveySummary?.data ?? defaultSurveySummary);
|
|
||||||
}, [dateRange, selectedFilter, survey, surveyId]);
|
|
||||||
|
|
||||||
const refreshSummary = useCallback(async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Promise.all([fetchSummary(), tab === "impressions" ? loadInitialDisplays() : Promise.resolve()]);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [fetchSummary, loadInitialDisplays, tab]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return registerAnalysisRefreshHandler(refreshSummary);
|
|
||||||
}, [refreshSummary, registerAnalysisRefreshHandler]);
|
|
||||||
|
|
||||||
// Only fetch data when filters change or when there's no initial data
|
// Only fetch data when filters change or when there's no initial data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If we have initial data and no filters are applied, don't fetch
|
// If we have initial data and no filters are applied, don't fetch
|
||||||
const hasNoFilters =
|
const hasNoFilters =
|
||||||
(!selectedFilter || Object.keys(selectedFilter).length === 0 || selectedFilter.filter?.length === 0) &&
|
(!selectedFilter ||
|
||||||
|
Object.keys(selectedFilter).length === 0 ||
|
||||||
|
(selectedFilter.filter && selectedFilter.filter.length === 0)) &&
|
||||||
(!dateRange || (!dateRange.from && !dateRange.to));
|
(!dateRange || (!dateRange.from && !dateRange.to));
|
||||||
|
|
||||||
if (initialSurveySummary && hasNoFilters) {
|
if (initialSurveySummary && hasNoFilters) {
|
||||||
@@ -171,11 +145,21 @@ export const SummaryPage = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchFilteredSummary = async () => {
|
const fetchSummary = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetchSummary();
|
// Recalculate filters inside the effect to ensure we have the latest values
|
||||||
|
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||||
|
let updatedSurveySummary;
|
||||||
|
|
||||||
|
updatedSurveySummary = await getSurveySummaryAction({
|
||||||
|
surveyId,
|
||||||
|
filterCriteria: currentFilters,
|
||||||
|
});
|
||||||
|
|
||||||
|
const surveySummary = updatedSurveySummary?.data ?? defaultSurveySummary;
|
||||||
|
setSurveySummary(surveySummary);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -183,8 +167,8 @@ export const SummaryPage = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchFilteredSummary();
|
fetchSummary();
|
||||||
}, [selectedFilter, dateRange, initialSurveySummary, fetchSummary]);
|
}, [selectedFilter, dateRange, survey, surveyId, initialSurveySummary]);
|
||||||
|
|
||||||
const surveyMemoized = useMemo(() => {
|
const surveyMemoized = useMemo(() => {
|
||||||
return replaceHeadlineRecall(survey, "default");
|
return replaceHeadlineRecall(survey, "default");
|
||||||
|
|||||||
+12
-29
@@ -1,18 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BellRing, Eye, ListRestart, RefreshCcwIcon, SquarePenIcon } from "lucide-react";
|
import { BellRing, Eye, ListRestart, SquarePenIcon } from "lucide-react";
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, 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 { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TSegment } from "@formbricks/types/segment";
|
import { TSegment } from "@formbricks/types/segment";
|
||||||
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
|
||||||
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
|
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
|
||||||
import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
|
import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
|
||||||
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
|
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
|
||||||
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
|
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
|
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
|
||||||
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
|
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
|
||||||
@@ -23,6 +23,8 @@ import { IconBar } from "@/modules/ui/components/iconbar";
|
|||||||
import { resetSurveyAction } from "../actions";
|
import { resetSurveyAction } from "../actions";
|
||||||
|
|
||||||
interface SurveyAnalysisCTAProps {
|
interface SurveyAnalysisCTAProps {
|
||||||
|
survey: TSurvey;
|
||||||
|
environment: TEnvironment;
|
||||||
isReadOnly: boolean;
|
isReadOnly: boolean;
|
||||||
user: TUser;
|
user: TUser;
|
||||||
publicDomain: string;
|
publicDomain: string;
|
||||||
@@ -39,6 +41,8 @@ interface ModalState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SurveyAnalysisCTA = ({
|
export const SurveyAnalysisCTA = ({
|
||||||
|
survey,
|
||||||
|
environment,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
user,
|
user,
|
||||||
publicDomain,
|
publicDomain,
|
||||||
@@ -59,12 +63,9 @@ export const SurveyAnalysisCTA = ({
|
|||||||
});
|
});
|
||||||
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
|
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
|
||||||
const [isResetting, setIsResetting] = useState(false);
|
const [isResetting, setIsResetting] = useState(false);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
|
|
||||||
const { environment, project } = useEnvironment();
|
const { organizationId, project } = useEnvironment();
|
||||||
const { survey } = useSurvey();
|
|
||||||
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
|
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
|
||||||
const { refreshAnalysisData } = useResponseFilter();
|
|
||||||
|
|
||||||
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
||||||
|
|
||||||
@@ -76,7 +77,7 @@ export const SurveyAnalysisCTA = ({
|
|||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
const handleShareModalToggle = (open: boolean) => {
|
const handleShareModalToggle = (open: boolean) => {
|
||||||
const params = new URLSearchParams(globalThis.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const currentShareParam = params.get("share") === "true";
|
const currentShareParam = params.get("share") === "true";
|
||||||
|
|
||||||
if (open && !currentShareParam) {
|
if (open && !currentShareParam) {
|
||||||
@@ -127,6 +128,7 @@ export const SurveyAnalysisCTA = ({
|
|||||||
setIsResetting(true);
|
setIsResetting(true);
|
||||||
const result = await resetSurveyAction({
|
const result = await resetSurveyAction({
|
||||||
surveyId: survey.id,
|
surveyId: survey.id,
|
||||||
|
organizationId: organizationId,
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
});
|
});
|
||||||
if (result?.data) {
|
if (result?.data) {
|
||||||
@@ -146,25 +148,6 @@ export const SurveyAnalysisCTA = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const iconActions = [
|
const iconActions = [
|
||||||
{
|
|
||||||
icon: RefreshCcwIcon,
|
|
||||||
tooltip: t("common.refresh"),
|
|
||||||
onClick: async () => {
|
|
||||||
if (isRefreshing) return;
|
|
||||||
setIsRefreshing(true);
|
|
||||||
try {
|
|
||||||
await refreshAnalysisData();
|
|
||||||
toast.success(t("common.data_refreshed_successfully"));
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : t("common.something_went_wrong");
|
|
||||||
toast.error(errorMessage);
|
|
||||||
} finally {
|
|
||||||
setIsRefreshing(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
disabled: isRefreshing,
|
|
||||||
isVisible: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
icon: BellRing,
|
icon: BellRing,
|
||||||
tooltip: t("environments.surveys.summary.configure_alerts"),
|
tooltip: t("environments.surveys.summary.configure_alerts"),
|
||||||
@@ -201,7 +184,7 @@ export const SurveyAnalysisCTA = ({
|
|||||||
return (
|
return (
|
||||||
<div className="hidden justify-end gap-x-1.5 sm:flex">
|
<div className="hidden justify-end gap-x-1.5 sm:flex">
|
||||||
{!isReadOnly && (appSetupCompleted || survey.type === "link") && survey.status !== "draft" && (
|
{!isReadOnly && (appSetupCompleted || survey.type === "link") && survey.status !== "draft" && (
|
||||||
<SurveyStatusDropdown />
|
<SurveyStatusDropdown environment={environment} survey={survey} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<IconBar actions={iconActions} />
|
<IconBar actions={iconActions} />
|
||||||
@@ -233,7 +216,7 @@ export const SurveyAnalysisCTA = ({
|
|||||||
projectCustomScripts={project.customHeadScripts}
|
projectCustomScripts={project.customHeadScripts}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<SuccessMessage />
|
<SuccessMessage environment={environment} survey={survey} />
|
||||||
|
|
||||||
{responseCount > 0 && (
|
{responseCount > 0 && (
|
||||||
<EditPublicSurveyAlertDialog
|
<EditPublicSurveyAlertDialog
|
||||||
|
|||||||
+5
-69
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { CopyIcon, SendIcon } from "lucide-react";
|
import { CopyIcon, SendIcon } from "lucide-react";
|
||||||
import { type SyntheticEvent, useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, 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 { AuthenticationError } from "@formbricks/types/errors";
|
import { AuthenticationError } from "@formbricks/types/errors";
|
||||||
@@ -21,7 +21,6 @@ interface EmailTabProps {
|
|||||||
export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
||||||
const [activeTab, setActiveTab] = useState("preview");
|
const [activeTab, setActiveTab] = useState("preview");
|
||||||
const [emailHtmlPreview, setEmailHtmlPreview] = useState<string>("");
|
const [emailHtmlPreview, setEmailHtmlPreview] = useState<string>("");
|
||||||
const [previewFrameHeight, setPreviewFrameHeight] = useState(560);
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const emailHtml = useMemo(() => {
|
const emailHtml = useMemo(() => {
|
||||||
@@ -32,40 +31,6 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
|||||||
.replaceAll("?preview=true", "");
|
.replaceAll("?preview=true", "");
|
||||||
}, [emailHtmlPreview]);
|
}, [emailHtmlPreview]);
|
||||||
|
|
||||||
const sanitizedEmailHtml = useMemo(() => {
|
|
||||||
if (!emailHtmlPreview) return "";
|
|
||||||
return DOMPurify.sanitize(emailHtmlPreview, { ADD_ATTR: ["bgcolor", "target"] });
|
|
||||||
}, [emailHtmlPreview]);
|
|
||||||
|
|
||||||
const emailPreviewDocument = useMemo(() => {
|
|
||||||
if (!sanitizedEmailHtml) return "";
|
|
||||||
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<meta name="color-scheme" content="only light" />
|
|
||||||
<meta name="supported-color-schemes" content="light" />
|
|
||||||
<base target="_blank" />
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
color-scheme: only light;
|
|
||||||
supported-color-schemes: light;
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
background: #ffffff;
|
|
||||||
color-scheme: only light;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>${sanitizedEmailHtml}</body>
|
|
||||||
</html>`;
|
|
||||||
}, [sanitizedEmailHtml]);
|
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
id: "preview",
|
id: "preview",
|
||||||
@@ -86,25 +51,6 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
|||||||
getData();
|
getData();
|
||||||
}, [surveyId]);
|
}, [surveyId]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPreviewFrameHeight(560);
|
|
||||||
}, [emailPreviewDocument]);
|
|
||||||
|
|
||||||
const handlePreviewFrameLoad = (event: SyntheticEvent<HTMLIFrameElement>) => {
|
|
||||||
const { contentDocument } = event.currentTarget;
|
|
||||||
if (!contentDocument) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextHeight = Math.max(
|
|
||||||
contentDocument.body.scrollHeight,
|
|
||||||
contentDocument.documentElement.scrollHeight,
|
|
||||||
560
|
|
||||||
);
|
|
||||||
|
|
||||||
setPreviewFrameHeight(nextHeight);
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendPreviewEmail = async () => {
|
const sendPreviewEmail = async () => {
|
||||||
try {
|
try {
|
||||||
const val = await sendEmbedSurveyPreviewEmailAction({ surveyId });
|
const val = await sendEmbedSurveyPreviewEmailAction({ surveyId });
|
||||||
@@ -127,9 +73,7 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
|||||||
if (activeTab === "preview") {
|
if (activeTab === "preview") {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 pb-4">
|
<div className="space-y-4 pb-4">
|
||||||
<div
|
<div className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4">
|
||||||
className="flex-1 overflow-y-auto rounded-lg border border-slate-200 bg-white p-4"
|
|
||||||
data-testid="survey-email-preview-shell">
|
|
||||||
<div className="mb-6 flex gap-2">
|
<div className="mb-6 flex gap-2">
|
||||||
<div className="h-3 w-3 rounded-full bg-red-500" />
|
<div className="h-3 w-3 rounded-full bg-red-500" />
|
||||||
<div className="h-3 w-3 rounded-full bg-amber-500" />
|
<div className="h-3 w-3 rounded-full bg-amber-500" />
|
||||||
@@ -143,17 +87,9 @@ export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
|||||||
{t("environments.surveys.share.send_email.email_subject_label")} :{" "}
|
{t("environments.surveys.share.send_email.email_subject_label")} :{" "}
|
||||||
{t("environments.surveys.share.send_email.formbricks_email_survey_preview")}
|
{t("environments.surveys.share.send_email.formbricks_email_survey_preview")}
|
||||||
</div>
|
</div>
|
||||||
<div data-testid="survey-email-preview-content">
|
<div className="p-2">
|
||||||
{emailPreviewDocument ? (
|
{emailHtml ? (
|
||||||
<iframe
|
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(emailHtml) }} />
|
||||||
className="mt-2 w-full rounded-md border-0 bg-white"
|
|
||||||
data-testid="survey-email-preview-frame"
|
|
||||||
onLoad={handlePreviewFrameLoad}
|
|
||||||
sandbox="allow-popups allow-popups-to-escape-sandbox allow-same-origin"
|
|
||||||
srcDoc={emailPreviewDocument}
|
|
||||||
style={{ height: `${previewFrameHeight}px` }}
|
|
||||||
title={t("environments.surveys.share.send_email.email_preview_tab")}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
)}
|
)}
|
||||||
|
|||||||
-1
@@ -163,7 +163,6 @@ export const PersonalLinksTab = ({
|
|||||||
<UpgradePrompt
|
<UpgradePrompt
|
||||||
title={t("environments.surveys.share.personal_links.upgrade_prompt_title")}
|
title={t("environments.surveys.share.personal_links.upgrade_prompt_title")}
|
||||||
description={t("environments.surveys.share.personal_links.upgrade_prompt_description")}
|
description={t("environments.surveys.share.personal_links.upgrade_prompt_description")}
|
||||||
feature="personal_links"
|
|
||||||
buttons={[
|
buttons={[
|
||||||
{
|
{
|
||||||
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||||
|
|||||||
+3
-18
@@ -16,19 +16,13 @@ export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => {
|
|||||||
const [embedModeEnabled, setEmbedModeEnabled] = useState(false);
|
const [embedModeEnabled, setEmbedModeEnabled] = useState(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const separator = surveyUrl.includes("?") ? "&" : "?";
|
const iframeCode = `<div style="position: relative; height:80dvh; overflow:auto;">
|
||||||
|
<iframe
|
||||||
const iframeSrc = embedModeEnabled ? `${surveyUrl}${separator}embed=true` : surveyUrl;
|
src="${surveyUrl}${embedModeEnabled ? "?embed=true" : ""}"
|
||||||
|
|
||||||
const iframeCode = `<div style="position: relative; height:80dvh; overflow:auto;">
|
|
||||||
<iframe
|
|
||||||
src="${iframeSrc}"
|
|
||||||
frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;">
|
frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;">
|
||||||
</iframe>
|
</iframe>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
const previewSrc = `${iframeSrc}${iframeSrc.includes("?") ? "&" : "?"}preview=true`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CodeBlock language="html" noMargin>
|
<CodeBlock language="html" noMargin>
|
||||||
@@ -54,15 +48,6 @@ export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => {
|
|||||||
{t("common.copy_code")}
|
{t("common.copy_code")}
|
||||||
<CopyIcon />
|
<CopyIcon />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<p className="text-base font-medium text-slate-800">{t("common.preview")}</p>
|
|
||||||
<div className="relative h-[500px] w-full overflow-hidden rounded-lg border border-slate-300">
|
|
||||||
<iframe
|
|
||||||
title={t("common.preview")}
|
|
||||||
src={previewSrc}
|
|
||||||
className="absolute inset-0 h-full w-full border-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
-59
@@ -1,59 +0,0 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
|
||||||
import { extractEmailBodyFragment } from "./emailTemplateFragment";
|
|
||||||
|
|
||||||
describe("extractEmailBodyFragment", () => {
|
|
||||||
test("returns the body contents for rendered email documents", () => {
|
|
||||||
const html = `
|
|
||||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<style>.foo { color: red; }</style>
|
|
||||||
</head>
|
|
||||||
<body class="email-body">
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td>Preview content</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
expect(extractEmailBodyFragment(html)).toBe(
|
|
||||||
"<table>\n <tr>\n <td>Preview content</td>\n </tr>\n </table>"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("removes document-level tags from rendered survey email markup", () => {
|
|
||||||
const fragment = extractEmailBodyFragment(`
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<style>.foo { color: red; }</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<table>
|
|
||||||
<tr>
|
|
||||||
<td>Which fruits do you like</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`);
|
|
||||||
|
|
||||||
expect(fragment).toBe(
|
|
||||||
"<table>\n <tr>\n <td>Which fruits do you like</td>\n </tr>\n </table>"
|
|
||||||
);
|
|
||||||
expect(fragment).not.toMatch(/<!DOCTYPE|<html|<head|<body/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("falls back to the original markup when no body tag exists", () => {
|
|
||||||
expect(extractEmailBodyFragment("<div>Preview content</div>")).toBe("<div>Preview content</div>");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("removes React server markers from rendered fragments", () => {
|
|
||||||
expect(extractEmailBodyFragment("<body><!--$--><div>Preview content</div><!--/$--></body>")).toBe(
|
|
||||||
"<div>Preview content</div>"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+7
-7
@@ -1,27 +1,27 @@
|
|||||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
import { toJsEnvironmentStateSurvey } from "@/lib/survey/client-utils";
|
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { getStyling } from "@/lib/utils/styling";
|
import { getStyling } from "@/lib/utils/styling";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
|
import { getPreviewEmailTemplateHtml } from "@/modules/email/components/preview-email-template";
|
||||||
import { extractEmailBodyFragment } from "./emailTemplateFragment";
|
|
||||||
|
|
||||||
export const getEmailTemplateHtml = async (surveyId: string, locale: string) => {
|
export const getEmailTemplateHtml = async (surveyId: string, locale: string) => {
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const survey = await getSurvey(surveyId);
|
const survey = await getSurvey(surveyId);
|
||||||
if (!survey) {
|
if (!survey) {
|
||||||
throw new ResourceNotFoundError(t("common.survey"), surveyId);
|
throw new Error("Survey not found");
|
||||||
}
|
}
|
||||||
const project = await getProjectByEnvironmentId(survey.environmentId);
|
const project = await getProjectByEnvironmentId(survey.environmentId);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
throw new Error("Workspace not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const styling = getStyling(project, toJsEnvironmentStateSurvey(survey));
|
const styling = getStyling(project, survey);
|
||||||
const surveyUrl = getPublicDomain() + "/s/" + survey.id;
|
const surveyUrl = getPublicDomain() + "/s/" + survey.id;
|
||||||
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
|
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale, t);
|
||||||
|
const doctype =
|
||||||
|
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
|
||||||
|
const htmlCleaned = html.toString().replace(doctype, "");
|
||||||
|
|
||||||
return extractEmailBodyFragment(html.toString());
|
return htmlCleaned;
|
||||||
};
|
};
|
||||||
|
|||||||
-11
@@ -1,11 +0,0 @@
|
|||||||
const EMAIL_DOCTYPE_PATTERN = /<!DOCTYPE[^>]*>/i;
|
|
||||||
const EMAIL_BODY_PATTERN = /<body\b[^>]*>([\s\S]*?)<\/body>/i;
|
|
||||||
const EMAIL_REACT_SERVER_MARKER_PATTERN = /<!--\/?\$-->/g;
|
|
||||||
|
|
||||||
export const extractEmailBodyFragment = (html: string): string => {
|
|
||||||
const htmlWithoutDoctype = html.replace(EMAIL_DOCTYPE_PATTERN, "").trim();
|
|
||||||
const bodyMatch = EMAIL_BODY_PATTERN.exec(htmlWithoutDoctype);
|
|
||||||
const fragment = bodyMatch?.[1].trim() ?? htmlWithoutDoctype;
|
|
||||||
|
|
||||||
return fragment.replaceAll(EMAIL_REACT_SERVER_MARKER_PATTERN, "").trim();
|
|
||||||
};
|
|
||||||
+68
-112
@@ -11,7 +11,8 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
|||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
|
||||||
|
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||||
import {
|
import {
|
||||||
getElementSummary,
|
getElementSummary,
|
||||||
getResponsesForSummary,
|
getResponsesForSummary,
|
||||||
@@ -43,7 +44,7 @@ vi.mock("@/lib/survey/service", () => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock("@/lib/surveyLogic/utils", () => ({
|
vi.mock("@/lib/surveyLogic/utils", () => ({
|
||||||
evaluateLogic: vi.fn(),
|
evaluateLogic: vi.fn(),
|
||||||
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredElementIds: [], calculations: {} })),
|
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredQuestionIds: [], calculations: {} })),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/utils/validate", () => ({
|
vi.mock("@/lib/utils/validate", () => ({
|
||||||
validateInputs: vi.fn(),
|
validateInputs: vi.fn(),
|
||||||
@@ -164,7 +165,7 @@ describe("getSurveySummaryMeta", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("calculates meta correctly", () => {
|
test("calculates meta correctly", () => {
|
||||||
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 10, mockQuotas);
|
const meta = getSurveySummaryMeta(mockResponses, 10, mockQuotas);
|
||||||
expect(meta.displayCount).toBe(10);
|
expect(meta.displayCount).toBe(10);
|
||||||
expect(meta.totalResponses).toBe(3);
|
expect(meta.totalResponses).toBe(3);
|
||||||
expect(meta.startsPercentage).toBe(30);
|
expect(meta.startsPercentage).toBe(30);
|
||||||
@@ -178,74 +179,19 @@ describe("getSurveySummaryMeta", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("handles zero display count", () => {
|
test("handles zero display count", () => {
|
||||||
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 0, mockQuotas);
|
const meta = getSurveySummaryMeta(mockResponses, 0, mockQuotas);
|
||||||
expect(meta.startsPercentage).toBe(0);
|
expect(meta.startsPercentage).toBe(0);
|
||||||
expect(meta.completedPercentage).toBe(0);
|
expect(meta.completedPercentage).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles zero responses", () => {
|
test("handles zero responses", () => {
|
||||||
const meta = getSurveySummaryMeta(mockBaseSurvey, [], 10, mockQuotas);
|
const meta = getSurveySummaryMeta([], 10, mockQuotas);
|
||||||
expect(meta.totalResponses).toBe(0);
|
expect(meta.totalResponses).toBe(0);
|
||||||
expect(meta.completedResponses).toBe(0);
|
expect(meta.completedResponses).toBe(0);
|
||||||
expect(meta.dropOffCount).toBe(0);
|
expect(meta.dropOffCount).toBe(0);
|
||||||
expect(meta.dropOffPercentage).toBe(0);
|
expect(meta.dropOffPercentage).toBe(0);
|
||||||
expect(meta.ttcAverage).toBe(0);
|
expect(meta.ttcAverage).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses block-level TTC to avoid multiplying by number of elements", () => {
|
|
||||||
const surveyWithOneBlockThreeElements: TSurvey = {
|
|
||||||
...mockBaseSurvey,
|
|
||||||
blocks: [
|
|
||||||
{
|
|
||||||
id: "block1",
|
|
||||||
name: "Block 1",
|
|
||||||
elements: [
|
|
||||||
{
|
|
||||||
id: "q1",
|
|
||||||
type: TSurveyElementTypeEnum.OpenText,
|
|
||||||
headline: { default: "Q1" },
|
|
||||||
required: false,
|
|
||||||
inputType: "text",
|
|
||||||
charLimit: { enabled: false },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "q2",
|
|
||||||
type: TSurveyElementTypeEnum.OpenText,
|
|
||||||
headline: { default: "Q2" },
|
|
||||||
required: false,
|
|
||||||
inputType: "text",
|
|
||||||
charLimit: { enabled: false },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "q3",
|
|
||||||
type: TSurveyElementTypeEnum.OpenText,
|
|
||||||
headline: { default: "Q3" },
|
|
||||||
required: false,
|
|
||||||
inputType: "text",
|
|
||||||
charLimit: { enabled: false },
|
|
||||||
},
|
|
||||||
] as TSurveyElement[],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
questions: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const responses = [
|
|
||||||
{
|
|
||||||
id: "r1",
|
|
||||||
data: { q1: "a", q2: "b", q3: "c" },
|
|
||||||
updatedAt: new Date(),
|
|
||||||
contact: null,
|
|
||||||
contactAttributes: {},
|
|
||||||
language: "en",
|
|
||||||
ttc: { q1: 5000, q2: 5000, q3: 4800, _total: 14800 },
|
|
||||||
finished: true,
|
|
||||||
},
|
|
||||||
] as any;
|
|
||||||
|
|
||||||
const meta = getSurveySummaryMeta(surveyWithOneBlockThreeElements, responses, 1, mockQuotas);
|
|
||||||
expect(meta.ttcAverage).toBe(5000);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getSurveySummaryDropOff", () => {
|
describe("getSurveySummaryDropOff", () => {
|
||||||
@@ -283,6 +229,12 @@ describe("getSurveySummaryDropOff", () => {
|
|||||||
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
|
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
|
||||||
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
|
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
|
||||||
);
|
);
|
||||||
|
vi.mocked(evaluateLogic).mockReturnValue(false); // Default: no logic triggers
|
||||||
|
vi.mocked(performActions).mockReturnValue({
|
||||||
|
jumpTarget: undefined,
|
||||||
|
requiredElementIds: [],
|
||||||
|
calculations: {},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("calculates dropOff correctly with welcome card disabled", () => {
|
test("calculates dropOff correctly with welcome card disabled", () => {
|
||||||
@@ -294,7 +246,7 @@ describe("getSurveySummaryDropOff", () => {
|
|||||||
contact: null,
|
contact: null,
|
||||||
contactAttributes: {},
|
contactAttributes: {},
|
||||||
language: "en",
|
language: "en",
|
||||||
ttc: { q1: 10, q2: 5 }, // Saw q2 but didn't answer it
|
ttc: { q1: 10 },
|
||||||
finished: false,
|
finished: false,
|
||||||
}, // Dropped at q2
|
}, // Dropped at q2
|
||||||
{
|
{
|
||||||
@@ -317,55 +269,22 @@ describe("getSurveySummaryDropOff", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(dropOff.length).toBe(2);
|
expect(dropOff.length).toBe(2);
|
||||||
// Q1: welcome card disabled so impressions = displayCount
|
// Q1
|
||||||
expect(dropOff[0].elementId).toBe("q1");
|
expect(dropOff[0].elementId).toBe("q1");
|
||||||
expect(dropOff[0].impressions).toBe(displayCount);
|
expect(dropOff[0].impressions).toBe(displayCount); // Welcome card disabled, so first question impressions = displayCount
|
||||||
expect(dropOff[0].dropOffCount).toBe(displayCount - responses.length); // 5 displays - 2 started = 3 dropped before q1
|
expect(dropOff[0].dropOffCount).toBe(displayCount - responses.length); // 5 displays - 2 started = 3 dropped before q1
|
||||||
expect(dropOff[0].dropOffPercentage).toBe(60); // (3/5)*100
|
expect(dropOff[0].dropOffPercentage).toBe(60); // (3/5)*100
|
||||||
expect(dropOff[0].ttc).toBe(10);
|
expect(dropOff[0].ttc).toBe(10);
|
||||||
|
|
||||||
// Q2: both responses saw q2 (r1 has ttc for q2, r2 answered q2)
|
// Q2
|
||||||
expect(dropOff[1].elementId).toBe("q2");
|
expect(dropOff[1].elementId).toBe("q2");
|
||||||
expect(dropOff[1].impressions).toBe(2);
|
expect(dropOff[1].impressions).toBe(responses.length); // 2 responses reached q1, so 2 impressions for q2
|
||||||
expect(dropOff[1].dropOffCount).toBe(1); // r1 dropped at q2 (last seen element)
|
expect(dropOff[1].dropOffCount).toBe(1); // 1 response dropped at q2
|
||||||
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
|
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
|
||||||
expect(dropOff[1].ttc).toBe(10); // block-level TTC uses max block time per response
|
expect(dropOff[1].ttc).toBe(10);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("drop-off attributed to last seen element when user doesn't reach next question", () => {
|
test("handles logic jumps", () => {
|
||||||
// Welcome card enabled so first element drop-off is NOT overridden by displayCount
|
|
||||||
const surveyWithWelcome: TSurvey = {
|
|
||||||
...surveyWithBlocks,
|
|
||||||
welcomeCard: { enabled: true, headline: { default: "Welcome" } } as unknown as TSurvey["welcomeCard"],
|
|
||||||
};
|
|
||||||
const responses = [
|
|
||||||
{
|
|
||||||
id: "r1",
|
|
||||||
data: { q1: "a" },
|
|
||||||
updatedAt: new Date(),
|
|
||||||
contact: null,
|
|
||||||
contactAttributes: {},
|
|
||||||
language: "en",
|
|
||||||
ttc: { q1: 10 }, // Only saw q1, never reached q2
|
|
||||||
finished: false,
|
|
||||||
},
|
|
||||||
] as any;
|
|
||||||
const displayCount = 1;
|
|
||||||
const dropOff = getSurveySummaryDropOff(
|
|
||||||
surveyWithWelcome,
|
|
||||||
getElementsFromBlocks(surveyWithWelcome.blocks),
|
|
||||||
responses,
|
|
||||||
displayCount
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(dropOff[0].impressions).toBe(1); // Saw q1
|
|
||||||
expect(dropOff[0].dropOffCount).toBe(1); // Dropped at q1 (last seen element)
|
|
||||||
expect(dropOff[1].impressions).toBe(0); // Never saw q2
|
|
||||||
expect(dropOff[1].dropOffCount).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles logic jumps — impressions based on actual ttc/data, not logic replay", () => {
|
|
||||||
// Survey with 4 questions across 4 blocks, logic on block2 jumps q2->q4 (skipping q3)
|
|
||||||
const surveyWithLogic: TSurvey = {
|
const surveyWithLogic: TSurvey = {
|
||||||
...mockBaseSurvey,
|
...mockBaseSurvey,
|
||||||
blocks: [
|
blocks: [
|
||||||
@@ -396,6 +315,36 @@ describe("getSurveySummaryDropOff", () => {
|
|||||||
charLimit: { enabled: false },
|
charLimit: { enabled: false },
|
||||||
},
|
},
|
||||||
] as TSurveyElement[],
|
] as TSurveyElement[],
|
||||||
|
logic: [
|
||||||
|
{
|
||||||
|
id: "logic1",
|
||||||
|
conditions: {
|
||||||
|
id: "condition1",
|
||||||
|
connector: "and" as const,
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
id: "c1",
|
||||||
|
leftOperand: {
|
||||||
|
type: "element" as const,
|
||||||
|
value: "q2",
|
||||||
|
},
|
||||||
|
operator: "equals" as const,
|
||||||
|
rightOperand: {
|
||||||
|
type: "static" as const,
|
||||||
|
value: "b",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: "action1",
|
||||||
|
objective: "jumpToBlock" as const,
|
||||||
|
target: "q4",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "block3",
|
id: "block3",
|
||||||
@@ -428,21 +377,28 @@ describe("getSurveySummaryDropOff", () => {
|
|||||||
],
|
],
|
||||||
questions: [],
|
questions: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Response where user answered q1, q2, then logic jumped to q4 (skipping q3).
|
|
||||||
// The ttc/data reflects exactly what elements were shown — no logic replay needed.
|
|
||||||
const responses = [
|
const responses = [
|
||||||
{
|
{
|
||||||
id: "r1",
|
id: "r1",
|
||||||
data: { q1: "a", q2: "b", q4: "d" },
|
data: { q1: "a", q2: "b" },
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
contact: null,
|
contact: null,
|
||||||
contactAttributes: {},
|
contactAttributes: {},
|
||||||
language: "en",
|
language: "en",
|
||||||
ttc: { q1: 10, q2: 10, q4: 10 }, // q3 has no ttc entry — was skipped by logic
|
ttc: { q1: 10, q2: 10 },
|
||||||
finished: false,
|
finished: false,
|
||||||
},
|
}, // Jumps from q2 to q4, drops at q4
|
||||||
];
|
];
|
||||||
|
vi.mocked(evaluateLogic).mockImplementation((_s, data, _v, _, _l) => {
|
||||||
|
// Simulate logic on q2 triggering
|
||||||
|
return data.q2 === "b";
|
||||||
|
});
|
||||||
|
vi.mocked(performActions).mockImplementation((_s, actions, _d, _v) => {
|
||||||
|
if (actions[0] && "objective" in actions[0] && actions[0].objective === "jumpToBlock") {
|
||||||
|
return { jumpTarget: actions[0].target, requiredElementIds: [], calculations: {} };
|
||||||
|
}
|
||||||
|
return { jumpTarget: undefined, requiredElementIds: [], calculations: {} };
|
||||||
|
});
|
||||||
|
|
||||||
const dropOff = getSurveySummaryDropOff(
|
const dropOff = getSurveySummaryDropOff(
|
||||||
surveyWithLogic,
|
surveyWithLogic,
|
||||||
@@ -451,11 +407,11 @@ describe("getSurveySummaryDropOff", () => {
|
|||||||
1
|
1
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(dropOff[0].impressions).toBe(1); // q1: seen
|
expect(dropOff[0].impressions).toBe(1); // q1
|
||||||
expect(dropOff[1].impressions).toBe(1); // q2: seen
|
expect(dropOff[1].impressions).toBe(1); // q2
|
||||||
expect(dropOff[2].impressions).toBe(0); // q3: skipped by logic (no ttc, no data)
|
expect(dropOff[2].impressions).toBe(0); // q3 (skipped)
|
||||||
expect(dropOff[3].impressions).toBe(1); // q4: jumped to, seen
|
expect(dropOff[3].impressions).toBe(1); // q4 (jumped to)
|
||||||
expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4 (last seen element, not finished)
|
expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+121
-72
@@ -11,6 +11,7 @@ import {
|
|||||||
TResponseData,
|
TResponseData,
|
||||||
TResponseFilterCriteria,
|
TResponseFilterCriteria,
|
||||||
TResponseTtc,
|
TResponseTtc,
|
||||||
|
TResponseVariables,
|
||||||
ZResponseFilterCriteria,
|
ZResponseFilterCriteria,
|
||||||
} from "@formbricks/types/responses";
|
} from "@formbricks/types/responses";
|
||||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
@@ -36,7 +37,8 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
|||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { buildWhereClause } from "@/lib/response/utils";
|
import { buildWhereClause } from "@/lib/response/utils";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
import { findElementLocation, getElementsFromBlocks } from "@/lib/survey/utils";
|
||||||
|
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
import { convertFloatTo2Decimal } from "./utils";
|
import { convertFloatTo2Decimal } from "./utils";
|
||||||
|
|
||||||
@@ -51,32 +53,7 @@ interface TSurveySummaryResponse {
|
|||||||
finished: boolean;
|
finished: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getElementIdToBlockIdMap = (survey: TSurvey): Record<string, string> => {
|
|
||||||
return survey.blocks.reduce<Record<string, string>>((acc, block) => {
|
|
||||||
block.elements.forEach((element) => {
|
|
||||||
acc[element.id] = block.id;
|
|
||||||
});
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBlockTimesForResponse = (
|
|
||||||
response: TSurveySummaryResponse,
|
|
||||||
survey: TSurvey
|
|
||||||
): Record<string, number> => {
|
|
||||||
return survey.blocks.reduce<Record<string, number>>((acc, block) => {
|
|
||||||
const maxElementTtc = block.elements.reduce((maxTtc, element) => {
|
|
||||||
const elementTtc = response.ttc?.[element.id] ?? 0;
|
|
||||||
return Math.max(maxTtc, elementTtc);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
acc[block.id] = maxElementTtc;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getSurveySummaryMeta = (
|
export const getSurveySummaryMeta = (
|
||||||
survey: TSurvey,
|
|
||||||
responses: TSurveySummaryResponse[],
|
responses: TSurveySummaryResponse[],
|
||||||
displayCount: number,
|
displayCount: number,
|
||||||
quotas: TSurveySummary["quotas"]
|
quotas: TSurveySummary["quotas"]
|
||||||
@@ -85,15 +62,9 @@ export const getSurveySummaryMeta = (
|
|||||||
|
|
||||||
let ttcResponseCount = 0;
|
let ttcResponseCount = 0;
|
||||||
const ttcSum = responses.reduce((acc, response) => {
|
const ttcSum = responses.reduce((acc, response) => {
|
||||||
const blockTimes = getBlockTimesForResponse(response, survey);
|
if (response.ttc?._total) {
|
||||||
const responseBlockTtcTotal = Object.values(blockTimes).reduce((sum, ttc) => sum + ttc, 0);
|
|
||||||
|
|
||||||
// Fallback to _total for malformed surveys with no block mappings.
|
|
||||||
const responseTtcTotal = responseBlockTtcTotal > 0 ? responseBlockTtcTotal : (response.ttc?._total ?? 0);
|
|
||||||
|
|
||||||
if (responseTtcTotal > 0) {
|
|
||||||
ttcResponseCount++;
|
ttcResponseCount++;
|
||||||
return acc + responseTtcTotal;
|
return acc + response.ttc._total;
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, 0);
|
}, 0);
|
||||||
@@ -122,13 +93,63 @@ export const getSurveySummaryMeta = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine whether a response interacted with a given element.
|
const evaluateLogicAndGetNextElementId = (
|
||||||
// An element was "seen" if the respondent has a ttc entry for it OR provided an answer.
|
localSurvey: TSurvey,
|
||||||
// This is more reliable than replaying survey logic, which can misattribute impressions
|
elements: TSurveyElement[],
|
||||||
// when branching logic skips elements or when partial response data is insufficient
|
data: TResponseData,
|
||||||
// to evaluate conditions correctly.
|
localVariables: TResponseVariables,
|
||||||
const wasElementSeen = (response: TSurveySummaryResponse, elementId: string): boolean => {
|
currentElementIndex: number,
|
||||||
return (response.ttc != null && response.ttc[elementId] > 0) || response.data[elementId] !== undefined;
|
currElementTemp: TSurveyElement,
|
||||||
|
selectedLanguage: string | null
|
||||||
|
): {
|
||||||
|
nextElementId: string | undefined;
|
||||||
|
updatedSurvey: TSurvey;
|
||||||
|
updatedVariables: TResponseVariables;
|
||||||
|
} => {
|
||||||
|
let updatedSurvey = { ...localSurvey };
|
||||||
|
let updatedVariables = { ...localVariables };
|
||||||
|
|
||||||
|
let firstJumpTarget: string | undefined;
|
||||||
|
|
||||||
|
const { block: currentBlock } = findElementLocation(localSurvey, currElementTemp.id);
|
||||||
|
|
||||||
|
if (currentBlock?.logic && currentBlock.logic.length > 0) {
|
||||||
|
for (const logic of currentBlock.logic) {
|
||||||
|
if (evaluateLogic(localSurvey, data, localVariables, logic.conditions, selectedLanguage ?? "default")) {
|
||||||
|
const { jumpTarget, requiredElementIds, calculations } = performActions(
|
||||||
|
updatedSurvey,
|
||||||
|
logic.actions,
|
||||||
|
data,
|
||||||
|
updatedVariables
|
||||||
|
);
|
||||||
|
|
||||||
|
if (requiredElementIds.length > 0) {
|
||||||
|
// Update blocks to mark elements as required
|
||||||
|
updatedSurvey.blocks = updatedSurvey.blocks.map((block) => ({
|
||||||
|
...block,
|
||||||
|
elements: block.elements.map((e) =>
|
||||||
|
requiredElementIds.includes(e.id) ? { ...e, required: true } : e
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
updatedVariables = { ...updatedVariables, ...calculations };
|
||||||
|
|
||||||
|
if (jumpTarget && !firstJumpTarget) {
|
||||||
|
firstJumpTarget = jumpTarget;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no jump target was set, check for a fallback logic
|
||||||
|
if (!firstJumpTarget && currentBlock?.logicFallback) {
|
||||||
|
firstJumpTarget = currentBlock.logicFallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the first jump target if found, otherwise go to the next element
|
||||||
|
const nextElementId = firstJumpTarget || elements[currentElementIndex + 1]?.id || undefined;
|
||||||
|
|
||||||
|
return { nextElementId, updatedSurvey, updatedVariables };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSurveySummaryDropOff = (
|
export const getSurveySummaryDropOff = (
|
||||||
@@ -148,35 +169,69 @@ export const getSurveySummaryDropOff = (
|
|||||||
let dropOffArr = new Array(elements.length).fill(0) as number[];
|
let dropOffArr = new Array(elements.length).fill(0) as number[];
|
||||||
let impressionsArr = new Array(elements.length).fill(0) as number[];
|
let impressionsArr = new Array(elements.length).fill(0) as number[];
|
||||||
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
|
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
|
||||||
const elementIdToBlockId = getElementIdToBlockIdMap(survey);
|
|
||||||
|
const surveyVariablesData = survey.variables?.reduce(
|
||||||
|
(acc, variable) => {
|
||||||
|
acc[variable.id] = variable.value;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string | number>
|
||||||
|
);
|
||||||
|
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
// Calculate total time-to-completion per element
|
// Calculate total time-to-completion
|
||||||
const blockTimes = getBlockTimesForResponse(response, survey);
|
|
||||||
Object.keys(totalTtc).forEach((elementId) => {
|
Object.keys(totalTtc).forEach((elementId) => {
|
||||||
const blockId = elementIdToBlockId[elementId];
|
if (response.ttc && response.ttc[elementId]) {
|
||||||
const blockTtc = blockId ? (blockTimes[blockId] ?? 0) : 0;
|
totalTtc[elementId] += response.ttc[elementId];
|
||||||
if (blockTtc > 0) {
|
|
||||||
totalTtc[elementId] += blockTtc;
|
|
||||||
responseCounts[elementId]++;
|
responseCounts[elementId]++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Count impressions based on actual interaction data (ttc + response data)
|
let localSurvey = structuredClone(survey);
|
||||||
// instead of replaying survey logic which is unreliable with branching
|
let localResponseData: TResponseData = { ...response.data };
|
||||||
let lastSeenIdx = -1;
|
let localVariables: TResponseVariables = {
|
||||||
|
...surveyVariablesData,
|
||||||
|
};
|
||||||
|
|
||||||
for (let i = 0; i < elements.length; i++) {
|
let currQuesIdx = 0;
|
||||||
const element = elements[i];
|
|
||||||
if (wasElementSeen(response, element.id)) {
|
while (currQuesIdx < elements.length) {
|
||||||
impressionsArr[i]++;
|
const currQues = elements[currQuesIdx];
|
||||||
lastSeenIdx = i;
|
if (!currQues) break;
|
||||||
|
|
||||||
|
// element is not answered and required
|
||||||
|
if (response.data[currQues.id] === undefined && currQues.required) {
|
||||||
|
dropOffArr[currQuesIdx]++;
|
||||||
|
impressionsArr[currQuesIdx]++;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Attribute drop-off to the last element the respondent interacted with
|
impressionsArr[currQuesIdx]++;
|
||||||
if (!response.finished && lastSeenIdx >= 0) {
|
|
||||||
dropOffArr[lastSeenIdx]++;
|
const { nextElementId, updatedSurvey, updatedVariables } = evaluateLogicAndGetNextElementId(
|
||||||
|
localSurvey,
|
||||||
|
elements,
|
||||||
|
localResponseData,
|
||||||
|
localVariables,
|
||||||
|
currQuesIdx,
|
||||||
|
currQues,
|
||||||
|
response.language
|
||||||
|
);
|
||||||
|
|
||||||
|
localSurvey = updatedSurvey;
|
||||||
|
localVariables = updatedVariables;
|
||||||
|
|
||||||
|
if (nextElementId) {
|
||||||
|
const nextQuesIdx = elements.findIndex((q) => q.id === nextElementId);
|
||||||
|
if (!response.data[nextElementId] && !response.finished) {
|
||||||
|
dropOffArr[nextQuesIdx]++;
|
||||||
|
impressionsArr[nextQuesIdx]++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
currQuesIdx = nextQuesIdx;
|
||||||
|
} else {
|
||||||
|
currQuesIdx++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,8 +240,6 @@ export const getSurveySummaryDropOff = (
|
|||||||
totalTtc[elementId] = responseCounts[elementId] > 0 ? totalTtc[elementId] / responseCounts[elementId] : 0;
|
totalTtc[elementId] = responseCounts[elementId] > 0 ? totalTtc[elementId] / responseCounts[elementId] : 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
// When the welcome card is disabled, the first element's impressions should equal displayCount
|
|
||||||
// because every survey display is an impression of the first element
|
|
||||||
if (!survey.welcomeCard.enabled) {
|
if (!survey.welcomeCard.enabled) {
|
||||||
dropOffArr[0] = displayCount - impressionsArr[0];
|
dropOffArr[0] = displayCount - impressionsArr[0];
|
||||||
if (impressionsArr[0] > displayCount) dropOffPercentageArr[0] = 0;
|
if (impressionsArr[0] > displayCount) dropOffPercentageArr[0] = 0;
|
||||||
@@ -198,7 +251,7 @@ export const getSurveySummaryDropOff = (
|
|||||||
|
|
||||||
impressionsArr[0] = displayCount;
|
impressionsArr[0] = displayCount;
|
||||||
} else {
|
} else {
|
||||||
dropOffPercentageArr[0] = impressionsArr[0] > 0 ? (dropOffArr[0] / impressionsArr[0]) * 100 : 0;
|
dropOffPercentageArr[0] = (dropOffArr[0] / impressionsArr[0]) * 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 1; i < elements.length; i++) {
|
for (let i = 1; i < elements.length; i++) {
|
||||||
@@ -1009,8 +1062,10 @@ export const getSurveySummary = reactCache(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const dropOff = getSurveySummaryDropOff(survey, elements, responses, displayCount);
|
const dropOff = getSurveySummaryDropOff(survey, elements, responses, displayCount);
|
||||||
const meta = getSurveySummaryMeta(survey, responses, displayCount, quotas);
|
const [meta, elementSummary] = await Promise.all([
|
||||||
const elementSummary = await getElementSummary(survey, elements, responses, dropOff);
|
getSurveySummaryMeta(responses, displayCount, quotas),
|
||||||
|
getElementSummary(survey, elements, responses, dropOff),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
meta,
|
meta,
|
||||||
@@ -1094,9 +1149,7 @@ export const getResponsesForSummary = reactCache(
|
|||||||
const transformedResponses: TSurveySummaryResponse[] = await Promise.all(
|
const transformedResponses: TSurveySummaryResponse[] = await Promise.all(
|
||||||
responses.map((responsePrisma) => {
|
responses.map((responsePrisma) => {
|
||||||
return {
|
return {
|
||||||
id: responsePrisma.id,
|
...responsePrisma,
|
||||||
data: (responsePrisma.data ?? {}) as TResponseData,
|
|
||||||
updatedAt: responsePrisma.updatedAt,
|
|
||||||
contact: responsePrisma.contact
|
contact: responsePrisma.contact
|
||||||
? {
|
? {
|
||||||
id: responsePrisma.contact.id as string,
|
id: responsePrisma.contact.id as string,
|
||||||
@@ -1105,10 +1158,6 @@ export const getResponsesForSummary = reactCache(
|
|||||||
)?.value as string,
|
)?.value as string,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
contactAttributes: (responsePrisma.contactAttributes ?? {}) as TResponseContactAttributes,
|
|
||||||
language: responsePrisma.language,
|
|
||||||
ttc: (responsePrisma.ttc ?? {}) as TResponseTtc,
|
|
||||||
finished: responsePrisma.finished,
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
+7
-6
@@ -1,5 +1,4 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||||
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
||||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||||
@@ -33,13 +32,13 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
|||||||
const survey = await getSurvey(params.surveyId);
|
const survey = await getSurvey(params.surveyId);
|
||||||
|
|
||||||
if (!survey) {
|
if (!survey) {
|
||||||
throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
|
throw new Error(t("common.survey_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
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 organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||||
|
|
||||||
@@ -47,11 +46,11 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
|||||||
const segments = isContactsEnabled ? await getSegments(environment.id) : [];
|
const segments = isContactsEnabled ? await getSegments(environment.id) : [];
|
||||||
|
|
||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
throw new ResourceNotFoundError(t("common.organization"), null);
|
throw new Error(t("common.organization_not_found"));
|
||||||
}
|
}
|
||||||
const organizationBilling = await getOrganizationBilling(organizationId);
|
const organizationBilling = await getOrganizationBilling(organizationId);
|
||||||
if (!organizationBilling) {
|
if (!organizationBilling) {
|
||||||
throw new ResourceNotFoundError(t("common.organization"), organizationId);
|
throw new Error(t("common.organization_not_found"));
|
||||||
}
|
}
|
||||||
const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
|
const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
|
||||||
|
|
||||||
@@ -66,6 +65,8 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
|||||||
pageTitle={survey.name}
|
pageTitle={survey.name}
|
||||||
cta={
|
cta={
|
||||||
<SurveyAnalysisCTA
|
<SurveyAnalysisCTA
|
||||||
|
environment={environment}
|
||||||
|
survey={survey}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
user={user}
|
user={user}
|
||||||
publicDomain={publicDomain}
|
publicDomain={publicDomain}
|
||||||
@@ -76,7 +77,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
|||||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||||
/>
|
/>
|
||||||
}>
|
}>
|
||||||
<SurveyAnalysisNavigation activeId="summary" />
|
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="summary" />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<SummaryPage
|
<SummaryPage
|
||||||
environment={environment}
|
environment={environment}
|
||||||
|
|||||||
@@ -2,17 +2,21 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
|
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
|
||||||
import { capturePostHogEvent } from "@/lib/posthog";
|
import { ZSurvey } from "@formbricks/types/surveys/types";
|
||||||
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
|
import { getResponseDownloadFile, getResponseFilteringValues } from "@/lib/response/service";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
import { getTagsByEnvironmentId } from "@/lib/tag/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 { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
|
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
|
||||||
|
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
|
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
|
||||||
|
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||||
|
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
|
||||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||||
|
|
||||||
const ZGetResponsesDownloadUrlAction = z.object({
|
const ZGetResponsesDownloadUrlAction = z.object({
|
||||||
@@ -24,11 +28,9 @@ const ZGetResponsesDownloadUrlAction = z.object({
|
|||||||
export const getResponsesDownloadUrlAction = authenticatedActionClient
|
export const getResponsesDownloadUrlAction = authenticatedActionClient
|
||||||
.inputSchema(ZGetResponsesDownloadUrlAction)
|
.inputSchema(ZGetResponsesDownloadUrlAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
|
||||||
|
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId,
|
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||||
access: [
|
access: [
|
||||||
{
|
{
|
||||||
type: "organization",
|
type: "organization",
|
||||||
@@ -42,27 +44,11 @@ export const getResponsesDownloadUrlAction = authenticatedActionClient
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
|
return await getResponseDownloadFile(
|
||||||
const result = await getResponseDownloadFile(
|
|
||||||
parsedInput.surveyId,
|
parsedInput.surveyId,
|
||||||
parsedInput.format,
|
parsedInput.format,
|
||||||
parsedInput.filterCriteria
|
parsedInput.filterCriteria
|
||||||
);
|
);
|
||||||
|
|
||||||
capturePostHogEvent(
|
|
||||||
ctx.user.id,
|
|
||||||
"responses_exported",
|
|
||||||
{
|
|
||||||
survey_id: parsedInput.surveyId,
|
|
||||||
format: parsedInput.format,
|
|
||||||
filter_applied: Object.keys(parsedInput.filterCriteria ?? {}).length > 0,
|
|
||||||
organization_id: organizationId,
|
|
||||||
workspace_id: projectId,
|
|
||||||
},
|
|
||||||
{ organizationId, workspaceId: projectId }
|
|
||||||
);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const ZGetSurveyFilterDataAction = z.object({
|
const ZGetSurveyFilterDataAction = z.object({
|
||||||
@@ -111,3 +97,68 @@ export const getSurveyFilterDataAction = authenticatedActionClient
|
|||||||
|
|
||||||
return { environmentTags: tags, attributes, meta, hiddenFields, quotas };
|
return { environmentTags: tags, attributes, meta, hiddenFields, quotas };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if survey follow-ups are enabled for the given organization.
|
||||||
|
*
|
||||||
|
* @param {string} organizationId The ID of the organization to check.
|
||||||
|
* @returns {Promise<void>} A promise that resolves if the permission is granted.
|
||||||
|
* @throws {ResourceNotFoundError} If the organization is not found.
|
||||||
|
* @throws {OperationNotAllowedError} If survey follow-ups are not enabled for the organization.
|
||||||
|
*/
|
||||||
|
const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<void> => {
|
||||||
|
const organization = await getOrganization(organizationId);
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
throw new ResourceNotFoundError("Organization not found", organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationId);
|
||||||
|
if (!isSurveyFollowUpsEnabled) {
|
||||||
|
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey).action(
|
||||||
|
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
|
||||||
|
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.id);
|
||||||
|
await checkAuthorizationUpdated({
|
||||||
|
userId: ctx.user?.id ?? "",
|
||||||
|
organizationId,
|
||||||
|
access: [
|
||||||
|
{
|
||||||
|
type: "organization",
|
||||||
|
roles: ["owner", "manager"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "projectTeam",
|
||||||
|
projectId: await getProjectIdFromSurveyId(parsedInput.id),
|
||||||
|
minPermission: "readWrite",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { followUps } = parsedInput;
|
||||||
|
|
||||||
|
const oldSurvey = await getSurvey(parsedInput.id);
|
||||||
|
|
||||||
|
if (parsedInput.recaptcha?.enabled) {
|
||||||
|
await checkSpamProtectionPermission(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (followUps?.length) {
|
||||||
|
await checkSurveyFollowUpsPermission(organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context for audit log
|
||||||
|
ctx.auditLoggingCtx.surveyId = parsedInput.id;
|
||||||
|
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||||
|
ctx.auditLoggingCtx.oldObject = oldSurvey;
|
||||||
|
|
||||||
|
const newSurvey = await updateSurvey(parsedInput);
|
||||||
|
|
||||||
|
ctx.auditLoggingCtx.newObject = newSurvey;
|
||||||
|
|
||||||
|
return newSurvey;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|||||||
+1
-26
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { TFunction } from "i18next";
|
|
||||||
import {
|
import {
|
||||||
AirplayIcon,
|
AirplayIcon,
|
||||||
ArrowUpFromDotIcon,
|
ArrowUpFromDotIcon,
|
||||||
@@ -55,25 +54,6 @@ export enum OptionsType {
|
|||||||
QUOTAS = "Quotas",
|
QUOTAS = "Quotas",
|
||||||
}
|
}
|
||||||
|
|
||||||
const getOptionsTypeTranslationKey = (type: OptionsType, t: TFunction): string => {
|
|
||||||
switch (type) {
|
|
||||||
case OptionsType.ELEMENTS:
|
|
||||||
return t("common.elements");
|
|
||||||
case OptionsType.TAGS:
|
|
||||||
return t("common.tags");
|
|
||||||
case OptionsType.ATTRIBUTES:
|
|
||||||
return t("common.attributes");
|
|
||||||
case OptionsType.OTHERS:
|
|
||||||
return t("common.other_filters");
|
|
||||||
case OptionsType.META:
|
|
||||||
return t("common.meta");
|
|
||||||
case OptionsType.HIDDEN_FIELDS:
|
|
||||||
return t("common.hidden_fields");
|
|
||||||
case OptionsType.QUOTAS:
|
|
||||||
return t("common.quotas");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ElementOption = {
|
export type ElementOption = {
|
||||||
label: string;
|
label: string;
|
||||||
elementType?: TSurveyElementTypeEnum;
|
elementType?: TSurveyElementTypeEnum;
|
||||||
@@ -238,12 +218,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
|
|||||||
{options?.map((data) => (
|
{options?.map((data) => (
|
||||||
<Fragment key={data.header}>
|
<Fragment key={data.header}>
|
||||||
{data?.option.length > 0 && (
|
{data?.option.length > 0 && (
|
||||||
<CommandGroup
|
<CommandGroup heading={<p className="text-sm font-medium text-slate-600">{data.header}</p>}>
|
||||||
heading={
|
|
||||||
<p className="text-sm font-medium text-slate-600">
|
|
||||||
{getOptionsTypeTranslationKey(data.header, t)}
|
|
||||||
</p>
|
|
||||||
}>
|
|
||||||
{data?.option?.map((o) => (
|
{data?.option?.map((o) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={o.id}
|
key={o.id}
|
||||||
|
|||||||
+17
-6
@@ -3,11 +3,9 @@
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
|
||||||
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
|
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { updateSurveyAction } from "@/modules/survey/editor/actions";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -16,10 +14,19 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/modules/ui/components/select";
|
} from "@/modules/ui/components/select";
|
||||||
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
|
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
|
||||||
|
import { updateSurveyAction } from "../actions";
|
||||||
|
|
||||||
export const SurveyStatusDropdown = () => {
|
interface SurveyStatusDropdownProps {
|
||||||
const { environment } = useEnvironment();
|
environment: TEnvironment;
|
||||||
const { survey } = useSurvey();
|
updateLocalSurveyStatus?: (status: TSurvey["status"]) => void;
|
||||||
|
survey: TSurvey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SurveyStatusDropdown = ({
|
||||||
|
environment,
|
||||||
|
updateLocalSurveyStatus,
|
||||||
|
survey,
|
||||||
|
}: SurveyStatusDropdownProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -39,6 +46,10 @@ export const SurveyStatusDropdown = () => {
|
|||||||
toast.success(toastMessage);
|
toast.success(toastMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (updateLocalSurveyStatus) {
|
||||||
|
updateLocalSurveyStatus(resultingStatus);
|
||||||
|
}
|
||||||
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse);
|
const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse);
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
|
||||||
import { SurveyContextWrapper } from "./context/survey-context";
|
import { SurveyContextWrapper } from "./context/survey-context";
|
||||||
|
|
||||||
interface SurveyLayoutProps {
|
interface SurveyLayoutProps {
|
||||||
@@ -12,10 +10,9 @@ const SurveyLayout = async ({ params, children }: SurveyLayoutProps) => {
|
|||||||
const resolvedParams = await params;
|
const resolvedParams = await params;
|
||||||
|
|
||||||
const survey = await getSurvey(resolvedParams.surveyId);
|
const survey = await getSurvey(resolvedParams.surveyId);
|
||||||
const t = await getTranslate();
|
|
||||||
|
|
||||||
if (!survey) {
|
if (!survey) {
|
||||||
throw new ResourceNotFoundError(t("common.survey"), resolvedParams.surveyId);
|
throw new Error("Survey not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
return <SurveyContextWrapper survey={survey}>{children}</SurveyContextWrapper>;
|
return <SurveyContextWrapper survey={survey}>{children}</SurveyContextWrapper>;
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import { type ReactNode } from "react";
|
|
||||||
import { SurveysQueryClientProvider } from "./query-client-provider";
|
|
||||||
|
|
||||||
const SurveysLayout = ({ children }: { children: ReactNode }) => {
|
|
||||||
return <SurveysQueryClientProvider>{children}</SurveysQueryClientProvider>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SurveysLayout;
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import { type ReactNode, useState } from "react";
|
|
||||||
|
|
||||||
export const SurveysQueryClientProvider = ({ children }: { children: ReactNode }) => {
|
|
||||||
const [queryClient] = useState(() => new QueryClient());
|
|
||||||
|
|
||||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
|
||||||
};
|
|
||||||
+208
@@ -0,0 +1,208 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CheckCircle2, Sparkles } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
|
||||||
|
const FORMBRICKS_HOST = "https://app.formbricks.com";
|
||||||
|
const SURVEY_ID = "cr9r4b2r73x6hlmn5aa2ha44";
|
||||||
|
const ENVIRONMENT_ID = "cmk41i8bi92bdad01svi74dec";
|
||||||
|
|
||||||
|
interface WorkflowsPageProps {
|
||||||
|
userEmail: string;
|
||||||
|
organizationName: string;
|
||||||
|
billingPlan: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Step = "prompt" | "followup" | "thankyou";
|
||||||
|
|
||||||
|
export const WorkflowsPage = ({ userEmail, organizationName, billingPlan }: WorkflowsPageProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [step, setStep] = useState<Step>("prompt");
|
||||||
|
const [promptValue, setPromptValue] = useState("");
|
||||||
|
const [detailsValue, setDetailsValue] = useState("");
|
||||||
|
const [responseId, setResponseId] = useState<string | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleGenerateWorkflow = async () => {
|
||||||
|
if (promptValue.trim().length < 100 || isSubmitting) return;
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${FORMBRICKS_HOST}/api/v2/client/${ENVIRONMENT_ID}/responses`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
surveyId: SURVEY_ID,
|
||||||
|
finished: false,
|
||||||
|
data: {
|
||||||
|
workflow: promptValue.trim(),
|
||||||
|
useremail: userEmail,
|
||||||
|
orgname: organizationName,
|
||||||
|
billingplan: billingPlan,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const json = await res.json();
|
||||||
|
setResponseId(json.data?.id ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStep("followup");
|
||||||
|
} catch {
|
||||||
|
setStep("followup");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitFeedback = async () => {
|
||||||
|
if (isSubmitting) return;
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
if (responseId) {
|
||||||
|
try {
|
||||||
|
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
finished: true,
|
||||||
|
data: {
|
||||||
|
details: detailsValue.trim(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(false);
|
||||||
|
setStep("thankyou");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkipFeedback = async () => {
|
||||||
|
if (!responseId) {
|
||||||
|
setStep("thankyou");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
finished: true,
|
||||||
|
data: {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
}
|
||||||
|
|
||||||
|
setStep("thankyou");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (step === "prompt") {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
|
||||||
|
<div className="w-full max-w-2xl space-y-8">
|
||||||
|
<div className="space-y-3 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br from-brand-light to-brand-dark shadow-md">
|
||||||
|
<Sparkles className="h-6 w-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight text-slate-800">{t("workflows.heading")}</h1>
|
||||||
|
<p className="text-lg text-slate-500">{t("workflows.subheading")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<textarea
|
||||||
|
value={promptValue}
|
||||||
|
onChange={(e) => setPromptValue(e.target.value)}
|
||||||
|
placeholder={t("workflows.placeholder")}
|
||||||
|
rows={5}
|
||||||
|
className="w-full resize-none rounded-xl border border-slate-200 bg-white px-5 py-4 text-base text-slate-800 shadow-sm transition-all placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-brand-light/20"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||||
|
handleGenerateWorkflow();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
className={`text-xs ${promptValue.trim().length >= 100 ? "text-slate-400" : "text-amber-500"}`}>
|
||||||
|
{promptValue.trim().length} / 100
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
onClick={handleGenerateWorkflow}
|
||||||
|
disabled={promptValue.trim().length < 100 || isSubmitting}
|
||||||
|
loading={isSubmitting}
|
||||||
|
size="lg">
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
{t("workflows.generate_button")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step === "followup") {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
|
||||||
|
<div className="w-full max-w-2xl space-y-8">
|
||||||
|
<div className="space-y-3 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100">
|
||||||
|
<Sparkles className="h-6 w-6 text-brand-dark" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-slate-800">
|
||||||
|
{t("workflows.coming_soon_title")}
|
||||||
|
</h1>
|
||||||
|
<p className="mx-auto max-w-md text-base text-slate-500">
|
||||||
|
{t("workflows.coming_soon_description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<label className="text-md mb-2 block font-medium text-slate-700">
|
||||||
|
{t("workflows.follow_up_label")}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={detailsValue}
|
||||||
|
onChange={(e) => setDetailsValue(e.target.value)}
|
||||||
|
placeholder={t("workflows.follow_up_placeholder")}
|
||||||
|
rows={4}
|
||||||
|
className="w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-800 transition-all placeholder:text-slate-400 focus:border-brand-dark focus:bg-white focus:outline-none focus:ring-2 focus:ring-brand-light/20"
|
||||||
|
/>
|
||||||
|
<div className="mt-4 flex items-center justify-end gap-3">
|
||||||
|
<Button variant="ghost" onClick={handleSkipFeedback} className="text-slate-500">
|
||||||
|
{t("common.skip")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmitFeedback}
|
||||||
|
disabled={!detailsValue.trim() || isSubmitting}
|
||||||
|
loading={isSubmitting}>
|
||||||
|
{t("workflows.submit_button")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
|
||||||
|
<div className="w-full max-w-md space-y-6 text-center">
|
||||||
|
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-50">
|
||||||
|
<CheckCircle2 className="h-8 w-8 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-800">{t("workflows.thank_you_title")}</h1>
|
||||||
|
<p className="text-base text-slate-500">{t("workflows.thank_you_description")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
|
import { getUser } from "@/lib/user/service";
|
||||||
|
import { getCloudBillingDisplayContext } from "@/modules/ee/billing/lib/cloud-billing-display";
|
||||||
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
|
import { WorkflowsPage } from "./components/workflows-page";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Workflows",
|
||||||
|
};
|
||||||
|
|
||||||
|
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||||
|
const params = await props.params;
|
||||||
|
|
||||||
|
if (!IS_FORMBRICKS_CLOUD) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { session, organization, isBilling } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
|
if (isBilling) {
|
||||||
|
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUser(session.user.id);
|
||||||
|
if (!user) {
|
||||||
|
return redirect("/auth/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloudBillingDisplayContext = await getCloudBillingDisplayContext(organization.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorkflowsPage
|
||||||
|
userEmail={user.email}
|
||||||
|
organizationName={organization.name}
|
||||||
|
billingPlan={cloudBillingDisplayContext.currentCloudPlan}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Page;
|
||||||
@@ -4,7 +4,6 @@ import { z } from "zod";
|
|||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { ZIntegrationInput } from "@formbricks/types/integration";
|
import { ZIntegrationInput } from "@formbricks/types/integration";
|
||||||
import { createOrUpdateIntegration, deleteIntegration } from "@/lib/integration/service";
|
import { createOrUpdateIntegration, deleteIntegration } from "@/lib/integration/service";
|
||||||
import { capturePostHogEvent } from "@/lib/posthog";
|
|
||||||
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 {
|
import {
|
||||||
@@ -43,23 +42,9 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
|
|||||||
});
|
});
|
||||||
|
|
||||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||||
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
|
|
||||||
const result = await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData);
|
const result = await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData);
|
||||||
ctx.auditLoggingCtx.integrationId = result.id;
|
ctx.auditLoggingCtx.integrationId = result.id;
|
||||||
ctx.auditLoggingCtx.newObject = result;
|
ctx.auditLoggingCtx.newObject = result;
|
||||||
|
|
||||||
capturePostHogEvent(
|
|
||||||
ctx.user.id,
|
|
||||||
"integration_connected",
|
|
||||||
{
|
|
||||||
integration_type: parsedInput.integrationData.type,
|
|
||||||
organization_id: organizationId,
|
|
||||||
workspace_id: projectId,
|
|
||||||
environment_id: parsedInput.environmentId,
|
|
||||||
},
|
|
||||||
{ organizationId, workspaceId: projectId }
|
|
||||||
);
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
-4
@@ -18,7 +18,6 @@ interface AirtableWrapperProps {
|
|||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
webAppUrl: string;
|
webAppUrl: string;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
showReconnectButton?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AirtableWrapper = ({
|
export const AirtableWrapper = ({
|
||||||
@@ -29,7 +28,6 @@ export const AirtableWrapper = ({
|
|||||||
isEnabled,
|
isEnabled,
|
||||||
webAppUrl,
|
webAppUrl,
|
||||||
locale,
|
locale,
|
||||||
showReconnectButton = false,
|
|
||||||
}: AirtableWrapperProps) => {
|
}: AirtableWrapperProps) => {
|
||||||
const [isConnected, setIsConnected] = useState(
|
const [isConnected, setIsConnected] = useState(
|
||||||
airtableIntegration ? airtableIntegration.config?.key : false
|
airtableIntegration ? airtableIntegration.config?.key : false
|
||||||
@@ -51,8 +49,6 @@ export const AirtableWrapper = ({
|
|||||||
setIsConnected={setIsConnected}
|
setIsConnected={setIsConnected}
|
||||||
surveys={surveys}
|
surveys={surveys}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
showReconnectButton={showReconnectButton}
|
|
||||||
handleAirtableAuthorization={handleAirtableAuthorization}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ConnectIntegration
|
<ConnectIntegration
|
||||||
|
|||||||
+9
-38
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
|
import { Trash2Icon } from "lucide-react";
|
||||||
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";
|
||||||
@@ -12,11 +12,9 @@ import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId
|
|||||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AddIntegrationModal";
|
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AddIntegrationModal";
|
||||||
import { timeSince } from "@/lib/time";
|
import { timeSince } from "@/lib/time";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
|
||||||
import { IntegrationModalInputs } from "../lib/types";
|
import { IntegrationModalInputs } from "../lib/types";
|
||||||
|
|
||||||
interface ManageIntegrationProps {
|
interface ManageIntegrationProps {
|
||||||
@@ -26,20 +24,10 @@ interface ManageIntegrationProps {
|
|||||||
surveys: TSurvey[];
|
surveys: TSurvey[];
|
||||||
airtableArray: TIntegrationItem[];
|
airtableArray: TIntegrationItem[];
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
showReconnectButton: boolean;
|
|
||||||
handleAirtableAuthorization: () => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ManageIntegration = ({
|
export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||||
airtableIntegration,
|
const { airtableIntegration, environmentId, setIsConnected, surveys, airtableArray } = props;
|
||||||
environmentId,
|
|
||||||
setIsConnected,
|
|
||||||
surveys,
|
|
||||||
airtableArray,
|
|
||||||
showReconnectButton,
|
|
||||||
handleAirtableAuthorization,
|
|
||||||
locale,
|
|
||||||
}: ManageIntegrationProps) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const tableHeaders = [
|
const tableHeaders = [
|
||||||
@@ -85,34 +73,15 @@ export const ManageIntegration = ({
|
|||||||
: { isEditMode: false as const };
|
: { isEditMode: false as const };
|
||||||
return (
|
return (
|
||||||
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
|
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
|
||||||
{showReconnectButton && (
|
<div className="flex w-full justify-end gap-x-6">
|
||||||
<Alert variant="warning" size="small" className="mb-4 w-full">
|
<div className="flex items-center">
|
||||||
<AlertDescription>{t("environments.integrations.reconnect_button_description")}</AlertDescription>
|
|
||||||
<AlertButton onClick={handleAirtableAuthorization}>
|
|
||||||
{t("environments.integrations.reconnect_button")}
|
|
||||||
</AlertButton>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<div className="flex w-full justify-end space-x-2">
|
|
||||||
<div className="mr-6 flex items-center">
|
|
||||||
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
|
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
|
||||||
<span className="text-slate-500">
|
<span className="cursor-pointer text-slate-500">
|
||||||
{t("environments.integrations.connected_with_email", {
|
{t("environments.integrations.connected_with_email", {
|
||||||
email: airtableIntegration.config.email,
|
email: airtableIntegration.config.email,
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button variant="outline" onClick={handleAirtableAuthorization}>
|
|
||||||
<RefreshCcwIcon className="mr-2 h-4 w-4" />
|
|
||||||
{t("environments.integrations.reconnect_button")}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>{t("environments.integrations.reconnect_button_tooltip")}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDefaultValues(null);
|
setDefaultValues(null);
|
||||||
@@ -153,7 +122,9 @@ export const ManageIntegration = ({
|
|||||||
<div className="col-span-2 text-center">{data.surveyName}</div>
|
<div className="col-span-2 text-center">{data.surveyName}</div>
|
||||||
<div className="col-span-2 text-center">{data.tableName}</div>
|
<div className="col-span-2 text-center">{data.tableName}</div>
|
||||||
<div className="col-span-2 text-center">{data.elements}</div>
|
<div className="col-span-2 text-center">{data.elements}</div>
|
||||||
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
|
<div className="col-span-2 text-center">
|
||||||
|
{timeSince(data.createdAt.toString(), props.locale)}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+9
-15
@@ -1,13 +1,12 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { logger } from "@formbricks/logger";
|
|
||||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||||
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
|
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
|
||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||||
import { getAirtableTables } from "@/lib/airtable/service";
|
import { getAirtableTables } from "@/lib/airtable/service";
|
||||||
import { AIRTABLE_CLIENT_ID, DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
|
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
||||||
import { getIntegrations } from "@/lib/integration/service";
|
import { getIntegrations } from "@/lib/integration/service";
|
||||||
import { getUserLocale } from "@/lib/user/service";
|
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||||
@@ -19,12 +18,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const isEnabled = !!AIRTABLE_CLIENT_ID;
|
const isEnabled = !!AIRTABLE_CLIENT_ID;
|
||||||
|
|
||||||
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
|
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
const [surveys, integrations, locale] = await Promise.all([
|
const [surveys, integrations] = await Promise.all([
|
||||||
getSurveys(params.environmentId),
|
getSurveys(params.environmentId),
|
||||||
getIntegrations(params.environmentId),
|
getIntegrations(params.environmentId),
|
||||||
getUserLocale(session.user.id),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find(
|
const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find(
|
||||||
@@ -32,15 +30,12 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let airtableArray: TIntegrationItem[] = [];
|
let airtableArray: TIntegrationItem[] = [];
|
||||||
let isTokenValid = true;
|
|
||||||
if (airtableIntegration?.config.key) {
|
if (airtableIntegration?.config.key) {
|
||||||
try {
|
airtableArray = await getAirtableTables(params.environmentId);
|
||||||
airtableArray = await getAirtableTables(params.environmentId);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error, "Failed to load Airtable bases — token may be expired or revoked");
|
|
||||||
isTokenValid = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const locale = await findMatchingLocale();
|
||||||
|
|
||||||
if (isReadOnly) {
|
if (isReadOnly) {
|
||||||
return redirect("./");
|
return redirect("./");
|
||||||
}
|
}
|
||||||
@@ -57,8 +52,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
surveys={surveys}
|
surveys={surveys}
|
||||||
webAppUrl={WEBAPP_URL}
|
webAppUrl={WEBAPP_URL}
|
||||||
locale={locale ?? DEFAULT_LOCALE}
|
locale={locale}
|
||||||
showReconnectButton={!isTokenValid}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
|
|||||||
+7
-6
@@ -3,14 +3,13 @@ import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-s
|
|||||||
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/GoogleSheetWrapper";
|
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/GoogleSheetWrapper";
|
||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||||
import {
|
import {
|
||||||
DEFAULT_LOCALE,
|
|
||||||
GOOGLE_SHEETS_CLIENT_ID,
|
GOOGLE_SHEETS_CLIENT_ID,
|
||||||
GOOGLE_SHEETS_CLIENT_SECRET,
|
GOOGLE_SHEETS_CLIENT_SECRET,
|
||||||
GOOGLE_SHEETS_REDIRECT_URL,
|
GOOGLE_SHEETS_REDIRECT_URL,
|
||||||
WEBAPP_URL,
|
WEBAPP_URL,
|
||||||
} from "@/lib/constants";
|
} from "@/lib/constants";
|
||||||
import { getIntegrations } from "@/lib/integration/service";
|
import { getIntegrations } from "@/lib/integration/service";
|
||||||
import { getUserLocale } from "@/lib/user/service";
|
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||||
@@ -22,17 +21,19 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
|
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
|
||||||
|
|
||||||
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
|
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
const [surveys, integrations, locale] = await Promise.all([
|
const [surveys, integrations] = await Promise.all([
|
||||||
getSurveys(params.environmentId),
|
getSurveys(params.environmentId),
|
||||||
getIntegrations(params.environmentId),
|
getIntegrations(params.environmentId),
|
||||||
getUserLocale(session.user.id),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const googleSheetIntegration: TIntegrationGoogleSheets | undefined = integrations?.find(
|
const googleSheetIntegration: TIntegrationGoogleSheets | undefined = integrations?.find(
|
||||||
(integration): integration is TIntegrationGoogleSheets => integration.type === "googleSheets"
|
(integration): integration is TIntegrationGoogleSheets => integration.type === "googleSheets"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const locale = await findMatchingLocale();
|
||||||
|
|
||||||
if (isReadOnly) {
|
if (isReadOnly) {
|
||||||
return redirect("./");
|
return redirect("./");
|
||||||
}
|
}
|
||||||
@@ -48,7 +49,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
surveys={surveys}
|
surveys={surveys}
|
||||||
googleSheetIntegration={googleSheetIntegration}
|
googleSheetIntegration={googleSheetIntegration}
|
||||||
webAppUrl={WEBAPP_URL}
|
webAppUrl={WEBAPP_URL}
|
||||||
locale={locale ?? DEFAULT_LOCALE}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
|
|||||||
+5
-6
@@ -3,7 +3,6 @@ import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/type
|
|||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||||
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/NotionWrapper";
|
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/NotionWrapper";
|
||||||
import {
|
import {
|
||||||
DEFAULT_LOCALE,
|
|
||||||
NOTION_AUTH_URL,
|
NOTION_AUTH_URL,
|
||||||
NOTION_OAUTH_CLIENT_ID,
|
NOTION_OAUTH_CLIENT_ID,
|
||||||
NOTION_OAUTH_CLIENT_SECRET,
|
NOTION_OAUTH_CLIENT_SECRET,
|
||||||
@@ -12,7 +11,7 @@ import {
|
|||||||
} from "@/lib/constants";
|
} from "@/lib/constants";
|
||||||
import { getIntegrationByType } from "@/lib/integration/service";
|
import { getIntegrationByType } from "@/lib/integration/service";
|
||||||
import { getNotionDatabases } from "@/lib/notion/service";
|
import { getNotionDatabases } from "@/lib/notion/service";
|
||||||
import { getUserLocale } from "@/lib/user/service";
|
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||||
@@ -29,18 +28,18 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
NOTION_REDIRECT_URI
|
NOTION_REDIRECT_URI
|
||||||
);
|
);
|
||||||
|
|
||||||
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
|
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
const [surveys, notionIntegration, locale] = await Promise.all([
|
const [surveys, notionIntegration] = await Promise.all([
|
||||||
getSurveys(params.environmentId),
|
getSurveys(params.environmentId),
|
||||||
getIntegrationByType(params.environmentId, "notion"),
|
getIntegrationByType(params.environmentId, "notion"),
|
||||||
getUserLocale(session.user.id),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let databasesArray: TIntegrationNotionDatabase[] = [];
|
let databasesArray: TIntegrationNotionDatabase[] = [];
|
||||||
if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) {
|
if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) {
|
||||||
databasesArray = (await getNotionDatabases(environment.id)) ?? [];
|
databasesArray = (await getNotionDatabases(environment.id)) ?? [];
|
||||||
}
|
}
|
||||||
|
const locale = await findMatchingLocale();
|
||||||
|
|
||||||
if (isReadOnly) {
|
if (isReadOnly) {
|
||||||
return redirect("./");
|
return redirect("./");
|
||||||
@@ -57,7 +56,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
notionIntegration={notionIntegration as TIntegrationNotion}
|
notionIntegration={notionIntegration as TIntegrationNotion}
|
||||||
webAppUrl={WEBAPP_URL}
|
webAppUrl={WEBAPP_URL}
|
||||||
databasesArray={databasesArray}
|
databasesArray={databasesArray}
|
||||||
locale={locale ?? DEFAULT_LOCALE}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ import notionLogo from "@/images/notion.png";
|
|||||||
import SlackLogo from "@/images/slacklogo.png";
|
import SlackLogo from "@/images/slacklogo.png";
|
||||||
import WebhookLogo from "@/images/webhook.png";
|
import WebhookLogo from "@/images/webhook.png";
|
||||||
import ZapierLogo from "@/images/zapier-small.png";
|
import ZapierLogo from "@/images/zapier-small.png";
|
||||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
|
||||||
import { getIntegrations } from "@/lib/integration/service";
|
import { getIntegrations } from "@/lib/integration/service";
|
||||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
|
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
|
||||||
@@ -55,7 +53,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
integrations.some((integration) => integration.type === type);
|
integrations.some((integration) => integration.type === type);
|
||||||
|
|
||||||
if (isBilling) {
|
if (isBilling) {
|
||||||
return redirect(getBillingFallbackPath(params.environmentId, IS_FORMBRICKS_CLOUD));
|
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isGoogleSheetsIntegrationConnected = isIntegrationConnected("googleSheets");
|
const isGoogleSheetsIntegrationConnected = isIntegrationConnected("googleSheets");
|
||||||
|
|||||||
+7
-6
@@ -2,9 +2,9 @@ import { redirect } from "next/navigation";
|
|||||||
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
|
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
|
||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||||
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/SlackWrapper";
|
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/SlackWrapper";
|
||||||
import { DEFAULT_LOCALE, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
|
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
|
||||||
import { getIntegrationByType } from "@/lib/integration/service";
|
import { getIntegrationByType } from "@/lib/integration/service";
|
||||||
import { getUserLocale } from "@/lib/user/service";
|
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||||
@@ -17,14 +17,15 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
|
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
|
|
||||||
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
|
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
const [surveys, slackIntegration, locale] = await Promise.all([
|
const [surveys, slackIntegration] = await Promise.all([
|
||||||
getSurveys(params.environmentId),
|
getSurveys(params.environmentId),
|
||||||
getIntegrationByType(params.environmentId, "slack"),
|
getIntegrationByType(params.environmentId, "slack"),
|
||||||
getUserLocale(session.user.id),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const locale = await findMatchingLocale();
|
||||||
|
|
||||||
if (isReadOnly) {
|
if (isReadOnly) {
|
||||||
return redirect("./");
|
return redirect("./");
|
||||||
}
|
}
|
||||||
@@ -40,7 +41,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
surveys={surveys}
|
surveys={surveys}
|
||||||
slackIntegration={slackIntegration as TIntegrationSlack}
|
slackIntegration={slackIntegration as TIntegrationSlack}
|
||||||
webAppUrl={WEBAPP_URL}
|
webAppUrl={WEBAPP_URL}
|
||||||
locale={locale ?? DEFAULT_LOCALE}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
|
|||||||
@@ -6,10 +6,8 @@ import {
|
|||||||
CHATWOOT_WEBSITE_TOKEN,
|
CHATWOOT_WEBSITE_TOKEN,
|
||||||
IS_CHATWOOT_CONFIGURED,
|
IS_CHATWOOT_CONFIGURED,
|
||||||
POSTHOG_KEY,
|
POSTHOG_KEY,
|
||||||
SESSION_MAX_AGE,
|
|
||||||
} from "@/lib/constants";
|
} from "@/lib/constants";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { NextAuthProvider } from "@/modules/auth/components/next-auth-provider";
|
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
||||||
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
||||||
@@ -25,7 +23,7 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NextAuthProvider sessionMaxAge={SESSION_MAX_AGE}>
|
<>
|
||||||
<NoMobileOverlay />
|
<NoMobileOverlay />
|
||||||
{POSTHOG_KEY && user && (
|
{POSTHOG_KEY && user && (
|
||||||
<PostHogIdentify posthogKey={POSTHOG_KEY} userId={user.id} email={user.email} name={user.name} />
|
<PostHogIdentify posthogKey={POSTHOG_KEY} userId={user.id} email={user.email} name={user.name} />
|
||||||
@@ -41,7 +39,7 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => {
|
|||||||
)}
|
)}
|
||||||
<ToasterClient />
|
<ToasterClient />
|
||||||
{children}
|
{children}
|
||||||
</NextAuthProvider>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
-160
@@ -1,160 +0,0 @@
|
|||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { logger } from "@formbricks/logger";
|
|
||||||
import { AuthorizationError } from "@formbricks/types/errors";
|
|
||||||
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
|
|
||||||
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
|
|
||||||
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
|
|
||||||
import { completeAccountDeletionSsoReauthenticationAndGetRedirectPath } from "./account-deletion-sso-complete";
|
|
||||||
|
|
||||||
vi.mock("server-only", () => ({}));
|
|
||||||
|
|
||||||
vi.mock("next-auth", () => ({
|
|
||||||
getServerSession: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@formbricks/logger", () => ({
|
|
||||||
logger: {
|
|
||||||
error: vi.fn(),
|
|
||||||
info: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/constants", () => ({
|
|
||||||
IS_FORMBRICKS_CLOUD: false,
|
|
||||||
WEBAPP_URL: "http://localhost:3000",
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/jwt", () => ({
|
|
||||||
verifyAccountDeletionSsoReauthIntent: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/account/lib/account-deletion", () => ({
|
|
||||||
deleteUserWithAccountDeletionAuthorization: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/auth/lib/authOptions", () => ({
|
|
||||||
authOptions: {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
|
||||||
queueAuditEventBackground: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockGetServerSession = vi.mocked(getServerSession);
|
|
||||||
const mockLoggerError = vi.mocked(logger.error);
|
|
||||||
const mockVerifyAccountDeletionSsoReauthIntent = vi.mocked(verifyAccountDeletionSsoReauthIntent);
|
|
||||||
const mockDeleteUserWithAccountDeletionAuthorization = vi.mocked(deleteUserWithAccountDeletionAuthorization);
|
|
||||||
const mockQueueAuditEventBackground = vi.mocked(queueAuditEventBackground);
|
|
||||||
|
|
||||||
const intent = {
|
|
||||||
id: "intent-id",
|
|
||||||
email: "delete-user@example.com",
|
|
||||||
provider: "google",
|
|
||||||
providerAccountId: "google-account-id",
|
|
||||||
purpose: "account_deletion_sso_reauth" as const,
|
|
||||||
returnToUrl: "http://localhost:3000/environments/env-id/settings/profile",
|
|
||||||
userId: "user-id",
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("completeAccountDeletionSsoReauthenticationAndGetRedirectPath", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
|
|
||||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
|
|
||||||
mockGetServerSession.mockResolvedValue({
|
|
||||||
user: {
|
|
||||||
email: intent.email,
|
|
||||||
id: intent.userId,
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
mockDeleteUserWithAccountDeletionAuthorization.mockResolvedValue({
|
|
||||||
oldUser: { id: intent.userId } as any,
|
|
||||||
});
|
|
||||||
mockQueueAuditEventBackground.mockResolvedValue(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns login without deleting when the callback has no intent", async () => {
|
|
||||||
await expect(completeAccountDeletionSsoReauthenticationAndGetRedirectPath({})).resolves.toBe(
|
|
||||||
"/auth/login"
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mockVerifyAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
|
|
||||||
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
|
|
||||||
expect(mockQueueAuditEventBackground).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("deletes the account after a completed SSO reauthentication", async () => {
|
|
||||||
await expect(
|
|
||||||
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
|
|
||||||
).resolves.toBe("/auth/login");
|
|
||||||
|
|
||||||
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalledWith({
|
|
||||||
confirmationEmail: intent.email,
|
|
||||||
userEmail: intent.email,
|
|
||||||
userId: intent.userId,
|
|
||||||
});
|
|
||||||
expect(mockQueueAuditEventBackground).toHaveBeenCalledWith({
|
|
||||||
action: "deleted",
|
|
||||||
targetType: "user",
|
|
||||||
userId: intent.userId,
|
|
||||||
userType: "user",
|
|
||||||
targetId: intent.userId,
|
|
||||||
organizationId: "unknown",
|
|
||||||
oldObject: { id: intent.userId },
|
|
||||||
status: "success",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not delete when the callback session does not match the intent user", async () => {
|
|
||||||
mockGetServerSession.mockResolvedValue({
|
|
||||||
user: {
|
|
||||||
email: "other@example.com",
|
|
||||||
id: "other-user-id",
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
|
|
||||||
).resolves.toBe("/environments/env-id/settings/profile");
|
|
||||||
|
|
||||||
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
|
|
||||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
|
||||||
{ error: expect.any(AuthorizationError) },
|
|
||||||
"Failed to complete account deletion after SSO reauth"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps the post-deletion redirect if audit logging fails after deletion", async () => {
|
|
||||||
mockQueueAuditEventBackground.mockRejectedValue(new Error("audit unavailable"));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
|
|
||||||
).resolves.toBe("/auth/login");
|
|
||||||
|
|
||||||
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalled();
|
|
||||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
|
||||||
{ error: expect.any(Error) },
|
|
||||||
"Failed to complete account deletion after SSO reauth"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("falls back to login when the intent return URL is not allowed", async () => {
|
|
||||||
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue({
|
|
||||||
...intent,
|
|
||||||
returnToUrl: "https://evil.example/settings/profile",
|
|
||||||
});
|
|
||||||
mockGetServerSession.mockResolvedValue({
|
|
||||||
user: {
|
|
||||||
email: "other@example.com",
|
|
||||||
id: "other-user-id",
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: ["intent-token"] })
|
|
||||||
).resolves.toBe("/auth/login");
|
|
||||||
|
|
||||||
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
-82
@@ -1,82 +0,0 @@
|
|||||||
import "server-only";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { logger } from "@formbricks/logger";
|
|
||||||
import { AuthorizationError } from "@formbricks/types/errors";
|
|
||||||
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@/lib/constants";
|
|
||||||
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
|
|
||||||
import { getValidatedCallbackUrl } from "@/lib/utils/url";
|
|
||||||
import { FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL } from "@/modules/account/constants";
|
|
||||||
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
|
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
|
||||||
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
|
|
||||||
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
|
||||||
|
|
||||||
type TAccountDeletionSsoCompleteSearchParams = {
|
|
||||||
intent?: string | string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const getIntentToken = (intent: string | string[] | undefined) => {
|
|
||||||
if (Array.isArray(intent)) {
|
|
||||||
return intent[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return intent;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSafeRedirectPath = (returnToUrl: string) => {
|
|
||||||
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL);
|
|
||||||
|
|
||||||
if (!validatedReturnToUrl) {
|
|
||||||
return "/auth/login";
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedReturnToUrl = new URL(validatedReturnToUrl);
|
|
||||||
return `${parsedReturnToUrl.pathname}${parsedReturnToUrl.search}${parsedReturnToUrl.hash}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPostDeletionRedirectPath = () =>
|
|
||||||
IS_FORMBRICKS_CLOUD ? FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL : "/auth/login";
|
|
||||||
|
|
||||||
export const completeAccountDeletionSsoReauthenticationAndGetRedirectPath = async ({
|
|
||||||
intent,
|
|
||||||
}: TAccountDeletionSsoCompleteSearchParams): Promise<string> => {
|
|
||||||
const intentToken = getIntentToken(intent);
|
|
||||||
let redirectPath = "/auth/login";
|
|
||||||
|
|
||||||
if (!intentToken) {
|
|
||||||
return redirectPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const verifiedIntent = verifyAccountDeletionSsoReauthIntent(intentToken);
|
|
||||||
redirectPath = getSafeRedirectPath(verifiedIntent.returnToUrl);
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
|
|
||||||
if (!session?.user?.id || !session.user.email || session.user.id !== verifiedIntent.userId) {
|
|
||||||
throw new AuthorizationError("Account deletion SSO reauthentication session mismatch");
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info({ userId: session.user.id }, "Completing account deletion after SSO reauth");
|
|
||||||
const { oldUser } = await deleteUserWithAccountDeletionAuthorization({
|
|
||||||
confirmationEmail: verifiedIntent.email,
|
|
||||||
userEmail: session.user.email,
|
|
||||||
userId: session.user.id,
|
|
||||||
});
|
|
||||||
redirectPath = getPostDeletionRedirectPath();
|
|
||||||
await queueAuditEventBackground({
|
|
||||||
action: "deleted",
|
|
||||||
targetType: "user",
|
|
||||||
userId: session.user.id,
|
|
||||||
userType: "user",
|
|
||||||
targetId: session.user.id,
|
|
||||||
organizationId: UNKNOWN_DATA,
|
|
||||||
oldObject: oldUser,
|
|
||||||
status: "success",
|
|
||||||
});
|
|
||||||
logger.info({ userId: session.user.id }, "Completed account deletion after SSO reauth");
|
|
||||||
} catch (error) {
|
|
||||||
logger.error({ error }, "Failed to complete account deletion after SSO reauth");
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirectPath;
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user