mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-23 02:45:21 -05:00
Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99d794ad53 | |||
| b123965d93 | |||
| aed47b94a8 | |||
| ecaa2887b7 | |||
| ad094b2d4c | |||
| b099219244 | |||
| af82329c4a | |||
| cfff6b1495 | |||
| 9bc4e69821 | |||
| 81b1c036f6 | |||
| 635200db78 | |||
| 793320746e | |||
| 6501041a48 | |||
| f5337e77f3 | |||
| 0192c1ed00 | |||
| 2e2b13c36b | |||
| 95dd4404d1 | |||
| 8a9912f839 | |||
| 016dc3d92a | |||
| 3a147a2b09 | |||
| e159b45911 | |||
| 96d14b98f0 | |||
| aa90d9fd1a | |||
| 3da7129413 | |||
| 75fbb23190 | |||
| 2ffe79ffd2 | |||
| 60f6ca9463 | |||
| aa27d242bb | |||
| a771ae189a | |||
| 029e069af6 | |||
| 81272b96e1 | |||
| cffeb0513e | |||
| bc334c24cf | |||
| 077a9934ad | |||
| 1ed8d8076e | |||
| 345b282733 | |||
| c7c30a9d58 | |||
| 08510659de | |||
| f8fa29d56e | |||
| 8b048c3105 | |||
| b2705a4f8f | |||
| e867caa373 | |||
| ff6176df0a | |||
| d0f4228b45 | |||
| de79b58648 | |||
| 04d528b9b8 | |||
| c815b11015 | |||
| 1e7830d850 | |||
| 77cd1e9bd1 | |||
| e665227437 | |||
| 3a802810e3 | |||
| fbbf917093 | |||
| a7e42bfd29 | |||
| 562fdec899 | |||
| 75e71e39bc | |||
| 337aedf463 | |||
| d670d5de31 | |||
| 5ccb4af249 | |||
| 62aa186a81 | |||
| cb094761ca | |||
| f35e54f21d | |||
| f49f40610b | |||
| 9e754bad9c | |||
| 4dcf6fda40 | |||
| 1b8ccd7199 | |||
| 4f9088559f | |||
| 18550f1d11 | |||
| 881cd31f74 | |||
| e00405dca2 |
@@ -0,0 +1 @@
|
||||
{"sessionId":"f77248e2-8840-41c6-968b-c3b7d8a9e913","pid":49125,"acquiredAt":1776168010367}
|
||||
@@ -1 +0,0 @@
|
||||
../skills
|
||||
@@ -1 +0,0 @@
|
||||
../skills
|
||||
@@ -53,7 +53,7 @@ function {QuestionType}({
|
||||
}: {QuestionType}Props): React.JSX.Element {
|
||||
// Ensure value is always the correct type (handle undefined/null)
|
||||
const currentValue = value ?? {defaultValue};
|
||||
|
||||
|
||||
// Detect text direction from content
|
||||
const detectedDir = useTextDirection({
|
||||
dir,
|
||||
@@ -63,11 +63,11 @@ function {QuestionType}({
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
htmlFor={inputId}
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
htmlFor={inputId}
|
||||
/>
|
||||
|
||||
{/* Question-specific controls */}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../skills
|
||||
+23
-60
@@ -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,24 @@ 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.
|
||||
|
||||
# Cube.js Analytics (optional — only needed for the analytics/dashboard feature)
|
||||
# Required when running the Cube service (docker-compose.dev.yml). Generate with: openssl rand -hex 32
|
||||
# Use the same value for CUBEJS_API_TOKEN so the client can authenticate.
|
||||
# CUBEJS_API_SECRET=
|
||||
# URL where the Cube.js instance is running
|
||||
# CUBEJS_API_URL=http://localhost:4000
|
||||
# API token sent with each Cube.js request; must match CUBEJS_API_SECRET when CUBEJS_DEV_MODE is off
|
||||
# CUBEJS_API_TOKEN=
|
||||
#
|
||||
# Cube connects to the Hub DB. With docker-compose.dev.yml defaults, use the local postgres service.
|
||||
# CUBEJS_DB_HOST=postgres
|
||||
# CUBEJS_DB_PORT=5432
|
||||
# CUBEJS_DB_NAME=postgres
|
||||
# CUBEJS_DB_USER=postgres
|
||||
# CUBEJS_DB_PASS=postgres
|
||||
#
|
||||
# Alternative (external Hub/Postgres on the hub network): formbricks_hub_postgres, db: hub, user/pass: formbricks/formbricks_dev
|
||||
|
||||
# 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
|
||||
|
||||
@@ -31,14 +31,14 @@ jobs:
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
# Get the latest release tag from GitHub API with error handling
|
||||
echo "Fetching latest release from GitHub API..."
|
||||
|
||||
|
||||
# Use curl with error handling - API returns 404 if no releases exist
|
||||
http_code=$(curl -s -w "%{http_code}" -H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
"https://api.github.com/repos/${REPO}/releases/latest" -o /tmp/latest_release.json)
|
||||
|
||||
|
||||
if [[ "$http_code" == "404" ]]; then
|
||||
echo "⚠️ No previous releases found (404). This appears to be the first release."
|
||||
echo "latest_release=" >> $GITHUB_OUTPUT
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
echo "❌ GitHub API error (HTTP ${http_code}). Treating as first release."
|
||||
echo "latest_release=" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
|
||||
echo "Current release tag: ${{ github.event.release.tag_name }}"
|
||||
|
||||
- name: Compare release tags
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
LATEST_TAG: ${{ steps.get_latest_release.outputs.latest_release }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
# Handle first release case (no previous releases)
|
||||
if [[ -z "${LATEST_TAG}" ]]; then
|
||||
echo "🎉 This is the first release (${CURRENT_TAG}) - treating as latest"
|
||||
@@ -155,113 +155,3 @@ jobs:
|
||||
commit_sha: ${{ github.sha }}
|
||||
is_prerelease: ${{ github.event.release.prerelease }}
|
||||
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
|
||||
|
||||
update-helm-app-version:
|
||||
name: Create Helm app version update
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
needs:
|
||||
- docker-build-community
|
||||
- helm-chart-release
|
||||
if: ${{ !github.event.release.prerelease }}
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Install YQ
|
||||
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
|
||||
|
||||
- name: Prepare Helm app version update
|
||||
id: update
|
||||
env:
|
||||
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Skipping Helm app version source update for non-stable version: ${VERSION}"
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
yq -i ".appVersion = \"${VERSION}\"" charts/formbricks/Chart.yaml
|
||||
perl -0pi -e "s/!\[AppVersion: [^\]]+\]/![AppVersion: ${VERSION}]/" charts/formbricks/README.md
|
||||
perl -0pi -e "s/AppVersion-[0-9A-Za-z._+-]+-informational/AppVersion-${VERSION}-informational/" charts/formbricks/README.md
|
||||
|
||||
if git diff --quiet -- charts/formbricks/Chart.yaml charts/formbricks/README.md; then
|
||||
echo "Helm chart appVersion already matches ${VERSION}"
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Helm app version PR
|
||||
if: steps.update.outputs.changed == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
branch="chore/update-helm-app-version-${VERSION}"
|
||||
title="chore: update Helm app version to ${VERSION}"
|
||||
body_file="$(mktemp)"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -B "$branch"
|
||||
git add charts/formbricks/Chart.yaml charts/formbricks/README.md
|
||||
git commit -m "$title"
|
||||
git push --force-with-lease origin "$branch"
|
||||
|
||||
cat > "$body_file" <<EOF
|
||||
Updates the Helm chart default app version after publishing stable Formbricks release ${VERSION}.
|
||||
|
||||
Release candidates and pre-releases do not create this source update.
|
||||
EOF
|
||||
|
||||
if gh pr view "$branch" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh pr edit "$branch" --repo "$GITHUB_REPOSITORY" --title "$title" --body-file "$body_file" --base main
|
||||
else
|
||||
gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$branch" --title "$title" --body-file "$body_file"
|
||||
fi
|
||||
|
||||
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
|
||||
- update-helm-app-version
|
||||
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 }}
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
|
||||
with:
|
||||
version: v3.15.4
|
||||
version: latest
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
env:
|
||||
@@ -70,25 +70,6 @@ jobs:
|
||||
|
||||
echo "✅ Successfully updated Chart.yaml"
|
||||
|
||||
- name: Validate default Formbricks image tag
|
||||
env:
|
||||
VERSION: ${{ env.VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
rendered="$(helm template qa charts/formbricks \
|
||||
--set formbricks.webappUrl=https://qa.example.com \
|
||||
--show-only templates/deployment.yaml \
|
||||
--show-only templates/migration-job.yaml)"
|
||||
|
||||
expected_image="ghcr.io/formbricks/formbricks:${VERSION}"
|
||||
image_count="$(grep -c "image: ${expected_image}$" <<< "$rendered" || true)"
|
||||
if [[ "$image_count" -ne 2 ]]; then
|
||||
echo "Expected web Deployment and migration Job to render ${expected_image}; found ${image_count} matches"
|
||||
grep "image: ghcr.io/formbricks/formbricks:" <<< "$rendered" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Package Helm chart
|
||||
env:
|
||||
VERSION: ${{ env.VERSION }}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
@@ -99,58 +99,6 @@ Prefer Vitest with Testing Library for logic in `.ts` files, keeping specs coloc
|
||||
- Prefer type inference, avoid `any`, and use shared types from `@formbricks/types`.
|
||||
- Keep components focused, avoid deep nesting, and ensure basic accessibility.
|
||||
|
||||
## Workbench Workflow
|
||||
|
||||
The project workbench lives at `workbench/`.
|
||||
|
||||
Start substantial tasks with targeted workbench context, not a full-doc sweep. Use `rg`, indexes, headings, and linked records to find the smallest relevant set, then open only the sections needed for the task:
|
||||
|
||||
1. `workbench/blueprint/PRODUCT.md`
|
||||
2. `workbench/blueprint/EPICS.md`
|
||||
3. `workbench/blueprint/business-rules/`
|
||||
4. `workbench/blueprint/decisions/`
|
||||
5. `workbench/cowork/COORDINATOR.md`
|
||||
6. `workbench/blueprint/MILESTONES.md`
|
||||
7. The relevant plan, checkpoint, bug-fix, setup, env, security, check, or manual QA file.
|
||||
|
||||
`workbench/GUIDE.md` is the workflow source of truth. Keep `AGENTS.md` concise and route detailed workflow rules there.
|
||||
|
||||
### Workbench Token Budget
|
||||
|
||||
Prefer narrow input and compact output. Do not read entire workbench directories or paste long excerpts unless the task requires it. Summarize findings, link files, and report only changed records plus validation results. For routine implementation, one concise checkpoint is enough after an end-to-end pass.
|
||||
|
||||
### Blueprint vs Cowork
|
||||
|
||||
- `workbench/blueprint/` contains durable product and application truth: product definition, epics, milestones, business rules, decisions, design, security, checks, manual QA, and env var expectations.
|
||||
- `workbench/cowork/` contains active execution records: plans, checkpoints, bug fixes, prompts, and coordination.
|
||||
- `workbench/scratch/` and `workbench/local/` are ignored local spaces. Do not rely on their contents for durable project state.
|
||||
|
||||
### Blueprint Human Ownership
|
||||
|
||||
AI agents may help improve wording, structure, consistency, and traceability for `workbench/blueprint/` documents. The underlying concepts, original prompts or drafts, and final review must come from humans. Treat blueprint records as human-owned product truth: do not invent product direction, business rules, milestones, decisions, security posture, env expectations, checks, manual QA, or design guidance without explicit human input and review.
|
||||
|
||||
### Workbench Review Gates
|
||||
|
||||
Do not start planning or implementation from a milestone, plan, or bug-fix record unless it has a completed human review line such as `- [ ] Reviewed and refined by: Javier`. If the line is missing or still says `TBD`, stop and ask for human review. Keep checkpoints proportional: one checkpoint is enough when a plan is implemented end to end in one pass.
|
||||
|
||||
### Workbench Validation
|
||||
|
||||
After editing `workbench/`, `skills/`, or this workflow section in `AGENTS.md`, run `node workbench/scripts/validate-workbench.mjs workbench` and report any failures or relevant warnings. Before implementing from workbench records, run the validator when the workbench structure, links, or review gates may have changed since the plan was reviewed.
|
||||
|
||||
### Documentation Sync
|
||||
|
||||
When code changes alter product behavior, business rules, architecture decisions, env vars, setup, security posture, automated checks, or manual QA expectations, update the relevant workbench files in the same change. Avoid process churn: do not create or rewrite workbench docs for purely mechanical implementation details, formatting, or phase-by-phase narration when one concise checkpoint covers an end-to-end implementation.
|
||||
|
||||
Use checkpoints for completed plan phases. Use bug-fix records for scoped defects. Use decision records for durable tradeoffs. Use business-rule records for current domain behavior.
|
||||
|
||||
## Git Discipline
|
||||
|
||||
- Do not create commits unless explicitly asked.
|
||||
- Preserve staged and unstaged work exactly.
|
||||
- Do not stage, unstage, reset, or discard files unless explicitly asked.
|
||||
- Before editing files in an existing repo, inspect `git status --short` and check relevant staged and unstaged diffs.
|
||||
- Treat staged content as human-selected work in progress.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
Commits follow a lightweight Conventional Commit format (`fix:`, `chore:`, `feat:`) and usually append the PR number, e.g. `fix: update OpenAPI schema (#6617)`. Keep commits scoped and lint-clean. Pull requests should outline the problem, summarize the solution, and link to issues or product specs. Attach screenshots or gifs for UI-facing work, list any migrations or env changes, and paste the output of relevant commands (`pnpm test`, `pnpm lint`, `pnpm db:migrate:dev`) so reviewers can verify readiness.
|
||||
|
||||
+12
-13
@@ -5,26 +5,25 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "eslint . --config .eslintrc.cjs --ext .ts,.tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./App.tsx";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,8 +1 @@
|
||||
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`);
|
||||
}
|
||||
export { WorkspaceSourcesPage as default } from "@/modules/workspaces/settings/sources/page";
|
||||
|
||||
+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";
|
||||
@@ -24,9 +23,13 @@ import { createWorkspace } from "@/modules/workspaces/settings/lib/workspace";
|
||||
import { getOrganizationsByUserId } from "./lib/organization";
|
||||
import { getWorkspacesByUserId } from "./lib/workspace";
|
||||
|
||||
const ZCreateWorkspaceInput = ZWorkspaceUpdateInput.extend({
|
||||
feedbackRecordDirectoryId: ZId.optional(),
|
||||
});
|
||||
|
||||
const ZCreateWorkspaceAction = z.object({
|
||||
organizationId: ZId,
|
||||
data: ZWorkspaceUpdateInput,
|
||||
data: ZCreateWorkspaceInput,
|
||||
});
|
||||
|
||||
export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCreateWorkspaceAction).action(
|
||||
@@ -41,7 +44,7 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
|
||||
access: [
|
||||
{
|
||||
data: parsedInput.data,
|
||||
schema: ZWorkspaceUpdateInput,
|
||||
schema: ZCreateWorkspaceInput,
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
@@ -81,19 +84,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 +121,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
@@ -1,453 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BellIcon,
|
||||
BlocksIcon,
|
||||
BrushIcon,
|
||||
Building2Icon,
|
||||
ChevronDownIcon,
|
||||
CreditCardIcon,
|
||||
FoldersIcon,
|
||||
GlobeIcon,
|
||||
KeyIcon,
|
||||
LanguagesIcon,
|
||||
ListChecksIcon,
|
||||
Loader2,
|
||||
ShapesIcon,
|
||||
ShieldIcon,
|
||||
TagIcon,
|
||||
UnplugIcon,
|
||||
UserCircleIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface SettingsSidebarContentProps {
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
organizationId: string;
|
||||
organizationName: string;
|
||||
membershipRole?: TOrganizationRole;
|
||||
isFormbricksCloud: boolean;
|
||||
isCollapsed: boolean;
|
||||
isTextVisible: boolean;
|
||||
// Workspace switcher
|
||||
workspaces: { id: string; name: string }[];
|
||||
isLoadingWorkspaces: boolean;
|
||||
onWorkspaceChange: (id: string) => void;
|
||||
onWorkspaceDropdownOpen: () => void;
|
||||
// Organization switcher
|
||||
organizations: { id: string; name: string }[];
|
||||
isLoadingOrganizations: boolean;
|
||||
onOrganizationChange: (id: string) => void;
|
||||
onOrganizationDropdownOpen: () => void;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
hidden?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SettingsNavLink = ({
|
||||
item,
|
||||
isActive,
|
||||
isCollapsed,
|
||||
isTextVisible,
|
||||
disabledMessage,
|
||||
}: {
|
||||
item: NavItem;
|
||||
isActive: boolean;
|
||||
isCollapsed: boolean;
|
||||
isTextVisible: boolean;
|
||||
disabledMessage?: string;
|
||||
}) => {
|
||||
const activeClass = "bg-slate-50 border-r-4 border-brand-dark font-semibold text-slate-900";
|
||||
const inactiveClass =
|
||||
"hover:bg-slate-50 border-r-4 border-transparent hover:border-slate-300 transition-all duration-150 ease-in-out";
|
||||
const disabledClass = "cursor-not-allowed border-r-4 border-transparent text-slate-400";
|
||||
|
||||
const isDisabled = item.disabled;
|
||||
|
||||
const getStateClass = () => {
|
||||
if (isDisabled) return disabledClass;
|
||||
return isActive ? activeClass : inactiveClass;
|
||||
};
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<li className={cn("rounded-l-md py-1.5 pl-2 text-sm", getStateClass())}>
|
||||
{isDisabled ? (
|
||||
<div className="flex items-center">{item.icon}</div>
|
||||
) : (
|
||||
<Link href={item.href} className="flex items-center text-slate-600 hover:text-slate-900">
|
||||
{item.icon}
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{isDisabled ? disabledMessage || item.label : item.label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDisabled) {
|
||||
return (
|
||||
<li className={cn("rounded-l-md py-1.5 pl-8 text-sm", disabledClass)}>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
{item.icon}
|
||||
<span
|
||||
className={cn(
|
||||
"ml-2 transition-opacity duration-100",
|
||||
isTextVisible ? "opacity-0" : "opacity-100"
|
||||
)}>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
|
||||
{disabledMessage || item.label}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"rounded-l-md py-1.5 pl-8 text-sm",
|
||||
isActive ? activeClass : inactiveClass,
|
||||
"text-slate-600 hover:text-slate-900"
|
||||
)}>
|
||||
<Link href={item.href} className="flex items-center">
|
||||
{item.icon}
|
||||
<span
|
||||
className={cn("ml-2 transition-opacity duration-100", isTextVisible ? "opacity-0" : "opacity-100")}>
|
||||
{item.label}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const SectionHeader = ({
|
||||
icon,
|
||||
label,
|
||||
isCollapsed,
|
||||
isTextVisible,
|
||||
switcherName,
|
||||
switcherItems,
|
||||
isLoadingSwitcher,
|
||||
currentId,
|
||||
onSwitcherChange,
|
||||
onSwitcherOpen,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
isCollapsed: boolean;
|
||||
isTextVisible: boolean;
|
||||
switcherName?: string;
|
||||
switcherItems?: { id: string; name: string }[];
|
||||
isLoadingSwitcher?: boolean;
|
||||
currentId?: string;
|
||||
onSwitcherChange?: (id: string) => void;
|
||||
onSwitcherOpen?: () => void;
|
||||
}) => {
|
||||
if (isCollapsed) {
|
||||
return <div className="mb-1 mt-3 flex justify-center px-2 text-slate-400">{icon}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mb-1 mt-4 flex min-w-0 items-center gap-2 px-3",
|
||||
isTextVisible ? "opacity-0" : "opacity-100"
|
||||
)}>
|
||||
<span className="text-slate-500">{icon}</span>
|
||||
<span className="shrink-0 text-xs font-semibold uppercase tracking-wider text-slate-500">{label}</span>
|
||||
{switcherName && switcherItems && onSwitcherChange && (
|
||||
<DropdownMenu onOpenChange={(open) => open && onSwitcherOpen?.()}>
|
||||
<DropdownMenuTrigger className="ml-auto flex min-w-0 max-w-[50%] items-center gap-1 rounded-md border border-slate-200 px-2 py-0.5 text-xs text-slate-600 hover:bg-slate-50">
|
||||
<span className="truncate">{switcherName}</span>
|
||||
<ChevronDownIcon className="h-3 w-3" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="max-h-[300px]">
|
||||
{isLoadingSwitcher ? (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<DropdownMenuGroup className="overflow-y-auto">
|
||||
{switcherItems.map((item) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={item.id}
|
||||
checked={item.id === currentId}
|
||||
onClick={() => onSwitcherChange(item.id)}
|
||||
className="cursor-pointer text-sm">
|
||||
{item.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SettingsSidebarContent = ({
|
||||
workspaceId,
|
||||
workspaceName,
|
||||
organizationId,
|
||||
organizationName,
|
||||
membershipRole,
|
||||
isFormbricksCloud,
|
||||
isCollapsed,
|
||||
isTextVisible,
|
||||
workspaces,
|
||||
isLoadingWorkspaces,
|
||||
onWorkspaceChange,
|
||||
onWorkspaceDropdownOpen,
|
||||
organizations,
|
||||
isLoadingOrganizations,
|
||||
onOrganizationChange,
|
||||
onOrganizationDropdownOpen,
|
||||
}: SettingsSidebarContentProps) => {
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslation();
|
||||
const { isMember, isBilling, isOwner, isManager } = getAccessFlags(membershipRole);
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
const iconClassName = "h-4 w-4 shrink-0";
|
||||
|
||||
const basePath = `/workspaces/${workspaceId}/settings`;
|
||||
|
||||
const workspaceItems: NavItem[] = [
|
||||
{
|
||||
id: "general",
|
||||
label: t("common.general"),
|
||||
href: `${basePath}/workspace/general`,
|
||||
icon: <FoldersIcon className={iconClassName} />,
|
||||
disabled: isBilling,
|
||||
},
|
||||
{
|
||||
id: "teams",
|
||||
label: t("common.team_access"),
|
||||
href: `${basePath}/workspace/teams`,
|
||||
icon: <UsersIcon className={iconClassName} />,
|
||||
disabled: isBilling,
|
||||
},
|
||||
{
|
||||
id: "languages",
|
||||
label: t("common.survey_languages"),
|
||||
href: `${basePath}/workspace/languages`,
|
||||
icon: <LanguagesIcon className={iconClassName} />,
|
||||
disabled: isBilling,
|
||||
},
|
||||
{
|
||||
id: "app-connection",
|
||||
label: t("common.connect_your_app"),
|
||||
href: `${basePath}/workspace/app-connection`,
|
||||
icon: <UnplugIcon className={iconClassName} />,
|
||||
disabled: isBilling,
|
||||
},
|
||||
{
|
||||
id: "feedback-sources",
|
||||
label: t("workspace.unify.feedback_sources"),
|
||||
href: `${basePath}/workspace/feedback-sources`,
|
||||
icon: <ShapesIcon className={iconClassName} />,
|
||||
disabled: isBilling,
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
label: t("common.integrations"),
|
||||
href: `${basePath}/workspace/integrations`,
|
||||
icon: <BlocksIcon className={iconClassName} />,
|
||||
disabled: isBilling,
|
||||
},
|
||||
{
|
||||
id: "look",
|
||||
label: t("common.appearance"),
|
||||
href: `${basePath}/workspace/look`,
|
||||
icon: <BrushIcon className={iconClassName} />,
|
||||
disabled: isBilling,
|
||||
},
|
||||
{
|
||||
id: "user-actions",
|
||||
label: t("common.user_actions"),
|
||||
href: `${basePath}/workspace/user-actions`,
|
||||
icon: <ListChecksIcon className={iconClassName} />,
|
||||
disabled: isBilling,
|
||||
},
|
||||
{
|
||||
id: "tags",
|
||||
label: t("common.tags"),
|
||||
href: `${basePath}/workspace/tags`,
|
||||
icon: <TagIcon className={iconClassName} />,
|
||||
disabled: isBilling,
|
||||
},
|
||||
];
|
||||
|
||||
const organizationItems: NavItem[] = [
|
||||
{
|
||||
id: "org-general",
|
||||
label: t("common.general"),
|
||||
href: `${basePath}/organization/general`,
|
||||
icon: <Building2Icon className={iconClassName} />,
|
||||
disabled: isBilling,
|
||||
},
|
||||
{
|
||||
id: "org-teams",
|
||||
label: t("common.teams"),
|
||||
href: `${basePath}/organization/teams`,
|
||||
icon: <UsersIcon className={iconClassName} />,
|
||||
disabled: isBilling,
|
||||
},
|
||||
{
|
||||
id: "org-feedback-directories",
|
||||
label: t("workspace.settings.feedback_directories.nav_label"),
|
||||
href: `${basePath}/organization/feedback-directories`,
|
||||
icon: <FoldersIcon className={iconClassName} />,
|
||||
hidden: isMember,
|
||||
disabled: !isOwnerOrManager,
|
||||
},
|
||||
{
|
||||
id: "org-api-keys",
|
||||
label: t("common.api_keys"),
|
||||
href: `${basePath}/organization/api-keys`,
|
||||
icon: <KeyIcon className={iconClassName} />,
|
||||
hidden: !isOwnerOrManager,
|
||||
},
|
||||
{
|
||||
id: "org-domain",
|
||||
label: t("common.domain"),
|
||||
href: `${basePath}/organization/domain`,
|
||||
icon: <GlobeIcon className={iconClassName} />,
|
||||
hidden: isFormbricksCloud,
|
||||
},
|
||||
{
|
||||
id: "org-billing",
|
||||
label: t("common.billing"),
|
||||
href: `${basePath}/organization/billing`,
|
||||
icon: <CreditCardIcon className={iconClassName} />,
|
||||
hidden: !isFormbricksCloud,
|
||||
},
|
||||
{
|
||||
id: "org-enterprise",
|
||||
label: t("common.enterprise_license"),
|
||||
href: `${basePath}/organization/enterprise`,
|
||||
icon: <ShieldIcon className={iconClassName} />,
|
||||
hidden: isFormbricksCloud,
|
||||
disabled: isMember || isBilling,
|
||||
},
|
||||
];
|
||||
|
||||
const accountItems: NavItem[] = [
|
||||
{
|
||||
id: "profile",
|
||||
label: t("common.your_profile"),
|
||||
href: `${basePath}/account/profile`,
|
||||
icon: <UserCircleIcon className={iconClassName} />,
|
||||
disabled: isBilling,
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
label: t("common.notifications"),
|
||||
href: `${basePath}/account/notifications`,
|
||||
icon: <BellIcon className={iconClassName} />,
|
||||
disabled: isBilling,
|
||||
},
|
||||
];
|
||||
|
||||
const disabledMessage = t("common.you_are_not_authorized_to_perform_this_action");
|
||||
|
||||
const renderSection = (items: NavItem[]) => {
|
||||
const visibleItems = items.filter((item) => !item.hidden);
|
||||
return (
|
||||
<ul className="space-y-0.5">
|
||||
{visibleItems.map((item) => (
|
||||
<SettingsNavLink
|
||||
key={item.id}
|
||||
item={item}
|
||||
isActive={pathname.includes(item.href)}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
disabledMessage={item.disabled ? disabledMessage : undefined}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-y-auto">
|
||||
<div>
|
||||
<SectionHeader
|
||||
icon={<FoldersIcon className="h-4 w-4" />}
|
||||
label={t("common.workspace")}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
switcherName={workspaceName}
|
||||
switcherItems={workspaces}
|
||||
isLoadingSwitcher={isLoadingWorkspaces}
|
||||
currentId={workspaceId}
|
||||
onSwitcherChange={onWorkspaceChange}
|
||||
onSwitcherOpen={onWorkspaceDropdownOpen}
|
||||
/>
|
||||
{renderSection(workspaceItems)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SectionHeader
|
||||
icon={<Building2Icon className="h-4 w-4" />}
|
||||
label={t("common.organization")}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
switcherName={organizationName}
|
||||
switcherItems={organizations}
|
||||
isLoadingSwitcher={isLoadingOrganizations}
|
||||
currentId={organizationId}
|
||||
onSwitcherChange={onOrganizationChange}
|
||||
onSwitcherOpen={onOrganizationDropdownOpen}
|
||||
/>
|
||||
{renderSection(organizationItems)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SectionHeader
|
||||
icon={<UserCircleIcon className="h-4 w-4" />}
|
||||
label={t("common.account")}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
/>
|
||||
{renderSection(accountItems)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { WorkspaceAndOrgSwitch } from "@/app/(app)/workspaces/[workspaceId]/components/workspace-and-org-switch";
|
||||
import { useWorkspaceContext } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
|
||||
interface TopControlBarProps {
|
||||
currentOrganizationId: string;
|
||||
@@ -25,6 +26,7 @@ export const TopControlBar = ({
|
||||
isAccessControlAllowed,
|
||||
membershipRole,
|
||||
}: TopControlBarProps) => {
|
||||
const { isMember, isBilling } = getAccessFlags(membershipRole);
|
||||
const { workspace } = useWorkspaceContext();
|
||||
const isMembershipPending = membershipRole === undefined;
|
||||
|
||||
@@ -40,6 +42,8 @@ export const TopControlBar = ({
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isLicenseActive={isLicenseActive}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
isMember={isMember}
|
||||
isBilling={isBilling}
|
||||
isMembershipPending={isMembershipPending}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
/>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user