mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-22 19:39:01 -05:00
Compare commits
208 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5239dfd9b1 | |||
| d45cbefcff | |||
| f1c6180ae2 | |||
| a771ae189a | |||
| 029e069af6 | |||
| 71cca557fc | |||
| 1500b6f7f3 | |||
| 2c9fbf83e4 | |||
| 59cc9c564e | |||
| 20dc147682 | |||
| 2bb7a6f277 | |||
| 81272b96e1 | |||
| deb062dd03 | |||
| 474be86d33 | |||
| e7ca66ed77 | |||
| 2b49dbecd3 | |||
| 6da4c6f352 | |||
| 659b240fca | |||
| 19c0b1d14d | |||
| b4472f48e9 | |||
| d197271771 | |||
| 37f652c70e | |||
| 645f0ab0d1 | |||
| 389a7d9e7b | |||
| c4cf468c7e | |||
| cbc3e923e4 | |||
| a96ba8b1e7 | |||
| e830871361 | |||
| 998e5c0819 | |||
| 13a56b0237 | |||
| 0b5418a03a | |||
| 0d8a338965 | |||
| d3250736a9 | |||
| e6ee6a6b0d | |||
| c0b097f929 | |||
| 78d336f8c7 | |||
| 95a7a265b9 | |||
| 136e59da68 | |||
| eb0a87cf80 | |||
| 0dcb98ac29 | |||
| 540f7aaae7 | |||
| 2d4614a0bd | |||
| 633bf18204 | |||
| 9a6cbd05b6 | |||
| 94b0248075 | |||
| 082de1042d | |||
| 8c19587baa | |||
| 433750d3fe | |||
| 61befd5ffd | |||
| 1e7817fb69 | |||
| f250bc7e88 | |||
| c7faa29437 | |||
| a51a006c26 | |||
| ce96cb0b89 | |||
| fb265d9dba | |||
| e4c155b501 | |||
| 2dc5c50f4d | |||
| bddcec0466 | |||
| 92677e1ec0 | |||
| b12228e305 | |||
| 91be2af30b | |||
| 84c668be86 | |||
| 4015c76f2b | |||
| a7b2ade4a9 | |||
| 75f44952c7 | |||
| 0df5e26381 | |||
| 89bb3bcd84 | |||
| 30fdb72c09 | |||
| cb58cf5825 | |||
| 99bd2ba256 | |||
| 9df423073f | |||
| 3e3c696972 | |||
| cb41e2d344 | |||
| 1e19cca7d9 | |||
| fa882dd4cc | |||
| 0b82c6de77 | |||
| a944d7626e | |||
| d1b12dc228 | |||
| 9f7d6038b1 | |||
| 1da92addd2 | |||
| 1e4aa5f54b | |||
| 96f173c3b1 | |||
| 9c9e55fba6 | |||
| 42541f86fd | |||
| 0ba469a73d | |||
| afa192e5b9 | |||
| 4860a9a5cf | |||
| af02ce9ea6 | |||
| fc1c91896a | |||
| f5c7dbdc71 | |||
| b88ea5cc66 | |||
| f31085a9e7 | |||
| 2ab0441404 | |||
| 299ae81b21 | |||
| f73f13f16c | |||
| e9bcbf6e4c | |||
| 32eda35a71 | |||
| 84999cddfd | |||
| f0a0cf531a | |||
| f3e02fa466 | |||
| f0a93ae092 | |||
| 1c922dfe2c | |||
| 33010fb6f5 | |||
| d5fdacadd7 | |||
| d939263472 | |||
| e4aa66b067 | |||
| ffcc101ed9 | |||
| 2740cd16b9 | |||
| 7eb94f0bd5 | |||
| 6dd2e707fe | |||
| 58d5de7d45 | |||
| 7c3fa8b5ea | |||
| 2601169877 | |||
| aecf85815a | |||
| c6ebaea989 | |||
| 68c1422733 | |||
| 6942502baf | |||
| a4bd217761 | |||
| fee770358c | |||
| 44f8f80cac | |||
| 858a7f7aa9 | |||
| ac40b90e81 | |||
| aa21b4e442 | |||
| fa72296de5 | |||
| 3776b31794 | |||
| 5c7ea33fb0 | |||
| 33f60ce2be | |||
| c0386cea5a | |||
| 7cea53130c | |||
| 0636989d67 | |||
| 219883266c | |||
| 55fc2b2bc8 | |||
| 6e4ef9a099 | |||
| ebf7d1e3a1 | |||
| 998162bc48 | |||
| 4fadc54b4e | |||
| f4ac9a8292 | |||
| 7c8a7606b7 | |||
| 225217330b | |||
| 589c04a530 | |||
| aa538a3a51 | |||
| 817e108ff5 | |||
| 33542d0c54 | |||
| f37d22f13d | |||
| 202ae903ac | |||
| 6ab5cc367c | |||
| 21559045ba | |||
| d7c57a7a48 | |||
| 11b2ef4788 | |||
| 6fefd51cce | |||
| 65af826222 | |||
| 12eb54c653 | |||
| 5aa1427e64 | |||
| 08ac490512 | |||
| 4538c7bbcb | |||
| 7495c04048 | |||
| 85a1318f77 | |||
| 22ae0a731e | |||
| f7e8bc1630 | |||
| 36f091bc73 | |||
| 091b78d1e3 | |||
| 18a7b233f0 | |||
| b52627b3e9 | |||
| 73e8e2f899 | |||
| fb0ef2fa82 | |||
| 8ab8adc3d0 | |||
| fad55e3486 | |||
| a5c92bbc7b | |||
| 48eff5b547 | |||
| ff10ca7d6a | |||
| 04c2b030f1 | |||
| 256b223925 | |||
| f3ff4c9951 | |||
| 2a590ef315 | |||
| 07a6cd6c0e | |||
| 335da2f1f5 | |||
| 13b9db915b | |||
| 76b25476b3 | |||
| 04220902b4 | |||
| 4649a2de3e | |||
| 56ce05fb94 | |||
| 1b81e68106 | |||
| 202958cac2 | |||
| 8e901fb3c9 | |||
| 29afb3e4e9 | |||
| 38a3b31761 | |||
| 2bfb79d999 | |||
| 7971b9b312 | |||
| 1143f58ba5 | |||
| 47fe3c73dd | |||
| 727e586b16 | |||
| 4a9b4d52ca | |||
| cbb0166419 | |||
| 4b0c518683 | |||
| 5f05f8d36b | |||
| f7558a7497 | |||
| 009beba866 | |||
| c3ec5ddc3a | |||
| 9573ae19e6 | |||
| 7b3f841c5e | |||
| 8f7d225d6a | |||
| 094b6dedba | |||
| 36f0be07c4 | |||
| e079055a43 | |||
| 9ae9a3a9fc | |||
| b4606c0113 | |||
| 6be654ab60 | |||
| 95c2e24416 |
+18
-3
@@ -38,6 +38,15 @@ LOG_LEVEL=info
|
|||||||
|
|
||||||
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public'
|
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public'
|
||||||
|
|
||||||
|
#################
|
||||||
|
# HUB (DEV) #
|
||||||
|
#################
|
||||||
|
# The dev stack (pnpm db:up / pnpm go) runs Formbricks Hub on port 8080.
|
||||||
|
# Set explicitly to avoid confusion; override as needed when using docker-compose.dev.yml.
|
||||||
|
HUB_API_KEY=dev-api-key
|
||||||
|
HUB_API_URL=http://localhost:8080
|
||||||
|
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
|
||||||
|
|
||||||
################
|
################
|
||||||
# MAIL SETUP #
|
# MAIL SETUP #
|
||||||
################
|
################
|
||||||
@@ -150,6 +159,7 @@ NOTION_OAUTH_CLIENT_ID=
|
|||||||
NOTION_OAUTH_CLIENT_SECRET=
|
NOTION_OAUTH_CLIENT_SECRET=
|
||||||
|
|
||||||
# Stripe Billing Variables
|
# Stripe Billing Variables
|
||||||
|
STRIPE_PUBLISHABLE_KEY=
|
||||||
STRIPE_SECRET_KEY=
|
STRIPE_SECRET_KEY=
|
||||||
STRIPE_WEBHOOK_SECRET=
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
|
||||||
@@ -184,8 +194,13 @@ ENTERPRISE_LICENSE_KEY=
|
|||||||
# Ignore Rate Limiting across the Formbricks app
|
# Ignore Rate Limiting across the Formbricks app
|
||||||
# RATE_LIMITING_DISABLED=1
|
# RATE_LIMITING_DISABLED=1
|
||||||
|
|
||||||
# OpenTelemetry URL for tracing
|
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
|
||||||
# OPENTELEMETRY_LISTENER_URL=http://localhost:4318/v1/traces
|
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||||
|
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
||||||
|
# OTEL_SERVICE_NAME=formbricks
|
||||||
|
# OTEL_RESOURCE_ATTRIBUTES=deployment.environment=development
|
||||||
|
# OTEL_TRACES_SAMPLER=parentbased_traceidratio
|
||||||
|
# OTEL_TRACES_SAMPLER_ARG=1
|
||||||
|
|
||||||
# Unsplash API Key
|
# Unsplash API Key
|
||||||
UNSPLASH_ACCESS_KEY=
|
UNSPLASH_ACCESS_KEY=
|
||||||
@@ -225,4 +240,4 @@ REDIS_URL=redis://localhost:6379
|
|||||||
|
|
||||||
|
|
||||||
# Lingo.dev API key for translation generation
|
# Lingo.dev API key for translation generation
|
||||||
LINGODOTDEV_API_KEY=your_api_key_here
|
LINGO_API_KEY=your_api_key_here
|
||||||
|
|||||||
@@ -285,12 +285,14 @@ runs:
|
|||||||
encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }}
|
encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }}
|
||||||
redis_url=${{ env.DUMMY_REDIS_URL }}
|
redis_url=${{ env.DUMMY_REDIS_URL }}
|
||||||
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
|
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
|
||||||
|
posthog_key=${{ env.POSTHOG_KEY }}
|
||||||
env:
|
env:
|
||||||
DEPOT_PROJECT_TOKEN: ${{ env.DEPOT_PROJECT_TOKEN }}
|
DEPOT_PROJECT_TOKEN: ${{ env.DEPOT_PROJECT_TOKEN }}
|
||||||
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
|
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
|
||||||
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
|
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
|
||||||
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
|
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
|
||||||
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
|
||||||
|
POSTHOG_KEY: ${{ env.POSTHOG_KEY }}
|
||||||
|
|
||||||
- name: Sign GHCR image (GHCR only)
|
- name: Sign GHCR image (GHCR only)
|
||||||
if: ${{ inputs.registry_type == 'ghcr' && (github.event_name == 'workflow_call' || github.event_name == 'release' || github.event_name == 'workflow_dispatch') }}
|
if: ${{ inputs.registry_type == 'ghcr' && (github.event_name == 'workflow_call' || github.event_name == 'release' || github.event_name == 'workflow_dispatch') }}
|
||||||
|
|||||||
@@ -92,3 +92,4 @@ jobs:
|
|||||||
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||||
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
|
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
echo "Updating Chart.yaml with version: ${VERSION}"
|
echo "Updating Chart.yaml with version: ${VERSION}"
|
||||||
yq -i ".version = \"${VERSION}\"" helm-chart/Chart.yaml
|
yq -i ".version = \"${VERSION}\"" charts/formbricks/Chart.yaml
|
||||||
yq -i ".appVersion = \"${VERSION}\"" helm-chart/Chart.yaml
|
yq -i ".appVersion = \"${VERSION}\"" charts/formbricks/Chart.yaml
|
||||||
|
|
||||||
echo "✅ Successfully updated Chart.yaml"
|
echo "✅ Successfully updated Chart.yaml"
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
echo "Packaging Helm chart version: ${VERSION}"
|
echo "Packaging Helm chart version: ${VERSION}"
|
||||||
helm package ./helm-chart
|
helm package ./charts/formbricks
|
||||||
|
|
||||||
echo "✅ Successfully packaged formbricks-${VERSION}.tgz"
|
echo "✅ Successfully packaged formbricks-${VERSION}.tgz"
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ on:
|
|||||||
merge_group:
|
merge_group:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
jobs:
|
jobs:
|
||||||
sonarqube:
|
sonarqube:
|
||||||
name: SonarQube
|
name: SonarQube
|
||||||
@@ -50,6 +51,9 @@ jobs:
|
|||||||
pnpm test:coverage
|
pnpm test:coverage
|
||||||
- name: SonarQube Scan
|
- name: SonarQube Scan
|
||||||
uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf
|
uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf
|
||||||
|
with:
|
||||||
|
args: >
|
||||||
|
-Dsonar.verbose=true
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
|
||||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
|
|||||||
@@ -6,19 +6,9 @@ permissions:
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [opened, synchronize, reopened]
|
types: [opened, synchronize, reopened]
|
||||||
paths:
|
|
||||||
- "apps/web/**/*.ts"
|
|
||||||
- "apps/web/**/*.tsx"
|
|
||||||
- "apps/web/locales/**/*.json"
|
|
||||||
- "scan-translations.ts"
|
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
|
||||||
- "apps/web/**/*.ts"
|
|
||||||
- "apps/web/**/*.tsx"
|
|
||||||
- "apps/web/locales/**/*.json"
|
|
||||||
- "scan-translations.ts"
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
validate-translations:
|
validate-translations:
|
||||||
@@ -32,32 +22,39 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
- name: Checkout repository
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Check for relevant changes
|
||||||
uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
id: changes
|
||||||
|
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
filters: |
|
||||||
|
translations:
|
||||||
|
- 'apps/web/**/*.ts'
|
||||||
|
- 'apps/web/**/*.tsx'
|
||||||
|
- 'apps/web/locales/**/*.json'
|
||||||
|
- 'packages/surveys/src/**/*.{ts,tsx}'
|
||||||
|
- 'packages/surveys/locales/**/*.json'
|
||||||
|
- 'packages/email/**/*.{ts,tsx}'
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup Node.js 22.x
|
||||||
uses: pnpm/action-setup@a3252b78c470c02df07e9d59298aecedc3ccdd6d # v3.0.0
|
if: steps.changes.outputs.translations == 'true'
|
||||||
|
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||||
with:
|
with:
|
||||||
version: 9.15.9
|
node-version: 22.x
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
if: steps.changes.outputs.translations == 'true'
|
||||||
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
if: steps.changes.outputs.translations == 'true'
|
||||||
|
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||||
|
|
||||||
- name: Validate translation keys
|
- name: Validate translation keys
|
||||||
run: |
|
if: steps.changes.outputs.translations == 'true'
|
||||||
echo ""
|
run: pnpm run scan-translations
|
||||||
echo "🔍 Validating translation keys..."
|
|
||||||
echo ""
|
|
||||||
pnpm run scan-translations
|
|
||||||
|
|
||||||
- name: Summary
|
- name: Skip (no translation-related changes)
|
||||||
if: success()
|
if: steps.changes.outputs.translations != 'true'
|
||||||
run: |
|
run: echo "No translation-related files changed — skipping validation."
|
||||||
echo ""
|
|
||||||
echo "✅ Translation validation completed successfully!"
|
|
||||||
echo ""
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
**/.next/
|
**/.next/
|
||||||
**/out/
|
**/out/
|
||||||
**/build
|
**/build
|
||||||
|
**/next-env.d.ts
|
||||||
|
|
||||||
# node
|
# node
|
||||||
**/dist/
|
**/dist/
|
||||||
@@ -63,3 +64,5 @@ packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdat
|
|||||||
.cursorrules
|
.cursorrules
|
||||||
i18n.cache
|
i18n.cache
|
||||||
stats.html
|
stats.html
|
||||||
|
# next-agents-md
|
||||||
|
.next-docs/
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ./branch.json
|
|
||||||
prettier --write ./branch.json
|
|
||||||
+1
-40
@@ -1,40 +1 @@
|
|||||||
# Load environment variables from .env files
|
pnpm lint-staged
|
||||||
if [ -f .env ]; then
|
|
||||||
set -a
|
|
||||||
. .env
|
|
||||||
set +a
|
|
||||||
fi
|
|
||||||
|
|
||||||
pnpm lint-staged
|
|
||||||
|
|
||||||
# Run Lingo.dev i18n workflow if LINGODOTDEV_API_KEY is set
|
|
||||||
if [ -n "$LINGODOTDEV_API_KEY" ]; then
|
|
||||||
echo ""
|
|
||||||
echo "🌍 Running Lingo.dev translation workflow..."
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Run translation generation and validation
|
|
||||||
if pnpm run i18n; then
|
|
||||||
echo ""
|
|
||||||
echo "✅ Translation validation passed"
|
|
||||||
echo ""
|
|
||||||
# Add updated locale files to git
|
|
||||||
git add apps/web/locales/*.json
|
|
||||||
else
|
|
||||||
echo ""
|
|
||||||
echo "❌ Translation validation failed!"
|
|
||||||
echo ""
|
|
||||||
echo "Please fix the translation issues above before committing:"
|
|
||||||
echo " • Add missing translation keys to your locale files"
|
|
||||||
echo " • Remove unused translation keys"
|
|
||||||
echo ""
|
|
||||||
echo "Or run 'pnpm i18n' to see the detailed report"
|
|
||||||
echo ""
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo ""
|
|
||||||
echo "⚠️ Skipping translation validation: LINGODOTDEV_API_KEY is not set"
|
|
||||||
echo " (This is expected for community contributors)"
|
|
||||||
echo ""
|
|
||||||
fi
|
|
||||||
+12
-17
@@ -10,25 +10,20 @@
|
|||||||
"build-storybook": "storybook build",
|
"build-storybook": "storybook build",
|
||||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
|
||||||
"@formbricks/survey-ui": "workspace:*"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chromatic-com/storybook": "^5.0.0",
|
"@chromatic-com/storybook": "^5.0.1",
|
||||||
"@storybook/addon-a11y": "10.1.11",
|
"@storybook/addon-a11y": "10.2.17",
|
||||||
"@storybook/addon-links": "10.1.11",
|
"@storybook/addon-links": "10.2.17",
|
||||||
"@storybook/addon-onboarding": "10.1.11",
|
"@storybook/addon-onboarding": "10.2.17",
|
||||||
"@storybook/react-vite": "10.1.11",
|
"@storybook/react-vite": "10.2.17",
|
||||||
"@typescript-eslint/eslint-plugin": "8.53.0",
|
"@typescript-eslint/eslint-plugin": "8.57.0",
|
||||||
"@tailwindcss/vite": "4.1.18",
|
"@tailwindcss/vite": "4.2.1",
|
||||||
"@typescript-eslint/parser": "8.53.0",
|
"@typescript-eslint/parser": "8.57.0",
|
||||||
"@vitejs/plugin-react": "5.1.2",
|
"@vitejs/plugin-react": "5.1.4",
|
||||||
"esbuild": "0.27.2",
|
|
||||||
"eslint-plugin-react-refresh": "0.4.26",
|
"eslint-plugin-react-refresh": "0.4.26",
|
||||||
"eslint-plugin-storybook": "10.1.11",
|
"eslint-plugin-storybook": "10.2.17",
|
||||||
"prop-types": "15.8.1",
|
"storybook": "10.2.17",
|
||||||
"storybook": "10.1.11",
|
|
||||||
"vite": "7.3.1",
|
"vite": "7.3.1",
|
||||||
"@storybook/addon-docs": "10.1.11"
|
"@storybook/addon-docs": "10.2.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,4 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
extends: ["@formbricks/eslint-config/legacy-next.js"],
|
extends: ["@formbricks/eslint-config/legacy-next.js"],
|
||||||
ignorePatterns: ["**/package.json", "**/tsconfig.json"],
|
ignorePatterns: ["**/package.json", "**/tsconfig.json"],
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
files: ["locales/*.json"],
|
|
||||||
plugins: ["i18n-json"],
|
|
||||||
rules: {
|
|
||||||
"i18n-json/identical-keys": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
filePath: require("path").join(__dirname, "locales", "en-US.json"),
|
|
||||||
checkExtraKeys: false,
|
|
||||||
checkMissingKeys: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
const baseConfig = require("../../.prettierrc.js");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
...baseConfig,
|
||||||
|
tailwindConfig: "./tailwind.config.js",
|
||||||
|
};
|
||||||
+38
-22
@@ -1,4 +1,4 @@
|
|||||||
FROM node:22-alpine3.22 AS base
|
FROM node:24-alpine3.23 AS base
|
||||||
|
|
||||||
#
|
#
|
||||||
## step 1: Prune monorepo
|
## step 1: Prune monorepo
|
||||||
@@ -18,9 +18,9 @@ FROM node:22-alpine3.22 AS base
|
|||||||
FROM base AS installer
|
FROM base AS installer
|
||||||
|
|
||||||
# Enable corepack and prepare pnpm
|
# Enable corepack and prepare pnpm
|
||||||
RUN npm install --ignore-scripts -g corepack@latest
|
RUN npm install --ignore-scripts -g corepack@latest
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
RUN corepack prepare pnpm@9.15.9 --activate
|
RUN corepack prepare pnpm@10.28.2 --activate
|
||||||
|
|
||||||
# Install necessary build tools and compilers
|
# Install necessary build tools and compilers
|
||||||
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
|
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
|
||||||
@@ -67,22 +67,18 @@ RUN --mount=type=secret,id=database_url \
|
|||||||
--mount=type=secret,id=encryption_key \
|
--mount=type=secret,id=encryption_key \
|
||||||
--mount=type=secret,id=redis_url \
|
--mount=type=secret,id=redis_url \
|
||||||
--mount=type=secret,id=sentry_auth_token \
|
--mount=type=secret,id=sentry_auth_token \
|
||||||
|
--mount=type=secret,id=posthog_key \
|
||||||
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
|
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
|
||||||
|
|
||||||
# Extract Prisma version
|
|
||||||
RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt
|
|
||||||
|
|
||||||
#
|
#
|
||||||
## step 3: setup production runner
|
## step 3: setup production runner
|
||||||
#
|
#
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|
||||||
RUN npm install --ignore-scripts -g corepack@latest && \
|
# Upgrade Alpine system packages to pick up security patches, update npm to latest, then create user
|
||||||
corepack enable
|
# Note: npm's bundled tar has a known vulnerability but npm is only used during build, not at runtime
|
||||||
|
RUN apk update && apk upgrade --no-cache \
|
||||||
RUN apk add --no-cache curl \
|
&& npm install --ignore-scripts -g npm@latest \
|
||||||
&& apk add --no-cache supercronic \
|
|
||||||
# && addgroup --system --gid 1001 nodejs \
|
|
||||||
&& addgroup -S nextjs \
|
&& addgroup -S nextjs \
|
||||||
&& adduser -S -u 1001 -G nextjs nextjs
|
&& adduser -S -u 1001 -G nextjs nextjs
|
||||||
|
|
||||||
@@ -107,26 +103,30 @@ RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
|
|||||||
# Create packages/database directory structure with proper ownership for runtime migrations
|
# Create packages/database directory structure with proper ownership for runtime migrations
|
||||||
RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages/database
|
RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages/database
|
||||||
|
|
||||||
|
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
|
||||||
|
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
|
||||||
|
|
||||||
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
|
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
|
||||||
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
|
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
|
||||||
|
|
||||||
COPY --from=installer /app/packages/database/dist ./packages/database/dist
|
COPY --from=installer /app/packages/database/dist ./packages/database/dist
|
||||||
RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist
|
RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist
|
||||||
|
|
||||||
|
# Copy prisma client packages
|
||||||
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
||||||
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
|
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
|
||||||
|
|
||||||
COPY --from=installer /app/node_modules/.prisma ./node_modules/.prisma
|
COPY --from=installer /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules/.prisma
|
RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules/.prisma
|
||||||
|
|
||||||
COPY --from=installer /prisma_version.txt .
|
|
||||||
RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
|
|
||||||
|
|
||||||
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
|
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
|
||||||
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
|
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
|
||||||
|
|
||||||
COPY --from=installer /app/node_modules/uuid ./node_modules/uuid
|
# Runtime migrations import uuid v7 from the database package, so copy the
|
||||||
RUN chmod -R 755 ./node_modules/uuid
|
# database package's resolved install instead of the repo-root hoisted version.
|
||||||
|
COPY --from=installer /app/packages/database/node_modules/uuid ./node_modules/uuid
|
||||||
|
RUN chmod -R 755 ./node_modules/uuid \
|
||||||
|
&& node --input-type=module -e "import('uuid').then((module) => { if (typeof module.v7 !== 'function') throw new Error('uuid v7 missing in runtime image'); })"
|
||||||
|
|
||||||
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
|
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
|
||||||
RUN chmod -R 755 ./node_modules/@noble/hashes
|
RUN chmod -R 755 ./node_modules/@noble/hashes
|
||||||
@@ -134,7 +134,25 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
|
|||||||
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
||||||
RUN chmod -R 755 ./node_modules/zod
|
RUN chmod -R 755 ./node_modules/zod
|
||||||
|
|
||||||
RUN npm install -g prisma@6
|
# Pino loads transport code in worker threads via dynamic require().
|
||||||
|
# Next.js file tracing only traces static imports, missing runtime-loaded files
|
||||||
|
# (e.g. pino/lib/transport-stream.js, transport targets).
|
||||||
|
# Copy the full packages to ensure all runtime files are available.
|
||||||
|
COPY --from=installer /app/node_modules/pino ./node_modules/pino
|
||||||
|
RUN chmod -R 755 ./node_modules/pino
|
||||||
|
|
||||||
|
COPY --from=installer /app/node_modules/pino-opentelemetry-transport ./node_modules/pino-opentelemetry-transport
|
||||||
|
RUN chmod -R 755 ./node_modules/pino-opentelemetry-transport
|
||||||
|
|
||||||
|
COPY --from=installer /app/node_modules/pino-abstract-transport ./node_modules/pino-abstract-transport
|
||||||
|
RUN chmod -R 755 ./node_modules/pino-abstract-transport
|
||||||
|
|
||||||
|
COPY --from=installer /app/node_modules/otlp-logger ./node_modules/otlp-logger
|
||||||
|
RUN chmod -R 755 ./node_modules/otlp-logger
|
||||||
|
|
||||||
|
# Install prisma CLI globally for database migrations and fix permissions for nextjs user
|
||||||
|
RUN npm install --ignore-scripts -g prisma@6 \
|
||||||
|
&& chown -R nextjs:nextjs /usr/local/lib/node_modules/prisma
|
||||||
|
|
||||||
# Create a startup script to handle the conditional logic
|
# Create a startup script to handle the conditional logic
|
||||||
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
|
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
|
||||||
@@ -144,13 +162,11 @@ EXPOSE 3000
|
|||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
# Prepare pnpm as the nextjs user to ensure it's available at runtime
|
|
||||||
# Prepare volumes for uploads and SAML connections
|
# Prepare volumes for uploads and SAML connections
|
||||||
RUN corepack prepare pnpm@9.15.9 --activate && \
|
RUN mkdir -p /home/nextjs/apps/web/uploads/ && \
|
||||||
mkdir -p /home/nextjs/apps/web/uploads/ && \
|
|
||||||
mkdir -p /home/nextjs/apps/web/saml-connection
|
mkdir -p /home/nextjs/apps/web/saml-connection
|
||||||
|
|
||||||
VOLUME /home/nextjs/apps/web/uploads/
|
VOLUME /home/nextjs/apps/web/uploads/
|
||||||
VOLUME /home/nextjs/apps/web/saml-connection
|
VOLUME /home/nextjs/apps/web/saml-connection
|
||||||
|
|
||||||
CMD ["/home/nextjs/start.sh"]
|
CMD ["/home/nextjs/start.sh"]
|
||||||
|
|||||||
+3
-3
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TProjectConfigChannel } from "@formbricks/types/project";
|
import { TWorkspaceConfigChannel } from "@formbricks/types/workspace";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
|
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
|
||||||
@@ -14,7 +14,7 @@ interface ConnectWithFormbricksProps {
|
|||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
publicDomain: string;
|
publicDomain: string;
|
||||||
appSetupCompleted: boolean;
|
appSetupCompleted: boolean;
|
||||||
channel: TProjectConfigChannel;
|
channel: TWorkspaceConfigChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConnectWithFormbricks = ({
|
export const ConnectWithFormbricks = ({
|
||||||
@@ -69,7 +69,7 @@ export const ConnectWithFormbricks = ({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex animate-pulse flex-col items-center space-y-4">
|
<div className="flex animate-pulse flex-col items-center space-y-4">
|
||||||
<span className="relative flex h-10 w-10">
|
<span className="relative flex h-10 w-10">
|
||||||
<span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-slate-400 opacity-75"></span>
|
<span className="absolute inline-flex h-full w-full animate-ping-slow rounded-full bg-slate-400 opacity-75"></span>
|
||||||
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
|
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
|
||||||
</span>
|
</span>
|
||||||
<p className="pt-4 text-sm font-medium text-slate-600">
|
<p className="pt-4 text-sm font-medium text-slate-600">
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@ import "prismjs/themes/prism.css";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TProjectConfigChannel } from "@formbricks/types/project";
|
import { TWorkspaceConfigChannel } from "@formbricks/types/workspace";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { CodeBlock } from "@/modules/ui/components/code-block";
|
import { CodeBlock } from "@/modules/ui/components/code-block";
|
||||||
import { Html5Icon, NpmIcon } from "@/modules/ui/components/icons";
|
import { Html5Icon, NpmIcon } from "@/modules/ui/components/icons";
|
||||||
@@ -19,7 +19,7 @@ const tabs = [
|
|||||||
interface OnboardingSetupInstructionsProps {
|
interface OnboardingSetupInstructionsProps {
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
publicDomain: string;
|
publicDomain: string;
|
||||||
channel: TProjectConfigChannel;
|
channel: TWorkspaceConfigChannel;
|
||||||
appSetupCompleted: boolean;
|
appSetupCompleted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
||||||
import { getEnvironment } from "@/lib/environment/service";
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Header } from "@/modules/ui/components/header";
|
import { Header } from "@/modules/ui/components/header";
|
||||||
@@ -20,15 +21,15 @@ const Page = async (props: ConnectPageProps) => {
|
|||||||
const environment = await getEnvironment(params.environmentId);
|
const environment = await getEnvironment(params.environmentId);
|
||||||
|
|
||||||
if (!environment) {
|
if (!environment) {
|
||||||
throw new Error(t("common.environment_not_found"));
|
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const project = await getProjectByEnvironmentId(environment.id);
|
const workspace = await getWorkspaceByEnvironmentId(environment.id);
|
||||||
if (!project) {
|
if (!workspace) {
|
||||||
throw new Error(t("common.workspace_not_found"));
|
throw new Error(t("common.workspace_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = project.config.channel || null;
|
const channel = workspace.config.channel || null;
|
||||||
|
|
||||||
const publicDomain = getPublicDomain();
|
const publicDomain = getPublicDomain();
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ const Page = async (props: ConnectPageProps) => {
|
|||||||
channel={channel}
|
channel={channel}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={`/environments/${environment.id}`}>
|
<Link href={`/environments/${environment.id}`}>
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { AuthorizationError } from "@formbricks/types/errors";
|
|||||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
|
|
||||||
const OnboardingLayout = async (props) => {
|
const OnboardingLayout = async (props: {
|
||||||
|
params: Promise<{ environmentId: string }>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
|
|||||||
+4
-4
@@ -5,10 +5,10 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TProject } from "@formbricks/types/project";
|
|
||||||
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
|
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
|
||||||
import { TXMTemplate } from "@formbricks/types/templates";
|
import { TXMTemplate } from "@formbricks/types/templates";
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
|
import { TWorkspace } from "@formbricks/types/workspace";
|
||||||
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils";
|
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils";
|
||||||
import { getXMTemplates } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates";
|
import { getXMTemplates } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates";
|
||||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||||
@@ -16,12 +16,12 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
|||||||
import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
|
import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
|
||||||
|
|
||||||
interface XMTemplateListProps {
|
interface XMTemplateListProps {
|
||||||
project: TProject;
|
workspace: TWorkspace;
|
||||||
user: TUser;
|
user: TUser;
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListProps) => {
|
export const XMTemplateList = ({ workspace, user, environmentId }: XMTemplateListProps) => {
|
||||||
const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null);
|
const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -48,7 +48,7 @@ export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListP
|
|||||||
const handleTemplateClick = (templateIdx: number) => {
|
const handleTemplateClick = (templateIdx: number) => {
|
||||||
setActiveTemplateId(templateIdx);
|
setActiveTemplateId(templateIdx);
|
||||||
const template = getXMTemplates(t)[templateIdx];
|
const template = getXMTemplates(t)[templateIdx];
|
||||||
const newTemplate = replacePresetPlaceholders(template, project);
|
const newTemplate = replacePresetPlaceholders(template, workspace);
|
||||||
createSurvey(newTemplate);
|
createSurvey(newTemplate);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+17
-16
@@ -1,16 +1,17 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
import { cleanup } from "@testing-library/react";
|
import { cleanup } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, test } from "vitest";
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
import { TProject } from "@formbricks/types/project";
|
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
|
||||||
import { TXMTemplate } from "@formbricks/types/templates";
|
import { TXMTemplate } from "@formbricks/types/templates";
|
||||||
|
import { TWorkspace } from "@formbricks/types/workspace";
|
||||||
import { replacePresetPlaceholders } from "./utils";
|
import { replacePresetPlaceholders } from "./utils";
|
||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
const mockProject: TProject = {
|
const mockWorkspace: TWorkspace = {
|
||||||
id: "project1",
|
id: "workspace1",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
name: "Test Project",
|
name: "Test Workspace",
|
||||||
organizationId: "org1",
|
organizationId: "org1",
|
||||||
styling: {
|
styling: {
|
||||||
allowStyleOverwrite: true,
|
allowStyleOverwrite: true,
|
||||||
@@ -25,13 +26,13 @@ const mockProject: TProject = {
|
|||||||
},
|
},
|
||||||
placement: "bottomRight",
|
placement: "bottomRight",
|
||||||
clickOutsideClose: true,
|
clickOutsideClose: true,
|
||||||
darkOverlay: false,
|
overlay: "none",
|
||||||
environments: [],
|
environments: [],
|
||||||
languages: [],
|
languages: [],
|
||||||
logo: null,
|
logo: null,
|
||||||
};
|
};
|
||||||
const mockTemplate: TXMTemplate = {
|
const mockTemplate: TXMTemplate = {
|
||||||
name: "$[projectName] Survey",
|
name: "$[workspaceName] Survey",
|
||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
id: "block1",
|
id: "block1",
|
||||||
@@ -39,13 +40,13 @@ const mockTemplate: TXMTemplate = {
|
|||||||
elements: [
|
elements: [
|
||||||
{
|
{
|
||||||
id: "q1",
|
id: "q1",
|
||||||
type: "openText" as const,
|
type: "openText" as TSurveyElementTypeEnum.OpenText,
|
||||||
inputType: "text" as const,
|
inputType: "text" as const,
|
||||||
headline: { default: "$[projectName] Question" },
|
headline: { default: "$[workspaceName] Question" },
|
||||||
subheader: { default: "" },
|
subheader: { default: "" },
|
||||||
required: false,
|
required: false,
|
||||||
placeholder: { default: "" },
|
placeholder: { default: "" },
|
||||||
charLimit: 1000,
|
charLimit: { enabled: true, max: 1000 },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -69,19 +70,19 @@ describe("replacePresetPlaceholders", () => {
|
|||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("replaces projectName placeholder in template name", () => {
|
test("replaces workspaceName placeholder in template name", () => {
|
||||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
|
||||||
expect(result.name).toBe("Test Project Survey");
|
expect(result.name).toBe("Test Workspace Survey");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("replaces projectName placeholder in element headline", () => {
|
test("replaces workspaceName placeholder in element headline", () => {
|
||||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
|
||||||
expect(result.blocks[0].elements[0].headline.default).toBe("Test Project Question");
|
expect(result.blocks[0].elements[0].headline.default).toBe("Test Workspace Question");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns a new object without mutating the original template", () => {
|
test("returns a new object without mutating the original template", () => {
|
||||||
const originalTemplate = structuredClone(mockTemplate);
|
const originalTemplate = structuredClone(mockTemplate);
|
||||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
|
||||||
expect(result).not.toBe(mockTemplate);
|
expect(result).not.toBe(mockTemplate);
|
||||||
expect(mockTemplate).toEqual(originalTemplate);
|
expect(mockTemplate).toEqual(originalTemplate);
|
||||||
});
|
});
|
||||||
|
|||||||
+5
-5
@@ -1,16 +1,16 @@
|
|||||||
import { TProject } from "@formbricks/types/project";
|
|
||||||
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
|
||||||
import { TXMTemplate } from "@formbricks/types/templates";
|
import { TXMTemplate } from "@formbricks/types/templates";
|
||||||
|
import { TWorkspace } from "@formbricks/types/workspace";
|
||||||
import { replaceElementPresetPlaceholders } from "@/lib/utils/templates";
|
import { replaceElementPresetPlaceholders } from "@/lib/utils/templates";
|
||||||
|
|
||||||
// replace all occurences of projectName with the actual project name in the current template
|
// replace all occurences of workspaceName with the actual workspace name in the current template
|
||||||
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject): TXMTemplate => {
|
export const replacePresetPlaceholders = (template: TXMTemplate, workspace: TWorkspace): TXMTemplate => {
|
||||||
const survey = structuredClone(template);
|
const survey = structuredClone(template);
|
||||||
|
|
||||||
const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({
|
const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({
|
||||||
...block,
|
...block,
|
||||||
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, project)),
|
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, workspace)),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { ...survey, name: survey.name.replace("$[projectName]", project.name), blocks: modifiedBlocks };
|
return { ...survey, name: survey.name.replace("$[workspaceName]", workspace.name), blocks: modifiedBlocks };
|
||||||
};
|
};
|
||||||
|
|||||||
+3
-3
@@ -14,7 +14,7 @@ describe("xm-templates", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("getXMSurveyDefault returns default survey template", () => {
|
test("getXMSurveyDefault returns default survey template", () => {
|
||||||
const tMock = vi.fn((key) => key) as TFunction;
|
const tMock = vi.fn((key: string) => key) as unknown as TFunction;
|
||||||
const result = getXMSurveyDefault(tMock);
|
const result = getXMSurveyDefault(tMock);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
@@ -29,7 +29,7 @@ describe("xm-templates", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("getXMTemplates returns all templates", () => {
|
test("getXMTemplates returns all templates", () => {
|
||||||
const tMock = vi.fn((key) => key) as TFunction;
|
const tMock = vi.fn((key: string) => key) as unknown as TFunction;
|
||||||
const result = getXMTemplates(tMock);
|
const result = getXMTemplates(tMock);
|
||||||
|
|
||||||
expect(result).toHaveLength(6);
|
expect(result).toHaveLength(6);
|
||||||
@@ -44,7 +44,7 @@ describe("xm-templates", () => {
|
|||||||
test("getXMTemplates handles errors gracefully", async () => {
|
test("getXMTemplates handles errors gracefully", async () => {
|
||||||
const tMock = vi.fn(() => {
|
const tMock = vi.fn(() => {
|
||||||
throw new Error("Test error");
|
throw new Error("Test error");
|
||||||
}) as TFunction;
|
}) as unknown as TFunction;
|
||||||
|
|
||||||
const result = getXMTemplates(tMock);
|
const result = getXMTemplates(tMock);
|
||||||
|
|
||||||
|
|||||||
+11
-10
@@ -1,11 +1,12 @@
|
|||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
|
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
|
||||||
import { getEnvironment } from "@/lib/environment/service";
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
|
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||||
|
import { getUserWorkspaces, getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
@@ -23,33 +24,33 @@ const Page = async (props: XMTemplatePageProps) => {
|
|||||||
const environment = await getEnvironment(params.environmentId);
|
const environment = await getEnvironment(params.environmentId);
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error(t("common.session_not_found"));
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await getUser(session.user.id);
|
const user = await getUser(session.user.id);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(t("common.user_not_found"));
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
}
|
}
|
||||||
if (!environment) {
|
if (!environment) {
|
||||||
throw new Error(t("common.environment_not_found"));
|
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||||
|
|
||||||
const project = await getProjectByEnvironmentId(environment.id);
|
const workspace = await getWorkspaceByEnvironmentId(environment.id);
|
||||||
if (!project) {
|
if (!workspace) {
|
||||||
throw new Error(t("common.workspace_not_found"));
|
throw new Error(t("common.workspace_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const projects = await getUserProjects(session.user.id, organizationId);
|
const workspaces = await getUserWorkspaces(session.user.id, organizationId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||||
<Header title={t("environments.xm-templates.headline")} />
|
<Header title={t("environments.xm-templates.headline")} />
|
||||||
<XMTemplateList project={project} user={user} environmentId={environment.id} />
|
<XMTemplateList workspace={workspace} user={user} environmentId={environment.id} />
|
||||||
{projects.length >= 2 && (
|
{workspaces.length >= 2 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={`/environments/${environment.id}/surveys`}>
|
<Link href={`/environments/${environment.id}/surveys`}>
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ describe("getTeamsByOrganizationId", () => {
|
|||||||
|
|
||||||
test("returns mapped teams", async () => {
|
test("returns mapped teams", async () => {
|
||||||
const mockTeams = [
|
const mockTeams = [
|
||||||
{ id: "t1", name: "Team 1" },
|
{ id: "t1", name: "Team 1", createdAt: new Date(), updatedAt: new Date(), organizationId: "org1" },
|
||||||
{ id: "t2", name: "Team 2" },
|
{ id: "t2", name: "Team 2", createdAt: new Date(), updatedAt: new Date(), organizationId: "org1" },
|
||||||
];
|
];
|
||||||
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
|
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
|
||||||
const result = await getTeamsByOrganizationId("org1");
|
const result = await getTeamsByOrganizationId("org1");
|
||||||
|
|||||||
@@ -22,12 +22,10 @@ export const getTeamsByOrganizationId = reactCache(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const projectTeams = teams.map((team) => ({
|
return teams.map((team: TOrganizationTeam) => ({
|
||||||
id: team.id,
|
id: team.id,
|
||||||
name: team.name,
|
name: team.name,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return projectTeams;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
throw new DatabaseError(error.message);
|
throw new DatabaseError(error.message);
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
|||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-sidebar-collapsed z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
|
"z-40 flex w-sidebar-collapsed flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
|
||||||
)}>
|
)}>
|
||||||
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
|
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import { getServerSession } from "next-auth";
|
|||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { getEnvironments } from "@/lib/environment/service";
|
import { getEnvironments } from "@/lib/environment/service";
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
import { getUserWorkspaces } from "@/lib/workspace/service";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
|
|
||||||
const LandingLayout = async (props) => {
|
const LandingLayout = async (props: {
|
||||||
|
params: Promise<{ organizationId: string }>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
@@ -21,11 +24,11 @@ const LandingLayout = async (props) => {
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const projects = await getUserProjects(session.user.id, params.organizationId);
|
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
|
||||||
|
|
||||||
if (projects.length !== 0) {
|
if (workspaces.length !== 0) {
|
||||||
const firstProject = projects[0];
|
const firstWorkspace = workspaces[0];
|
||||||
const environments = await getEnvironments(firstProject.id);
|
const environments = await getEnvironments(firstWorkspace.id);
|
||||||
const prodEnvironment = environments.find((e) => e.type === "production");
|
const prodEnvironment = environments.find((e) => e.type === "production");
|
||||||
|
|
||||||
if (prodEnvironment) {
|
if (prodEnvironment) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
|
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
|
||||||
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
|
import { WorkspaceAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/workspace-and-org-switch";
|
||||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
@@ -10,7 +10,7 @@ import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
|||||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
import { Header } from "@/modules/ui/components/header";
|
import { Header } from "@/modules/ui/components/header";
|
||||||
|
|
||||||
const Page = async (props) => {
|
const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
|
|
||||||
@@ -34,12 +34,12 @@ const Page = async (props) => {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
{/* we only need to render organization breadcrumb on this page, organizations/projects are lazy-loaded */}
|
{/* we only need to render organization breadcrumb on this page, organizations/workspaces are lazy-loaded */}
|
||||||
<ProjectAndOrgSwitch
|
<WorkspaceAndOrgSwitch
|
||||||
currentOrganizationId={organization.id}
|
currentOrganizationId={organization.id}
|
||||||
currentOrganizationName={organization.name}
|
currentOrganizationName={organization.name}
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
organizationProjectsLimit={0}
|
organizationWorkspacesLimit={0}
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
isLicenseActive={false}
|
isLicenseActive={false}
|
||||||
isOwnerOrManager={false}
|
isOwnerOrManager={false}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AuthorizationError } from "@formbricks/types/errors";
|
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { canUserAccessOrganization } from "@/lib/organization/auth";
|
import { canUserAccessOrganization } from "@/lib/organization/auth";
|
||||||
import { getOrganization } from "@/lib/organization/service";
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
@@ -8,7 +8,10 @@ import { getTranslate } from "@/lingodotdev/server";
|
|||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
||||||
|
|
||||||
const ProjectOnboardingLayout = async (props) => {
|
const WorkspaceOnboardingLayout = async (props: {
|
||||||
|
params: Promise<{ organizationId: string }>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
@@ -22,7 +25,7 @@ const ProjectOnboardingLayout = async (props) => {
|
|||||||
|
|
||||||
const user = await getUser(session.user.id);
|
const user = await getUser(session.user.id);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(t("common.user_not_found"));
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
|
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
|
||||||
@@ -33,7 +36,7 @@ const ProjectOnboardingLayout = async (props) => {
|
|||||||
|
|
||||||
const organization = await getOrganization(params.organizationId);
|
const organization = await getOrganization(params.organizationId);
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new Error(t("common.organization_not_found"));
|
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -44,4 +47,4 @@ const ProjectOnboardingLayout = async (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ProjectOnboardingLayout;
|
export default WorkspaceOnboardingLayout;
|
||||||
|
|||||||
+4
-4
@@ -2,7 +2,7 @@ import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
import { getUserWorkspaces } from "@/lib/workspace/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
@@ -39,7 +39,7 @@ const Page = async (props: ChannelPageProps) => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const projects = await getUserProjects(session.user.id, params.organizationId);
|
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||||
@@ -48,9 +48,9 @@ const Page = async (props: ChannelPageProps) => {
|
|||||||
subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")}
|
subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")}
|
||||||
/>
|
/>
|
||||||
<OnboardingOptionsContainer options={channelOptions} />
|
<OnboardingOptionsContainer options={channelOptions} />
|
||||||
{projects.length >= 1 && (
|
{workspaces.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
|
|||||||
+13
-7
@@ -1,14 +1,18 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
import { getOrganization } from "@/lib/organization/service";
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
import { getOrganizationWorkspacesCount } from "@/lib/workspace/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
import { getOrganizationWorkspacesLimit } from "@/modules/ee/license-check/lib/utils";
|
||||||
|
|
||||||
const OnboardingLayout = async (props) => {
|
const OnboardingLayout = async (props: {
|
||||||
|
params: Promise<{ organizationId: string }>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
@@ -25,13 +29,15 @@ const OnboardingLayout = async (props) => {
|
|||||||
|
|
||||||
const organization = await getOrganization(params.organizationId);
|
const organization = await getOrganization(params.organizationId);
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new Error(t("common.organization_not_found"));
|
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
const [organizationWorkspacesLimit, organizationWorkspacesCount] = await Promise.all([
|
||||||
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
|
getOrganizationWorkspacesLimit(organization.id),
|
||||||
|
getOrganizationWorkspacesCount(organization.id),
|
||||||
|
]);
|
||||||
|
|
||||||
if (organizationProjectsCount >= organizationProjectsLimit) {
|
if (organizationWorkspacesCount >= organizationWorkspacesLimit) {
|
||||||
return redirect(`/`);
|
return redirect(`/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -2,7 +2,7 @@ import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
import { getUserWorkspaces } from "@/lib/workspace/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
@@ -39,15 +39,15 @@ const Page = async (props: ModePageProps) => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const projects = await getUserProjects(session.user.id, params.organizationId);
|
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||||
<Header title={t("organizations.workspaces.new.mode.what_are_you_here_for")} />
|
<Header title={t("organizations.workspaces.new.mode.what_are_you_here_for")} />
|
||||||
<OnboardingOptionsContainer options={channelOptions} />
|
<OnboardingOptionsContainer options={channelOptions} />
|
||||||
{projects.length >= 1 && (
|
{workspaces.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
|
|||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
|
import { SelectPlanCard } from "@/modules/ee/billing/components/select-plan-card";
|
||||||
|
import { Header } from "@/modules/ui/components/header";
|
||||||
|
|
||||||
|
interface SelectPlanOnboardingProps {
|
||||||
|
organizationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 space-y-8">
|
||||||
|
<Header
|
||||||
|
title={t("environments.settings.billing.select_plan_header_title")}
|
||||||
|
subtitle={t("environments.settings.billing.select_plan_header_subtitle")}
|
||||||
|
/>
|
||||||
|
<SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { TCloudBillingPlan } from "@formbricks/types/organizations";
|
||||||
|
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
|
import { getOrganizationBillingWithReadThroughSync } from "@/modules/ee/billing/lib/organization-billing";
|
||||||
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
|
import { SelectPlanOnboarding } from "./components/select-plan-onboarding";
|
||||||
|
|
||||||
|
const PAID_PLANS = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
|
||||||
|
|
||||||
|
interface PlanPageProps {
|
||||||
|
params: Promise<{
|
||||||
|
organizationId: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Page = async (props: PlanPageProps) => {
|
||||||
|
const params = await props.params;
|
||||||
|
|
||||||
|
if (!IS_FORMBRICKS_CLOUD) {
|
||||||
|
return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { session } = await getOrganizationAuth(params.organizationId);
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return redirect(`/auth/login`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users with an existing paid/trial subscription should not be shown the trial page.
|
||||||
|
// Redirect them directly to the next onboarding step.
|
||||||
|
const billing = await getOrganizationBillingWithReadThroughSync(params.organizationId);
|
||||||
|
const currentPlan = billing?.stripe?.plan;
|
||||||
|
const hasExistingSubscription = currentPlan !== undefined && PAID_PLANS.has(currentPlan);
|
||||||
|
|
||||||
|
if (hasExistingSubscription) {
|
||||||
|
return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SelectPlanOnboarding organizationId={params.organizationId} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Page;
|
||||||
+52
-45
@@ -3,23 +3,24 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
TProjectConfigChannel,
|
TWorkspaceConfigChannel,
|
||||||
TProjectConfigIndustry,
|
TWorkspaceConfigIndustry,
|
||||||
TProjectMode,
|
TWorkspaceMode,
|
||||||
TProjectUpdateInput,
|
TWorkspaceUpdateInput,
|
||||||
ZProjectUpdateInput,
|
ZWorkspaceUpdateInput,
|
||||||
} from "@formbricks/types/project";
|
} from "@formbricks/types/workspace";
|
||||||
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
|
import { createWorkspaceAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||||
import { previewSurvey } from "@/app/lib/templates";
|
import { previewSurvey } from "@/app/lib/templates";
|
||||||
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
|
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
|
||||||
|
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
|
|
||||||
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
|
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
|
||||||
|
import { TOrganizationTeam } from "@/modules/ee/teams/workspace-teams/types/team";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { ColorPicker } from "@/modules/ui/components/color-picker";
|
import { ColorPicker } from "@/modules/ui/components/color-picker";
|
||||||
import {
|
import {
|
||||||
@@ -35,52 +36,59 @@ import { Input } from "@/modules/ui/components/input";
|
|||||||
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
||||||
import { SurveyInline } from "@/modules/ui/components/survey";
|
import { SurveyInline } from "@/modules/ui/components/survey";
|
||||||
|
|
||||||
interface ProjectSettingsProps {
|
interface WorkspaceSettingsProps {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
projectMode: TProjectMode;
|
workspaceMode: TWorkspaceMode;
|
||||||
channel: TProjectConfigChannel;
|
channel: TWorkspaceConfigChannel;
|
||||||
industry: TProjectConfigIndustry;
|
industry: TWorkspaceConfigIndustry;
|
||||||
defaultBrandColor: string;
|
defaultBrandColor: string;
|
||||||
organizationTeams: TOrganizationTeam[];
|
organizationTeams: TOrganizationTeam[];
|
||||||
isAccessControlAllowed: boolean;
|
isAccessControlAllowed: boolean;
|
||||||
userProjectsCount: number;
|
userWorkspacesCount: number;
|
||||||
publicDomain: string;
|
publicDomain: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProjectSettings = ({
|
export const WorkspaceSettings = ({
|
||||||
organizationId,
|
organizationId,
|
||||||
projectMode,
|
workspaceMode,
|
||||||
channel,
|
channel,
|
||||||
industry,
|
industry,
|
||||||
defaultBrandColor,
|
defaultBrandColor,
|
||||||
organizationTeams,
|
organizationTeams,
|
||||||
isAccessControlAllowed = false,
|
isAccessControlAllowed = false,
|
||||||
userProjectsCount,
|
userWorkspacesCount,
|
||||||
publicDomain,
|
publicDomain,
|
||||||
}: ProjectSettingsProps) => {
|
}: WorkspaceSettingsProps) => {
|
||||||
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const addProject = async (data: TProjectUpdateInput) => {
|
const addWorkspace = async (data: TWorkspaceUpdateInput) => {
|
||||||
try {
|
try {
|
||||||
const createProjectResponse = await createProjectAction({
|
// Build the full styling from the chosen brand color so all derived
|
||||||
|
// colours (question, button, input, option, progress, etc.) are persisted.
|
||||||
|
// Without this, only brandColor is saved and the look-and-feel page falls
|
||||||
|
// back to STYLE_DEFAULTS computed from the default brand (#64748b).
|
||||||
|
const fullStyling = buildStylingFromBrandColor(data.styling?.brandColor?.light);
|
||||||
|
|
||||||
|
const createWorkspaceResponse = await createWorkspaceAction({
|
||||||
organizationId,
|
organizationId,
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
|
styling: fullStyling,
|
||||||
config: { channel, industry },
|
config: { channel, industry },
|
||||||
teamIds: data.teamIds,
|
teamIds: data.teamIds,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (createProjectResponse?.data) {
|
if (createWorkspaceResponse?.data) {
|
||||||
// get production environment
|
// get production environment
|
||||||
const productionEnvironment = createProjectResponse.data.environments.find(
|
const productionEnvironment = createWorkspaceResponse.data.environments.find(
|
||||||
(environment) => environment.type === "production"
|
(environment: { type: string }) => environment.type === "production"
|
||||||
);
|
);
|
||||||
if (productionEnvironment) {
|
if (productionEnvironment) {
|
||||||
if (globalThis.window !== undefined) {
|
if (globalThis.window !== undefined) {
|
||||||
// Rmove filters when creating a new project
|
// Remove filters when creating a new workspace
|
||||||
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,11 +96,11 @@ export const ProjectSettings = ({
|
|||||||
router.push(`/environments/${productionEnvironment?.id}/connect`);
|
router.push(`/environments/${productionEnvironment?.id}/connect`);
|
||||||
} else if (channel === "link") {
|
} else if (channel === "link") {
|
||||||
router.push(`/environments/${productionEnvironment?.id}/surveys`);
|
router.push(`/environments/${productionEnvironment?.id}/surveys`);
|
||||||
} else if (projectMode === "cx") {
|
} else if (workspaceMode === "cx") {
|
||||||
router.push(`/environments/${productionEnvironment?.id}/xm-templates`);
|
router.push(`/environments/${productionEnvironment?.id}/xm-templates`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const errorMessage = getFormattedErrorMessage(createProjectResponse);
|
const errorMessage = getFormattedErrorMessage(createWorkspaceResponse);
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -101,17 +109,18 @@ export const ProjectSettings = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const form = useForm<TProjectUpdateInput>({
|
const form = useForm<TWorkspaceUpdateInput>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
|
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
|
||||||
teamIds: [],
|
teamIds: [],
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZProjectUpdateInput),
|
resolver: zodResolver(ZWorkspaceUpdateInput),
|
||||||
});
|
});
|
||||||
const projectName = form.watch("name");
|
const workspaceName = form.watch("name");
|
||||||
const logoUrl = form.watch("logo.url");
|
const logoUrl = form.watch("logo.url");
|
||||||
const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
|
const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
|
||||||
|
const previewStyling = useMemo(() => buildStylingFromBrandColor(brandColor), [brandColor]);
|
||||||
const { isSubmitting } = form.formState;
|
const { isSubmitting } = form.formState;
|
||||||
|
|
||||||
const organizationTeamsOptions = organizationTeams.map((team) => ({
|
const organizationTeamsOptions = organizationTeams.map((team) => ({
|
||||||
@@ -123,7 +132,7 @@ export const ProjectSettings = ({
|
|||||||
<div className="mt-6 flex w-5/6 space-x-10 lg:w-2/3 2xl:w-1/2">
|
<div className="mt-6 flex w-5/6 space-x-10 lg:w-2/3 2xl:w-1/2">
|
||||||
<div className="flex w-1/2 flex-col space-y-4">
|
<div className="flex w-1/2 flex-col space-y-4">
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<form onSubmit={form.handleSubmit(addProject)} className="w-full space-y-4">
|
<form onSubmit={form.handleSubmit(addWorkspace)} className="w-full space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="styling.brandColor.light"
|
name="styling.brandColor.light"
|
||||||
@@ -175,7 +184,7 @@ export const ProjectSettings = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isAccessControlAllowed && userProjectsCount > 0 && (
|
{isAccessControlAllowed && userWorkspacesCount > 0 && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="teamIds"
|
name="teamIds"
|
||||||
@@ -219,29 +228,27 @@ export const ProjectSettings = ({
|
|||||||
</FormProvider>
|
</FormProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative flex h-[30rem] w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 shadow">
|
<div className="relative flex w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 p-6 shadow">
|
||||||
{logoUrl && (
|
{logoUrl && (
|
||||||
<Image
|
<Image
|
||||||
src={logoUrl}
|
src={logoUrl}
|
||||||
alt="Logo"
|
alt="Logo"
|
||||||
width={256}
|
width={256}
|
||||||
height={56}
|
height={56}
|
||||||
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm text-slate-400">{t("common.preview")}</p>
|
<p className="text-sm text-slate-400">{t("common.preview")}</p>
|
||||||
<div className="z-0 h-3/4 w-3/4">
|
<SurveyInline
|
||||||
<SurveyInline
|
appUrl={publicDomain}
|
||||||
appUrl={publicDomain}
|
isPreviewMode={true}
|
||||||
isPreviewMode={true}
|
survey={previewSurvey(workspaceName || t("common.my_product"), t)}
|
||||||
survey={previewSurvey(projectName || "my Product", t)}
|
styling={previewStyling}
|
||||||
styling={{ brandColor: { light: brandColor } }}
|
isBrandingEnabled={false}
|
||||||
isBrandingEnabled={false}
|
languageCode="default"
|
||||||
languageCode="default"
|
onFileUpload={async (file) => file.name}
|
||||||
onFileUpload={async (file) => file.name}
|
autoFocus={false}
|
||||||
autoFocus={false}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<CreateTeamModal
|
<CreateTeamModal
|
||||||
open={createTeamModalOpen}
|
open={createTeamModalOpen}
|
||||||
+21
-16
@@ -1,30 +1,35 @@
|
|||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
|
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
|
import {
|
||||||
|
TWorkspaceConfigChannel,
|
||||||
|
TWorkspaceConfigIndustry,
|
||||||
|
TWorkspaceMode,
|
||||||
|
} from "@formbricks/types/workspace";
|
||||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||||
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
|
import { WorkspaceSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/WorkspaceSettings";
|
||||||
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
import { getUserWorkspaces } from "@/lib/workspace/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Header } from "@/modules/ui/components/header";
|
import { Header } from "@/modules/ui/components/header";
|
||||||
|
|
||||||
interface ProjectSettingsPageProps {
|
interface WorkspaceSettingsPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
}>;
|
}>;
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
channel?: TProjectConfigChannel;
|
channel?: TWorkspaceConfigChannel;
|
||||||
industry?: TProjectConfigIndustry;
|
industry?: TWorkspaceConfigIndustry;
|
||||||
mode?: TProjectMode;
|
mode?: TWorkspaceMode;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Page = async (props: ProjectSettingsPageProps) => {
|
const Page = async (props: WorkspaceSettingsPageProps) => {
|
||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
@@ -38,14 +43,14 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
|||||||
const channel = searchParams.channel ?? null;
|
const channel = searchParams.channel ?? null;
|
||||||
const industry = searchParams.industry ?? null;
|
const industry = searchParams.industry ?? null;
|
||||||
const mode = searchParams.mode ?? "surveys";
|
const mode = searchParams.mode ?? "surveys";
|
||||||
const projects = await getUserProjects(session.user.id, params.organizationId);
|
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
|
||||||
|
|
||||||
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
|
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
|
||||||
|
|
||||||
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
|
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
|
||||||
|
|
||||||
if (!organizationTeams) {
|
if (!organizationTeams) {
|
||||||
throw new Error(t("common.organization_teams_not_found"));
|
throw new ResourceNotFoundError(t("common.team"), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicDomain = getPublicDomain();
|
const publicDomain = getPublicDomain();
|
||||||
@@ -56,20 +61,20 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
|||||||
title={t("organizations.workspaces.new.settings.workspace_settings_title")}
|
title={t("organizations.workspaces.new.settings.workspace_settings_title")}
|
||||||
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
|
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
|
||||||
/>
|
/>
|
||||||
<ProjectSettings
|
<WorkspaceSettings
|
||||||
organizationId={params.organizationId}
|
organizationId={params.organizationId}
|
||||||
projectMode={mode}
|
workspaceMode={mode}
|
||||||
channel={channel}
|
channel={channel}
|
||||||
industry={industry}
|
industry={industry}
|
||||||
defaultBrandColor={DEFAULT_BRAND_COLOR}
|
defaultBrandColor={DEFAULT_BRAND_COLOR}
|
||||||
organizationTeams={organizationTeams}
|
organizationTeams={organizationTeams}
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
isAccessControlAllowed={isAccessControlAllowed}
|
||||||
userProjectsCount={projects.length}
|
userWorkspacesCount={workspaces.length}
|
||||||
publicDomain={publicDomain}
|
publicDomain={publicDomain}
|
||||||
/>
|
/>
|
||||||
{projects.length >= 1 && (
|
{workspaces.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ interface OnboardingOptionsContainerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContainerProps) => {
|
export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContainerProps) => {
|
||||||
const getOptionCard = (option) => {
|
const getOptionCard = (option: OnboardingOptionsContainerProps["options"][number]) => {
|
||||||
const Icon = option.icon;
|
const Icon = option.icon;
|
||||||
return (
|
return (
|
||||||
<OptionCard
|
<OptionCard
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const ZOrganizationTeam = z.object({
|
export const ZOrganizationTeam = z.object({
|
||||||
id: z.string().cuid2(),
|
id: z.cuid2(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { getEnvironment } from "@/lib/environment/service";
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||||
|
|
||||||
const SurveyEditorEnvironmentLayout = async (props) => {
|
const SurveyEditorEnvironmentLayout = async (props: {
|
||||||
|
params: Promise<{ environmentId: string }>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
@@ -14,13 +18,13 @@ const SurveyEditorEnvironmentLayout = async (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(t("common.user_not_found"));
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const environment = await getEnvironment(params.environmentId);
|
const environment = await getEnvironment(params.environmentId);
|
||||||
|
|
||||||
if (!environment) {
|
if (!environment) {
|
||||||
throw new Error(t("common.environment_not_found"));
|
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,15 +6,26 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Confetti } from "@/modules/ui/components/confetti";
|
import { Confetti } from "@/modules/ui/components/confetti";
|
||||||
|
|
||||||
interface ConfirmationPageProps {
|
const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId";
|
||||||
environmentId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => {
|
export const ConfirmationPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showConfetti, setShowConfetti] = useState(false);
|
const [showConfetti, setShowConfetti] = useState(false);
|
||||||
|
const [resolvedEnvironmentId, setResolvedEnvironmentId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowConfetti(true);
|
setShowConfetti(true);
|
||||||
|
|
||||||
|
if (globalThis.window === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedEnvironmentId = globalThis.window.sessionStorage.getItem(
|
||||||
|
BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY
|
||||||
|
);
|
||||||
|
if (storedEnvironmentId) {
|
||||||
|
setResolvedEnvironmentId(storedEnvironmentId);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -30,7 +41,12 @@ export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild className="w-full justify-center">
|
<Button asChild className="w-full justify-center">
|
||||||
<Link href={`/environments/${environmentId}/settings/billing`}>
|
<Link
|
||||||
|
href={
|
||||||
|
resolvedEnvironmentId
|
||||||
|
? `/environments/${resolvedEnvironmentId}/settings/billing`
|
||||||
|
: "/environments"
|
||||||
|
}>
|
||||||
{t("billing_confirmation.back_to_billing_overview")}
|
{t("billing_confirmation.back_to_billing_overview")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -3,13 +3,10 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const Page = async (props) => {
|
const Page = async () => {
|
||||||
const searchParams = await props.searchParams;
|
|
||||||
const { environmentId } = searchParams;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<ConfirmationPage environmentId={environmentId?.toString()} />
|
<ConfirmationPage />
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,90 +2,89 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
import {
|
||||||
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
AuthorizationError,
|
||||||
|
OperationNotAllowedError,
|
||||||
|
ResourceNotFoundError,
|
||||||
|
} from "@formbricks/types/errors";
|
||||||
|
import { ZWorkspaceUpdateInput } from "@formbricks/types/workspace";
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getOrganization } from "@/lib/organization/service";
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
|
||||||
import { updateUser } from "@/lib/user/service";
|
import { updateUser } from "@/lib/user/service";
|
||||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
import { getOrganizationWorkspacesCount } from "@/lib/workspace/service";
|
||||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
import {
|
import {
|
||||||
getAccessControlPermission,
|
getAccessControlPermission,
|
||||||
getOrganizationProjectsLimit,
|
getOrganizationWorkspacesLimit,
|
||||||
} from "@/modules/ee/license-check/lib/utils";
|
} from "@/modules/ee/license-check/lib/utils";
|
||||||
import { createProject } from "@/modules/projects/settings/lib/project";
|
import { createWorkspace } from "@/modules/workspaces/settings/lib/workspace";
|
||||||
import { getOrganizationsByUserId } from "./lib/organization";
|
import { getOrganizationsByUserId } from "./lib/organization";
|
||||||
import { getProjectsByUserId } from "./lib/project";
|
import { getWorkspacesByUserId } from "./lib/workspace";
|
||||||
|
|
||||||
const ZCreateProjectAction = z.object({
|
const ZCreateWorkspaceAction = z.object({
|
||||||
organizationId: ZId,
|
organizationId: ZId,
|
||||||
data: ZProjectUpdateInput,
|
data: ZWorkspaceUpdateInput,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createProjectAction = authenticatedActionClient.schema(ZCreateProjectAction).action(
|
export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCreateWorkspaceAction).action(
|
||||||
withAuditLogging(
|
withAuditLogging("created", "workspace", async ({ ctx, parsedInput }) => {
|
||||||
"created",
|
const { user } = ctx;
|
||||||
"project",
|
|
||||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
|
||||||
const { user } = ctx;
|
|
||||||
|
|
||||||
const organizationId = parsedInput.organizationId;
|
const organizationId = parsedInput.organizationId;
|
||||||
|
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
organizationId: parsedInput.organizationId,
|
organizationId: parsedInput.organizationId,
|
||||||
access: [
|
access: [
|
||||||
{
|
{
|
||||||
data: parsedInput.data,
|
data: parsedInput.data,
|
||||||
schema: ZProjectUpdateInput,
|
schema: ZWorkspaceUpdateInput,
|
||||||
type: "organization",
|
type: "organization",
|
||||||
roles: ["owner", "manager"],
|
roles: ["owner", "manager"],
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const organization = await getOrganization(organizationId);
|
|
||||||
|
|
||||||
if (!organization) {
|
|
||||||
throw new Error("Organization not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
|
||||||
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
|
|
||||||
|
|
||||||
if (organizationProjectsCount >= organizationProjectsLimit) {
|
|
||||||
throw new OperationNotAllowedError("Organization workspace limit reached");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
|
|
||||||
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
|
|
||||||
|
|
||||||
if (!isAccessControlAllowed) {
|
|
||||||
throw new OperationNotAllowedError("You do not have permission to manage roles");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const project = await createProject(parsedInput.organizationId, parsedInput.data);
|
|
||||||
const updatedNotificationSettings = {
|
|
||||||
...user.notificationSettings,
|
|
||||||
alert: {
|
|
||||||
...user.notificationSettings?.alert,
|
|
||||||
},
|
},
|
||||||
};
|
],
|
||||||
|
});
|
||||||
|
|
||||||
await updateUser(user.id, {
|
const organization = await getOrganization(organizationId);
|
||||||
notificationSettings: updatedNotificationSettings,
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
if (!organization) {
|
||||||
ctx.auditLoggingCtx.projectId = project.id;
|
throw new ResourceNotFoundError("Organization", organizationId);
|
||||||
ctx.auditLoggingCtx.newObject = project;
|
|
||||||
return project;
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
const organizationWorkspacesLimit = await getOrganizationWorkspacesLimit(organization.id);
|
||||||
|
const organizationWorkspacesCount = await getOrganizationWorkspacesCount(organization.id);
|
||||||
|
|
||||||
|
if (organizationWorkspacesCount >= organizationWorkspacesLimit) {
|
||||||
|
throw new OperationNotAllowedError("Organization workspace limit reached");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
|
||||||
|
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
|
||||||
|
|
||||||
|
if (!isAccessControlAllowed) {
|
||||||
|
throw new OperationNotAllowedError("You do not have permission to manage roles");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await createWorkspace(parsedInput.organizationId, parsedInput.data);
|
||||||
|
const updatedNotificationSettings = {
|
||||||
|
...user.notificationSettings,
|
||||||
|
alert: {
|
||||||
|
...user.notificationSettings?.alert,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateUser(user.id, {
|
||||||
|
notificationSettings: updatedNotificationSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||||
|
ctx.auditLoggingCtx.workspaceId = workspace.id;
|
||||||
|
ctx.auditLoggingCtx.newObject = workspace;
|
||||||
|
return workspace;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const ZGetOrganizationsForSwitcherAction = z.object({
|
const ZGetOrganizationsForSwitcherAction = z.object({
|
||||||
@@ -97,7 +96,7 @@ const ZGetOrganizationsForSwitcherAction = z.object({
|
|||||||
* Called on-demand when user opens the organization switcher.
|
* Called on-demand when user opens the organization switcher.
|
||||||
*/
|
*/
|
||||||
export const getOrganizationsForSwitcherAction = authenticatedActionClient
|
export const getOrganizationsForSwitcherAction = authenticatedActionClient
|
||||||
.schema(ZGetOrganizationsForSwitcherAction)
|
.inputSchema(ZGetOrganizationsForSwitcherAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
@@ -113,16 +112,16 @@ export const getOrganizationsForSwitcherAction = authenticatedActionClient
|
|||||||
return await getOrganizationsByUserId(ctx.user.id);
|
return await getOrganizationsByUserId(ctx.user.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
const ZGetProjectsForSwitcherAction = z.object({
|
const ZGetWorkspacesForSwitcherAction = z.object({
|
||||||
organizationId: ZId, // Changed from environmentId to avoid extra query
|
organizationId: ZId, // Changed from environmentId to avoid extra query
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches projects list for switcher dropdown.
|
* Fetches projects list for switcher dropdown.
|
||||||
* Called on-demand when user opens the project switcher.
|
* Called on-demand when user opens the workspace switcher.
|
||||||
*/
|
*/
|
||||||
export const getProjectsForSwitcherAction = authenticatedActionClient
|
export const getWorkspacesForSwitcherAction = authenticatedActionClient
|
||||||
.schema(ZGetProjectsForSwitcherAction)
|
.inputSchema(ZGetWorkspacesForSwitcherAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
@@ -135,11 +134,11 @@ export const getProjectsForSwitcherAction = authenticatedActionClient
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// Need membership for getProjectsByUserId (1 DB query)
|
// Need membership for getWorkspacesByUserId (1 DB query)
|
||||||
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
|
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
throw new Error("Membership not found");
|
throw new AuthorizationError("Membership not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
return await getProjectsByUserId(ctx.user.id, membership);
|
return await getWorkspacesByUserId(ctx.user.id, membership);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
||||||
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
||||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
import { getOrganizationWorkspacesLimit } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { TEnvironmentLayoutData } from "@/modules/environments/types/environment-auth";
|
import { TEnvironmentLayoutData } from "@/modules/environments/types/environment-auth";
|
||||||
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
||||||
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
|
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
|
||||||
@@ -24,25 +25,24 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
|||||||
environment,
|
environment,
|
||||||
organization,
|
organization,
|
||||||
membership,
|
membership,
|
||||||
project, // Current project details
|
workspace, // Current workspace details
|
||||||
environments, // All project environments (for environment switcher)
|
environments, // All workspace environments (for environment switcher)
|
||||||
isAccessControlAllowed,
|
isAccessControlAllowed,
|
||||||
projectPermission,
|
workspacePermission,
|
||||||
license,
|
license,
|
||||||
peopleCount,
|
|
||||||
responseCount,
|
responseCount,
|
||||||
} = layoutData;
|
} = layoutData;
|
||||||
|
|
||||||
// Calculate derived values (no queries)
|
// Calculate derived values (no queries)
|
||||||
const { isMember, isOwner, isManager } = getAccessFlags(membership.role);
|
const { isMember, isOwner, isManager } = getAccessFlags(membership.role);
|
||||||
|
|
||||||
const { features, lastChecked, isPendingDowngrade, active } = license;
|
const { features, lastChecked, isPendingDowngrade, active, status } = license;
|
||||||
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
const organizationWorkspacesLimit = await getOrganizationWorkspacesLimit(organization.id);
|
||||||
const isOwnerOrManager = isOwner || isManager;
|
const isOwnerOrManager = isOwner || isManager;
|
||||||
|
|
||||||
// Validate that project permission exists for members
|
// Validate that workspace permission exists for members
|
||||||
if (isMember && !projectPermission) {
|
if (isMember && !workspacePermission) {
|
||||||
throw new Error(t("common.workspace_permission_not_found"));
|
throw new Error(t("common.workspace_permission_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +52,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
|||||||
<LimitsReachedBanner
|
<LimitsReachedBanner
|
||||||
organization={organization}
|
organization={organization}
|
||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
peopleCount={peopleCount}
|
|
||||||
responseCount={responseCount}
|
responseCount={responseCount}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -63,6 +62,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
|||||||
active={active}
|
active={active}
|
||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
locale={user.locale}
|
locale={user.locale}
|
||||||
|
status={status}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
@@ -70,7 +70,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
|||||||
environment={environment}
|
environment={environment}
|
||||||
organization={organization}
|
organization={organization}
|
||||||
user={user}
|
user={user}
|
||||||
project={{ id: project.id, name: project.name }}
|
workspace={{ id: workspace.id, name: workspace.name }}
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
isDevelopment={IS_DEVELOPMENT}
|
isDevelopment={IS_DEVELOPMENT}
|
||||||
membershipRole={membership.role}
|
membershipRole={membership.role}
|
||||||
@@ -80,9 +80,9 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
|||||||
<TopControlBar
|
<TopControlBar
|
||||||
environments={environments}
|
environments={environments}
|
||||||
currentOrganizationId={organization.id}
|
currentOrganizationId={organization.id}
|
||||||
currentProjectId={project.id}
|
currentWorkspaceId={workspace.id}
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
organizationProjectsLimit={organizationProjectsLimit}
|
organizationWorkspacesLimit={organizationWorkspacesLimit}
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
isLicenseActive={active}
|
isLicenseActive={active}
|
||||||
isOwnerOrManager={isOwnerOrManager}
|
isOwnerOrManager={isOwnerOrManager}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
RocketIcon,
|
RocketIcon,
|
||||||
UserCircleIcon,
|
UserCircleIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
|
WorkflowIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -27,7 +28,7 @@ import FBLogo from "@/images/formbricks-wordmark.svg";
|
|||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||||
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
|
||||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import {
|
import {
|
||||||
@@ -36,13 +37,14 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
|
import { getLatestStableFbReleaseAction } from "@/modules/workspaces/settings/(setup)/app-connection/actions";
|
||||||
import packageJson from "../../../../../package.json";
|
import packageJson from "../../../../../package.json";
|
||||||
|
|
||||||
interface NavigationProps {
|
interface NavigationProps {
|
||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
user: TUser;
|
user: TUser;
|
||||||
organization: TOrganization;
|
organization: TOrganization;
|
||||||
project: { id: string; name: string };
|
workspace: { id: string; name: string };
|
||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
isDevelopment: boolean;
|
isDevelopment: boolean;
|
||||||
membershipRole?: TOrganizationRole;
|
membershipRole?: TOrganizationRole;
|
||||||
@@ -53,7 +55,7 @@ export const MainNavigation = ({
|
|||||||
environment,
|
environment,
|
||||||
organization,
|
organization,
|
||||||
user,
|
user,
|
||||||
project,
|
workspace,
|
||||||
membershipRole,
|
membershipRole,
|
||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
isDevelopment,
|
isDevelopment,
|
||||||
@@ -90,7 +92,7 @@ export const MainNavigation = ({
|
|||||||
}, [isCollapsed]);
|
}, [isCollapsed]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Auto collapse project navbar on org and account settings
|
// Auto collapse workspace navbar on org and account settings
|
||||||
if (pathname?.includes("/settings")) {
|
if (pathname?.includes("/settings")) {
|
||||||
setIsCollapsed(true);
|
setIsCollapsed(true);
|
||||||
}
|
}
|
||||||
@@ -109,16 +111,26 @@ export const MainNavigation = ({
|
|||||||
href: `/environments/${environment.id}/contacts`,
|
href: `/environments/${environment.id}/contacts`,
|
||||||
name: t("common.contacts"),
|
name: t("common.contacts"),
|
||||||
icon: UserIcon,
|
icon: UserIcon,
|
||||||
isActive: pathname?.includes("/contacts") || pathname?.includes("/segments"),
|
isActive:
|
||||||
|
pathname?.includes("/contacts") ||
|
||||||
|
pathname?.includes("/segments") ||
|
||||||
|
pathname?.includes("/attributes"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t("common.workflows"),
|
||||||
|
href: `/environments/${environment.id}/workflows`,
|
||||||
|
icon: WorkflowIcon,
|
||||||
|
isActive: pathname?.includes("/workflows"),
|
||||||
|
isHidden: !isFormbricksCloud,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t("common.configuration"),
|
name: t("common.configuration"),
|
||||||
href: `/environments/${environment.id}/workspace/general`,
|
href: `/environments/${environment.id}/workspace/general`,
|
||||||
icon: Cog,
|
icon: Cog,
|
||||||
isActive: pathname?.includes("/project"),
|
isActive: pathname?.includes("/workspace"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t, environment.id, pathname]
|
[t, environment.id, pathname, isFormbricksCloud]
|
||||||
);
|
);
|
||||||
|
|
||||||
const dropdownNavigation = [
|
const dropdownNavigation = [
|
||||||
@@ -156,11 +168,25 @@ export const MainNavigation = ({
|
|||||||
if (isOwnerOrManager) loadReleases();
|
if (isOwnerOrManager) loadReleases();
|
||||||
}, [isOwnerOrManager]);
|
}, [isOwnerOrManager]);
|
||||||
|
|
||||||
|
const trialDaysRemaining = useMemo(() => {
|
||||||
|
if (!isFormbricksCloud || organization.billing?.stripe?.subscriptionStatus !== "trialing") return null;
|
||||||
|
const trialEnd = organization.billing.stripe.trialEnd;
|
||||||
|
if (!trialEnd) return null;
|
||||||
|
const ts = new Date(trialEnd).getTime();
|
||||||
|
if (!Number.isFinite(ts)) return null;
|
||||||
|
const msPerDay = 86_400_000;
|
||||||
|
return Math.ceil((ts - Date.now()) / msPerDay);
|
||||||
|
}, [
|
||||||
|
isFormbricksCloud,
|
||||||
|
organization.billing?.stripe?.subscriptionStatus,
|
||||||
|
organization.billing?.stripe?.trialEnd,
|
||||||
|
]);
|
||||||
|
|
||||||
const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`;
|
const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{project && (
|
{workspace && (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
|
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
|
||||||
@@ -185,7 +211,7 @@ export const MainNavigation = ({
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
|
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
|
||||||
)}>
|
)}>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||||
@@ -230,6 +256,13 @@ export const MainNavigation = ({
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Trial Days Remaining */}
|
||||||
|
{!isCollapsed && isFormbricksCloud && trialDaysRemaining !== null && (
|
||||||
|
<Link href={`/environments/${environment.id}/settings/billing`} className="m-2 block">
|
||||||
|
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* User Switch */}
|
{/* User Switch */}
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|||||||
@@ -2,16 +2,16 @@
|
|||||||
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||||
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
|
import { WorkspaceAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/workspace-and-org-switch";
|
||||||
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
|
|
||||||
interface TopControlBarProps {
|
interface TopControlBarProps {
|
||||||
environments: TEnvironment[];
|
environments: TEnvironment[];
|
||||||
currentOrganizationId: string;
|
currentOrganizationId: string;
|
||||||
currentProjectId: string;
|
currentWorkspaceId: string;
|
||||||
isMultiOrgEnabled: boolean;
|
isMultiOrgEnabled: boolean;
|
||||||
organizationProjectsLimit: number;
|
organizationWorkspacesLimit: number;
|
||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
isLicenseActive: boolean;
|
isLicenseActive: boolean;
|
||||||
isOwnerOrManager: boolean;
|
isOwnerOrManager: boolean;
|
||||||
@@ -22,9 +22,9 @@ interface TopControlBarProps {
|
|||||||
export const TopControlBar = ({
|
export const TopControlBar = ({
|
||||||
environments,
|
environments,
|
||||||
currentOrganizationId,
|
currentOrganizationId,
|
||||||
currentProjectId,
|
currentWorkspaceId,
|
||||||
isMultiOrgEnabled,
|
isMultiOrgEnabled,
|
||||||
organizationProjectsLimit,
|
organizationWorkspacesLimit,
|
||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
isLicenseActive,
|
isLicenseActive,
|
||||||
isOwnerOrManager,
|
isOwnerOrManager,
|
||||||
@@ -38,13 +38,13 @@ export const TopControlBar = ({
|
|||||||
<div
|
<div
|
||||||
className="flex h-14 w-full items-center justify-between bg-slate-50 px-6"
|
className="flex h-14 w-full items-center justify-between bg-slate-50 px-6"
|
||||||
data-testid="fb__global-top-control-bar">
|
data-testid="fb__global-top-control-bar">
|
||||||
<ProjectAndOrgSwitch
|
<WorkspaceAndOrgSwitch
|
||||||
currentEnvironmentId={environment.id}
|
currentEnvironmentId={environment.id}
|
||||||
environments={environments}
|
environments={environments}
|
||||||
currentOrganizationId={currentOrganizationId}
|
currentOrganizationId={currentOrganizationId}
|
||||||
currentProjectId={currentProjectId}
|
currentWorkspaceId={currentWorkspaceId}
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
organizationProjectsLimit={organizationProjectsLimit}
|
organizationWorkspacesLimit={organizationWorkspacesLimit}
|
||||||
isFormbricksCloud={isFormbricksCloud}
|
isFormbricksCloud={isFormbricksCloud}
|
||||||
isLicenseActive={isLicenseActive}
|
isLicenseActive={isLicenseActive}
|
||||||
isOwnerOrManager={isOwnerOrManager}
|
isOwnerOrManager={isOwnerOrManager}
|
||||||
|
|||||||
+1
-1
@@ -53,7 +53,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
|
|||||||
<currentStatus.icon />
|
<currentStatus.icon />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
|
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
|
||||||
<p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
|
<p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
|
||||||
{status === "notImplemented" && (
|
{status === "notImplemented" && (
|
||||||
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
|
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
|
||||||
<RotateCcwIcon />
|
<RotateCcwIcon />
|
||||||
|
|||||||
+2
-2
@@ -1,13 +1,13 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
interface ProjectNavItemProps {
|
interface WorkspaceNavItemProps {
|
||||||
href: string;
|
href: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProjectNavItem = ({ href, children, isActive }: ProjectNavItemProps) => {
|
export const WorkspaceNavItem = ({ href, children, isActive }: WorkspaceNavItemProps) => {
|
||||||
const activeClass = "bg-slate-50 font-semibold";
|
const activeClass = "bg-slate-50 font-semibold";
|
||||||
const inactiveClass = "hover:bg-slate-50";
|
const inactiveClass = "hover:bg-slate-50";
|
||||||
|
|
||||||
+7
-1
@@ -81,7 +81,7 @@ export const OrganizationBreadcrumb = ({
|
|||||||
getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
||||||
if (result?.data) {
|
if (result?.data) {
|
||||||
// Sort organizations by name
|
// Sort organizations by name
|
||||||
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
|
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
setOrganizations(sorted);
|
setOrganizations(sorted);
|
||||||
} else {
|
} else {
|
||||||
// Handle server errors or validation errors
|
// Handle server errors or validation errors
|
||||||
@@ -138,6 +138,12 @@ export const OrganizationBreadcrumb = ({
|
|||||||
label: t("common.members_and_teams"),
|
label: t("common.members_and_teams"),
|
||||||
href: `/environments/${currentEnvironmentId}/settings/teams`,
|
href: `/environments/${currentEnvironmentId}/settings/teams`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "feedback-record-directories",
|
||||||
|
label: t("environments.settings.feedback_record_directories.nav_label"),
|
||||||
|
href: `/environments/${currentEnvironmentId}/settings/feedback-record-directories`,
|
||||||
|
hidden: isMember,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "api-keys",
|
id: "api-keys",
|
||||||
label: t("common.api_keys"),
|
label: t("common.api_keys"),
|
||||||
|
|||||||
+15
-15
@@ -2,18 +2,18 @@
|
|||||||
|
|
||||||
import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb";
|
import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb";
|
||||||
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
|
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
|
||||||
import { ProjectBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/project-breadcrumb";
|
import { WorkspaceBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/workspace-breadcrumb";
|
||||||
import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
|
import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
|
||||||
|
|
||||||
interface ProjectAndOrgSwitchProps {
|
interface WorkspaceAndOrgSwitchProps {
|
||||||
currentOrganizationId: string;
|
currentOrganizationId: string;
|
||||||
currentOrganizationName?: string; // Optional: for pages without context
|
currentOrganizationName?: string; // Optional: for pages without context
|
||||||
currentProjectId?: string;
|
currentWorkspaceId?: string;
|
||||||
currentProjectName?: string; // Optional: for pages without context
|
currentWorkspaceName?: string; // Optional: for pages without context
|
||||||
currentEnvironmentId?: string;
|
currentEnvironmentId?: string;
|
||||||
environments: { id: string; type: string }[];
|
environments: { id: string; type: string }[];
|
||||||
isMultiOrgEnabled: boolean;
|
isMultiOrgEnabled: boolean;
|
||||||
organizationProjectsLimit: number;
|
organizationWorkspacesLimit: number;
|
||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
isLicenseActive: boolean;
|
isLicenseActive: boolean;
|
||||||
isOwnerOrManager: boolean;
|
isOwnerOrManager: boolean;
|
||||||
@@ -21,21 +21,21 @@ interface ProjectAndOrgSwitchProps {
|
|||||||
isAccessControlAllowed: boolean;
|
isAccessControlAllowed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProjectAndOrgSwitch = ({
|
export const WorkspaceAndOrgSwitch = ({
|
||||||
currentOrganizationId,
|
currentOrganizationId,
|
||||||
currentOrganizationName,
|
currentOrganizationName,
|
||||||
currentProjectId,
|
currentWorkspaceId,
|
||||||
currentProjectName,
|
currentWorkspaceName,
|
||||||
currentEnvironmentId,
|
currentEnvironmentId,
|
||||||
environments,
|
environments,
|
||||||
isMultiOrgEnabled,
|
isMultiOrgEnabled,
|
||||||
organizationProjectsLimit,
|
organizationWorkspacesLimit,
|
||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
isLicenseActive,
|
isLicenseActive,
|
||||||
isOwnerOrManager,
|
isOwnerOrManager,
|
||||||
isAccessControlAllowed,
|
isAccessControlAllowed,
|
||||||
isMember,
|
isMember,
|
||||||
}: ProjectAndOrgSwitchProps) => {
|
}: WorkspaceAndOrgSwitchProps) => {
|
||||||
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
||||||
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
|
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
|
||||||
|
|
||||||
@@ -51,14 +51,14 @@ export const ProjectAndOrgSwitch = ({
|
|||||||
isMember={isMember}
|
isMember={isMember}
|
||||||
isOwnerOrManager={isOwnerOrManager}
|
isOwnerOrManager={isOwnerOrManager}
|
||||||
/>
|
/>
|
||||||
{currentProjectId && currentEnvironmentId && (
|
{currentWorkspaceId && currentEnvironmentId && (
|
||||||
<ProjectBreadcrumb
|
<WorkspaceBreadcrumb
|
||||||
currentProjectId={currentProjectId}
|
currentWorkspaceId={currentWorkspaceId}
|
||||||
currentProjectName={currentProjectName}
|
currentWorkspaceName={currentWorkspaceName}
|
||||||
currentOrganizationId={currentOrganizationId}
|
currentOrganizationId={currentOrganizationId}
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
currentEnvironmentId={currentEnvironmentId}
|
||||||
isOwnerOrManager={isOwnerOrManager}
|
isOwnerOrManager={isOwnerOrManager}
|
||||||
organizationProjectsLimit={organizationProjectsLimit}
|
organizationWorkspacesLimit={organizationWorkspacesLimit}
|
||||||
isFormbricksCloud={isFormbricksCloud}
|
isFormbricksCloud={isFormbricksCloud}
|
||||||
isLicenseActive={isLicenseActive}
|
isLicenseActive={isLicenseActive}
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
isAccessControlAllowed={isAccessControlAllowed}
|
||||||
+63
-63
@@ -6,10 +6,8 @@ import { usePathname, useRouter } from "next/navigation";
|
|||||||
import { useEffect, useState, useTransition } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { getProjectsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
|
import { getWorkspacesForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
|
|
||||||
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
|
|
||||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -20,13 +18,15 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
||||||
import { useProject } from "../context/environment-context";
|
import { CreateWorkspaceModal } from "@/modules/workspaces/components/create-workspace-modal";
|
||||||
|
import { WorkspaceLimitModal } from "@/modules/workspaces/components/workspace-limit-modal";
|
||||||
|
import { useWorkspace } from "../context/environment-context";
|
||||||
|
|
||||||
interface ProjectBreadcrumbProps {
|
interface WorkspaceBreadcrumbProps {
|
||||||
currentProjectId: string;
|
currentWorkspaceId: string;
|
||||||
currentProjectName?: string; // Optional: pass directly if context not available
|
currentWorkspaceName?: string; // Optional: pass directly if context not available
|
||||||
isOwnerOrManager: boolean;
|
isOwnerOrManager: boolean;
|
||||||
organizationProjectsLimit: number;
|
organizationWorkspacesLimit: number;
|
||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
isLicenseActive: boolean;
|
isLicenseActive: boolean;
|
||||||
currentOrganizationId: string;
|
currentOrganizationId: string;
|
||||||
@@ -35,7 +35,7 @@ interface ProjectBreadcrumbProps {
|
|||||||
isEnvironmentBreadcrumbVisible: boolean;
|
isEnvironmentBreadcrumbVisible: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
const isActiveWorkspaceSetting = (pathname: string, settingId: string): boolean => {
|
||||||
// Match /workspace/{settingId} or /workspace/{settingId}/... but exclude settings paths
|
// Match /workspace/{settingId} or /workspace/{settingId}/... but exclude settings paths
|
||||||
if (pathname.includes("/settings/")) {
|
if (pathname.includes("/settings/")) {
|
||||||
return false;
|
return false;
|
||||||
@@ -45,59 +45,59 @@ const isActiveProjectSetting = (pathname: string, settingId: string): boolean =>
|
|||||||
return pattern.test(pathname);
|
return pattern.test(pathname);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ProjectBreadcrumb = ({
|
export const WorkspaceBreadcrumb = ({
|
||||||
currentProjectId,
|
currentWorkspaceId,
|
||||||
currentProjectName,
|
currentWorkspaceName,
|
||||||
isOwnerOrManager,
|
isOwnerOrManager,
|
||||||
organizationProjectsLimit,
|
organizationWorkspacesLimit,
|
||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
isLicenseActive,
|
isLicenseActive,
|
||||||
currentOrganizationId,
|
currentOrganizationId,
|
||||||
currentEnvironmentId,
|
currentEnvironmentId,
|
||||||
isAccessControlAllowed,
|
isAccessControlAllowed,
|
||||||
isEnvironmentBreadcrumbVisible,
|
isEnvironmentBreadcrumbVisible,
|
||||||
}: ProjectBreadcrumbProps) => {
|
}: WorkspaceBreadcrumbProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
|
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = useState(false);
|
||||||
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
|
const [openCreateWorkspaceModal, setOpenCreateWorkspaceModal] = useState(false);
|
||||||
const [openLimitModal, setOpenLimitModal] = useState(false);
|
const [openLimitModal, setOpenLimitModal] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false);
|
||||||
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
|
const [workspaces, setWorkspaces] = useState<{ id: string; name: string }[]>([]);
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
// Get current project name from context OR prop
|
// Get current workspace name from context OR prop
|
||||||
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
|
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
|
||||||
const { project: currentProject } = useProject();
|
const { workspace: currentWorkspace } = useWorkspace();
|
||||||
const projectName = currentProject?.name || currentProjectName || "";
|
const workspaceName = currentWorkspace?.name || currentWorkspaceName || "";
|
||||||
|
|
||||||
// Lazy-load projects when dropdown opens
|
// Lazy-load workspaces when dropdown opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only fetch when dropdown opened for first time (and no error state)
|
// Only fetch when dropdown opened for first time (and no error state)
|
||||||
if (isProjectDropdownOpen && projects.length === 0 && !isLoadingProjects && !loadError) {
|
if (isWorkspaceDropdownOpen && workspaces.length === 0 && !isLoadingWorkspaces && !loadError) {
|
||||||
setIsLoadingProjects(true);
|
setIsLoadingWorkspaces(true);
|
||||||
setLoadError(null); // Clear any previous errors
|
setLoadError(null); // Clear any previous errors
|
||||||
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
getWorkspacesForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
||||||
if (result?.data) {
|
if (result?.data) {
|
||||||
// Sort projects by name
|
// Sort workspaces by name
|
||||||
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
|
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
setProjects(sorted);
|
setWorkspaces(sorted);
|
||||||
} else {
|
} else {
|
||||||
// Handle server errors or validation errors
|
// Handle server errors or validation errors
|
||||||
const errorMessage = getFormattedErrorMessage(result);
|
const errorMessage = getFormattedErrorMessage(result);
|
||||||
const error = new Error(errorMessage);
|
const error = new Error(errorMessage);
|
||||||
logger.error(error, "Failed to load projects");
|
logger.error(error, "Failed to load workspaces");
|
||||||
Sentry.captureException(error);
|
Sentry.captureException(error);
|
||||||
setLoadError(errorMessage || t("common.failed_to_load_workspaces"));
|
setLoadError(errorMessage || t("common.failed_to_load_workspaces"));
|
||||||
}
|
}
|
||||||
setIsLoadingProjects(false);
|
setIsLoadingWorkspaces(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [isProjectDropdownOpen, currentOrganizationId, projects.length, isLoadingProjects, loadError, t]);
|
}, [isWorkspaceDropdownOpen, currentOrganizationId, workspaces.length, isLoadingWorkspaces, loadError, t]);
|
||||||
|
|
||||||
const projectSettings = [
|
const workspaceSettings = [
|
||||||
{
|
{
|
||||||
id: "general",
|
id: "general",
|
||||||
label: t("common.general"),
|
label: t("common.general"),
|
||||||
@@ -135,29 +135,29 @@ export const ProjectBreadcrumb = ({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!currentProject) {
|
if (!currentWorkspace) {
|
||||||
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
|
const errorMessage = `Workspace not found for workspace id: ${currentWorkspaceId}`;
|
||||||
logger.error(errorMessage);
|
logger.error(errorMessage);
|
||||||
Sentry.captureException(new Error(errorMessage));
|
Sentry.captureException(new Error(errorMessage));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleProjectChange = (projectId: string) => {
|
const handleWorkspaceChange = (workspaceId: string) => {
|
||||||
if (projectId === currentProjectId) return;
|
if (workspaceId === currentWorkspaceId) return;
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
router.push(`/workspaces/${projectId}/`);
|
router.push(`/workspaces/${workspaceId}/`);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddProject = () => {
|
const handleAddWorkspace = () => {
|
||||||
if (projects.length >= organizationProjectsLimit) {
|
if (workspaces.length >= organizationWorkspacesLimit) {
|
||||||
setOpenLimitModal(true);
|
setOpenLimitModal(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setOpenCreateProjectModal(true);
|
setOpenCreateWorkspaceModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProjectSettingsNavigation = (settingId: string) => {
|
const handleWorkspaceSettingsNavigation = (settingId: string) => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
router.push(`/environments/${currentEnvironmentId}/workspace/${settingId}`);
|
router.push(`/environments/${currentEnvironmentId}/workspace/${settingId}`);
|
||||||
});
|
});
|
||||||
@@ -191,17 +191,17 @@ export const ProjectBreadcrumb = ({
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<BreadcrumbItem isActive={isProjectDropdownOpen}>
|
<BreadcrumbItem isActive={isWorkspaceDropdownOpen}>
|
||||||
<DropdownMenu onOpenChange={setIsProjectDropdownOpen}>
|
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
className="flex cursor-pointer items-center gap-1 outline-none"
|
className="flex cursor-pointer items-center gap-1 outline-none"
|
||||||
id="projectDropdownTrigger"
|
id="workspaceDropdownTrigger"
|
||||||
asChild>
|
asChild>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<HotelIcon className="h-3 w-3" strokeWidth={1.5} />
|
<HotelIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
<span>{projectName}</span>
|
<span>{workspaceName}</span>
|
||||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||||
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
|
{isEnvironmentBreadcrumbVisible && !isWorkspaceDropdownOpen ? (
|
||||||
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
|
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
) : (
|
) : (
|
||||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
@@ -214,32 +214,32 @@ export const ProjectBreadcrumb = ({
|
|||||||
<HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
<HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||||
{t("common.choose_workspace")}
|
{t("common.choose_workspace")}
|
||||||
</div>
|
</div>
|
||||||
{isLoadingProjects && (
|
{isLoadingWorkspaces && (
|
||||||
<div className="flex items-center justify-center py-2">
|
<div className="flex items-center justify-center py-2">
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isLoadingProjects && loadError && (
|
{!isLoadingWorkspaces && loadError && (
|
||||||
<div className="px-2 py-4">
|
<div className="px-2 py-4">
|
||||||
<p className="mb-2 text-sm text-red-600">{loadError}</p>
|
<p className="mb-2 text-sm text-red-600">{loadError}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
setProjects([]);
|
setWorkspaces([]);
|
||||||
}}
|
}}
|
||||||
className="text-xs text-slate-600 underline hover:text-slate-800">
|
className="text-xs text-slate-600 underline hover:text-slate-800">
|
||||||
{t("common.try_again")}
|
{t("common.try_again")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!isLoadingProjects && !loadError && (
|
{!isLoadingWorkspaces && !loadError && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||||
{projects.map((proj) => (
|
{workspaces.map((proj) => (
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
key={proj.id}
|
key={proj.id}
|
||||||
checked={proj.id === currentProjectId}
|
checked={proj.id === currentWorkspaceId}
|
||||||
onClick={() => handleProjectChange(proj.id)}
|
onClick={() => handleWorkspaceChange(proj.id)}
|
||||||
className="cursor-pointer">
|
className="cursor-pointer">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>{proj.name}</span>
|
<span>{proj.name}</span>
|
||||||
@@ -249,7 +249,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
{isOwnerOrManager && (
|
{isOwnerOrManager && (
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
onClick={handleAddProject}
|
onClick={handleAddWorkspace}
|
||||||
className="w-full cursor-pointer justify-between">
|
className="w-full cursor-pointer justify-between">
|
||||||
<span>{t("common.add_new_workspace")}</span>
|
<span>{t("common.add_new_workspace")}</span>
|
||||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||||
@@ -263,11 +263,11 @@ export const ProjectBreadcrumb = ({
|
|||||||
<CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
<CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||||
{t("common.workspace_configuration")}
|
{t("common.workspace_configuration")}
|
||||||
</div>
|
</div>
|
||||||
{projectSettings.map((setting) => (
|
{workspaceSettings.map((setting) => (
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
key={setting.id}
|
key={setting.id}
|
||||||
checked={isActiveProjectSetting(pathname, setting.id)}
|
checked={isActiveWorkspaceSetting(pathname, setting.id)}
|
||||||
onClick={() => handleProjectSettingsNavigation(setting.id)}
|
onClick={() => handleWorkspaceSettingsNavigation(setting.id)}
|
||||||
className="cursor-pointer">
|
className="cursor-pointer">
|
||||||
{setting.label}
|
{setting.label}
|
||||||
</DropdownMenuCheckboxItem>
|
</DropdownMenuCheckboxItem>
|
||||||
@@ -277,17 +277,17 @@ export const ProjectBreadcrumb = ({
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
{openLimitModal && (
|
{openLimitModal && (
|
||||||
<ProjectLimitModal
|
<WorkspaceLimitModal
|
||||||
open={openLimitModal}
|
open={openLimitModal}
|
||||||
setOpen={setOpenLimitModal}
|
setOpen={setOpenLimitModal}
|
||||||
buttons={LimitModalButtons()}
|
buttons={LimitModalButtons()}
|
||||||
projectLimit={organizationProjectsLimit}
|
workspaceLimit={organizationWorkspacesLimit}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{openCreateProjectModal && (
|
{openCreateWorkspaceModal && (
|
||||||
<CreateProjectModal
|
<CreateWorkspaceModal
|
||||||
open={openCreateProjectModal}
|
open={openCreateWorkspaceModal}
|
||||||
setOpen={setOpenCreateProjectModal}
|
setOpen={setOpenCreateWorkspaceModal}
|
||||||
organizationId={currentOrganizationId}
|
organizationId={currentOrganizationId}
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
isAccessControlAllowed={isAccessControlAllowed}
|
||||||
/>
|
/>
|
||||||
@@ -3,11 +3,11 @@
|
|||||||
import { createContext, useContext, useMemo } from "react";
|
import { createContext, useContext, useMemo } from "react";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
import { TProject } from "@formbricks/types/project";
|
import { TWorkspace } from "@formbricks/types/workspace";
|
||||||
|
|
||||||
export interface EnvironmentContextType {
|
export interface EnvironmentContextType {
|
||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
project: TProject;
|
workspace: TWorkspace;
|
||||||
organization: TOrganization;
|
organization: TOrganization;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
}
|
}
|
||||||
@@ -22,12 +22,12 @@ export const useEnvironment = () => {
|
|||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useProject = () => {
|
export const useWorkspace = () => {
|
||||||
const context = useContext(EnvironmentContext);
|
const context = useContext(EnvironmentContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
return { project: null };
|
return { workspace: null };
|
||||||
}
|
}
|
||||||
return { project: context.project };
|
return { workspace: context.workspace };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useOrganization = () => {
|
export const useOrganization = () => {
|
||||||
@@ -41,25 +41,25 @@ export const useOrganization = () => {
|
|||||||
// Client wrapper component to be used in server components
|
// Client wrapper component to be used in server components
|
||||||
interface EnvironmentContextWrapperProps {
|
interface EnvironmentContextWrapperProps {
|
||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
project: TProject;
|
workspace: TWorkspace;
|
||||||
organization: TOrganization;
|
organization: TOrganization;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EnvironmentContextWrapper = ({
|
export const EnvironmentContextWrapper = ({
|
||||||
environment,
|
environment,
|
||||||
project,
|
workspace,
|
||||||
organization,
|
organization,
|
||||||
children,
|
children,
|
||||||
}: EnvironmentContextWrapperProps) => {
|
}: EnvironmentContextWrapperProps) => {
|
||||||
const environmentContextValue = useMemo(
|
const environmentContextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
environment,
|
environment,
|
||||||
project,
|
workspace,
|
||||||
organization,
|
organization,
|
||||||
organizationId: project.organizationId,
|
organizationId: workspace.organizationId,
|
||||||
}),
|
}),
|
||||||
[environment, project, organization]
|
[environment, workspace, organization]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const EnvLayout = async (props: {
|
|||||||
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
||||||
<EnvironmentContextWrapper
|
<EnvironmentContextWrapper
|
||||||
environment={layoutData.environment}
|
environment={layoutData.environment}
|
||||||
project={layoutData.project}
|
workspace={layoutData.workspace}
|
||||||
organization={layoutData.organization}>
|
organization={layoutData.organization}>
|
||||||
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
|
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
|
||||||
</EnvironmentContextWrapper>
|
</EnvironmentContextWrapper>
|
||||||
|
|||||||
+33
-33
@@ -3,18 +3,18 @@ import { describe, expect, test, vi } from "vitest";
|
|||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { DatabaseError } from "@formbricks/types/errors";
|
import { DatabaseError } from "@formbricks/types/errors";
|
||||||
import { TMembership } from "@formbricks/types/memberships";
|
import { TMembership } from "@formbricks/types/memberships";
|
||||||
import { getProjectsByUserId } from "./project";
|
import { getWorkspacesByUserId } from "./workspace";
|
||||||
|
|
||||||
vi.mock("@formbricks/database", () => ({
|
vi.mock("@formbricks/database", () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
project: {
|
workspace: {
|
||||||
findMany: vi.fn(),
|
findMany: vi.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("Project", () => {
|
describe("Workspace", () => {
|
||||||
describe("getUserProjects", () => {
|
describe("getUserWorkspaces", () => {
|
||||||
const mockAdminMembership: TMembership = {
|
const mockAdminMembership: TMembership = {
|
||||||
role: "manager",
|
role: "manager",
|
||||||
organizationId: "org1",
|
organizationId: "org1",
|
||||||
@@ -29,17 +29,17 @@ describe("Project", () => {
|
|||||||
accepted: true,
|
accepted: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
test("should return projects for admin role", async () => {
|
test("should return workspaces for admin role", async () => {
|
||||||
const mockProjects = [
|
const mockWorkspaces = [
|
||||||
{ id: "project1", name: "Project 1" },
|
{ id: "workspace1", name: "Workspace 1" },
|
||||||
{ id: "project2", name: "Project 2" },
|
{ id: "workspace2", name: "Workspace 2" },
|
||||||
];
|
];
|
||||||
|
|
||||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
|
vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any);
|
||||||
|
|
||||||
const result = await getProjectsByUserId("user1", mockAdminMembership);
|
const result = await getWorkspacesByUserId("user1", mockAdminMembership);
|
||||||
|
|
||||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
expect(prisma.workspace.findMany).toHaveBeenCalledWith({
|
||||||
where: {
|
where: {
|
||||||
organizationId: "org1",
|
organizationId: "org1",
|
||||||
},
|
},
|
||||||
@@ -48,20 +48,20 @@ describe("Project", () => {
|
|||||||
name: true,
|
name: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result).toEqual(mockProjects);
|
expect(result).toEqual(mockWorkspaces);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return projects for member role with team restrictions", async () => {
|
test("should return workspaces for member role with team restrictions", async () => {
|
||||||
const mockProjects = [{ id: "project1", name: "Project 1" }];
|
const mockWorkspaces = [{ id: "workspace1", name: "Workspace 1" }];
|
||||||
|
|
||||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
|
vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any);
|
||||||
|
|
||||||
const result = await getProjectsByUserId("user1", mockMemberMembership);
|
const result = await getWorkspacesByUserId("user1", mockMemberMembership);
|
||||||
|
|
||||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
expect(prisma.workspace.findMany).toHaveBeenCalledWith({
|
||||||
where: {
|
where: {
|
||||||
organizationId: "org1",
|
organizationId: "org1",
|
||||||
projectTeams: {
|
workspaceTeams: {
|
||||||
some: {
|
some: {
|
||||||
team: {
|
team: {
|
||||||
teamUsers: {
|
teamUsers: {
|
||||||
@@ -78,13 +78,13 @@ describe("Project", () => {
|
|||||||
name: true,
|
name: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result).toEqual(mockProjects);
|
expect(result).toEqual(mockWorkspaces);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return empty array when no projects found", async () => {
|
test("should return empty array when no workspaces found", async () => {
|
||||||
vi.mocked(prisma.project.findMany).mockResolvedValue([]);
|
vi.mocked(prisma.workspace.findMany).mockResolvedValue([]);
|
||||||
|
|
||||||
const result = await getProjectsByUserId("user1", mockAdminMembership);
|
const result = await getWorkspacesByUserId("user1", mockAdminMembership);
|
||||||
|
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -95,27 +95,27 @@ describe("Project", () => {
|
|||||||
clientVersion: "5.0.0",
|
clientVersion: "5.0.0",
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mocked(prisma.project.findMany).mockRejectedValue(prismaError);
|
vi.mocked(prisma.workspace.findMany).mockRejectedValue(prismaError);
|
||||||
|
|
||||||
await expect(getProjectsByUserId("user1", mockAdminMembership)).rejects.toThrow(
|
await expect(getWorkspacesByUserId("user1", mockAdminMembership)).rejects.toThrow(
|
||||||
new DatabaseError("Database error")
|
new DatabaseError("Database error")
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should re-throw unknown errors", async () => {
|
test("should re-throw unknown errors", async () => {
|
||||||
const unknownError = new Error("Unknown error");
|
const unknownError = new Error("Unknown error");
|
||||||
vi.mocked(prisma.project.findMany).mockRejectedValue(unknownError);
|
vi.mocked(prisma.workspace.findMany).mockRejectedValue(unknownError);
|
||||||
|
|
||||||
await expect(getProjectsByUserId("user1", mockAdminMembership)).rejects.toThrow(unknownError);
|
await expect(getWorkspacesByUserId("user1", mockAdminMembership)).rejects.toThrow(unknownError);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should validate inputs correctly", async () => {
|
test("should validate inputs correctly", async () => {
|
||||||
await expect(getProjectsByUserId(123 as any, mockAdminMembership)).rejects.toThrow();
|
await expect(getWorkspacesByUserId(123 as any, mockAdminMembership)).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should validate membership input correctly", async () => {
|
test("should validate membership input correctly", async () => {
|
||||||
const invalidMembership = {} as TMembership;
|
const invalidMembership = {} as TMembership;
|
||||||
await expect(getProjectsByUserId("user1", invalidMembership)).rejects.toThrow();
|
await expect(getWorkspacesByUserId("user1", invalidMembership)).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should handle owner role like manager", async () => {
|
test("should handle owner role like manager", async () => {
|
||||||
@@ -126,12 +126,12 @@ describe("Project", () => {
|
|||||||
accepted: true,
|
accepted: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockProjects = [{ id: "project1", name: "Project 1" }];
|
const mockWorkspaces = [{ id: "workspace1", name: "Workspace 1" }];
|
||||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
|
vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any);
|
||||||
|
|
||||||
const result = await getProjectsByUserId("user1", mockOwnerMembership);
|
const result = await getWorkspacesByUserId("user1", mockOwnerMembership);
|
||||||
|
|
||||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
expect(prisma.workspace.findMany).toHaveBeenCalledWith({
|
||||||
where: {
|
where: {
|
||||||
organizationId: "org1",
|
organizationId: "org1",
|
||||||
},
|
},
|
||||||
@@ -140,7 +140,7 @@ describe("Project", () => {
|
|||||||
name: true,
|
name: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result).toEqual(mockProjects);
|
expect(result).toEqual(mockWorkspaces);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+7
-7
@@ -6,15 +6,15 @@ import { DatabaseError } from "@formbricks/types/errors";
|
|||||||
import { TMembership, ZMembership } from "@formbricks/types/memberships";
|
import { TMembership, ZMembership } from "@formbricks/types/memberships";
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
|
|
||||||
export const getProjectsByUserId = reactCache(
|
export const getWorkspacesByUserId = reactCache(
|
||||||
async (userId: string, orgMembership: TMembership): Promise<{ id: string; name: string }[]> => {
|
async (userId: string, orgMembership: TMembership): Promise<{ id: string; name: string }[]> => {
|
||||||
validateInputs([userId, ZString], [orgMembership, ZMembership]);
|
validateInputs([userId, ZString], [orgMembership, ZMembership]);
|
||||||
|
|
||||||
let projectWhereClause: Prisma.ProjectWhereInput = {};
|
let workspaceWhereClause: Prisma.WorkspaceWhereInput = {};
|
||||||
|
|
||||||
if (orgMembership.role === "member") {
|
if (orgMembership.role === "member") {
|
||||||
projectWhereClause = {
|
workspaceWhereClause = {
|
||||||
projectTeams: {
|
workspaceTeams: {
|
||||||
some: {
|
some: {
|
||||||
team: {
|
team: {
|
||||||
teamUsers: {
|
teamUsers: {
|
||||||
@@ -29,17 +29,17 @@ export const getProjectsByUserId = reactCache(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const projects = await prisma.project.findMany({
|
const workspaces = await prisma.workspace.findMany({
|
||||||
where: {
|
where: {
|
||||||
organizationId: orgMembership.organizationId,
|
organizationId: orgMembership.organizationId,
|
||||||
...projectWhereClause,
|
...workspaceWhereClause,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return projects;
|
return workspaces;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
throw new DatabaseError(error.message);
|
throw new DatabaseError(error.message);
|
||||||
@@ -4,7 +4,7 @@ import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
|||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
|
|
||||||
const EnvironmentPage = async (props) => {
|
const EnvironmentPage = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const { session, organization } = await getEnvironmentAuth(params.environmentId);
|
const { session, organization } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,35 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
|
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
|
|
||||||
const AccountSettingsLayout = async (props) => {
|
const AccountSettingsLayout = async (props: {
|
||||||
|
params: Promise<{ environmentId: string }>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
|
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const [organization, project, session] = await Promise.all([
|
const [organization, workspace, session] = await Promise.all([
|
||||||
getOrganizationByEnvironmentId(params.environmentId),
|
getOrganizationByEnvironmentId(params.environmentId),
|
||||||
getProjectByEnvironmentId(params.environmentId),
|
getWorkspaceByEnvironmentId(params.environmentId),
|
||||||
getServerSession(authOptions),
|
getServerSession(authOptions),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new Error(t("common.organization_not_found"));
|
throw new ResourceNotFoundError(t("common.organization"), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!project) {
|
if (!workspace) {
|
||||||
throw new Error(t("common.workspace_not_found"));
|
throw new Error(t("common.workspace_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error(t("common.session_not_found"));
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
+11
-22
@@ -4,7 +4,6 @@ import { z } from "zod";
|
|||||||
import { ZUserNotificationSettings } from "@formbricks/types/user";
|
import { ZUserNotificationSettings } from "@formbricks/types/user";
|
||||||
import { getUser, updateUser } from "@/lib/user/service";
|
import { getUser, updateUser } from "@/lib/user/service";
|
||||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
|
||||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
|
|
||||||
const ZUpdateNotificationSettingsAction = z.object({
|
const ZUpdateNotificationSettingsAction = z.object({
|
||||||
@@ -12,26 +11,16 @@ const ZUpdateNotificationSettingsAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const updateNotificationSettingsAction = authenticatedActionClient
|
export const updateNotificationSettingsAction = authenticatedActionClient
|
||||||
.schema(ZUpdateNotificationSettingsAction)
|
.inputSchema(ZUpdateNotificationSettingsAction)
|
||||||
.action(
|
.action(
|
||||||
withAuditLogging(
|
withAuditLogging("updated", "user", async ({ ctx, parsedInput }) => {
|
||||||
"updated",
|
const oldObject = await getUser(ctx.user.id);
|
||||||
"user",
|
const result = await updateUser(ctx.user.id, {
|
||||||
async ({
|
notificationSettings: parsedInput.notificationSettings,
|
||||||
ctx,
|
});
|
||||||
parsedInput,
|
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||||
}: {
|
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||||
ctx: AuthenticatedActionClientCtx;
|
ctx.auditLoggingCtx.newObject = result;
|
||||||
parsedInput: Record<string, any>;
|
return result;
|
||||||
}) => {
|
})
|
||||||
const oldObject = await getUser(ctx.user.id);
|
|
||||||
const result = await updateUser(ctx.user.id, {
|
|
||||||
notificationSettings: parsedInput.notificationSettings,
|
|
||||||
});
|
|
||||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
|
||||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
|
||||||
ctx.auditLoggingCtx.newObject = result;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|||||||
+8
-8
@@ -40,7 +40,7 @@ export const EditAlerts = ({
|
|||||||
{t("environments.settings.notifications.auto_subscribe_to_new_surveys")}
|
{t("environments.settings.notifications.auto_subscribe_to_new_surveys")}
|
||||||
</p>
|
</p>
|
||||||
<NotificationSwitch
|
<NotificationSwitch
|
||||||
surveyOrProjectOrOrganizationId={membership.organization.id}
|
surveyOrWorkspaceOrOrganizationId={membership.organization.id}
|
||||||
notificationSettings={user.notificationSettings!}
|
notificationSettings={user.notificationSettings!}
|
||||||
notificationType={"unsubscribedOrganizationIds"}
|
notificationType={"unsubscribedOrganizationIds"}
|
||||||
autoDisableNotificationType={autoDisableNotificationType}
|
autoDisableNotificationType={autoDisableNotificationType}
|
||||||
@@ -66,13 +66,13 @@ export const EditAlerts = ({
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{membership.organization.projects.some((project) =>
|
{membership.organization.workspaces.some((workspace) =>
|
||||||
project.environments.some((environment) => environment.surveys.length > 0)
|
workspace.environments.some((environment) => environment.surveys.length > 0)
|
||||||
) ? (
|
) ? (
|
||||||
<div className="grid-cols-8 space-y-1 p-2">
|
<div className="grid-cols-8 space-y-1 p-2">
|
||||||
{membership.organization.projects.map((project) => (
|
{membership.organization.workspaces.map((workspace) => (
|
||||||
<div key={project.id}>
|
<div key={workspace.id}>
|
||||||
{project.environments.map((environment) => (
|
{workspace.environments.map((environment) => (
|
||||||
<div key={environment.id}>
|
<div key={environment.id}>
|
||||||
{environment.surveys.map((survey) => (
|
{environment.surveys.map((survey) => (
|
||||||
<div
|
<div
|
||||||
@@ -80,11 +80,11 @@ export const EditAlerts = ({
|
|||||||
key={survey.name}>
|
key={survey.name}>
|
||||||
<div className="col-span-2 text-left">
|
<div className="col-span-2 text-left">
|
||||||
<div className="font-medium text-slate-900">{survey.name}</div>
|
<div className="font-medium text-slate-900">{survey.name}</div>
|
||||||
<div className="text-xs text-slate-400">{project.name}</div>
|
<div className="text-xs text-slate-400">{workspace.name}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-1 text-center">
|
<div className="col-span-1 text-center">
|
||||||
<NotificationSwitch
|
<NotificationSwitch
|
||||||
surveyOrProjectOrOrganizationId={survey.id}
|
surveyOrWorkspaceOrOrganizationId={survey.id}
|
||||||
notificationSettings={user.notificationSettings!}
|
notificationSettings={user.notificationSettings!}
|
||||||
notificationType={"alert"}
|
notificationType={"alert"}
|
||||||
autoDisableNotificationType={autoDisableNotificationType}
|
autoDisableNotificationType={autoDisableNotificationType}
|
||||||
|
|||||||
+17
-12
@@ -10,7 +10,7 @@ import { Switch } from "@/modules/ui/components/switch";
|
|||||||
import { updateNotificationSettingsAction } from "../actions";
|
import { updateNotificationSettingsAction } from "../actions";
|
||||||
|
|
||||||
interface NotificationSwitchProps {
|
interface NotificationSwitchProps {
|
||||||
surveyOrProjectOrOrganizationId: string;
|
surveyOrWorkspaceOrOrganizationId: string;
|
||||||
notificationSettings: TUserNotificationSettings;
|
notificationSettings: TUserNotificationSettings;
|
||||||
notificationType: "alert" | "unsubscribedOrganizationIds";
|
notificationType: "alert" | "unsubscribedOrganizationIds";
|
||||||
autoDisableNotificationType?: string;
|
autoDisableNotificationType?: string;
|
||||||
@@ -18,7 +18,7 @@ interface NotificationSwitchProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const NotificationSwitch = ({
|
export const NotificationSwitch = ({
|
||||||
surveyOrProjectOrOrganizationId,
|
surveyOrWorkspaceOrOrganizationId,
|
||||||
notificationSettings,
|
notificationSettings,
|
||||||
notificationType,
|
notificationType,
|
||||||
autoDisableNotificationType,
|
autoDisableNotificationType,
|
||||||
@@ -29,8 +29,8 @@ export const NotificationSwitch = ({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isChecked =
|
const isChecked =
|
||||||
notificationType === "unsubscribedOrganizationIds"
|
notificationType === "unsubscribedOrganizationIds"
|
||||||
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
|
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrWorkspaceOrOrganizationId)
|
||||||
: notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true;
|
: notificationSettings[notificationType]?.[surveyOrWorkspaceOrOrganizationId] === true;
|
||||||
|
|
||||||
const handleSwitchChange = async () => {
|
const handleSwitchChange = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -38,19 +38,22 @@ export const NotificationSwitch = ({
|
|||||||
let updatedNotificationSettings = { ...notificationSettings };
|
let updatedNotificationSettings = { ...notificationSettings };
|
||||||
if (notificationType === "unsubscribedOrganizationIds") {
|
if (notificationType === "unsubscribedOrganizationIds") {
|
||||||
const unsubscribedOrganizationIds = updatedNotificationSettings.unsubscribedOrganizationIds ?? [];
|
const unsubscribedOrganizationIds = updatedNotificationSettings.unsubscribedOrganizationIds ?? [];
|
||||||
if (unsubscribedOrganizationIds.includes(surveyOrProjectOrOrganizationId)) {
|
if (unsubscribedOrganizationIds.includes(surveyOrWorkspaceOrOrganizationId)) {
|
||||||
updatedNotificationSettings.unsubscribedOrganizationIds = unsubscribedOrganizationIds.filter(
|
updatedNotificationSettings.unsubscribedOrganizationIds = unsubscribedOrganizationIds.filter(
|
||||||
(id) => id !== surveyOrProjectOrOrganizationId
|
(id) => id !== surveyOrWorkspaceOrOrganizationId
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
updatedNotificationSettings.unsubscribedOrganizationIds = [
|
updatedNotificationSettings.unsubscribedOrganizationIds = [
|
||||||
...unsubscribedOrganizationIds,
|
...unsubscribedOrganizationIds,
|
||||||
surveyOrProjectOrOrganizationId,
|
surveyOrWorkspaceOrOrganizationId,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId] =
|
updatedNotificationSettings[notificationType] = {
|
||||||
!updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId];
|
...updatedNotificationSettings[notificationType],
|
||||||
|
[surveyOrWorkspaceOrOrganizationId]:
|
||||||
|
!updatedNotificationSettings[notificationType]?.[surveyOrWorkspaceOrOrganizationId],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
|
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
|
||||||
@@ -73,12 +76,12 @@ export const NotificationSwitch = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
autoDisableNotificationType &&
|
autoDisableNotificationType &&
|
||||||
autoDisableNotificationElementId === surveyOrProjectOrOrganizationId &&
|
autoDisableNotificationElementId === surveyOrWorkspaceOrOrganizationId &&
|
||||||
isChecked
|
isChecked
|
||||||
) {
|
) {
|
||||||
switch (notificationType) {
|
switch (notificationType) {
|
||||||
case "alert":
|
case "alert":
|
||||||
if (notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true) {
|
if (notificationSettings[notificationType]?.[surveyOrWorkspaceOrOrganizationId] === true) {
|
||||||
handleSwitchChange();
|
handleSwitchChange();
|
||||||
toast.success(
|
toast.success(
|
||||||
t(
|
t(
|
||||||
@@ -92,7 +95,9 @@ export const NotificationSwitch = ({
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "unsubscribedOrganizationIds":
|
case "unsubscribedOrganizationIds":
|
||||||
if (!notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)) {
|
if (
|
||||||
|
!notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrWorkspaceOrOrganizationId)
|
||||||
|
) {
|
||||||
handleSwitchChange();
|
handleSwitchChange();
|
||||||
toast.success(
|
toast.success(
|
||||||
t(
|
t(
|
||||||
|
|||||||
+22
-17
@@ -1,5 +1,6 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||||
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||||
@@ -16,17 +17,18 @@ const setCompleteNotificationSettings = (
|
|||||||
notificationSettings: TUserNotificationSettings,
|
notificationSettings: TUserNotificationSettings,
|
||||||
memberships: Membership[]
|
memberships: Membership[]
|
||||||
): TUserNotificationSettings => {
|
): TUserNotificationSettings => {
|
||||||
const newNotificationSettings = {
|
const newNotificationSettings: TUserNotificationSettings = {
|
||||||
alert: {},
|
alert: {} as Record<string, boolean>,
|
||||||
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
|
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
|
||||||
};
|
};
|
||||||
for (const membership of memberships) {
|
for (const membership of memberships) {
|
||||||
for (const project of membership.organization.projects) {
|
for (const workspace of membership.organization.workspaces) {
|
||||||
// set default values for alerts
|
// set default values for alerts
|
||||||
for (const environment of project.environments) {
|
for (const environment of workspace.environments) {
|
||||||
for (const survey of environment.surveys) {
|
for (const survey of environment.surveys) {
|
||||||
newNotificationSettings.alert[survey.id] =
|
newNotificationSettings.alert[survey.id] =
|
||||||
notificationSettings[survey.id]?.responseFinished ||
|
(notificationSettings as unknown as Record<string, Record<string, boolean>>)[survey.id]
|
||||||
|
?.responseFinished ||
|
||||||
(notificationSettings.alert && notificationSettings.alert[survey.id]) ||
|
(notificationSettings.alert && notificationSettings.alert[survey.id]) ||
|
||||||
false; // check for legacy notification settings w/o "alerts" key
|
false; // check for legacy notification settings w/o "alerts" key
|
||||||
}
|
}
|
||||||
@@ -45,17 +47,17 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
|
|||||||
},
|
},
|
||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
// Fetch all projects if user role is owner or manager
|
// Fetch all workspaces if user role is owner or manager
|
||||||
role: {
|
role: {
|
||||||
in: ["owner", "manager"],
|
in: ["owner", "manager"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Filter projects based on team membership if user is not owner or manager
|
// Filter workspaces based on team membership if user is not owner or manager
|
||||||
organization: {
|
organization: {
|
||||||
projects: {
|
workspaces: {
|
||||||
some: {
|
some: {
|
||||||
projectTeams: {
|
workspaceTeams: {
|
||||||
some: {
|
some: {
|
||||||
team: {
|
team: {
|
||||||
teamUsers: {
|
teamUsers: {
|
||||||
@@ -77,12 +79,12 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
projects: {
|
workspaces: {
|
||||||
// Apply conditional filtering based on user's role
|
// Apply conditional filtering based on user's role
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
// Fetch all projects if user is owner or manager
|
// Fetch all workspaces if user is owner or manager
|
||||||
organization: {
|
organization: {
|
||||||
memberships: {
|
memberships: {
|
||||||
some: {
|
some: {
|
||||||
@@ -95,8 +97,8 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Only include projects accessible through teams if user is not owner or manager
|
// Only include workspaces accessible through teams if user is not owner or manager
|
||||||
projectTeams: {
|
workspaceTeams: {
|
||||||
some: {
|
some: {
|
||||||
team: {
|
team: {
|
||||||
teamUsers: {
|
teamUsers: {
|
||||||
@@ -136,24 +138,27 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
|
|||||||
return memberships;
|
return memberships;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Page = async (props) => {
|
const Page = async (props: {
|
||||||
|
params: Promise<{ environmentId: string }>;
|
||||||
|
searchParams: Promise<Record<string, string>>;
|
||||||
|
}) => {
|
||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error(t("common.session_not_found"));
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
}
|
}
|
||||||
const autoDisableNotificationType = searchParams["type"];
|
const autoDisableNotificationType = searchParams["type"];
|
||||||
const autoDisableNotificationElementId = searchParams["elementId"];
|
const autoDisableNotificationElementId = searchParams["elementId"];
|
||||||
|
|
||||||
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
|
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(t("common.user_not_found"));
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!memberships) {
|
if (!memberships) {
|
||||||
throw new Error(t("common.membership_not_found"));
|
throw new ResourceNotFoundError(t("common.membership"), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user?.notificationSettings) {
|
if (user?.notificationSettings) {
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ export interface Membership {
|
|||||||
organization: {
|
organization: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
projects: {
|
workspaces: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
environments: {
|
environments: {
|
||||||
|
|||||||
+27
-41
@@ -20,7 +20,7 @@ import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
|||||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
|
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
|
||||||
|
|
||||||
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
|
function buildUserUpdatePayload(parsedInput: TUserPersonalInfoUpdateInput): TUserUpdateInput {
|
||||||
return {
|
return {
|
||||||
...(parsedInput.name && { name: parsedInput.name }),
|
...(parsedInput.name && { name: parsedInput.name }),
|
||||||
...(parsedInput.locale && { locale: parsedInput.locale }),
|
...(parsedInput.locale && { locale: parsedInput.locale }),
|
||||||
@@ -63,50 +63,36 @@ async function handleEmailUpdate({
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action(
|
export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPersonalInfoUpdateInput).action(
|
||||||
withAuditLogging(
|
withAuditLogging("updated", "user", async ({ ctx, parsedInput }) => {
|
||||||
"updated",
|
const oldObject = await getUser(ctx.user.id);
|
||||||
"user",
|
let payload = buildUserUpdatePayload(parsedInput);
|
||||||
async ({
|
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
|
||||||
ctx,
|
|
||||||
parsedInput,
|
|
||||||
}: {
|
|
||||||
ctx: AuthenticatedActionClientCtx;
|
|
||||||
parsedInput: TUserPersonalInfoUpdateInput;
|
|
||||||
}) => {
|
|
||||||
const oldObject = await getUser(ctx.user.id);
|
|
||||||
let payload = buildUserUpdatePayload(parsedInput);
|
|
||||||
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
|
|
||||||
|
|
||||||
// Only proceed with updateUser if we have actual changes to make
|
// Only proceed with updateUser if we have actual changes to make
|
||||||
let newObject = oldObject;
|
let newObject = oldObject;
|
||||||
if (Object.keys(payload).length > 0) {
|
if (Object.keys(payload).length > 0) {
|
||||||
newObject = await updateUser(ctx.user.id, payload);
|
newObject = await updateUser(ctx.user.id, payload);
|
||||||
}
|
|
||||||
|
|
||||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
|
||||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
|
||||||
ctx.auditLoggingCtx.newObject = newObject;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||||
|
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||||
|
ctx.auditLoggingCtx.newObject = newObject;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export const resetPasswordAction = authenticatedActionClient.action(
|
export const resetPasswordAction = authenticatedActionClient.action(
|
||||||
withAuditLogging(
|
withAuditLogging("passwordReset", "user", async ({ ctx }) => {
|
||||||
"passwordReset",
|
if (ctx.user.identityProvider !== "email") {
|
||||||
"user",
|
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
|
||||||
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
|
|
||||||
if (ctx.user.identityProvider !== "email") {
|
|
||||||
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await sendForgotPasswordEmail(ctx.user);
|
|
||||||
|
|
||||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
await sendForgotPasswordEmail(ctx.user);
|
||||||
|
|
||||||
|
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
+49
-36
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
|
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
|
||||||
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
|
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
|
||||||
import { appLanguages } from "@/lib/i18n/utils";
|
import { appLanguages, sortedAppLanguages } from "@/lib/i18n/utils";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
@@ -198,41 +198,54 @@ export const EditProfileDetailsForm = ({
|
|||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="locale"
|
name="locale"
|
||||||
render={({ field }) => (
|
render={({ field }) => {
|
||||||
<FormItem className="mt-4">
|
const selectedLanguage = appLanguages.find((l) => l.code === field.value);
|
||||||
<FormLabel>{t("common.language")}</FormLabel>
|
|
||||||
<FormControl>
|
return (
|
||||||
<DropdownMenu>
|
<FormItem className="mt-4">
|
||||||
<DropdownMenuTrigger asChild>
|
<FormLabel>{t("common.language")}</FormLabel>
|
||||||
<Button
|
<FormControl>
|
||||||
type="button"
|
<DropdownMenu>
|
||||||
variant="ghost"
|
<DropdownMenuTrigger asChild>
|
||||||
className="h-10 w-full border border-slate-300 px-3 text-left">
|
<Button
|
||||||
<div className="flex w-full items-center justify-between">
|
type="button"
|
||||||
{appLanguages.find((l) => l.code === field.value)?.label["en-US"] ?? "NA"}
|
variant="ghost"
|
||||||
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
|
className="h-10 w-full border border-slate-300 px-3 text-left">
|
||||||
</div>
|
<div className="flex w-full items-center justify-between">
|
||||||
</Button>
|
{selectedLanguage ? (
|
||||||
</DropdownMenuTrigger>
|
<>
|
||||||
<DropdownMenuContent
|
{selectedLanguage.label["en-US"]}
|
||||||
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
|
{selectedLanguage.label.native !== selectedLanguage.label["en-US"] &&
|
||||||
align="start">
|
` (${selectedLanguage.label.native})`}
|
||||||
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
|
</>
|
||||||
{appLanguages.map((lang) => (
|
) : (
|
||||||
<DropdownMenuRadioItem
|
t("common.select")
|
||||||
key={lang.code}
|
)}
|
||||||
value={lang.code}
|
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
|
||||||
className="min-h-8 cursor-pointer">
|
</div>
|
||||||
{lang.label["en-US"]}
|
</Button>
|
||||||
</DropdownMenuRadioItem>
|
</DropdownMenuTrigger>
|
||||||
))}
|
<DropdownMenuContent
|
||||||
</DropdownMenuRadioGroup>
|
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
|
||||||
</DropdownMenuContent>
|
align="start">
|
||||||
</DropdownMenu>
|
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
|
||||||
</FormControl>
|
{sortedAppLanguages.map((lang) => (
|
||||||
<FormError />
|
<DropdownMenuRadioItem
|
||||||
</FormItem>
|
key={lang.code}
|
||||||
)}
|
value={lang.code}
|
||||||
|
className="min-h-8 cursor-pointer">
|
||||||
|
{lang.label["en-US"]}
|
||||||
|
{lang.label.native !== lang.label["en-US"] && ` (${lang.label.native})`}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</FormControl>
|
||||||
|
<FormError />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isPasswordResetEnabled && (
|
{isPasswordResetEnabled && (
|
||||||
|
|||||||
+1
-1
@@ -98,7 +98,7 @@ export const PasswordConfirmationModal = ({
|
|||||||
aria-label="password"
|
aria-label="password"
|
||||||
aria-required="true"
|
aria-required="true"
|
||||||
required
|
required
|
||||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
className="block w-full rounded-md border-slate-300 shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm"
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onChange={(password) => field.onChange(password)}
|
onChange={(password) => field.onChange(password)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AuthenticationError } from "@formbricks/types/errors";
|
||||||
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||||
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
|
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
|
||||||
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||||
@@ -28,7 +29,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
const user = session?.user ? await getUser(session.user.id) : null;
|
const user = session?.user ? await getUser(session.user.id) : null;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(t("common.user_not_found"));
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
|
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
|
||||||
@@ -60,7 +61,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
buttons={[
|
buttons={[
|
||||||
{
|
{
|
||||||
text: IS_FORMBRICKS_CLOUD
|
text: IS_FORMBRICKS_CLOUD
|
||||||
? t("common.start_free_trial")
|
? t("common.upgrade_plan")
|
||||||
: t("common.request_trial_license"),
|
: t("common.request_trial_license"),
|
||||||
href: IS_FORMBRICKS_CLOUD
|
href: IS_FORMBRICKS_CLOUD
|
||||||
? `/environments/${params.environmentId}/settings/billing`
|
? `/environments/${params.environmentId}/settings/billing`
|
||||||
|
|||||||
+7
@@ -40,6 +40,13 @@ export const OrganizationSettingsNavbar = ({
|
|||||||
href: `/environments/${environmentId}/settings/teams`,
|
href: `/environments/${environmentId}/settings/teams`,
|
||||||
current: pathname?.includes("/teams"),
|
current: pathname?.includes("/teams"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "feedback-record-directories",
|
||||||
|
label: t("environments.settings.feedback_record_directories.nav_label"),
|
||||||
|
href: `/environments/${environmentId}/settings/feedback-record-directories`,
|
||||||
|
current: pathname?.includes("/feedback-record-directories"),
|
||||||
|
hidden: isMember,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "api-keys",
|
id: "api-keys",
|
||||||
label: t("common.api_keys"),
|
label: t("common.api_keys"),
|
||||||
|
|||||||
+3
-3
@@ -14,7 +14,7 @@ interface SurveyWithSlug {
|
|||||||
environment: {
|
environment: {
|
||||||
id: string;
|
id: string;
|
||||||
type: "production" | "development";
|
type: "production" | "development";
|
||||||
project: {
|
workspace: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
@@ -40,7 +40,7 @@ export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("environments.settings.domain.workspace"),
|
label: t("environments.settings.domain.workspace"),
|
||||||
key: "project",
|
key: "workspace",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("environments.settings.domain.pretty_url"),
|
label: t("environments.settings.domain.pretty_url"),
|
||||||
@@ -81,7 +81,7 @@ export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
|
|||||||
{survey.name}
|
{survey.name}
|
||||||
</Link>
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{survey.environment.project.name}</TableCell>
|
<TableCell>{survey.environment.workspace.name}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<IdBadge id={survey.slug ?? ""} />
|
<IdBadge id={survey.slug ?? ""} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
+3
-2
@@ -1,4 +1,5 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { AuthenticationError } from "@formbricks/types/errors";
|
||||||
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
|
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
|
||||||
@@ -25,10 +26,10 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error(t("common.session_not_found"));
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan);
|
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
|
||||||
const isOwnerOrManager = isManager || isOwner;
|
const isOwnerOrManager = isManager || isOwner;
|
||||||
|
|
||||||
const surveys = await getSurveysWithSlugsByOrganizationId(organization.id);
|
const surveys = await getSurveysWithSlugsByOrganizationId(organization.id);
|
||||||
|
|||||||
+146
@@ -0,0 +1,146 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||||
|
import type { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
|
||||||
|
import { Badge } from "@/modules/ui/components/badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||||
|
|
||||||
|
type TPublicLicenseFeatureKey = Exclude<keyof TEnterpriseLicenseFeatures, "isMultiOrgEnabled" | "ai">;
|
||||||
|
|
||||||
|
type TFeatureDefinition = {
|
||||||
|
key: TPublicLicenseFeatureKey;
|
||||||
|
labelKey: string;
|
||||||
|
docsUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "contacts",
|
||||||
|
labelKey: t("environments.settings.enterprise.license_feature_contacts"),
|
||||||
|
docsUrl:
|
||||||
|
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/contact-management-segments",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "workspaces",
|
||||||
|
labelKey: t("environments.settings.enterprise.license_feature_workspaces"),
|
||||||
|
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/license",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "whitelabel",
|
||||||
|
labelKey: t("environments.settings.enterprise.license_feature_whitelabel"),
|
||||||
|
docsUrl:
|
||||||
|
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/whitelabel-email-follow-ups",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "removeBranding",
|
||||||
|
labelKey: t("environments.settings.enterprise.license_feature_remove_branding"),
|
||||||
|
docsUrl:
|
||||||
|
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/hide-powered-by-formbricks",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "twoFactorAuth",
|
||||||
|
labelKey: t("environments.settings.enterprise.license_feature_two_factor_auth"),
|
||||||
|
docsUrl: "https://formbricks.com/docs/xm-and-surveys/core-features/user-management/two-factor-auth",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "sso",
|
||||||
|
labelKey: t("environments.settings.enterprise.license_feature_sso"),
|
||||||
|
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/oidc-sso",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "saml",
|
||||||
|
labelKey: t("environments.settings.enterprise.license_feature_saml"),
|
||||||
|
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/saml-sso",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "spamProtection",
|
||||||
|
labelKey: t("environments.settings.enterprise.license_feature_spam_protection"),
|
||||||
|
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/spam-protection",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "auditLogs",
|
||||||
|
labelKey: t("environments.settings.enterprise.license_feature_audit_logs"),
|
||||||
|
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/audit-logging",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "accessControl",
|
||||||
|
labelKey: t("environments.settings.enterprise.license_feature_access_control"),
|
||||||
|
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/team-access",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "quotas",
|
||||||
|
labelKey: t("environments.settings.enterprise.license_feature_quotas"),
|
||||||
|
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/quota-management",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface EnterpriseLicenseFeaturesTableProps {
|
||||||
|
features: TEnterpriseLicenseFeatures;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EnterpriseLicenseFeaturesTable = ({ features }: EnterpriseLicenseFeaturesTableProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsCard
|
||||||
|
title={t("environments.settings.enterprise.license_features_table_title")}
|
||||||
|
description={t("environments.settings.enterprise.license_features_table_description")}
|
||||||
|
noPadding>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="hover:bg-white">
|
||||||
|
<TableHead>{t("environments.settings.enterprise.license_features_table_feature")}</TableHead>
|
||||||
|
<TableHead>{t("environments.settings.enterprise.license_features_table_access")}</TableHead>
|
||||||
|
<TableHead>{t("environments.settings.enterprise.license_features_table_value")}</TableHead>
|
||||||
|
<TableHead>{t("common.documentation")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{getFeatureDefinitions(t).map((feature) => {
|
||||||
|
const value = features[feature.key];
|
||||||
|
const isEnabled = typeof value === "boolean" ? value : value === null || value > 0;
|
||||||
|
let displayValue: number | string = "—";
|
||||||
|
|
||||||
|
if (typeof value === "number") {
|
||||||
|
displayValue = value;
|
||||||
|
} else if (value === null) {
|
||||||
|
displayValue = t("environments.settings.enterprise.license_features_table_unlimited");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={feature.key} className="hover:bg-white">
|
||||||
|
<TableCell className="font-medium text-slate-900">{t(feature.labelKey)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
type={isEnabled ? "success" : "gray"}
|
||||||
|
size="normal"
|
||||||
|
text={
|
||||||
|
isEnabled
|
||||||
|
? t("environments.settings.enterprise.license_features_table_enabled")
|
||||||
|
: t("environments.settings.enterprise.license_features_table_disabled")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-slate-600">{displayValue}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
href={feature.docsUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm font-medium text-slate-700 underline underline-offset-2 hover:text-slate-900">
|
||||||
|
{t("common.read_docs")}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</SettingsCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { TFunction } from "i18next";
|
||||||
|
import { RotateCcwIcon } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
||||||
|
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
|
||||||
|
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
|
||||||
|
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||||
|
import { Badge } from "@/modules/ui/components/badge";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { SettingsCard } from "../../../components/SettingsCard";
|
||||||
|
|
||||||
|
interface EnterpriseLicenseStatusProps {
|
||||||
|
status: TLicenseStatus;
|
||||||
|
lastChecked: Date;
|
||||||
|
gracePeriodEnd?: Date;
|
||||||
|
environmentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBadgeConfig = (
|
||||||
|
status: TLicenseStatus,
|
||||||
|
t: TFunction
|
||||||
|
): { type: "success" | "error" | "warning" | "gray"; label: string } => {
|
||||||
|
switch (status) {
|
||||||
|
case "active":
|
||||||
|
return { type: "success", label: t("environments.settings.enterprise.license_status_active") };
|
||||||
|
case "expired":
|
||||||
|
return { type: "error", label: t("environments.settings.enterprise.license_status_expired") };
|
||||||
|
case "instance_mismatch":
|
||||||
|
return {
|
||||||
|
type: "error",
|
||||||
|
label: t("environments.settings.enterprise.license_status_instance_mismatch"),
|
||||||
|
};
|
||||||
|
case "unreachable":
|
||||||
|
return { type: "warning", label: t("environments.settings.enterprise.license_status_unreachable") };
|
||||||
|
case "invalid_license":
|
||||||
|
return { type: "error", label: t("environments.settings.enterprise.license_status_invalid") };
|
||||||
|
default:
|
||||||
|
return { type: "gray", label: t("environments.settings.enterprise.license_status") };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EnterpriseLicenseStatus = ({
|
||||||
|
status,
|
||||||
|
lastChecked,
|
||||||
|
gracePeriodEnd,
|
||||||
|
environmentId,
|
||||||
|
}: EnterpriseLicenseStatusProps) => {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
|
||||||
|
const router = useRouter();
|
||||||
|
const [isRechecking, setIsRechecking] = useState(false);
|
||||||
|
|
||||||
|
const handleRecheck = async () => {
|
||||||
|
setIsRechecking(true);
|
||||||
|
try {
|
||||||
|
const result = await recheckLicenseAction({ environmentId });
|
||||||
|
if (result?.serverError) {
|
||||||
|
toast.error(result.serverError || t("environments.settings.enterprise.recheck_license_failed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result?.data) {
|
||||||
|
if (result.data.status === "unreachable") {
|
||||||
|
toast.error(t("environments.settings.enterprise.recheck_license_unreachable"));
|
||||||
|
} else if (result.data.status === "instance_mismatch") {
|
||||||
|
toast.error(t("environments.settings.enterprise.recheck_license_instance_mismatch"));
|
||||||
|
} else if (result.data.status === "invalid_license") {
|
||||||
|
toast.error(t("environments.settings.enterprise.recheck_license_invalid"));
|
||||||
|
} else {
|
||||||
|
toast.success(t("environments.settings.enterprise.recheck_license_success"));
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
toast.error(t("environments.settings.enterprise.recheck_license_failed"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : t("environments.settings.enterprise.recheck_license_failed")
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsRechecking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const badgeConfig = getBadgeConfig(status, t);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsCard
|
||||||
|
title={t("environments.settings.enterprise.license_status")}
|
||||||
|
description={t("environments.settings.enterprise.license_status_description")}>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
|
||||||
|
<span className="text-sm text-slate-500">
|
||||||
|
{t("common.updated_at")} {formatDateTimeForDisplay(new Date(lastChecked), locale)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRecheck}
|
||||||
|
disabled={isRechecking}
|
||||||
|
className="shrink-0">
|
||||||
|
{isRechecking ? (
|
||||||
|
<>
|
||||||
|
<RotateCcwIcon className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
{t("environments.settings.enterprise.rechecking")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RotateCcwIcon className="mr-2 h-4 w-4" />
|
||||||
|
{t("environments.settings.enterprise.recheck_license")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{status === "unreachable" && gracePeriodEnd && (
|
||||||
|
<Alert variant="warning" size="small">
|
||||||
|
<AlertDescription className="overflow-visible whitespace-normal">
|
||||||
|
{t("environments.settings.enterprise.license_unreachable_grace_period", {
|
||||||
|
gracePeriodEnd: formatDateForDisplay(new Date(gracePeriodEnd), locale, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
}),
|
||||||
|
})}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{status === "invalid_license" && (
|
||||||
|
<Alert variant="error" size="small">
|
||||||
|
<AlertDescription className="overflow-visible whitespace-normal">
|
||||||
|
{t("environments.settings.enterprise.license_invalid_description")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{status === "instance_mismatch" && (
|
||||||
|
<Alert variant="error" size="small">
|
||||||
|
<AlertDescription className="overflow-visible whitespace-normal">
|
||||||
|
{t("environments.settings.enterprise.license_instance_mismatch_description")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<p className="border-t border-slate-100 pt-4 text-sm text-slate-500">
|
||||||
|
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
|
||||||
|
<a
|
||||||
|
className="font-medium text-slate-700 underline hover:text-slate-900"
|
||||||
|
href="mailto:hola@formbricks.com">
|
||||||
|
hola@formbricks.com
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
+23
-29
@@ -2,15 +2,17 @@ import { CheckIcon } from "lucide-react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||||
|
import { EnterpriseLicenseStatus } from "@/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/components/EnterpriseLicenseStatus";
|
||||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
|
import { EnterpriseLicenseFeaturesTable } from "./components/EnterpriseLicenseFeaturesTable";
|
||||||
|
|
||||||
const Page = async (props) => {
|
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
if (IS_FORMBRICKS_CLOUD) {
|
if (IS_FORMBRICKS_CLOUD) {
|
||||||
@@ -25,7 +27,8 @@ const Page = async (props) => {
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { active: isEnterpriseEdition } = await getEnterpriseLicense();
|
const licenseState = await getEnterpriseLicense();
|
||||||
|
const hasLicense = licenseState.status !== "no-license";
|
||||||
|
|
||||||
const paidFeatures = [
|
const paidFeatures = [
|
||||||
{
|
{
|
||||||
@@ -90,35 +93,26 @@ const Page = async (props) => {
|
|||||||
activeId="enterprise"
|
activeId="enterprise"
|
||||||
/>
|
/>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
{isEnterpriseEdition ? (
|
{hasLicense ? (
|
||||||
<div>
|
<>
|
||||||
<div className="mt-8 max-w-4xl rounded-lg border border-slate-300 bg-slate-100 shadow-sm">
|
<EnterpriseLicenseStatus
|
||||||
<div className="space-y-4 p-8">
|
status={licenseState.status}
|
||||||
<div className="flex items-center gap-x-2">
|
lastChecked={licenseState.lastChecked}
|
||||||
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800">
|
gracePeriodEnd={
|
||||||
<CheckIcon className="h-5 w-5 p-0.5 text-green-500 dark:text-green-400" />
|
licenseState.status === "unreachable"
|
||||||
</div>
|
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
|
||||||
<p className="text-slate-800">
|
: undefined
|
||||||
{t(
|
}
|
||||||
"environments.settings.enterprise.your_enterprise_license_is_active_all_features_unlocked"
|
environmentId={params.environmentId}
|
||||||
)}
|
/>
|
||||||
</p>
|
{licenseState.features && <EnterpriseLicenseFeaturesTable features={licenseState.features} />}
|
||||||
</div>
|
</>
|
||||||
<p className="text-sm text-slate-500">
|
|
||||||
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
|
|
||||||
<a className="font-semibold underline" href="mailto:hola@formbricks.com">
|
|
||||||
hola@formbricks.com
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
|
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 1024 1024"
|
viewBox="0 0 1024 1024"
|
||||||
className="absolute top-1/2 left-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
|
className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
|
||||||
aria-hidden="true">
|
aria-hidden="true">
|
||||||
<circle
|
<circle
|
||||||
cx={512}
|
cx={512}
|
||||||
@@ -153,8 +147,8 @@ const Page = async (props) => {
|
|||||||
{t("environments.settings.enterprise.enterprise_features")}
|
{t("environments.settings.enterprise.enterprise_features")}
|
||||||
</h2>
|
</h2>
|
||||||
<ul className="my-4 space-y-4">
|
<ul className="my-4 space-y-4">
|
||||||
{paidFeatures.map((feature, index) => (
|
{paidFeatures.map((feature) => (
|
||||||
<li key={index} className="flex items-center">
|
<li key={feature.title} className="flex items-center">
|
||||||
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800">
|
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800">
|
||||||
<CheckIcon className="h-5 w-5 p-0.5 text-green-500 dark:text-green-400" />
|
<CheckIcon className="h-5 w-5 p-0.5 text-green-500 dark:text-green-400" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export { FeedbackRecordDirectoriesPage as default } from "@/modules/ee/feedback-record-directory/page";
|
||||||
+27
-40
@@ -7,7 +7,6 @@ import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
|
|||||||
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
|
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
|
||||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
|
||||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
|
|
||||||
@@ -17,49 +16,38 @@ const ZUpdateOrganizationNameAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const updateOrganizationNameAction = authenticatedActionClient
|
export const updateOrganizationNameAction = authenticatedActionClient
|
||||||
.schema(ZUpdateOrganizationNameAction)
|
.inputSchema(ZUpdateOrganizationNameAction)
|
||||||
.action(
|
.action(
|
||||||
withAuditLogging(
|
withAuditLogging("updated", "organization", async ({ ctx, parsedInput }) => {
|
||||||
"updated",
|
await checkAuthorizationUpdated({
|
||||||
"organization",
|
userId: ctx.user.id,
|
||||||
async ({
|
organizationId: parsedInput.organizationId,
|
||||||
ctx,
|
access: [
|
||||||
parsedInput,
|
{
|
||||||
}: {
|
type: "organization",
|
||||||
ctx: AuthenticatedActionClientCtx;
|
schema: ZOrganizationUpdateInput.pick({ name: true }),
|
||||||
parsedInput: Record<string, any>;
|
data: parsedInput.data,
|
||||||
}) => {
|
roles: ["owner"],
|
||||||
await checkAuthorizationUpdated({
|
},
|
||||||
userId: ctx.user.id,
|
],
|
||||||
organizationId: parsedInput.organizationId,
|
});
|
||||||
access: [
|
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||||
{
|
const oldObject = await getOrganization(parsedInput.organizationId);
|
||||||
type: "organization",
|
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
|
||||||
schema: ZOrganizationUpdateInput.pick({ name: true }),
|
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||||
data: parsedInput.data,
|
ctx.auditLoggingCtx.newObject = result;
|
||||||
roles: ["owner"],
|
return result;
|
||||||
},
|
})
|
||||||
],
|
|
||||||
});
|
|
||||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
|
||||||
const oldObject = await getOrganization(parsedInput.organizationId);
|
|
||||||
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
|
|
||||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
|
||||||
ctx.auditLoggingCtx.newObject = result;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const ZDeleteOrganizationAction = z.object({
|
const ZDeleteOrganizationAction = z.object({
|
||||||
organizationId: ZId,
|
organizationId: ZId,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const deleteOrganizationAction = authenticatedActionClient.schema(ZDeleteOrganizationAction).action(
|
export const deleteOrganizationAction = authenticatedActionClient
|
||||||
withAuditLogging(
|
.inputSchema(ZDeleteOrganizationAction)
|
||||||
"deleted",
|
.action(
|
||||||
"organization",
|
withAuditLogging("deleted", "organization", async ({ ctx, parsedInput }) => {
|
||||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
|
||||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||||
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
|
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
|
||||||
|
|
||||||
@@ -77,6 +65,5 @@ export const deleteOrganizationAction = authenticatedActionClient.schema(ZDelete
|
|||||||
const oldObject = await getOrganization(parsedInput.organizationId);
|
const oldObject = await getOrganization(parsedInput.organizationId);
|
||||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||||
return await deleteOrganization(parsedInput.organizationId);
|
return await deleteOrganization(parsedInput.organizationId);
|
||||||
}
|
})
|
||||||
)
|
);
|
||||||
);
|
|
||||||
|
|||||||
+8
-2
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { TOrganization } from "@formbricks/types/organizations";
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
import { deleteOrganizationAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
|
import { deleteOrganizationAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
|
||||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||||
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
@@ -32,7 +33,12 @@ export const DeleteOrganization = ({
|
|||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteOrganizationAction({ organizationId: organization.id });
|
const result = await deleteOrganizationAction({ organizationId: organization.id });
|
||||||
|
if (result?.serverError) {
|
||||||
|
toast.error(getFormattedErrorMessage(result));
|
||||||
|
setIsDeleting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
toast.success(t("environments.settings.general.organization_deleted_successfully"));
|
toast.success(t("environments.settings.general.organization_deleted_successfully"));
|
||||||
if (typeof localStorage !== "undefined") {
|
if (typeof localStorage !== "undefined") {
|
||||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||||
@@ -101,7 +107,7 @@ const DeleteOrganizationModal = ({
|
|||||||
}: DeleteOrganizationModalProps) => {
|
}: DeleteOrganizationModalProps) => {
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const handleInputChange = (e) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setInputValue(e.target.value);
|
setInputValue(e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -61,7 +61,7 @@ export const EditOrganizationNameForm = ({ organization, membershipRole }: EditO
|
|||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(`Error: ${err.message}`);
|
toast.error(`Error: ${err instanceof Error ? err.message : "Unknown error occurred"}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -9,6 +9,7 @@ import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
|||||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
|
import packageJson from "@/package.json";
|
||||||
import { SettingsCard } from "../../components/SettingsCard";
|
import { SettingsCard } from "../../components/SettingsCard";
|
||||||
import { DeleteOrganization } from "./components/DeleteOrganization";
|
import { DeleteOrganization } from "./components/DeleteOrganization";
|
||||||
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
||||||
@@ -25,7 +26,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
const user = session?.user?.id ? await getUser(session.user.id) : null;
|
const user = session?.user?.id ? await getUser(session.user.id) : null;
|
||||||
|
|
||||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||||
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan);
|
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
|
||||||
|
|
||||||
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
|
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
|
||||||
const currentUserRole = currentUserMembership?.role;
|
const currentUserRole = currentUserMembership?.role;
|
||||||
@@ -81,7 +82,10 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
|
<div className="space-y-2">
|
||||||
|
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
|
||||||
|
<IdBadge id={packageJson.version} label={t("common.formbricks_version")} variant="column" />
|
||||||
|
</div>
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,31 +1,32 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
|
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
|
|
||||||
const Layout = async (props) => {
|
const Layout = async (props: { params: Promise<{ environmentId: string }>; children: React.ReactNode }) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
|
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const [organization, project, session] = await Promise.all([
|
const [organization, workspace, session] = await Promise.all([
|
||||||
getOrganizationByEnvironmentId(params.environmentId),
|
getOrganizationByEnvironmentId(params.environmentId),
|
||||||
getProjectByEnvironmentId(params.environmentId),
|
getWorkspaceByEnvironmentId(params.environmentId),
|
||||||
getServerSession(authOptions),
|
getServerSession(authOptions),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new Error(t("common.organization_not_found"));
|
throw new ResourceNotFoundError(t("common.organization"), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!project) {
|
if (!workspace) {
|
||||||
throw new Error(t("common.workspace_not_found"));
|
throw new Error(t("common.workspace_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error(t("common.session_not_found"));
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
export const SettingsTitle = ({ title }) => {
|
export const SettingsTitle = ({ title }: { title: string }) => {
|
||||||
return <h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">{title}</h2>;
|
return <h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">{title}</h2>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
const Page = async (props) => {
|
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
return redirect(`/environments/${params.environmentId}/settings/profile`);
|
return redirect(`/environments/${params.environmentId}/settings/profile`);
|
||||||
};
|
};
|
||||||
|
|||||||
+39
-10
@@ -4,10 +4,11 @@ import { revalidatePath } from "next/cache";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
|
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
|
||||||
|
import { getDisplaysBySurveyIdWithContact } from "@/lib/display/service";
|
||||||
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
|
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
|
||||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||||
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
|
import { getOrganizationIdFromSurveyId, getWorkspaceIdFromSurveyId } from "@/lib/utils/helper";
|
||||||
import { getSurveySummary } from "./summary/lib/surveySummary";
|
import { getSurveySummary } from "./summary/lib/surveySummary";
|
||||||
|
|
||||||
export const revalidateSurveyIdPath = async (environmentId: string, surveyId: string) => {
|
export const revalidateSurveyIdPath = async (environmentId: string, surveyId: string) => {
|
||||||
@@ -22,7 +23,7 @@ const ZGetResponsesAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const getResponsesAction = authenticatedActionClient
|
export const getResponsesAction = authenticatedActionClient
|
||||||
.schema(ZGetResponsesAction)
|
.inputSchema(ZGetResponsesAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
@@ -35,9 +36,9 @@ export const getResponsesAction = authenticatedActionClient
|
|||||||
roles: ["owner", "manager"],
|
roles: ["owner", "manager"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "projectTeam",
|
type: "workspaceTeam",
|
||||||
minPermission: "read",
|
minPermission: "read",
|
||||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -56,7 +57,7 @@ const ZGetSurveySummaryAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const getSurveySummaryAction = authenticatedActionClient
|
export const getSurveySummaryAction = authenticatedActionClient
|
||||||
.schema(ZGetSurveySummaryAction)
|
.inputSchema(ZGetSurveySummaryAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
@@ -69,9 +70,9 @@ export const getSurveySummaryAction = authenticatedActionClient
|
|||||||
roles: ["owner", "manager"],
|
roles: ["owner", "manager"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "projectTeam",
|
type: "workspaceTeam",
|
||||||
minPermission: "read",
|
minPermission: "read",
|
||||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -84,7 +85,7 @@ const ZGetResponseCountAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const getResponseCountAction = authenticatedActionClient
|
export const getResponseCountAction = authenticatedActionClient
|
||||||
.schema(ZGetResponseCountAction)
|
.inputSchema(ZGetResponseCountAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
@@ -97,12 +98,40 @@ export const getResponseCountAction = authenticatedActionClient
|
|||||||
roles: ["owner", "manager"],
|
roles: ["owner", "manager"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "projectTeam",
|
type: "workspaceTeam",
|
||||||
minPermission: "read",
|
minPermission: "read",
|
||||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria);
|
return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ZGetDisplaysWithContactAction = z.object({
|
||||||
|
surveyId: ZId,
|
||||||
|
limit: z.int().min(1).max(100),
|
||||||
|
offset: z.int().nonnegative(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getDisplaysWithContactAction = authenticatedActionClient
|
||||||
|
.inputSchema(ZGetDisplaysWithContactAction)
|
||||||
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
|
await checkAuthorizationUpdated({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||||
|
access: [
|
||||||
|
{
|
||||||
|
type: "organization",
|
||||||
|
roles: ["owner", "manager"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "workspaceTeam",
|
||||||
|
minPermission: "read",
|
||||||
|
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return getDisplaysBySurveyIdWithContact(parsedInput.surveyId, parsedInput.limit, parsedInput.offset);
|
||||||
|
});
|
||||||
|
|||||||
+4
-2
@@ -3,6 +3,7 @@ import { getServerSession } from "next-auth";
|
|||||||
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -14,10 +15,11 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
|
|||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
const survey = await getSurvey(params.surveyId);
|
const survey = await getSurvey(params.surveyId);
|
||||||
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||||
|
const t = await getTranslate();
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
return {
|
return {
|
||||||
title: `${responseCount} Responses | ${survey?.name} Results`,
|
title: `${t("common.count_responses", { count: responseCount })} | ${t("environments.surveys.summary.survey_results", { surveyName: survey?.name })}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -25,7 +27,7 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const SurveyLayout = async ({ children }) => {
|
const SurveyLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||||
return <ResponseFilterProvider>{children}</ResponseFilterProvider>;
|
return <ResponseFilterProvider>{children}</ResponseFilterProvider>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+4
-5
@@ -96,8 +96,8 @@ export const ResponseTable = ({
|
|||||||
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
|
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
|
||||||
// Generate columns
|
// Generate columns
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
|
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, locale, t, showQuotasColumn),
|
||||||
[survey, isExpanded, isReadOnly, t, showQuotasColumn]
|
[survey, isExpanded, isReadOnly, locale, t, showQuotasColumn]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save settings to localStorage when they change
|
// Save settings to localStorage when they change
|
||||||
@@ -205,11 +205,11 @@ export const ResponseTable = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Handle downloading selected responses
|
// Handle downloading selected responses
|
||||||
const downloadSelectedRows = async (responseIds: string[], format: "csv" | "xlsx") => {
|
const downloadSelectedRows = async (responseIds: string[], format: "xlsx" | "csv") => {
|
||||||
try {
|
try {
|
||||||
const downloadResponse = await getResponsesDownloadUrlAction({
|
const downloadResponse = await getResponsesDownloadUrlAction({
|
||||||
surveyId: survey.id,
|
surveyId: survey.id,
|
||||||
format: format,
|
format,
|
||||||
filterCriteria: { responseIds },
|
filterCriteria: { responseIds },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -300,7 +300,6 @@ export const ResponseTable = ({
|
|||||||
<DataTableSettingsModal
|
<DataTableSettingsModal
|
||||||
open={isTableSettingsModalOpen}
|
open={isTableSettingsModalOpen}
|
||||||
setOpen={setIsTableSettingsModalOpen}
|
setOpen={setIsTableSettingsModalOpen}
|
||||||
survey={survey}
|
|
||||||
table={table}
|
table={table}
|
||||||
columnOrder={columnOrder}
|
columnOrder={columnOrder}
|
||||||
handleDragEnd={handleDragEnd}
|
handleDragEnd={handleDragEnd}
|
||||||
|
|||||||
+22
-6
@@ -5,13 +5,14 @@ import { TFunction } from "i18next";
|
|||||||
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
|
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { TResponseTableData } from "@formbricks/types/responses";
|
import { TResponseTableData } from "@formbricks/types/responses";
|
||||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
|
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
|
||||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||||
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
import { formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
|
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||||
@@ -34,6 +35,7 @@ const getElementColumnsData = (
|
|||||||
element: TSurveyElement,
|
element: TSurveyElement,
|
||||||
survey: TSurvey,
|
survey: TSurvey,
|
||||||
isExpanded: boolean,
|
isExpanded: boolean,
|
||||||
|
locale: TUserLocale,
|
||||||
t: TFunction
|
t: TFunction
|
||||||
): ColumnDef<TResponseTableData>[] => {
|
): ColumnDef<TResponseTableData>[] => {
|
||||||
const ELEMENTS_ICON_MAP = getElementIconMap(t);
|
const ELEMENTS_ICON_MAP = getElementIconMap(t);
|
||||||
@@ -41,7 +43,7 @@ const getElementColumnsData = (
|
|||||||
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
|
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
|
||||||
|
|
||||||
// Helper function to create consistent column headers
|
// Helper function to create consistent column headers
|
||||||
const createElementHeader = (elementType: string, headline: string, suffix?: string) => {
|
const createElementHeader = (elementType: TSurveyElementTypeEnum, headline: string, suffix?: string) => {
|
||||||
const title = suffix ? `${headline} - ${suffix}` : headline;
|
const title = suffix ? `${headline} - ${suffix}` : headline;
|
||||||
const ElementHeader = () => (
|
const ElementHeader = () => (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -167,6 +169,7 @@ const getElementColumnsData = (
|
|||||||
survey={survey}
|
survey={survey}
|
||||||
responseData={responseValue}
|
responseData={responseValue}
|
||||||
language={language}
|
language={language}
|
||||||
|
locale={locale}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
showId={false}
|
showId={false}
|
||||||
/>
|
/>
|
||||||
@@ -218,6 +221,7 @@ const getElementColumnsData = (
|
|||||||
survey={survey}
|
survey={survey}
|
||||||
responseData={responseValue}
|
responseData={responseValue}
|
||||||
language={language}
|
language={language}
|
||||||
|
locale={locale}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
showId={false}
|
showId={false}
|
||||||
/>
|
/>
|
||||||
@@ -232,7 +236,7 @@ const getMetadataColumnsData = (t: TFunction): ColumnDef<TResponseTableData>[] =
|
|||||||
const metadataColumns: ColumnDef<TResponseTableData>[] = [];
|
const metadataColumns: ColumnDef<TResponseTableData>[] = [];
|
||||||
|
|
||||||
METADATA_FIELDS.forEach((label) => {
|
METADATA_FIELDS.forEach((label) => {
|
||||||
const IconComponent = COLUMNS_ICON_MAP[label];
|
const IconComponent = COLUMNS_ICON_MAP[label as keyof typeof COLUMNS_ICON_MAP];
|
||||||
|
|
||||||
metadataColumns.push({
|
metadataColumns.push({
|
||||||
accessorKey: "METADATA_" + label,
|
accessorKey: "METADATA_" + label,
|
||||||
@@ -259,11 +263,14 @@ export const generateResponseTableColumns = (
|
|||||||
survey: TSurvey,
|
survey: TSurvey,
|
||||||
isExpanded: boolean,
|
isExpanded: boolean,
|
||||||
isReadOnly: boolean,
|
isReadOnly: boolean,
|
||||||
|
locale: TUserLocale,
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
showQuotasColumn: boolean
|
showQuotasColumn: boolean
|
||||||
): ColumnDef<TResponseTableData>[] => {
|
): ColumnDef<TResponseTableData>[] => {
|
||||||
const elements = getElementsFromBlocks(survey.blocks);
|
const elements = getElementsFromBlocks(survey.blocks);
|
||||||
const elementColumns = elements.flatMap((element) => getElementColumnsData(element, survey, isExpanded, t));
|
const elementColumns = elements.flatMap((element) =>
|
||||||
|
getElementColumnsData(element, survey, isExpanded, locale, t)
|
||||||
|
);
|
||||||
|
|
||||||
const dateColumn: ColumnDef<TResponseTableData> = {
|
const dateColumn: ColumnDef<TResponseTableData> = {
|
||||||
accessorKey: "createdAt",
|
accessorKey: "createdAt",
|
||||||
@@ -271,7 +278,7 @@ export const generateResponseTableColumns = (
|
|||||||
size: 200,
|
size: 200,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const date = new Date(row.original.createdAt);
|
const date = new Date(row.original.createdAt);
|
||||||
return <p className="text-slate-900">{getFormattedDateTimeString(date)}</p>;
|
return <p className="text-slate-900">{formatDateTimeForDisplay(date, locale)}</p>;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -316,6 +323,14 @@ export const generateResponseTableColumns = (
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const responseIdColumn: ColumnDef<TResponseTableData> = {
|
||||||
|
accessorKey: "responseId",
|
||||||
|
header: () => <div className="gap-x-1.5">{t("common.response_id")}</div>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return <IdBadge id={row.original.responseId} />;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const quotasColumn: ColumnDef<TResponseTableData> = {
|
const quotasColumn: ColumnDef<TResponseTableData> = {
|
||||||
accessorKey: "quota",
|
accessorKey: "quota",
|
||||||
header: t("common.quota"),
|
header: t("common.quota"),
|
||||||
@@ -414,6 +429,7 @@ export const generateResponseTableColumns = (
|
|||||||
const baseColumns = [
|
const baseColumns = [
|
||||||
personColumn,
|
personColumn,
|
||||||
singleUseIdColumn,
|
singleUseIdColumn,
|
||||||
|
responseIdColumn,
|
||||||
dateColumn,
|
dateColumn,
|
||||||
...(showQuotasColumn ? [quotasColumn] : []),
|
...(showQuotasColumn ? [quotasColumn] : []),
|
||||||
statusColumn,
|
statusColumn,
|
||||||
|
|||||||
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { TFunction } from "i18next";
|
||||||
import {
|
import {
|
||||||
AirplayIcon,
|
AirplayIcon,
|
||||||
ArrowUpFromDotIcon,
|
ArrowUpFromDotIcon,
|
||||||
@@ -38,7 +39,7 @@ describe("utils", () => {
|
|||||||
"environments.surveys.responses.source": "Source",
|
"environments.surveys.responses.source": "Source",
|
||||||
};
|
};
|
||||||
return translations[key] || key;
|
return translations[key] || key;
|
||||||
});
|
}) as unknown as TFunction;
|
||||||
|
|
||||||
describe("getAddressFieldLabel", () => {
|
describe("getAddressFieldLabel", () => {
|
||||||
test("returns correct label for addressLine1", () => {
|
test("returns correct label for addressLine1", () => {
|
||||||
|
|||||||
+19
-4
@@ -80,9 +80,24 @@ export const COLUMNS_ICON_MAP = {
|
|||||||
const userAgentFields = ["browser", "os", "device"];
|
const userAgentFields = ["browser", "os", "device"];
|
||||||
export const METADATA_FIELDS = ["action", "country", ...userAgentFields, "source", "url"];
|
export const METADATA_FIELDS = ["action", "country", ...userAgentFields, "source", "url"];
|
||||||
|
|
||||||
export const getMetadataValue = (meta: TResponseMeta, label: string) => {
|
export const getMetadataValue = (
|
||||||
if (userAgentFields.includes(label)) {
|
meta: TResponseMeta,
|
||||||
return meta.userAgent?.[label];
|
label: (typeof METADATA_FIELDS)[number]
|
||||||
|
): string | undefined => {
|
||||||
|
switch (label) {
|
||||||
|
case "browser":
|
||||||
|
return meta.userAgent?.browser;
|
||||||
|
case "os":
|
||||||
|
return meta.userAgent?.os;
|
||||||
|
case "device":
|
||||||
|
return meta.userAgent?.device;
|
||||||
|
case "action":
|
||||||
|
return meta.action;
|
||||||
|
case "country":
|
||||||
|
return meta.country;
|
||||||
|
case "source":
|
||||||
|
return meta.source;
|
||||||
|
case "url":
|
||||||
|
return meta.url;
|
||||||
}
|
}
|
||||||
return meta[label];
|
|
||||||
};
|
};
|
||||||
|
|||||||
+10
-11
@@ -1,3 +1,4 @@
|
|||||||
|
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||||
@@ -7,7 +8,6 @@ import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service
|
|||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
||||||
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
@@ -17,31 +17,30 @@ import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
|||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
|
|
||||||
const Page = async (props) => {
|
const Page = async (props: { params: Promise<{ environmentId: string; surveyId: string }> }) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
|
|
||||||
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([
|
const [survey, user, tags, isContactsEnabled, responseCount] = await Promise.all([
|
||||||
getSurvey(params.surveyId),
|
getSurvey(params.surveyId),
|
||||||
getUser(session.user.id),
|
getUser(session.user.id),
|
||||||
getTagsByEnvironmentId(params.environmentId),
|
getTagsByEnvironmentId(params.environmentId),
|
||||||
getIsContactsEnabled(),
|
getIsContactsEnabled(organization.id),
|
||||||
getResponseCountBySurveyId(params.surveyId),
|
getResponseCountBySurveyId(params.surveyId),
|
||||||
findMatchingLocale(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!survey) {
|
if (!survey) {
|
||||||
throw new Error(t("common.survey_not_found"));
|
throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(t("common.user_not_found"));
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new Error(t("common.organization_not_found"));
|
throw new ResourceNotFoundError(t("common.organization"), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
|
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
|
||||||
@@ -50,10 +49,10 @@ const Page = async (props) => {
|
|||||||
|
|
||||||
const organizationBilling = await getOrganizationBilling(organization.id);
|
const organizationBilling = await getOrganizationBilling(organization.id);
|
||||||
if (!organizationBilling) {
|
if (!organizationBilling) {
|
||||||
throw new Error(t("common.organization_not_found"));
|
throw new ResourceNotFoundError(t("common.organization"), organization.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
|
const isQuotasAllowed = await getIsQuotasEnabled(organization.id);
|
||||||
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
|
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
|
||||||
|
|
||||||
// Fetch initial responses on the server to prevent duplicate client-side fetch
|
// Fetch initial responses on the server to prevent duplicate client-side fetch
|
||||||
@@ -86,7 +85,7 @@ const Page = async (props) => {
|
|||||||
environmentTags={tags}
|
environmentTags={tags}
|
||||||
user={user}
|
user={user}
|
||||||
responsesPerPage={RESPONSES_PER_PAGE}
|
responsesPerPage={RESPONSES_PER_PAGE}
|
||||||
locale={locale}
|
locale={user.locale}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
isQuotasAllowed={isQuotasAllowed}
|
isQuotasAllowed={isQuotasAllowed}
|
||||||
quotas={quotas}
|
quotas={quotas}
|
||||||
|
|||||||
+52
-60
@@ -7,9 +7,8 @@ import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/s
|
|||||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
|
||||||
import { convertToCsv } from "@/lib/utils/file-conversion";
|
import { convertToCsv } from "@/lib/utils/file-conversion";
|
||||||
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
|
import { getOrganizationIdFromSurveyId, getWorkspaceIdFromSurveyId } from "@/lib/utils/helper";
|
||||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
import { generatePersonalLinks } from "@/modules/ee/contacts/lib/contacts";
|
import { generatePersonalLinks } from "@/modules/ee/contacts/lib/contacts";
|
||||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
@@ -22,7 +21,7 @@ const ZSendEmbedSurveyPreviewEmailAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
|
export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
|
||||||
.schema(ZSendEmbedSurveyPreviewEmailAction)
|
.inputSchema(ZSendEmbedSurveyPreviewEmailAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||||
const organizationLogoUrl = await getOrganizationLogoUrl(organizationId);
|
const organizationLogoUrl = await getOrganizationLogoUrl(organizationId);
|
||||||
@@ -36,9 +35,9 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
|
|||||||
roles: ["owner", "manager"],
|
roles: ["owner", "manager"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "projectTeam",
|
type: "workspaceTeam",
|
||||||
minPermission: "read",
|
minPermission: "read",
|
||||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -65,57 +64,49 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
|
|||||||
|
|
||||||
const ZResetSurveyAction = z.object({
|
const ZResetSurveyAction = z.object({
|
||||||
surveyId: ZId,
|
surveyId: ZId,
|
||||||
organizationId: ZId,
|
workspaceId: ZId,
|
||||||
projectId: ZId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAction).action(
|
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
|
||||||
withAuditLogging(
|
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
|
||||||
"updated",
|
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||||
"survey",
|
const workspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId);
|
||||||
async ({
|
|
||||||
ctx,
|
|
||||||
parsedInput,
|
|
||||||
}: {
|
|
||||||
ctx: AuthenticatedActionClientCtx;
|
|
||||||
parsedInput: z.infer<typeof ZResetSurveyAction>;
|
|
||||||
}) => {
|
|
||||||
await checkAuthorizationUpdated({
|
|
||||||
userId: ctx.user.id,
|
|
||||||
organizationId: parsedInput.organizationId,
|
|
||||||
access: [
|
|
||||||
{
|
|
||||||
type: "organization",
|
|
||||||
roles: ["owner", "manager"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "projectTeam",
|
|
||||||
minPermission: "readWrite",
|
|
||||||
projectId: parsedInput.projectId,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
await checkAuthorizationUpdated({
|
||||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
userId: ctx.user.id,
|
||||||
ctx.auditLoggingCtx.oldObject = null;
|
organizationId,
|
||||||
|
access: [
|
||||||
|
{
|
||||||
|
type: "organization",
|
||||||
|
roles: ["owner", "manager"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "workspaceTeam",
|
||||||
|
minPermission: "readWrite",
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
|
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||||
parsedInput.surveyId
|
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
||||||
);
|
ctx.auditLoggingCtx.oldObject = null;
|
||||||
|
|
||||||
ctx.auditLoggingCtx.newObject = {
|
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
|
||||||
deletedResponsesCount: deletedResponsesCount,
|
parsedInput.surveyId
|
||||||
deletedDisplaysCount: deletedDisplaysCount,
|
);
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
ctx.auditLoggingCtx.newObject = {
|
||||||
success: true,
|
deletedResponsesCount: deletedResponsesCount,
|
||||||
deletedResponsesCount: deletedResponsesCount,
|
deletedDisplaysCount: deletedDisplaysCount,
|
||||||
deletedDisplaysCount: deletedDisplaysCount,
|
};
|
||||||
};
|
|
||||||
}
|
return {
|
||||||
)
|
success: true,
|
||||||
|
deletedResponsesCount: deletedResponsesCount,
|
||||||
|
deletedDisplaysCount: deletedDisplaysCount,
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const ZGetEmailHtmlAction = z.object({
|
const ZGetEmailHtmlAction = z.object({
|
||||||
@@ -123,7 +114,7 @@ const ZGetEmailHtmlAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const getEmailHtmlAction = authenticatedActionClient
|
export const getEmailHtmlAction = authenticatedActionClient
|
||||||
.schema(ZGetEmailHtmlAction)
|
.inputSchema(ZGetEmailHtmlAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
@@ -134,9 +125,9 @@ export const getEmailHtmlAction = authenticatedActionClient
|
|||||||
roles: ["owner", "manager"],
|
roles: ["owner", "manager"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "projectTeam",
|
type: "workspaceTeam",
|
||||||
minPermission: "readWrite",
|
minPermission: "readWrite",
|
||||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -152,9 +143,10 @@ const ZGeneratePersonalLinksAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const generatePersonalLinksAction = authenticatedActionClient
|
export const generatePersonalLinksAction = authenticatedActionClient
|
||||||
.schema(ZGeneratePersonalLinksAction)
|
.inputSchema(ZGeneratePersonalLinksAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
const isContactsEnabled = await getIsContactsEnabled();
|
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||||
|
const isContactsEnabled = await getIsContactsEnabled(organizationId);
|
||||||
if (!isContactsEnabled) {
|
if (!isContactsEnabled) {
|
||||||
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
|
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
|
||||||
}
|
}
|
||||||
@@ -168,8 +160,8 @@ export const generatePersonalLinksAction = authenticatedActionClient
|
|||||||
roles: ["owner", "manager"],
|
roles: ["owner", "manager"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "projectTeam",
|
type: "workspaceTeam",
|
||||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
|
||||||
minPermission: "readWrite",
|
minPermission: "readWrite",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -231,7 +223,7 @@ const ZUpdateSingleUseLinksAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const updateSingleUseLinksAction = authenticatedActionClient
|
export const updateSingleUseLinksAction = authenticatedActionClient
|
||||||
.schema(ZUpdateSingleUseLinksAction)
|
.inputSchema(ZUpdateSingleUseLinksAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
@@ -242,8 +234,8 @@ export const updateSingleUseLinksAction = authenticatedActionClient
|
|||||||
roles: ["owner", "manager"],
|
roles: ["owner", "manager"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "projectTeam",
|
type: "workspaceTeam",
|
||||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
workspaceId: await getWorkspaceIdFromSurveyId(parsedInput.surveyId),
|
||||||
minPermission: "readWrite",
|
minPermission: "readWrite",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
+2
-4
@@ -30,8 +30,7 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
{elementSummary.booked.count}{" "}
|
{t("common.count_responses", { count: elementSummary.booked.count })}
|
||||||
{elementSummary.booked.count === 1 ? t("common.response") : t("common.responses")}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.booked.percentage / 100} />
|
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.booked.percentage / 100} />
|
||||||
@@ -47,8 +46,7 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
{elementSummary.skipped.count}{" "}
|
{t("common.count_responses", { count: elementSummary.skipped.count })}
|
||||||
{elementSummary.skipped.count === 1 ? t("common.response") : t("common.responses")}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.skipped.percentage / 100} />
|
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.skipped.percentage / 100} />
|
||||||
|
|||||||
+1
-1
@@ -64,7 +64,7 @@ export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSum
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
{summaryItem.count} {summaryItem.count === 1 ? t("common.response") : t("common.responses")}
|
{t("common.count_responses", { count: summaryItem.count })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="group-hover:opacity-80">
|
<div className="group-hover:opacity-80">
|
||||||
|
|||||||
+10
-9
@@ -7,7 +7,7 @@ import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/ty
|
|||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { timeSince } from "@/lib/time";
|
import { timeSince } from "@/lib/time";
|
||||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||||
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
|
import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
|
||||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||||
@@ -32,13 +32,14 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderResponseValue = (value: string) => {
|
const renderResponseValue = (value: string) => {
|
||||||
const parsedDate = new Date(value);
|
const formattedDate = formatStoredDateForDisplay(value, elementSummary.element.format, locale);
|
||||||
|
|
||||||
const formattedDate = isNaN(parsedDate.getTime())
|
return (
|
||||||
? `${t("common.invalid_date")}(${value})`
|
formattedDate ??
|
||||||
: formatDateWithOrdinal(parsedDate);
|
t("common.invalid_date_with_value", {
|
||||||
|
value,
|
||||||
return formattedDate;
|
})
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -59,7 +60,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
|
|||||||
elementSummary.samples.slice(0, visibleResponses).map((response) => (
|
elementSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||||
<div
|
<div
|
||||||
key={response.id}
|
key={response.id}
|
||||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
|
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent">
|
||||||
<div className="pl-4 md:pl-6">
|
<div className="pl-4 md:pl-6">
|
||||||
{response.contact ? (
|
{response.contact ? (
|
||||||
<Link
|
<Link
|
||||||
@@ -84,7 +85,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
|
|||||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||||
{renderResponseValue(response.value)}
|
{renderResponseValue(response.value)}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 text-slate-500 md:px-6">
|
<div className="px-4 md:px-6">
|
||||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@ export const ElementSummaryHeader = ({
|
|||||||
{showResponses && (
|
{showResponses && (
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxIcon className="mr-2 h-4 w-4" />
|
<InboxIcon className="mr-2 h-4 w-4" />
|
||||||
{`${elementSummary.responseCount} ${t("common.responses")}`}
|
{t("common.count_responses", { count: elementSummary.responseCount })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{additionalInfo}
|
{additionalInfo}
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ import { TSurvey, TSurveyElementSummaryFileUpload } from "@formbricks/types/surv
|
|||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { timeSince } from "@/lib/time";
|
import { timeSince } from "@/lib/time";
|
||||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||||
import { getOriginalFileNameFromUrl } from "@/modules/storage/utils";
|
import { getOriginalFileNameFromUrl } from "@/modules/storage/url-helpers";
|
||||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||||
|
|||||||
+1
-2
@@ -41,8 +41,7 @@ export const HiddenFieldsSummary = ({ environment, elementSummary, locale }: Hid
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxIcon className="mr-2 h-4 w-4" />
|
<InboxIcon className="mr-2 h-4 w-4" />
|
||||||
{elementSummary.responseCount}{" "}
|
{t("common.count_responses", { count: elementSummary.responseCount })}
|
||||||
{elementSummary.responseCount === 1 ? t("common.response") : t("common.responses")}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-2
@@ -31,7 +31,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
|
|||||||
if (label) {
|
if (label) {
|
||||||
return label;
|
return label;
|
||||||
} else if (percentage !== undefined && totalResponsesForRow !== undefined) {
|
} else if (percentage !== undefined && totalResponsesForRow !== undefined) {
|
||||||
return `${Math.round((percentage / 100) * totalResponsesForRow)} ${t("common.responses")}`;
|
return t("common.count_responses", { count: Math.round((percentage / 100) * totalResponsesForRow) });
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
@@ -77,7 +77,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
|
|||||||
)}>
|
)}>
|
||||||
<button
|
<button
|
||||||
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
|
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
|
||||||
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline"
|
className="m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline hover:outline-brand-dark"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setFilter(
|
setFilter(
|
||||||
elementSummary.element.id,
|
elementSummary.element.id,
|
||||||
|
|||||||
+2
-2
@@ -75,7 +75,7 @@ export const MultipleChoiceSummary = ({
|
|||||||
elementSummary.type === "multipleChoiceMulti" ? (
|
elementSummary.type === "multipleChoiceMulti" ? (
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxIcon className="mr-2 h-4 w-4" />
|
<InboxIcon className="mr-2 h-4 w-4" />
|
||||||
{`${elementSummary.selectionCount} ${t("common.selections")}`}
|
{t("common.count_selections", { count: elementSummary.selectionCount })}
|
||||||
</div>
|
</div>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
@@ -110,7 +110,7 @@ export const MultipleChoiceSummary = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex w-full space-x-2">
|
<div className="flex w-full space-x-2">
|
||||||
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
|
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
|
||||||
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
|
{t("common.count_selections", { count: result.count })}
|
||||||
</p>
|
</p>
|
||||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||||
|
|||||||
+6
-5
@@ -60,7 +60,9 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const filter = filters[group];
|
const filter = (filters as Record<string, { comparison: string; values: string | string[] | undefined }>)[
|
||||||
|
group
|
||||||
|
];
|
||||||
|
|
||||||
if (filter) {
|
if (filter) {
|
||||||
setFilter(
|
setFilter(
|
||||||
@@ -104,7 +106,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
|
|||||||
<TabsContent value="aggregated" className="mt-4">
|
<TabsContent value="aggregated" className="mt-4">
|
||||||
<div className="px-4 pb-6 pt-4 md:px-6">
|
<div className="px-4 pb-6 pt-4 md:px-6">
|
||||||
<div className="space-y-5 text-sm md:text-base">
|
<div className="space-y-5 text-sm md:text-base">
|
||||||
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
|
{(["promoters", "passives", "detractors", "dismissed"] as const).map((group) => (
|
||||||
<button
|
<button
|
||||||
className="w-full cursor-pointer hover:opacity-80"
|
className="w-full cursor-pointer hover:opacity-80"
|
||||||
key={group}
|
key={group}
|
||||||
@@ -123,8 +125,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
{elementSummary[group]?.count}{" "}
|
{t("common.count_responses", { count: elementSummary[group]?.count })}
|
||||||
{elementSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
@@ -158,7 +159,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
|
|||||||
}>
|
}>
|
||||||
<div className="flex h-32 w-full flex-col items-center justify-end">
|
<div className="flex h-32 w-full flex-col items-center justify-end">
|
||||||
<div
|
<div
|
||||||
className="bg-brand-dark w-full rounded-t-lg border border-slate-200 transition-all group-hover:brightness-110"
|
className="w-full rounded-t-lg border border-slate-200 bg-brand-dark transition-all group-hover:brightness-110"
|
||||||
style={{
|
style={{
|
||||||
height: `${Math.max(choice.percentage, 2)}%`,
|
height: `${Math.max(choice.percentage, 2)}%`,
|
||||||
opacity,
|
opacity,
|
||||||
|
|||||||
+2
-2
@@ -37,7 +37,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
|
|||||||
elementSummary.element.allowMulti ? (
|
elementSummary.element.allowMulti ? (
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxIcon className="mr-2 h-4 w-4" />
|
<InboxIcon className="mr-2 h-4 w-4" />
|
||||||
{`${elementSummary.selectionCount} ${t("common.selections")}`}
|
{t("common.count_selections", { count: elementSummary.selectionCount })}
|
||||||
</div>
|
</div>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex w-full space-x-2">
|
<div className="flex w-full space-x-2">
|
||||||
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
|
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
|
||||||
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
|
{t("common.count_selections", { count: result.count })}
|
||||||
</p>
|
</p>
|
||||||
<p className="self-end rounded-lg bg-slate-100 px-2 text-slate-700">
|
<p className="self-end rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||||
|
|||||||
+3
-4
@@ -116,7 +116,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
|
|||||||
)
|
)
|
||||||
}>
|
}>
|
||||||
<div
|
<div
|
||||||
className={`bg-brand-dark h-full ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
|
className={`h-full bg-brand-dark ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
|
||||||
style={{ opacity }}
|
style={{ opacity }}
|
||||||
/>
|
/>
|
||||||
</ClickableBarSegment>
|
</ClickableBarSegment>
|
||||||
@@ -198,7 +198,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
|
{t("common.count_responses", { count: result.count })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||||
@@ -215,8 +215,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
|
|||||||
<div className="text flex justify-between px-2">
|
<div className="text flex justify-between px-2">
|
||||||
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
|
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
{elementSummary.dismissed.count}{" "}
|
{t("common.count_responses", { count: elementSummary.dismissed.count })}
|
||||||
{elementSummary.dismissed.count === 1 ? t("common.response") : t("common.responses")}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AlertCircleIcon, InfoIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TDisplayWithContact } from "@formbricks/types/displays";
|
||||||
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
|
import { timeSince } from "@/lib/time";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
|
||||||
|
interface SummaryImpressionsProps {
|
||||||
|
displays: TDisplayWithContact[];
|
||||||
|
isLoading: boolean;
|
||||||
|
hasMore: boolean;
|
||||||
|
displaysError: string | null;
|
||||||
|
environmentId: string;
|
||||||
|
locale: TUserLocale;
|
||||||
|
onLoadMore: () => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDisplayContactIdentifier = (display: TDisplayWithContact): string => {
|
||||||
|
if (!display.contact) return "";
|
||||||
|
return display.contact.attributes?.email || display.contact.attributes?.userId || display.contact.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SummaryImpressions = ({
|
||||||
|
displays,
|
||||||
|
isLoading,
|
||||||
|
hasMore,
|
||||||
|
displaysError,
|
||||||
|
environmentId,
|
||||||
|
locale,
|
||||||
|
onLoadMore,
|
||||||
|
onRetry,
|
||||||
|
}: SummaryImpressionsProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (displaysError) {
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="flex flex-col items-center gap-4 text-center">
|
||||||
|
<div className="flex items-center gap-2 text-red-600">
|
||||||
|
<AlertCircleIcon className="h-5 w-5" />
|
||||||
|
<span className="text-sm font-medium">{t("common.error_loading_data")}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-500">{displaysError}</p>
|
||||||
|
<Button onClick={onRetry} variant="secondary" size="sm">
|
||||||
|
{t("common.try_again")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displays.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-sm text-slate-500">
|
||||||
|
{t("environments.surveys.summary.no_identified_impressions")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grid min-h-10 grid-cols-4 items-center border-b border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
|
||||||
|
<div className="col-span-2 px-4 md:px-6">{t("common.user")}</div>
|
||||||
|
<div className="col-span-2 px-4 md:px-6">{t("environments.contacts.survey_viewed_at")}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[62vh] overflow-y-auto">
|
||||||
|
{displays.map((display) => (
|
||||||
|
<div
|
||||||
|
key={display.id}
|
||||||
|
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-xs text-slate-800 last:border-transparent md:text-sm">
|
||||||
|
<div className="col-span-2 pl-4 md:pl-6">
|
||||||
|
{display.contact ? (
|
||||||
|
<Link
|
||||||
|
className="ph-no-capture break-all text-slate-600 hover:underline"
|
||||||
|
href={`/environments/${environmentId}/contacts/${display.contact.id}`}>
|
||||||
|
{getDisplayContactIdentifier(display)}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="break-all text-slate-600">{t("common.anonymous")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 px-4 text-slate-500 md:px-6">
|
||||||
|
{timeSince(display.createdAt.toString(), locale)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<div className="flex justify-center border-t border-slate-100 py-4">
|
||||||
|
<Button onClick={onLoadMore} variant="secondary" size="sm">
|
||||||
|
{t("common.load_more")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="h-6 w-32 animate-pulse rounded-full bg-slate-200"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
<div className="flex items-center gap-2 rounded-t-xl border-b border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||||
|
<InfoIcon className="h-4 w-4 shrink-0" />
|
||||||
|
<span>{t("environments.surveys.summary.impressions_identified_only")}</span>
|
||||||
|
</div>
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+9
-5
@@ -10,12 +10,12 @@ interface SummaryMetadataProps {
|
|||||||
surveySummary: TSurveySummary["meta"];
|
surveySummary: TSurveySummary["meta"];
|
||||||
quotasCount: number;
|
quotasCount: number;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
tab: "dropOffs" | "quotas" | undefined;
|
tab: "dropOffs" | "quotas" | "impressions" | undefined;
|
||||||
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>;
|
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | "impressions" | undefined>>;
|
||||||
isQuotasAllowed: boolean;
|
isQuotasAllowed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatTime = (ttc) => {
|
const formatTime = (ttc: number) => {
|
||||||
const seconds = ttc / 1000;
|
const seconds = ttc / 1000;
|
||||||
let formattedValue;
|
let formattedValue;
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ export const SummaryMetadata = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const dropoffCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
|
const dropoffCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
|
||||||
|
|
||||||
const handleTabChange = (val: "dropOffs" | "quotas") => {
|
const handleTabChange = (val: "dropOffs" | "quotas" | "impressions") => {
|
||||||
const change = tab === val ? undefined : val;
|
const change = tab === val ? undefined : val;
|
||||||
setTab(change);
|
setTab(change);
|
||||||
};
|
};
|
||||||
@@ -65,12 +65,16 @@ export const SummaryMetadata = ({
|
|||||||
`grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`,
|
`grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`,
|
||||||
isQuotasAllowed && quotasCount > 0 && "2xl:grid-cols-6"
|
isQuotasAllowed && quotasCount > 0 && "2xl:grid-cols-6"
|
||||||
)}>
|
)}>
|
||||||
<StatCard
|
<InteractiveCard
|
||||||
|
key="impressions"
|
||||||
|
tab="impressions"
|
||||||
label={t("environments.surveys.summary.impressions")}
|
label={t("environments.surveys.summary.impressions")}
|
||||||
percentage={null}
|
percentage={null}
|
||||||
value={displayCount === 0 ? <span>-</span> : displayCount}
|
value={displayCount === 0 ? <span>-</span> : displayCount}
|
||||||
tooltipText={t("environments.surveys.summary.impressions_tooltip")}
|
tooltipText={t("environments.surveys.summary.impressions_tooltip")}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
onClick={() => handleTabChange("impressions")}
|
||||||
|
isActive={tab === "impressions"}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t("environments.surveys.summary.starts")}
|
label={t("environments.surveys.summary.starts")}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user