mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-20 19:48:52 -05:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71ebd77c74 | |||
| 0d3b6ce2d8 | |||
| 3da7129413 | |||
| 75fbb23190 | |||
| 60f6ca9463 | |||
| aa27d242bb | |||
| a771ae189a | |||
| 029e069af6 | |||
| 81272b96e1 |
@@ -349,3 +349,4 @@ When creating a new question element, verify:
|
||||
- [ ] TypeScript types properly exported
|
||||
- [ ] Error message display included if applicable
|
||||
- [ ] Disabled state supported if applicable
|
||||
|
||||
|
||||
+10
-65
@@ -32,24 +32,6 @@ CRON_SECRET=
|
||||
# Set the minimum log level(debug, info, warn, error, fatal)
|
||||
LOG_LEVEL=info
|
||||
|
||||
# BullMQ workers require REDIS_URL (for example `redis://localhost:6379`) to be set.
|
||||
# BullMQ worker startup is enabled by default outside tests. Set to 0 to disable.
|
||||
# BULLMQ_WORKER_ENABLED=1
|
||||
# Set to 1 on web/API pods that only enqueue jobs while a separate BullMQ worker deployment consumes them.
|
||||
# BULLMQ_EXTERNAL_WORKER_ENABLED=0
|
||||
|
||||
# Number of BullMQ worker instances started per Formbricks server process.
|
||||
# BULLMQ_WORKER_COUNT=1
|
||||
|
||||
# Number of concurrent jobs each BullMQ worker can process.
|
||||
# BULLMQ_WORKER_CONCURRENCY=1
|
||||
|
||||
# Survey publish/close scheduling is configured with public build-time env vars because the editor UI
|
||||
# also needs to render the selected execution time and timezone.
|
||||
# NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE=Europe/Berlin
|
||||
# NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR=0
|
||||
# NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE=0
|
||||
|
||||
##############
|
||||
# DATABASE #
|
||||
##############
|
||||
@@ -63,28 +45,7 @@ DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=pu
|
||||
# Set explicitly to avoid confusion; override as needed when using docker-compose.dev.yml.
|
||||
HUB_API_KEY=dev-api-key
|
||||
HUB_API_URL=http://localhost:8080
|
||||
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/hub?sslmode=disable
|
||||
# Hub image tag used by docker-compose.dev.yml (hub + hub-migrate). Leave unset to use the
|
||||
# pinned default in the compose file; override here when testing a specific Hub release.
|
||||
# HUB_IMAGE_TAG=0.3.0
|
||||
|
||||
# Hub embeddings are optional. Set a provider and model to enable semantic search embeddings in
|
||||
# the Hub API and hub-worker. For provider-specific settings, see:
|
||||
# https://hub.formbricks.com/reference/environment-variables/#embeddings
|
||||
# Example with Google AI Studio:
|
||||
# EMBEDDING_PROVIDER=google
|
||||
# EMBEDDING_MODEL=gemini-embedding-001
|
||||
# EMBEDDING_PROVIDER_API_KEY=
|
||||
|
||||
####################
|
||||
# CUBE ANALYTICS #
|
||||
####################
|
||||
# Cube semantic-layer API. Required. The bundled Docker stack exposes Cube on port 4000.
|
||||
CUBEJS_API_URL=http://localhost:4000
|
||||
# Generate with: openssl rand -hex 32. `pnpm dev:setup` will create/preserve this automatically.
|
||||
CUBEJS_API_SECRET=
|
||||
CUBEJS_JWT_ISSUER=formbricks-web
|
||||
CUBEJS_JWT_AUDIENCE=formbricks-cube
|
||||
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
|
||||
|
||||
################
|
||||
# MAIL SETUP #
|
||||
@@ -118,7 +79,7 @@ SMTP_PASSWORD=smtpPassword
|
||||
# S3 STORAGE #
|
||||
##############
|
||||
|
||||
# S3 Storage is required for the file upload in serverless environments
|
||||
# S3 Storage is required for the file upload in serverless environments like Vercel
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_KEY=
|
||||
S3_REGION=
|
||||
@@ -154,14 +115,6 @@ PASSWORD_RESET_DISABLED=1
|
||||
# Organization Invite. Disable the ability for invited users to create an account.
|
||||
# INVITE_DISABLED=1
|
||||
|
||||
###########################################
|
||||
# Account deletion SSO confirmation #
|
||||
###########################################
|
||||
|
||||
# Danger: skips the SSO identity confirmation redirect for passwordless account deletion.
|
||||
# Users can delete SSO accounts with only the in-app email text confirmation. Keep unset unless you accept the risk.
|
||||
# DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION=1
|
||||
|
||||
|
||||
##########
|
||||
# Other #
|
||||
@@ -196,17 +149,16 @@ AZUREAD_TENANT_ID=
|
||||
|
||||
# Configure Formbricks AI at the instance level
|
||||
# Set the provider used for AI features on this instance.
|
||||
# Accepted values for AI_PROVIDER: aws, google, azure
|
||||
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching provider settings below.
|
||||
# AI_PROVIDER=google
|
||||
# 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 Cloud settings for Gemini models
|
||||
# Credentials are optional when Application Default Credentials are available.
|
||||
# AI_GOOGLE_CLOUD_PROJECT=
|
||||
# AI_GOOGLE_CLOUD_LOCATION=
|
||||
# AI_GOOGLE_CLOUD_CREDENTIALS_JSON=
|
||||
# AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS=
|
||||
# Google Vertex AI credentials
|
||||
# AI_GCP_PROJECT=
|
||||
# AI_GCP_LOCATION=
|
||||
# AI_GCP_CREDENTIALS_JSON=
|
||||
# AI_GCP_APPLICATION_CREDENTIALS=
|
||||
|
||||
# Amazon Bedrock credentials
|
||||
# AI_AWS_REGION=
|
||||
@@ -325,13 +277,6 @@ REDIS_URL=redis://localhost:6379
|
||||
# If the ip should be added in the log or not. Default 0
|
||||
# AUDIT_LOG_GET_USER_IP=0
|
||||
|
||||
# Optional Cube.js database overrides. The official local docker-compose.dev.yml stack points Cube at the
|
||||
# local `postgres` service automatically; set these only when running Cube yourself or changing bundled defaults.
|
||||
# CUBEJS_DB_HOST=postgres
|
||||
# CUBEJS_DB_PORT=5432
|
||||
# CUBEJS_DB_NAME=postgres
|
||||
# CUBEJS_DB_USER=postgres
|
||||
# CUBEJS_DB_PASS=postgres
|
||||
|
||||
# Lingo.dev API key for translation generation
|
||||
LINGO_API_KEY=your_api_key_here
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
name: Accessibility issue
|
||||
description: "Report an accessibility barrier in Formbricks (WCAG, screen reader, keyboard, contrast, etc.)"
|
||||
type: bug
|
||||
labels: ["accessibility", "bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for helping make Formbricks accessible to everyone. Please fill in as much as you can — see [ACCESSIBILITY.md](https://github.com/formbricks/formbricks/blob/main/ACCESSIBILITY.md) for context.
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Summary
|
||||
description: What part of Formbricks is affected and what's wrong?
|
||||
placeholder: "e.g. The language switcher in survey runtime can't be reached with Tab."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
placeholder: |
|
||||
1. Open a survey with multiple languages
|
||||
2. Press Tab repeatedly
|
||||
3. Focus never lands on the language switcher
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: wcag
|
||||
attributes:
|
||||
label: Related WCAG criterion (if known)
|
||||
placeholder: "e.g. 2.1.1 Keyboard"
|
||||
- type: dropdown
|
||||
id: severity
|
||||
attributes:
|
||||
label: Severity
|
||||
options:
|
||||
- "Critical — blocks a user from completing a core task"
|
||||
- "High — significant barrier with no easy workaround"
|
||||
- "Medium — barrier with a workaround"
|
||||
- "Low — minor friction"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: at
|
||||
attributes:
|
||||
label: Assistive technology
|
||||
placeholder: "e.g. NVDA 2026.1, VoiceOver on macOS 15, keyboard only"
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
label: Browser and OS
|
||||
placeholder: "e.g. Firefox 138 on Windows 11"
|
||||
- type: dropdown
|
||||
id: environment
|
||||
attributes:
|
||||
label: Your Environment
|
||||
options:
|
||||
- Formbricks Cloud (app.formbricks.com)
|
||||
- Self-hosted Formbricks
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: other
|
||||
attributes:
|
||||
label: Other information (screenshots, recordings, axe output)
|
||||
@@ -284,10 +284,6 @@ runs:
|
||||
database_url=${{ env.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }}
|
||||
redis_url=${{ env.DUMMY_REDIS_URL }}
|
||||
hub_api_url=${{ env.DUMMY_HUB_API_URL }}
|
||||
hub_api_key=${{ env.DUMMY_HUB_API_KEY }}
|
||||
cubejs_api_url=${{ env.DUMMY_CUBEJS_API_URL }}
|
||||
cubejs_api_secret=${{ env.DUMMY_CUBEJS_API_SECRET }}
|
||||
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
|
||||
posthog_key=${{ env.POSTHOG_KEY }}
|
||||
env:
|
||||
@@ -295,10 +291,6 @@ runs:
|
||||
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
|
||||
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
|
||||
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
|
||||
DUMMY_HUB_API_URL: ${{ env.DUMMY_HUB_API_URL }}
|
||||
DUMMY_HUB_API_KEY: ${{ env.DUMMY_HUB_API_KEY }}
|
||||
DUMMY_CUBEJS_API_URL: ${{ env.DUMMY_CUBEJS_API_URL }}
|
||||
DUMMY_CUBEJS_API_SECRET: ${{ env.DUMMY_CUBEJS_API_SECRET }}
|
||||
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
|
||||
POSTHOG_KEY: ${{ env.POSTHOG_KEY }}
|
||||
|
||||
|
||||
@@ -20,12 +20,12 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Cache Build
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
uses: actions/cache@v3
|
||||
id: cache-build
|
||||
env:
|
||||
cache-name: prod-build
|
||||
@@ -43,7 +43,7 @@ runs:
|
||||
shell: bash
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20.x
|
||||
if: steps.cache-build.outputs.cache-hit != 'true'
|
||||
@@ -53,18 +53,20 @@ runs:
|
||||
if: steps.cache-build.outputs.cache-hit != 'true'
|
||||
|
||||
- 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'
|
||||
shell: bash
|
||||
|
||||
- name: Create .env
|
||||
run: pnpm dev:setup
|
||||
- name: create .env
|
||||
run: cp .env.example .env
|
||||
shell: bash
|
||||
|
||||
- name: Fill E2E_TESTING in .env
|
||||
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
|
||||
env:
|
||||
E2E_TESTING_MODE: ${{ inputs.e2e_testing_mode }}
|
||||
run: |
|
||||
RANDOM_KEY=$(openssl rand -hex 32)
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
echo "E2E_TESTING=$E2E_TESTING_MODE" >> .env
|
||||
shell: bash
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 2
|
||||
|
||||
@@ -91,9 +91,5 @@ jobs:
|
||||
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
|
||||
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
|
||||
DUMMY_HUB_API_URL: ${{ secrets.DUMMY_HUB_API_URL }}
|
||||
DUMMY_HUB_API_KEY: ${{ secrets.DUMMY_HUB_API_KEY }}
|
||||
DUMMY_CUBEJS_API_URL: ${{ secrets.DUMMY_CUBEJS_API_URL }}
|
||||
DUMMY_CUBEJS_API_SECRET: ${{ secrets.DUMMY_CUBEJS_API_SECRET }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- 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
|
||||
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
|
||||
|
||||
@@ -73,10 +73,6 @@ jobs:
|
||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
redis_url=redis://localhost:6379
|
||||
hub_api_url=http://localhost:4000
|
||||
hub_api_key=build-time-placeholder
|
||||
cubejs_api_url=http://localhost:4000
|
||||
cubejs_api_secret=build-time-placeholder
|
||||
|
||||
- name: Verify and Initialize PostgreSQL
|
||||
run: |
|
||||
@@ -147,10 +143,6 @@ jobs:
|
||||
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
|
||||
-e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \
|
||||
-e REDIS_URL="redis://host.docker.internal:6379" \
|
||||
-e HUB_API_URL="http://localhost:4000" \
|
||||
-e HUB_API_KEY="build-time-placeholder" \
|
||||
-e CUBEJS_API_URL="http://localhost:4000" \
|
||||
-e CUBEJS_API_SECRET="build-time-placeholder" \
|
||||
-d "formbricks-test:$GITHUB_SHA"
|
||||
|
||||
# Start health check polling immediately (every 5 seconds for up to 5 minutes)
|
||||
|
||||
+55
-40
@@ -57,7 +57,7 @@ jobs:
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Setup Node.js 22.x
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
|
||||
with:
|
||||
node-version: 22.x
|
||||
|
||||
@@ -65,15 +65,19 @@ jobs:
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- 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
|
||||
|
||||
- name: Create .env
|
||||
run: pnpm dev:setup
|
||||
- name: create .env
|
||||
run: cp .env.example .env
|
||||
shell: bash
|
||||
|
||||
- name: Fill ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
|
||||
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
|
||||
run: |
|
||||
RANDOM_KEY=$(openssl rand -hex 32)
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
|
||||
sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env
|
||||
echo "" >> .env
|
||||
@@ -81,48 +85,65 @@ jobs:
|
||||
echo "S3_REGION=us-east-1" >> .env
|
||||
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
|
||||
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
|
||||
echo "S3_ACCESS_KEY=devrustfs-service" >> .env
|
||||
echo "S3_SECRET_KEY=devrustfs-service123" >> .env
|
||||
echo "S3_ACCESS_KEY=devminio" >> .env
|
||||
echo "S3_SECRET_KEY=devminio123" >> .env
|
||||
echo "S3_FORCE_PATH_STYLE=1" >> .env
|
||||
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: |
|
||||
set -euo pipefail
|
||||
|
||||
# Start RustFS server in background
|
||||
# Start MinIO server in background
|
||||
docker run -d \
|
||||
--name rustfs-server \
|
||||
--name minio-server \
|
||||
-p 9000:9000 \
|
||||
-p 9001:9001 \
|
||||
-e RUSTFS_ACCESS_KEY=devrustfs \
|
||||
-e RUSTFS_SECRET_KEY=devrustfs123 \
|
||||
-e RUSTFS_ADDRESS=:9000 \
|
||||
-e RUSTFS_CONSOLE_ENABLE=true \
|
||||
-e RUSTFS_CONSOLE_ADDRESS=:9001 \
|
||||
rustfs/rustfs:1.0.0-alpha.93 \
|
||||
/data
|
||||
-e MINIO_ROOT_USER=devminio \
|
||||
-e MINIO_ROOT_PASSWORD=devminio123 \
|
||||
minio/minio:RELEASE.2025-09-07T16-13-09Z \
|
||||
server /data --console-address :9001
|
||||
|
||||
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: |
|
||||
set -euo pipefail
|
||||
|
||||
docker run --rm \
|
||||
--network host \
|
||||
--entrypoint /bin/sh \
|
||||
-e RUSTFS_ENDPOINT_URL=http://127.0.0.1:9000 \
|
||||
-e RUSTFS_ADMIN_USER=devrustfs \
|
||||
-e RUSTFS_ADMIN_PASSWORD=devrustfs123 \
|
||||
-e RUSTFS_SERVICE_USER=devrustfs-service \
|
||||
-e RUSTFS_SERVICE_PASSWORD=devrustfs-service123 \
|
||||
-e RUSTFS_BUCKET_NAME=formbricks-e2e \
|
||||
-e RUSTFS_POLICY_NAME=formbricks-e2e-policy \
|
||||
-e RUSTFS_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 \
|
||||
-v "$PWD/docker/rustfs-init.sh:/tmp/rustfs-init.sh:ro" \
|
||||
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 \
|
||||
/tmp/rustfs-init.sh
|
||||
echo "Waiting for MinIO to be ready..."
|
||||
ready=0
|
||||
for i in {1..60}; do
|
||||
if curl -fsS http://localhost:9000/minio/health/live >/dev/null; then
|
||||
echo "MinIO is up after ${i} seconds"
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ "$ready" -ne 1 ]; then
|
||||
echo "::error::MinIO did not become ready within 60 seconds"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mc alias set local http://localhost:9000 devminio devminio123
|
||||
mc mb --ignore-existing local/formbricks-e2e
|
||||
|
||||
- name: Build App
|
||||
run: |
|
||||
@@ -221,14 +242,8 @@ jobs:
|
||||
if: failure()
|
||||
with:
|
||||
name: app-logs
|
||||
if-no-files-found: ignore
|
||||
path: app.log
|
||||
|
||||
- name: Output App Logs
|
||||
if: failure()
|
||||
run: |
|
||||
if [ -f app.log ]; then
|
||||
cat app.log
|
||||
else
|
||||
echo "app.log not found because the Run App step did not execute or failed before log creation."
|
||||
fi
|
||||
run: cat app.log
|
||||
|
||||
@@ -155,31 +155,3 @@ jobs:
|
||||
commit_sha: ${{ github.sha }}
|
||||
is_prerelease: ${{ github.event.release.prerelease }}
|
||||
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||
|
||||
linear-release-complete:
|
||||
name: Mark Linear release as complete
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
needs:
|
||||
- docker-build-community
|
||||
- docker-build-cloud
|
||||
- helm-chart-release
|
||||
- move-stable-tag
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Complete Linear release
|
||||
uses: linear/linear-release-action@0353b5fa8c00326913966f00557d68f8f30b8b6b # v0.7.0
|
||||
with:
|
||||
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
|
||||
command: complete
|
||||
version: ${{ github.event.release.tag_name }}
|
||||
|
||||
@@ -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
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||
with:
|
||||
node-version: 20.x
|
||||
|
||||
@@ -29,10 +29,17 @@ jobs:
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- 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
|
||||
run: pnpm dev:setup
|
||||
- name: create .env
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
|
||||
run: |
|
||||
RANDOM_KEY=$(openssl rand -hex 32)
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
@@ -47,8 +47,4 @@ jobs:
|
||||
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
|
||||
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
|
||||
DUMMY_HUB_API_URL: ${{ secrets.DUMMY_HUB_API_URL }}
|
||||
DUMMY_HUB_API_KEY: ${{ secrets.DUMMY_HUB_API_KEY }}
|
||||
DUMMY_CUBEJS_API_URL: ${{ secrets.DUMMY_CUBEJS_API_URL }}
|
||||
DUMMY_CUBEJS_API_SECRET: ${{ secrets.DUMMY_CUBEJS_API_SECRET }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
@@ -105,8 +105,4 @@ jobs:
|
||||
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
|
||||
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
|
||||
DUMMY_HUB_API_URL: ${{ secrets.DUMMY_HUB_API_URL }}
|
||||
DUMMY_HUB_API_KEY: ${{ secrets.DUMMY_HUB_API_KEY }}
|
||||
DUMMY_CUBEJS_API_URL: ${{ secrets.DUMMY_CUBEJS_API_URL }}
|
||||
DUMMY_CUBEJS_API_SECRET: ${{ secrets.DUMMY_CUBEJS_API_SECRET }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
|
||||
- name: Setup Node.js 22.x
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||
with:
|
||||
node-version: 22.x
|
||||
|
||||
@@ -33,13 +33,17 @@ jobs:
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- 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
|
||||
run: pnpm dev:setup
|
||||
- name: create .env
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Adjust CI-specific env values
|
||||
- name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
|
||||
run: |
|
||||
RANDOM_KEY=$(openssl rand -hex 32)
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
|
||||
|
||||
- name: Run tests with coverage
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
|
||||
with:
|
||||
node-version: 20.x
|
||||
|
||||
@@ -30,13 +30,17 @@ jobs:
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- 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
|
||||
run: pnpm dev:setup
|
||||
- name: create .env
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Adjust CI-specific env values
|
||||
- name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
|
||||
run: |
|
||||
RANDOM_KEY=$(openssl rand -hex 32)
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
|
||||
|
||||
- name: Test
|
||||
|
||||
@@ -2,7 +2,6 @@ name: Translation Validation
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -40,7 +39,7 @@ jobs:
|
||||
|
||||
- name: Setup Node.js 22.x
|
||||
if: steps.changes.outputs.translations == 'true'
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||
with:
|
||||
node-version: 22.x
|
||||
|
||||
@@ -50,7 +49,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
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
|
||||
if: steps.changes.outputs.translations == 'true'
|
||||
|
||||
@@ -64,5 +64,7 @@ packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdat
|
||||
.cursorrules
|
||||
i18n.cache
|
||||
stats.html
|
||||
api-testing-plan.config.json
|
||||
reports/api-testing-plan/
|
||||
# next-agents-md
|
||||
.next-docs/
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
apps/web/.env
|
||||
@@ -1,48 +0,0 @@
|
||||
# Accessibility
|
||||
|
||||
Formbricks is committed to making our platform usable by everyone, including people who rely on assistive technologies.
|
||||
|
||||
## Standards
|
||||
|
||||
We aim to conform to:
|
||||
|
||||
- **[WCAG 2.1 Level AA](https://www.w3.org/TR/WCAG21/)** — the web content baseline.
|
||||
- **[EN 301 549](https://www.etsi.org/deliver/etsi_en/301500_301599/301549/)** — the European harmonised standard referenced by the **European Accessibility Act (EAA)**, applicable to us as a Germany-based company.
|
||||
- **Section 508** — for users in US public-sector contexts.
|
||||
|
||||
## Priorities
|
||||
|
||||
1. **End-user surveys** (`packages/surveys`) — everything respondents see and interact with. This is our highest priority because survey takers don't choose Formbricks; the organisations running surveys choose for them.
|
||||
2. **Admin app** (`apps/web`) — survey creation, response analysis, and team management used by Formbricks customers.
|
||||
|
||||
In both areas we focus on:
|
||||
|
||||
- Keyboard navigation with a clearly visible focus indicator
|
||||
- Screen reader support through semantic HTML and correctly scoped ARIA
|
||||
- Sufficient color and contrast
|
||||
- Programmatically associated labels and announced status messages
|
||||
|
||||
## Supported Environments
|
||||
|
||||
- Latest two versions of Chrome, Firefox, Safari, and Edge
|
||||
- VoiceOver (macOS/iOS), NVDA (Windows), and TalkBack (Android)
|
||||
|
||||
## Contributing
|
||||
|
||||
When contributing UI changes:
|
||||
|
||||
- Prefer semantic HTML over ARIA.
|
||||
- Tab through your change end-to-end and confirm focus is visible at every stop.
|
||||
- Label every control. Don't convey meaning by color alone.
|
||||
- Run [axe DevTools](https://www.deque.com/axe/devtools/) or Lighthouse on the page you changed.
|
||||
|
||||
## Reporting Accessibility Issues
|
||||
|
||||
If you encounter an accessibility barrier, please [open an issue](https://github.com/formbricks/formbricks/issues/new?labels=accessibility&template=accessibility.yml) using the accessibility template. For blocking issues in a procurement or compliance context, email **[hola@formbricks.com](mailto:hola@formbricks.com)**.
|
||||
|
||||
## Resources
|
||||
|
||||
- [WCAG 2.1 Quick Reference](https://www.w3.org/WAI/WCAG21/quickref/)
|
||||
- [EN 301 549](https://www.etsi.org/deliver/etsi_en/301500_301599/301549/)
|
||||
- [European Accessibility Act overview](https://ec.europa.eu/social/main.jsp?catId=1202)
|
||||
- [MDN Accessibility Reference](https://developer.mozilla.org/en-US/docs/Web/Accessibility)
|
||||
@@ -32,7 +32,6 @@ The `@formbricks/surveys` package is pre-compiled (Vite → UMD + ESM) and the b
|
||||
|
||||
TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable.
|
||||
We are using SonarQube to identify code smells and security hotspots.
|
||||
Always mark React component props as `Readonly<>` (e.g., `({ children }: Readonly<MyProps>)`).
|
||||
|
||||
## Architecture & Patterns
|
||||
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
# API Regression Testing Plan (Environment -> Workspace Migration)
|
||||
|
||||
## Goal
|
||||
|
||||
Validate that API key authorization and returned data stay behaviorally identical after moving from environment-centric access to workspace-centric access.
|
||||
|
||||
This plan is based on current route/auth logic in:
|
||||
- `apps/web/app/api/v1/**`
|
||||
- `apps/web/modules/api/v2/**`
|
||||
- `apps/web/app/api/v3/**`
|
||||
- `apps/web/modules/ee/contacts/api/**`
|
||||
|
||||
## API Keys Under Test
|
||||
|
||||
| Key Alias | Actual Key | Intended Scope |
|
||||
|---|---|---|
|
||||
| `K1_W1_DEV_MANAGE` | `fbk_k65Cpf1ZHJTJjKyhixQ7OzWHTTTUIC5u2UQun1PMhcg` | Workspace 1 / Dev / `manage` |
|
||||
| `K2_W1_PROD_READ` | `fbk_f829m6TovrkojaPC40WBFJ7DVBVPs7iz0T0yWf9i_zo` | Workspace 1 / Prod / `read` |
|
||||
| `K3_W2_DEV_MANAGE` | `fbk_0_bfuUpv6p9mbZe3o5EWnMnziw3gBUnrdMK0YG0aAKs` | Workspace 2 / Dev / `manage` |
|
||||
| `K4_W2_PROD_WRITE` | `fbk_-6ozHplGDklxK6qTivfYEbv_gRSsWx_ZPonc4eaGcYo` | Workspace 2 / Prod / `write` |
|
||||
|
||||
## Permission Model To Regress
|
||||
|
||||
Environment permission mapping from `hasPermission(...)`:
|
||||
- `GET` -> requires `read` (or higher)
|
||||
- `POST` / `PUT` / `PATCH` -> requires `write` (or `manage`)
|
||||
- `DELETE` -> requires `manage`
|
||||
|
||||
Expected by key:
|
||||
- `K1` and `K3` (`manage`): allowed for all methods in their own environment.
|
||||
- `K4` (`write`): allowed for `GET/POST/PUT/PATCH` in own environment; denied `DELETE`.
|
||||
- `K2` (`read`): allowed for `GET` in own environment only; denied write/delete methods.
|
||||
|
||||
## Test Data Setup (Required Before Execution)
|
||||
|
||||
Create stable fixtures per environment/workspace so every endpoint can be validated deterministically:
|
||||
|
||||
- Workspace 1 Dev (`W1_DEV_ENV_ID`)
|
||||
- Workspace 1 Prod (`W1_PROD_ENV_ID`)
|
||||
- Workspace 2 Dev (`W2_DEV_ENV_ID`)
|
||||
- Workspace 2 Prod (`W2_PROD_ENV_ID`)
|
||||
|
||||
For **each** environment, seed at least:
|
||||
- 1 survey (`link` type), with one completed and one unfinished response
|
||||
- 1 webhook tied to that survey
|
||||
- 1 action class
|
||||
- 1 contact + contact attributes + custom contact attribute key (if EE contacts enabled)
|
||||
- 1 segment containing that contact (for contact-link endpoints)
|
||||
- 1 storage-upload-capable target (for signed URL endpoint)
|
||||
|
||||
Also keep fixture IDs for every resource so both positive and negative access tests can reference exact resources.
|
||||
|
||||
---
|
||||
|
||||
## Expected Data Visibility Rules (Applies Everywhere)
|
||||
|
||||
1. **Collection endpoints** must return only resources belonging to environments listed in the API key permissions.
|
||||
2. **Resource-by-id endpoints** must:
|
||||
- return success only when resource environment matches key scope and method permission,
|
||||
- otherwise return authorization failure (`401`/`403` depending on endpoint family).
|
||||
3. **Cross-workspace leakage is never allowed**: no key should ever read/modify resource in another workspace/env.
|
||||
4. **v3 workspace endpoints** must map workspace -> environment consistently; authorization outcome should match legacy env permission semantics.
|
||||
|
||||
---
|
||||
|
||||
## Endpoint Matrix: v1
|
||||
|
||||
### Auth and Profile
|
||||
|
||||
| Endpoint | Method | Scope Source | Expected By Key |
|
||||
|---|---|---|---|
|
||||
| `/api/v1/auth` | `GET` | API key itself | All 4 keys: `200`; returns API key + linked env permissions |
|
||||
| `/api/v1/management/me` | `GET` | API key itself | `200` only if key has exactly one env permission (these 4 should); payload must match that env/project |
|
||||
|
||||
### Surveys
|
||||
|
||||
| Endpoint | Method | Scope Source | `K1` | `K2` | `K3` | `K4` |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `/api/v1/management/surveys` | `GET` | all key envs | 200, only W1 Dev surveys | 200, only W1 Prod surveys | 200, only W2 Dev surveys | 200, only W2 Prod surveys |
|
||||
| `/api/v1/management/surveys` | `POST` | `body.environmentId` | 200 on W1 Dev, deny others | 401 | 200 on W2 Dev, deny others | 200 on W2 Prod, deny others |
|
||||
| `/api/v1/management/surveys/{surveyId}` | `GET` | survey env | allow own env | allow own env | allow own env | allow own env |
|
||||
| `/api/v1/management/surveys/{surveyId}` | `PUT` | survey env | allow own env | 401 | allow own env | allow own env |
|
||||
| `/api/v1/management/surveys/{surveyId}` | `DELETE` | survey env | allow own env | 401 | allow own env | 401 |
|
||||
| `/api/v1/management/surveys/{surveyId}/singleUseIds` | `GET` | survey env | allow own env link survey | allow own env link survey | allow own env link survey | allow own env link survey |
|
||||
|
||||
### Responses
|
||||
|
||||
| Endpoint | Method | Scope Source | `K1` | `K2` | `K3` | `K4` |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `/api/v1/management/responses` | `GET` | query `surveyId` env, or all key envs | 200 scoped | 200 scoped | 200 scoped | 200 scoped |
|
||||
| `/api/v1/management/responses` | `POST` | `body.environmentId` + survey env match | allow own env | 401 | allow own env | allow own env |
|
||||
| `/api/v1/management/responses/{responseId}` | `GET` | response->survey env | allow own env | allow own env | allow own env | allow own env |
|
||||
| `/api/v1/management/responses/{responseId}` | `PUT` | response->survey env | allow own env | 401 | allow own env | allow own env |
|
||||
| `/api/v1/management/responses/{responseId}` | `DELETE` | response->survey env | allow own env | 401 | allow own env | 401 |
|
||||
|
||||
### Action Classes
|
||||
|
||||
| Endpoint | Method | Scope Source | `K1` | `K2` | `K3` | `K4` |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `/api/v1/management/action-classes` | `GET` | all key envs | 200 scoped | 200 scoped | 200 scoped | 200 scoped |
|
||||
| `/api/v1/management/action-classes` | `POST` | `body.environmentId` | allow own env | 401 | allow own env | allow own env |
|
||||
| `/api/v1/management/action-classes/{id}` | `GET` | actionClass env | allow own env | allow own env | allow own env | allow own env |
|
||||
| `/api/v1/management/action-classes/{id}` | `PUT` | actionClass env | allow own env | 401 | allow own env | allow own env |
|
||||
| `/api/v1/management/action-classes/{id}` | `DELETE` | actionClass env | allow own env | 401 | allow own env | 401 |
|
||||
|
||||
### Webhooks
|
||||
|
||||
| Endpoint | Method | Scope Source | `K1` | `K2` | `K3` | `K4` |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `/api/v1/webhooks` | `GET` | all key envs | 200 scoped | 200 scoped | 200 scoped | 200 scoped |
|
||||
| `/api/v1/webhooks` | `POST` | `body.environmentId` | allow own env | 401 | allow own env | allow own env |
|
||||
| `/api/v1/webhooks/{id}` | `GET` | webhook env | allow own env | allow own env | allow own env | allow own env |
|
||||
| `/api/v1/webhooks/{id}` | `DELETE` | webhook env | allow own env | 401 | allow own env | 401 |
|
||||
|
||||
### Storage Signed Upload
|
||||
|
||||
| Endpoint | Method | Scope Source | `K1` | `K2` | `K3` | `K4` |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `/api/v1/management/storage` | `POST` | `body.environmentId` | allow own env | 401 | allow own env | allow own env |
|
||||
|
||||
### EE Contacts (if enabled)
|
||||
|
||||
| Endpoint | Method | Scope Source | `K1` | `K2` | `K3` | `K4` |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `/api/v1/management/contacts` | `GET` | all key envs | 200 scoped (or 403 if EE off) | same | same | same |
|
||||
| `/api/v1/management/contacts/{id}` | `GET` | contact env | allow own env | allow own env | allow own env | allow own env |
|
||||
| `/api/v1/management/contacts/{id}` | `DELETE` | contact env | allow own env | 401 | allow own env | 401 |
|
||||
| `/api/v1/management/contact-attributes` | `GET` | all key envs | 200 scoped | 200 scoped | 200 scoped | 200 scoped |
|
||||
| `/api/v1/management/contact-attribute-keys` | `GET` | all key envs | 200 scoped | 200 scoped | 200 scoped | 200 scoped |
|
||||
| `/api/v1/management/contact-attribute-keys` | `POST` | `body.environmentId` | allow own env | 401 | allow own env | allow own env |
|
||||
| `/api/v1/management/contact-attribute-keys/{id}` | `GET` | key env | allow own env | allow own env | allow own env | allow own env |
|
||||
| `/api/v1/management/contact-attribute-keys/{id}` | `PUT` | key env | allow own env | 401 | allow own env | allow own env |
|
||||
| `/api/v1/management/contact-attribute-keys/{id}` | `DELETE` | key env | allow own env | 401 | allow own env | 401 |
|
||||
|
||||
---
|
||||
|
||||
## Endpoint Matrix: v2
|
||||
|
||||
### Global / Org-Level
|
||||
|
||||
| Endpoint | Method | Expected |
|
||||
|---|---|---|
|
||||
| `/api/v2/health` | `GET` | 200 if healthy (no API key required) |
|
||||
| `/api/v2/roles` | `GET` | All 4 keys should get 200 with same role list |
|
||||
| `/api/v2/me` | `GET` | 200 only when key has organization access (`read`/`write`), otherwise 401 |
|
||||
|
||||
> Note: v2 org endpoints (`/api/v2/organizations/{organizationId}/teams`, `/project-teams`, `/users`) require `organizationAccess` and matching organizationId param. If your four keys only have env permissions, expected result is unauthorized for all methods.
|
||||
|
||||
### Responses
|
||||
|
||||
| Endpoint | Method | Scope Source | `K1` | `K2` | `K3` | `K4` |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `/api/v2/management/responses` | `GET` | all key envs (plus query filters) | 200 scoped | 200 scoped | 200 scoped | 200 scoped |
|
||||
| `/api/v2/management/responses` | `POST` | survey -> env | allow own env (201) | 401 | allow own env (201) | allow own env (201) |
|
||||
| `/api/v2/management/responses/{id}` | `GET` | response -> env | allow own env | allow own env | allow own env | allow own env |
|
||||
| `/api/v2/management/responses/{id}` | `PUT` | response -> env | allow own env | 401 | allow own env | allow own env |
|
||||
| `/api/v2/management/responses/{id}` | `DELETE` | response -> env | allow own env | 401 | allow own env | 401 |
|
||||
|
||||
### Webhooks
|
||||
|
||||
| Endpoint | Method | Scope Source | `K1` | `K2` | `K3` | `K4` |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `/api/v2/management/webhooks` | `GET` | all key envs | 200 scoped | 200 scoped | 200 scoped | 200 scoped |
|
||||
| `/api/v2/management/webhooks` | `POST` | `body.environmentId` | allow own env (201) | 403 | allow own env (201) | allow own env (201) |
|
||||
| `/api/v2/management/webhooks/{id}` | `GET` | webhook env | allow own env | allow own env | allow own env | allow own env |
|
||||
| `/api/v2/management/webhooks/{id}` | `PUT` | webhook env | allow own env | 401 | allow own env | allow own env |
|
||||
| `/api/v2/management/webhooks/{id}` | `DELETE` | webhook env | allow own env | 401 | allow own env | 401 |
|
||||
|
||||
### Contact Attribute Keys
|
||||
|
||||
| Endpoint | Method | Scope Source | `K1` | `K2` | `K3` | `K4` |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `/api/v2/management/contact-attribute-keys` | `GET` | query env or all key envs | 200 scoped | 200 scoped | 200 scoped | 200 scoped |
|
||||
| `/api/v2/management/contact-attribute-keys` | `POST` | `body.environmentId` | allow own env (201) | 403 | allow own env (201) | allow own env (201) |
|
||||
| `/api/v2/management/contact-attribute-keys/{id}` | `GET` | key env | allow own env | allow own env | allow own env | allow own env |
|
||||
| `/api/v2/management/contact-attribute-keys/{id}` | `PUT` | key env | allow own env | 401 | allow own env | allow own env |
|
||||
| `/api/v2/management/contact-attribute-keys/{id}` | `DELETE` | key env | allow own env | 401 | allow own env | 401 |
|
||||
|
||||
### Survey Contact Links (EE Contacts + link surveys)
|
||||
|
||||
| Endpoint | Method | Scope Source | `K1` | `K2` | `K3` | `K4` |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `/api/v2/management/surveys/{surveyId}/contact-links/contacts/{contactId}` | `GET` | survey -> env | allow own env | allow own env | allow own env | allow own env |
|
||||
| `/api/v2/management/surveys/{surveyId}/contact-links/segments/{segmentId}` | `GET` | survey -> env | allow own env | allow own env | allow own env | allow own env |
|
||||
|
||||
### EE Contacts (if enabled)
|
||||
|
||||
| Endpoint | Method | Scope Source | `K1` | `K2` | `K3` | `K4` |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `/api/v2/management/contacts` | `POST` | `body.environmentId` | allow own env (201) | 403 | allow own env (201) | allow own env (201) |
|
||||
| `/api/v2/management/contacts/bulk` | `PUT` | `body.environmentId` | allow own env (200/207) | 403 | allow own env (200/207) | allow own env (200/207) |
|
||||
|
||||
---
|
||||
|
||||
## Endpoint Matrix: v3
|
||||
|
||||
| Endpoint | Method | Scope Source | `K1` | `K2` | `K3` | `K4` |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `/api/v3/surveys?workspaceId={ws}` | `GET` | workspaceId -> environment via resolver | 200 for W1 Dev workspace; 403 for others | 200 for W1 Prod workspace; 403 for others | 200 for W2 Dev workspace; 403 for others | 200 for W2 Prod workspace; 403 for others |
|
||||
|
||||
Regression-critical checks for v3:
|
||||
- No workspace can be accessed via key from another workspace.
|
||||
- Returned survey list/count/cursor must match legacy env-scoped survey visibility.
|
||||
- Response always includes `X-Request-Id`.
|
||||
|
||||
---
|
||||
|
||||
## Concrete Regression Test Cases (Execute For Every Endpoint Family)
|
||||
|
||||
For each endpoint/method:
|
||||
|
||||
1. **Positive Same-Scope**
|
||||
- Use key with sufficient permission against its own env/workspace resource.
|
||||
- Assert success status and payload shape.
|
||||
2. **Negative Cross-Workspace**
|
||||
- Use same key against a resource in another workspace.
|
||||
- Assert auth failure and no data leakage.
|
||||
3. **Negative Insufficient Method Permission**
|
||||
- `K2` (`read`) on write endpoints -> deny.
|
||||
- `K4` (`write`) on delete endpoints -> deny.
|
||||
4. **Collection Leakage Test**
|
||||
- Call list endpoint with each key.
|
||||
- Assert every returned row belongs only to that key’s allowed env(s).
|
||||
5. **ID Enumeration Safety**
|
||||
- Try valid IDs from unauthorized envs.
|
||||
- Assert deny response, no foreign resource details.
|
||||
|
||||
---
|
||||
|
||||
## Suggested Execution Order
|
||||
|
||||
1. v1 auth/me
|
||||
2. v1 survey/response/action-class/webhook/storage
|
||||
3. v1 EE contacts
|
||||
4. v2 global + management
|
||||
5. v2 org endpoints (if organizationAccess is configured on these keys)
|
||||
6. v3 surveys by workspace
|
||||
|
||||
Run each step for all 4 keys before proceeding.
|
||||
|
||||
---
|
||||
|
||||
## Pass Criteria
|
||||
|
||||
Migration is regression-safe when all are true:
|
||||
|
||||
- Authorization decisions are unchanged (same allow/deny per key/method/resource scope).
|
||||
- Returned datasets are unchanged in scope (no missing own-scope data, no foreign-scope leakage).
|
||||
- Error classes/statuses remain consistent per API family (v1/v2/v3).
|
||||
- v3 workspace mapping yields equivalent visibility to legacy environment mapping.
|
||||
|
||||
---
|
||||
|
||||
## Optional Automation Harness (Recommended)
|
||||
|
||||
Implement an automated matrix runner (Postman/Newman or Playwright API tests) with:
|
||||
|
||||
- Inputs: `baseUrl`, 4 API keys, fixture IDs by env/workspace
|
||||
- For each test: endpoint, method, key alias, expected status, expected env/workspace ownership assertions
|
||||
- Final report grouped by endpoint family and key alias
|
||||
|
||||
This makes repeated migration verification deterministic and CI-friendly.
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"baseUrl": "http://localhost:3000",
|
||||
"expectations": {
|
||||
"v1ManagementMeStatusByKey": {
|
||||
"K1_W1_DEV_MANAGE": 200,
|
||||
"K2_W1_PROD_READ": 200,
|
||||
"K3_W2_DEV_MANAGE": 200,
|
||||
"K4_W2_PROD_WRITE": 200
|
||||
},
|
||||
"v2MeStatusByKey": {
|
||||
"K1_W1_DEV_MANAGE": 401,
|
||||
"K2_W1_PROD_READ": 401,
|
||||
"K3_W2_DEV_MANAGE": 401,
|
||||
"K4_W2_PROD_WRITE": 401
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"eeContacts": true,
|
||||
"storage": true
|
||||
},
|
||||
"planPath": "API Testing plan.md",
|
||||
"scopes": {
|
||||
"w1Dev": {
|
||||
"actionClassId": "cmo1hb042002b0jmfh5sw4oln",
|
||||
"contactAttributeKeyId": "cmo1gowa900070jmfyfzowueh",
|
||||
"contactId": "cmo1w5vtb000306zndlqg833n",
|
||||
"environmentId": "cmo1gowa900060jmfab7c10ue",
|
||||
"responseId": "cmo1h6rgl00280jmfskrznu1t",
|
||||
"segmentId": "cmo1w5vtl000706znnv0nnai5",
|
||||
"surveyId": "cmo1h667t00250jmfliszkm7s",
|
||||
"webhookId": "cmo1w5vt2000106zn3th11yg6",
|
||||
"workspaceId": "cmo1gow9300050jmf2zkbd649"
|
||||
},
|
||||
"w1Prod": {
|
||||
"actionClassId": "cmo1hcc6j002c0jmfw97ney4c",
|
||||
"contactAttributeKeyId": "cmo1gowb4000d0jmfukh4lm6g",
|
||||
"contactId": "cmo1h2xs7001t0jmfat14ofz1",
|
||||
"environmentId": "cmo1gowb4000c0jmfxjntbf9h",
|
||||
"responseId": "cmo1w5w1j000906znfn2waklw",
|
||||
"segmentId": "cmo1h5hbt00240jmfp4ddwwto",
|
||||
"surveyId": "cmo1gphm3000i0jmfz7crtakg",
|
||||
"webhookId": "cmo1gzg8i000n0jmfx35fkxr1",
|
||||
"workspaceId": "cmo1gow9300050jmf2zkbd649"
|
||||
},
|
||||
"w2Dev": {
|
||||
"actionClassId": null,
|
||||
"contactAttributeKeyId": "cmo1hu1dy003c0jmfiaraklmk",
|
||||
"contactId": null,
|
||||
"environmentId": "cmo1hu1dx003b0jmfe7qy50to",
|
||||
"responseId": null,
|
||||
"segmentId": null,
|
||||
"surveyId": null,
|
||||
"webhookId": null,
|
||||
"workspaceId": "cmo1hu1dh003a0jmf0y80rcak"
|
||||
},
|
||||
"w2Prod": {
|
||||
"actionClassId": null,
|
||||
"contactAttributeKeyId": "cmo1hu1e9003i0jmfciblxunp",
|
||||
"contactId": null,
|
||||
"environmentId": "cmo1hu1e9003h0jmfmsqk8mwo",
|
||||
"responseId": "cmo1hugl2003p0jmf52na95w2",
|
||||
"segmentId": null,
|
||||
"surveyId": "cmo1hu7ih003n0jmfz0vgep9d",
|
||||
"webhookId": null,
|
||||
"workspaceId": "cmo1hu1dh003a0jmf0y80rcak"
|
||||
}
|
||||
},
|
||||
"timeouts": {
|
||||
"healthPollIntervalMs": 2000,
|
||||
"maxConsecutiveNetworkFailures": 3,
|
||||
"requestMs": 15000,
|
||||
"startupWaitMs": 120000,
|
||||
"stepMs": 30000
|
||||
}
|
||||
}
|
||||
+12
-12
@@ -11,19 +11,19 @@
|
||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "5.0.2",
|
||||
"@storybook/addon-a11y": "10.3.6",
|
||||
"@storybook/addon-docs": "10.3.6",
|
||||
"@storybook/addon-links": "10.3.6",
|
||||
"@storybook/addon-onboarding": "10.3.6",
|
||||
"@storybook/react-vite": "10.3.6",
|
||||
"@tailwindcss/vite": "4.2.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.2",
|
||||
"@typescript-eslint/parser": "8.57.2",
|
||||
"@chromatic-com/storybook": "^5.0.1",
|
||||
"@storybook/addon-a11y": "10.2.17",
|
||||
"@storybook/addon-links": "10.2.17",
|
||||
"@storybook/addon-onboarding": "10.2.17",
|
||||
"@storybook/react-vite": "10.2.17",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.0",
|
||||
"@tailwindcss/vite": "4.2.1",
|
||||
"@typescript-eslint/parser": "8.57.0",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"eslint-plugin-react-refresh": "0.4.26",
|
||||
"eslint-plugin-storybook": "10.3.6",
|
||||
"storybook": "10.3.6",
|
||||
"vite": "7.3.3"
|
||||
"eslint-plugin-storybook": "10.2.17",
|
||||
"storybook": "10.2.17",
|
||||
"vite": "7.3.1",
|
||||
"@storybook/addon-docs": "10.2.17"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,10 +66,6 @@ RUN pnpm build --filter=@formbricks/database
|
||||
RUN --mount=type=secret,id=database_url \
|
||||
--mount=type=secret,id=encryption_key \
|
||||
--mount=type=secret,id=redis_url \
|
||||
--mount=type=secret,id=hub_api_url \
|
||||
--mount=type=secret,id=hub_api_key \
|
||||
--mount=type=secret,id=cubejs_api_url \
|
||||
--mount=type=secret,id=cubejs_api_secret \
|
||||
--mount=type=secret,id=sentry_auth_token \
|
||||
--mount=type=secret,id=posthog_key \
|
||||
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
|
||||
|
||||
@@ -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 { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
@@ -32,7 +32,7 @@ describe("getTeamsByOrganizationId", () => {
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
|
||||
new 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);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
@@ -27,7 +27,7 @@ export const getTeamsByOrganizationId = reactCache(
|
||||
name: team.name,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
|
||||
+29
-148
@@ -1,32 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
Building2Icon,
|
||||
ChevronRightIcon,
|
||||
Loader2,
|
||||
LogOutIcon,
|
||||
PlusIcon,
|
||||
} from "lucide-react";
|
||||
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState, useTransition } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { getOrganizationsForSwitcherAction } from "@/app/(app)/workspaces/[workspaceId]/actions";
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
@@ -34,65 +22,14 @@ import {
|
||||
interface LandingSidebarProps {
|
||||
user: TUser;
|
||||
organization: TOrganization;
|
||||
isMultiOrgEnabled: boolean;
|
||||
}
|
||||
|
||||
export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: LandingSidebarProps) => {
|
||||
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
|
||||
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
|
||||
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
|
||||
const [organizationLoadError, setOrganizationLoadError] = useState<string | null>(null);
|
||||
const [isOrgDropdownOpen, setIsOrgDropdownOpen] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
||||
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
|
||||
const loadOrganizations = useCallback(async () => {
|
||||
setIsLoadingOrganizations(true);
|
||||
setOrganizationLoadError(null);
|
||||
try {
|
||||
const result = await getOrganizationsForSwitcherAction({ organizationId: organization.id });
|
||||
if (result?.data) {
|
||||
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
|
||||
setOrganizations(sorted);
|
||||
} else {
|
||||
setOrganizationLoadError(
|
||||
getFormattedErrorMessage(result) || t("common.failed_to_load_organizations")
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
setOrganizationLoadError(t("common.failed_to_load_organizations"));
|
||||
} finally {
|
||||
setIsLoadingOrganizations(false);
|
||||
}
|
||||
}, [organization.id, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isOrgDropdownOpen &&
|
||||
organizations.length === 0 &&
|
||||
!isLoadingOrganizations &&
|
||||
!organizationLoadError
|
||||
) {
|
||||
loadOrganizations();
|
||||
}
|
||||
}, [
|
||||
isOrgDropdownOpen,
|
||||
organizations.length,
|
||||
isLoadingOrganizations,
|
||||
organizationLoadError,
|
||||
loadOrganizations,
|
||||
]);
|
||||
|
||||
const handleOrganizationChange = (orgId: string) => {
|
||||
startTransition(() => {
|
||||
setIsOrgDropdownOpen(false);
|
||||
router.push(`/organizations/${orgId}/`);
|
||||
});
|
||||
};
|
||||
|
||||
const dropdownNavigation = [
|
||||
{
|
||||
label: t("common.documentation"),
|
||||
@@ -102,11 +39,6 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
|
||||
},
|
||||
];
|
||||
|
||||
const switcherTriggerClasses =
|
||||
"w-full border-t px-3 py-3 text-left transition-colors duration-200 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-inset";
|
||||
const switcherIconClasses =
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600";
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
@@ -114,97 +46,45 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
|
||||
)}>
|
||||
<Image src={FBLogo} width={160} height={30} alt={t("workspace.formbricks_logo")} />
|
||||
|
||||
<div className="flex flex-col">
|
||||
{/* Organization Switcher */}
|
||||
<DropdownMenu onOpenChange={setIsOrgDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild className={switcherTriggerClasses}>
|
||||
<button type="button" className="flex w-full items-center gap-3">
|
||||
<span className={switcherIconClasses}>
|
||||
<Building2Icon className="h-4 w-4" strokeWidth={1.5} />
|
||||
</span>
|
||||
<div className="grow overflow-hidden">
|
||||
<p className="truncate text-sm font-bold text-slate-700">{organization.name}</p>
|
||||
<p className="text-sm text-slate-500">{t("common.organization")}</p>
|
||||
</div>
|
||||
{isPending && <Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />}
|
||||
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<Building2Icon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.change_organization")}
|
||||
</div>
|
||||
{isLoadingOrganizations && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingOrganizations && organizationLoadError && (
|
||||
<div className="px-2 py-4">
|
||||
<p className="mb-2 text-sm text-red-600">{organizationLoadError}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setOrganizationLoadError(null);
|
||||
setOrganizations([]);
|
||||
}}
|
||||
className="text-xs text-slate-600 underline hover:text-slate-800">
|
||||
{t("common.try_again")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingOrganizations && !organizationLoadError && (
|
||||
<>
|
||||
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||
{organizations.map((org) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={org.id}
|
||||
checked={org.id === organization.id}
|
||||
onClick={() => handleOrganizationChange(org.id)}
|
||||
className="cursor-pointer">
|
||||
{org.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isMultiOrgEnabled && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={() => setOpenCreateOrganizationModal(true)}
|
||||
className="w-full cursor-pointer justify-between">
|
||||
<span>{t("common.create_new_organization")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* User Dropdown */}
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
id="userDropdownTrigger"
|
||||
className={cn(switcherTriggerClasses, "rounded-br-xl")}>
|
||||
<button type="button" className="flex w-full items-center gap-3">
|
||||
<span className={switcherIconClasses}>
|
||||
<ProfileAvatar userId={user.id} />
|
||||
</span>
|
||||
className="w-full rounded-br-xl border-t p-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
||||
<button
|
||||
type="button"
|
||||
className={cn("flex w-full cursor-pointer flex-row items-center gap-3 text-left")}
|
||||
aria-haspopup="menu">
|
||||
<ProfileAvatar userId={user.id} />
|
||||
<div className="grow overflow-hidden">
|
||||
<p
|
||||
title={user?.email}
|
||||
className="ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700">
|
||||
className={cn(
|
||||
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
|
||||
)}>
|
||||
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">{t("common.account")}</p>
|
||||
<p title={organization?.name} className="truncate text-sm text-slate-500">
|
||||
{organization?.name}
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
|
||||
<DropdownMenuContent
|
||||
id="userDropdownInnerContentWrapper"
|
||||
side="right"
|
||||
sideOffset={10}
|
||||
alignOffset={5}
|
||||
align="end">
|
||||
{/* Dropdown Items */}
|
||||
|
||||
{dropdownNavigation.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
id={link.href}
|
||||
href={link.href}
|
||||
target={link.target}
|
||||
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}
|
||||
@@ -215,6 +95,8 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Logout */}
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await signOutWithAudit({
|
||||
@@ -231,7 +113,6 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<CreateOrganizationModal open={openCreateOrganizationModal} setOpen={setOpenCreateOrganizationModal} />
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organiza
|
||||
import { WorkspaceAndOrgSwitch } from "@/app/(app)/workspaces/[workspaceId]/components/workspace-and-org-switch";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -25,11 +26,12 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
|
||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||
const { isMember, isBilling } = getAccessFlags(membership?.role);
|
||||
const isMembershipPending = membership?.role === undefined;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-row">
|
||||
<LandingSidebar user={user} organization={organization} isMultiOrgEnabled={isMultiOrgEnabled} />
|
||||
<LandingSidebar user={user} organization={organization} />
|
||||
<div className="flex-1">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="p-6">
|
||||
@@ -43,6 +45,8 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
|
||||
isLicenseActive={false}
|
||||
isOwnerOrManager={false}
|
||||
isAccessControlAllowed={false}
|
||||
isMember={isMember}
|
||||
isBilling={isBilling}
|
||||
isMembershipPending={isMembershipPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
-11
@@ -2,7 +2,6 @@ import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getUserWorkspaces } from "@/lib/workspace/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
@@ -42,16 +41,6 @@ const Page = async (props: ChannelPageProps) => {
|
||||
|
||||
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
|
||||
|
||||
capturePostHogEvent(
|
||||
session.user.id,
|
||||
"organization_mode_selected",
|
||||
{
|
||||
organization_id: params.organizationId,
|
||||
mode: "surveys",
|
||||
},
|
||||
{ organizationId: params.organizationId }
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<Header
|
||||
|
||||
-8
@@ -2,7 +2,6 @@ import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||
import { getPostHogFeatureFlag } from "@/lib/posthog/get-feature-flag";
|
||||
import { getUserWorkspaces } from "@/lib/workspace/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
@@ -24,13 +23,6 @@ const Page = async (props: ModePageProps) => {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
const experimentVariant =
|
||||
(await getPostHogFeatureFlag(session.user.id, "a-b_onboarding_skip-first-screen")) || "control";
|
||||
|
||||
if (experimentVariant === "remove-cx-and-surveys-mode") {
|
||||
return redirect(`/organizations/${params.organizationId}/workspaces/new/channel`);
|
||||
}
|
||||
|
||||
const t = await getTranslate();
|
||||
const channelOptions = [
|
||||
{
|
||||
|
||||
+10
-5
@@ -1,17 +1,22 @@
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { SelectPlanCard } from "@/modules/ee/billing/components/select-plan-card";
|
||||
import { type TPlanVariant } from "@/modules/ee/billing/lib/select-plan-variants";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
|
||||
interface SelectPlanOnboardingProps {
|
||||
organizationId: string;
|
||||
variant: TPlanVariant;
|
||||
}
|
||||
|
||||
export const SelectPlanOnboarding = ({ organizationId, variant }: Readonly<SelectPlanOnboardingProps>) => {
|
||||
export const SelectPlanOnboarding = async ({ organizationId }: SelectPlanOnboardingProps) => {
|
||||
const t = await getTranslate();
|
||||
const nextUrl = `/organizations/${organizationId}/workspaces/new/mode`;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center">
|
||||
<SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} variant={variant} />
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-8">
|
||||
<Header
|
||||
title={t("workspace.settings.billing.select_plan_header_title")}
|
||||
subtitle={t("workspace.settings.billing.select_plan_header_subtitle")}
|
||||
/>
|
||||
<SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-21
@@ -1,14 +1,11 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { TCloudBillingPlan } from "@formbricks/types/organizations";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getPostHogFeatureFlag } from "@/lib/posthog/get-feature-flag";
|
||||
import { getOrganizationBillingWithReadThroughSync } from "@/modules/ee/billing/lib/organization-billing";
|
||||
import { PLAN_VARIANTS, type TPlanVariant } from "@/modules/ee/billing/lib/select-plan-variants";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { SelectPlanOnboarding } from "./components/select-plan-onboarding";
|
||||
|
||||
const PAID_PLANS = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
|
||||
const VALID_VARIANTS = new Set<TPlanVariant>(PLAN_VARIANTS);
|
||||
|
||||
interface PlanPageProps {
|
||||
params: Promise<{
|
||||
@@ -39,24 +36,7 @@ const Page = async (props: PlanPageProps) => {
|
||||
return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`);
|
||||
}
|
||||
|
||||
let variant: TPlanVariant = "control";
|
||||
const flagValue = await getPostHogFeatureFlag(
|
||||
session.user.id,
|
||||
"a-b_onboarding_trial-conversion-screen-copy",
|
||||
{
|
||||
organizationId: params.organizationId,
|
||||
}
|
||||
);
|
||||
if (typeof flagValue === "string" && VALID_VARIANTS.has(flagValue as TPlanVariant)) {
|
||||
variant = flagValue as TPlanVariant;
|
||||
}
|
||||
|
||||
const selectPlanOnboardingProps = {
|
||||
organizationId: params.organizationId,
|
||||
variant,
|
||||
};
|
||||
|
||||
return <SelectPlanOnboarding {...selectPlanOnboardingProps} />;
|
||||
return <SelectPlanOnboarding organizationId={params.organizationId} />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
+1
-2
@@ -18,7 +18,6 @@ import { createWorkspaceAction } from "@/app/(app)/workspaces/[workspaceId]/acti
|
||||
import { previewSurvey } from "@/app/lib/templates";
|
||||
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
|
||||
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
|
||||
import { toJsWorkspaceStateSurvey } from "@/lib/survey/client-utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
|
||||
import { TOrganizationTeam } from "@/modules/ee/teams/workspace-teams/types/team";
|
||||
@@ -238,7 +237,7 @@ export const WorkspaceSettings = ({
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={toJsWorkspaceStateSurvey(previewSurvey(workspaceName || t("common.my_product"), t))}
|
||||
survey={previewSurvey(workspaceName || t("common.my_product"), t)}
|
||||
styling={previewStyling}
|
||||
isBrandingEnabled={false}
|
||||
languageCode="default"
|
||||
|
||||
-37
@@ -11,16 +11,12 @@ import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboardin
|
||||
import { WorkspaceSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/WorkspaceSettings";
|
||||
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getPostHogFeatureFlag } from "@/lib/posthog/get-feature-flag";
|
||||
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
|
||||
import { getUserWorkspaces } from "@/lib/workspace/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
import { createWorkspace } from "@/modules/workspaces/settings/lib/workspace";
|
||||
|
||||
interface WorkspaceSettingsPageProps {
|
||||
params: Promise<{
|
||||
@@ -47,29 +43,8 @@ const Page = async (props: WorkspaceSettingsPageProps) => {
|
||||
const channel = searchParams.channel ?? null;
|
||||
const industry = searchParams.industry ?? null;
|
||||
const mode = searchParams.mode ?? "surveys";
|
||||
|
||||
const experimentVariant =
|
||||
(await getPostHogFeatureFlag(session.user.id, "a-b_onboarding_skip-theme-screen")) || "control";
|
||||
|
||||
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
|
||||
|
||||
if (experimentVariant === "remove-theme") {
|
||||
const existing = workspaces.find((w) => w.name === organization.name);
|
||||
const workspace =
|
||||
existing ??
|
||||
(await createWorkspace(params.organizationId, {
|
||||
name: organization.name,
|
||||
styling: buildStylingFromBrandColor(DEFAULT_BRAND_COLOR),
|
||||
config: { channel, industry },
|
||||
}));
|
||||
if (channel === "app" || channel === "website") {
|
||||
return redirect(`/workspaces/${workspace.id}/connect`);
|
||||
} else if (channel === "link") {
|
||||
return redirect(`/workspaces/${workspace.id}/surveys`);
|
||||
}
|
||||
return redirect(`/workspaces/${workspace.id}/xm-templates`);
|
||||
}
|
||||
|
||||
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
|
||||
|
||||
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
|
||||
@@ -80,18 +55,6 @@ const Page = async (props: WorkspaceSettingsPageProps) => {
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
if (searchParams.mode === "cx") {
|
||||
capturePostHogEvent(
|
||||
session.user.id,
|
||||
"organization_mode_selected",
|
||||
{
|
||||
organization_id: params.organizationId,
|
||||
mode: "cx",
|
||||
},
|
||||
{ organizationId: params.organizationId }
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<Header
|
||||
|
||||
+2
-2
@@ -60,8 +60,8 @@ const mockTemplate: TXMTemplate = {
|
||||
],
|
||||
styling: {
|
||||
brandColor: { light: "#0000FF" },
|
||||
elementHeadlineColor: { light: "#00FF00" },
|
||||
inputBgColor: { light: "#FF0000" },
|
||||
questionColor: { light: "#00FF00" },
|
||||
inputColor: { light: "#FF0000" },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -39,10 +39,7 @@ export const ConfirmationPage = () => {
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild className="w-full justify-center">
|
||||
<Link
|
||||
href={
|
||||
resolvedWorkspaceId ? `/workspaces/${resolvedWorkspaceId}/settings/organization/billing` : "/"
|
||||
}>
|
||||
<Link href={resolvedWorkspaceId ? `/workspaces/${resolvedWorkspaceId}/settings/billing` : "/"}>
|
||||
{t("billing_confirmation.back_to_billing_overview")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
const Page = () => {
|
||||
return redirect("/");
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,8 +0,0 @@
|
||||
import { ChartsListPage } from "@/modules/ee/analysis/charts/components/charts-list-page";
|
||||
|
||||
const ChartsPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const { workspaceId } = await props.params;
|
||||
return <ChartsListPage workspaceId={workspaceId} />;
|
||||
};
|
||||
|
||||
export default ChartsPage;
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
import { DashboardDetailPage } from "@/modules/ee/analysis/dashboards/pages/dashboard-detail-page";
|
||||
|
||||
const Page = (props: { params: Promise<{ workspaceId: string; dashboardId: string }> }) => {
|
||||
return <DashboardDetailPage params={props.params} />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,8 +0,0 @@
|
||||
import { DashboardsListPage } from "@/modules/ee/analysis/dashboards/pages/dashboards-list-page";
|
||||
|
||||
const DashboardsPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const { workspaceId } = await props.params;
|
||||
return <DashboardsListPage workspaceId={workspaceId} />;
|
||||
};
|
||||
|
||||
export default DashboardsPage;
|
||||
@@ -1,3 +0,0 @@
|
||||
import { AnalysisListLoading } from "@/modules/ee/analysis/loading";
|
||||
|
||||
export default AnalysisListLoading;
|
||||
@@ -1,8 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function FeedbackSourcesRedirect(
|
||||
props: Readonly<{ params: Promise<{ workspaceId: string }> }>
|
||||
) {
|
||||
const { workspaceId } = await props.params;
|
||||
redirect(`/workspaces/${workspaceId}/settings/workspace/feedback-sources`);
|
||||
}
|
||||
+4
-10
@@ -46,16 +46,10 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
|
||||
ctx.auditLoggingCtx.integrationId = result.id;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
|
||||
capturePostHogEvent(
|
||||
ctx.user.id,
|
||||
"integration_connected",
|
||||
{
|
||||
integration_type: parsedInput.integrationData.type,
|
||||
organization_id: organizationId,
|
||||
workspace_id: parsedInput.workspaceId,
|
||||
},
|
||||
{ organizationId, workspaceId: parsedInput.workspaceId }
|
||||
);
|
||||
capturePostHogEvent(ctx.user.id, "integration_connected", {
|
||||
integration_type: parsedInput.integrationData.type,
|
||||
organization_id: organizationId,
|
||||
});
|
||||
|
||||
return result;
|
||||
})
|
||||
+3
-3
@@ -17,9 +17,9 @@ import {
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/actions";
|
||||
import { BaseSelectDropdown } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/BaseSelectDropdown";
|
||||
import { fetchTables } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/lib/airtable";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
|
||||
import { BaseSelectDropdown } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/components/BaseSelectDropdown";
|
||||
import { fetchTables } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/lib/airtable";
|
||||
import AirtableLogo from "@/images/airtableLogo.svg";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
+2
-6
@@ -5,8 +5,8 @@ import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/lib/airtable";
|
||||
import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/lib/airtable";
|
||||
import airtableLogo from "@/images/airtableLogo.svg";
|
||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||
|
||||
@@ -18,7 +18,6 @@ interface AirtableWrapperProps {
|
||||
isEnabled: boolean;
|
||||
webAppUrl: string;
|
||||
locale: TUserLocale;
|
||||
showReconnectButton?: boolean;
|
||||
}
|
||||
|
||||
export const AirtableWrapper = ({
|
||||
@@ -29,7 +28,6 @@ export const AirtableWrapper = ({
|
||||
isEnabled,
|
||||
webAppUrl,
|
||||
locale,
|
||||
showReconnectButton = false,
|
||||
}: AirtableWrapperProps) => {
|
||||
const [isConnected, setIsConnected] = useState(
|
||||
airtableIntegration ? airtableIntegration.config?.key : false
|
||||
@@ -51,8 +49,6 @@ export const AirtableWrapper = ({
|
||||
setIsConnected={setIsConnected}
|
||||
surveys={surveys}
|
||||
locale={locale}
|
||||
showReconnectButton={showReconnectButton}
|
||||
handleAirtableAuthorization={handleAirtableAuthorization}
|
||||
/>
|
||||
) : (
|
||||
<ConnectIntegration
|
||||
+11
-40
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
|
||||
import { Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -8,15 +8,13 @@ import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/actions";
|
||||
import { AddIntegrationModal } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/AddIntegrationModal";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
|
||||
import { AddIntegrationModal } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/components/AddIntegrationModal";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { IntegrationModalInputs } from "../lib/types";
|
||||
|
||||
interface ManageIntegrationProps {
|
||||
@@ -26,20 +24,10 @@ interface ManageIntegrationProps {
|
||||
surveys: TSurvey[];
|
||||
airtableArray: TIntegrationItem[];
|
||||
locale: TUserLocale;
|
||||
showReconnectButton: boolean;
|
||||
handleAirtableAuthorization: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const ManageIntegration = ({
|
||||
airtableIntegration,
|
||||
workspaceId,
|
||||
setIsConnected,
|
||||
surveys,
|
||||
airtableArray,
|
||||
showReconnectButton,
|
||||
handleAirtableAuthorization,
|
||||
locale,
|
||||
}: ManageIntegrationProps) => {
|
||||
export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
const { airtableIntegration, workspaceId, setIsConnected, surveys, airtableArray } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tableHeaders = [
|
||||
@@ -85,34 +73,15 @@ export const ManageIntegration = ({
|
||||
: { isEditMode: false as const };
|
||||
return (
|
||||
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
|
||||
{showReconnectButton && (
|
||||
<Alert variant="warning" size="small" className="mb-4 w-full">
|
||||
<AlertDescription>{t("workspace.integrations.reconnect_button_description")}</AlertDescription>
|
||||
<AlertButton onClick={handleAirtableAuthorization}>
|
||||
{t("workspace.integrations.reconnect_button")}
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex w-full justify-end space-x-2">
|
||||
<div className="mr-6 flex items-center">
|
||||
<div className="flex w-full justify-end gap-x-6">
|
||||
<div className="flex items-center">
|
||||
<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("workspace.integrations.connected_with_email", {
|
||||
email: airtableIntegration.config.email,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" onClick={handleAirtableAuthorization}>
|
||||
<RefreshCcwIcon className="mr-2 h-4 w-4" />
|
||||
{t("workspace.integrations.reconnect_button")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("workspace.integrations.reconnect_button_tooltip")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDefaultValues(null);
|
||||
@@ -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.tableName}</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>
|
||||
))}
|
||||
</div>
|
||||
+4
-12
@@ -1,9 +1,8 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { AirtableWrapper } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/airtable/components/AirtableWrapper";
|
||||
import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/lib/surveys";
|
||||
import { AirtableWrapper } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/components/AirtableWrapper";
|
||||
import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/lib/surveys";
|
||||
import { getAirtableTables } from "@/lib/airtable/service";
|
||||
import { AIRTABLE_CLIENT_ID, DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
@@ -32,14 +31,8 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
);
|
||||
|
||||
let airtableArray: TIntegrationItem[] = [];
|
||||
let isTokenValid = true;
|
||||
if (airtableIntegration?.config.key) {
|
||||
try {
|
||||
airtableArray = await getAirtableTables(workspace.id);
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to load Airtable bases — token may be expired or revoked");
|
||||
isTokenValid = false;
|
||||
}
|
||||
airtableArray = await getAirtableTables(workspace.id);
|
||||
}
|
||||
if (isReadOnly) {
|
||||
return redirect("./");
|
||||
@@ -47,7 +40,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<GoBackButton url={`${WEBAPP_URL}/workspaces/${params.workspaceId}/settings/workspace/integrations`} />
|
||||
<GoBackButton url={`${WEBAPP_URL}/workspaces/${params.workspaceId}/integrations`} />
|
||||
<PageHeader pageTitle={t("workspace.integrations.airtable.airtable_integration")} />
|
||||
<div className="h-[75vh] w-full">
|
||||
<AirtableWrapper
|
||||
@@ -58,7 +51,6 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
surveys={surveys}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
locale={locale ?? DEFAULT_LOCALE}
|
||||
showReconnectButton={!isTokenValid}
|
||||
/>
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
+3
-3
@@ -12,13 +12,13 @@ import {
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/actions";
|
||||
import { getSpreadsheetNameByIdAction } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/actions";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
|
||||
import { getSpreadsheetNameByIdAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/actions";
|
||||
import {
|
||||
constructGoogleSheetsUrl,
|
||||
extractSpreadsheetIdFromUrl,
|
||||
isValidGoogleSheetsUrl,
|
||||
} from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/lib/util";
|
||||
} from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/lib/util";
|
||||
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
|
||||
import {
|
||||
GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION,
|
||||
+3
-3
@@ -7,9 +7,9 @@ import {
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { validateGoogleSheetsConnectionAction } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/actions";
|
||||
import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/lib/google";
|
||||
import { validateGoogleSheetsConnectionAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/actions";
|
||||
import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/lib/google";
|
||||
import googleSheetLogo from "@/images/googleSheetsLogo.png";
|
||||
import { GOOGLE_SHEET_INTEGRATION_INVALID_GRANT } from "@/lib/googleSheet/constants";
|
||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||
+1
-1
@@ -9,7 +9,7 @@ import {
|
||||
TIntegrationGoogleSheetsConfigData,
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/actions";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
||||
import { GoogleSheetWrapper } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/google-sheets/components/GoogleSheetWrapper";
|
||||
import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/lib/surveys";
|
||||
import { GoogleSheetWrapper } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/components/GoogleSheetWrapper";
|
||||
import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/lib/surveys";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
GOOGLE_SHEETS_CLIENT_ID,
|
||||
@@ -39,7 +39,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<GoBackButton url={`${WEBAPP_URL}/workspaces/${params.workspaceId}/settings/workspace/integrations`} />
|
||||
<GoBackButton url={`${WEBAPP_URL}/workspaces/${params.workspaceId}/integrations`} />
|
||||
<PageHeader pageTitle={t("workspace.integrations.google_sheets.google_sheets_integration")} />
|
||||
<div className="h-[75vh] w-full">
|
||||
<GoogleSheetWrapper
|
||||
+2
-2
@@ -15,12 +15,12 @@ import {
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/actions";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
|
||||
import {
|
||||
MappingRow,
|
||||
TMapping,
|
||||
createEmptyMapping,
|
||||
} from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/MappingRow";
|
||||
} from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/notion/components/MappingRow";
|
||||
import NotionLogo from "@/images/notion.png";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
+1
-1
@@ -6,7 +6,7 @@ import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/actions";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
||||
ERRORS,
|
||||
TYPE_MAPPING,
|
||||
UNSUPPORTED_TYPES_BY_NOTION,
|
||||
} from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/constants";
|
||||
} from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/notion/constants";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { getElementTypes } from "@/modules/survey/lib/elements";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
+2
-2
@@ -8,8 +8,8 @@ import {
|
||||
} from "@formbricks/types/integration/notion";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { AddIntegrationModal } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/AddIntegrationModal";
|
||||
import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/ManageIntegration";
|
||||
import { AddIntegrationModal } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/notion/components/AddIntegrationModal";
|
||||
import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/notion/components/ManageIntegration";
|
||||
import notionLogo from "@/images/notion.png";
|
||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||
import { authorize } from "../lib/notion";
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
|
||||
import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/lib/surveys";
|
||||
import { NotionWrapper } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/notion/components/NotionWrapper";
|
||||
import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/lib/surveys";
|
||||
import { NotionWrapper } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/notion/components/NotionWrapper";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
NOTION_AUTH_URL,
|
||||
+11
-8
@@ -2,7 +2,7 @@ import { TFunction } from "i18next";
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
import { TIntegrationType } from "@formbricks/types/integration";
|
||||
import { getWebhookCountBySource } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/lib/webhook";
|
||||
import { getWebhookCountBySource } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/lib/webhook";
|
||||
import ActivePiecesLogo from "@/images/activepieces.webp";
|
||||
import AirtableLogo from "@/images/airtableLogo.svg";
|
||||
import GoogleSheetsLogo from "@/images/googleSheetsLogo.png";
|
||||
@@ -21,6 +21,7 @@ import { Card } from "@/modules/ui/components/integration-card";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
import { WorkspaceConfigNavigation } from "@/modules/workspaces/settings/components/workspace-config-navigation";
|
||||
|
||||
const getStatusText = (count: number, t: TFunction, type: string) => {
|
||||
if (count === 1) return `1 ${type}`;
|
||||
@@ -80,7 +81,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
disabled: isReadOnly,
|
||||
},
|
||||
{
|
||||
connectHref: `/workspaces/${params.workspaceId}/settings/workspace/integrations/webhooks`,
|
||||
connectHref: `/workspaces/${params.workspaceId}/integrations/webhooks`,
|
||||
connectText: t("workspace.integrations.manage_webhooks"),
|
||||
connectNewTab: false,
|
||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks",
|
||||
@@ -94,7 +95,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
connectHref: `/workspaces/${params.workspaceId}/settings/workspace/integrations/google-sheets`,
|
||||
connectHref: `/workspaces/${params.workspaceId}/integrations/google-sheets`,
|
||||
connectText: `${isGoogleSheetsIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
||||
connectNewTab: false,
|
||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/google-sheets",
|
||||
@@ -108,7 +109,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
disabled: isReadOnly,
|
||||
},
|
||||
{
|
||||
connectHref: `/workspaces/${params.workspaceId}/settings/workspace/integrations/airtable`,
|
||||
connectHref: `/workspaces/${params.workspaceId}/integrations/airtable`,
|
||||
connectText: `${isAirtableIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
||||
connectNewTab: false,
|
||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/airtable",
|
||||
@@ -122,7 +123,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
disabled: isReadOnly,
|
||||
},
|
||||
{
|
||||
connectHref: `/workspaces/${params.workspaceId}/settings/workspace/integrations/slack`,
|
||||
connectHref: `/workspaces/${params.workspaceId}/integrations/slack`,
|
||||
connectText: `${isSlackIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
||||
connectNewTab: false,
|
||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/slack",
|
||||
@@ -164,7 +165,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
disabled: isReadOnly,
|
||||
},
|
||||
{
|
||||
connectHref: `/workspaces/${params.workspaceId}/settings/workspace/integrations/notion`,
|
||||
connectHref: `/workspaces/${params.workspaceId}/integrations/notion`,
|
||||
connectText: `${isNotionIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
||||
connectNewTab: false,
|
||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/notion",
|
||||
@@ -197,7 +198,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
docsHref: "https://formbricks.com/docs/app-surveys/quickstart",
|
||||
docsText: t("common.docs"),
|
||||
docsNewTab: true,
|
||||
connectHref: `/workspaces/${params.workspaceId}/settings/workspace/app-connection`,
|
||||
connectHref: `/workspaces/${params.workspaceId}/app-connection`,
|
||||
connectText: t("common.connect"),
|
||||
connectNewTab: false,
|
||||
label: "Javascript SDK",
|
||||
@@ -210,7 +211,9 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.integrations")} />
|
||||
<PageHeader pageTitle={t("common.workspace_configuration")}>
|
||||
<WorkspaceConfigNavigation activeId="integrations" />
|
||||
</PageHeader>
|
||||
<div className="grid grid-cols-3 place-content-stretch gap-4 lg:grid-cols-3">
|
||||
{integrationCards.map((card) => (
|
||||
<Card
|
||||
+1
-1
@@ -15,7 +15,7 @@ import {
|
||||
} from "@formbricks/types/integration/slack";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/actions";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
|
||||
import SlackLogo from "@/images/slacklogo.png";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
+1
-1
@@ -6,7 +6,7 @@ import toast from "react-hot-toast";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/actions";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
+4
-4
@@ -5,10 +5,10 @@ import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getSlackChannelsAction } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/slack/actions";
|
||||
import { AddChannelMappingModal } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/slack/components/AddChannelMappingModal";
|
||||
import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/slack/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/slack/lib/slack";
|
||||
import { getSlackChannelsAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/slack/actions";
|
||||
import { AddChannelMappingModal } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/slack/components/AddChannelMappingModal";
|
||||
import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/slack/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/slack/lib/slack";
|
||||
import slackLogo from "@/images/slacklogo.png";
|
||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
|
||||
import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/lib/surveys";
|
||||
import { SlackWrapper } from "@/app/(app)/workspaces/[workspaceId]/settings/workspace/integrations/slack/components/SlackWrapper";
|
||||
import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/lib/surveys";
|
||||
import { SlackWrapper } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/slack/components/SlackWrapper";
|
||||
import { DEFAULT_LOCALE, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getIntegrationByType } from "@/lib/integration/service";
|
||||
import { getUserLocale } from "@/lib/user/service";
|
||||
@@ -31,7 +31,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<GoBackButton url={`${WEBAPP_URL}/workspaces/${params.workspaceId}/settings/workspace/integrations`} />
|
||||
<GoBackButton url={`${WEBAPP_URL}/workspaces/${params.workspaceId}/integrations`} />
|
||||
<PageHeader pageTitle={t("workspace.integrations.slack.slack_integration")} />
|
||||
<div className="h-[75vh] w-full">
|
||||
<SlackWrapper
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import { ZWorkspaceUpdateInput } from "@formbricks/types/workspace";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { capturePostHogEvent, groupIdentifyPostHog } from "@/lib/posthog";
|
||||
import { updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
@@ -81,19 +80,6 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
|
||||
groupIdentifyPostHog("workspace", workspace.id, { name: workspace.name });
|
||||
|
||||
capturePostHogEvent(
|
||||
user.id,
|
||||
"workspace_created",
|
||||
{
|
||||
organization_id: organizationId,
|
||||
workspace_id: workspace.id,
|
||||
name: workspace.name,
|
||||
},
|
||||
{ organizationId, workspaceId: workspace.id }
|
||||
);
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.workspaceId = workspace.id;
|
||||
ctx.auditLoggingCtx.newObject = workspace;
|
||||
@@ -131,7 +117,7 @@ const ZGetWorkspacesForSwitcherAction = z.object({
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetches workspaces list for switcher dropdown.
|
||||
* Fetches projects list for switcher dropdown.
|
||||
* Called on-demand when user opens the workspace switcher.
|
||||
*/
|
||||
export const getWorkspacesForSwitcherAction = authenticatedActionClient
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user