mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-21 11:30:27 -05:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b4cb4fc49 | |||
| f8204d659a | |||
| be38c41c6d | |||
| f4598c3db5 | |||
| 0831c8c31d | |||
| 20ce0f9f34 | |||
| 56d6d201f8 | |||
| 9a32e60441 | |||
| 2eaa098602 | |||
| e5393997c2 | |||
| 0c47a6eb61 | |||
| b96bc8809b | |||
| 3b6b804a68 | |||
| 5b776b04b1 | |||
| e3f3516fa7 | |||
| 91486f3dc9 | |||
| 8b9179bfcc | |||
| 1b337aeac3 | |||
| cd64848cc5 | |||
| 42411694a7 | |||
| 721d972901 |
@@ -1,9 +0,0 @@
|
|||||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
|
||||||
version = 1
|
|
||||||
name = "formbricks"
|
|
||||||
|
|
||||||
[setup]
|
|
||||||
script = '''
|
|
||||||
pnpm install
|
|
||||||
pnpm dev:setup
|
|
||||||
'''
|
|
||||||
+1
-68
@@ -32,39 +32,12 @@ CRON_SECRET=
|
|||||||
# Set the minimum log level(debug, info, warn, error, fatal)
|
# Set the minimum log level(debug, info, warn, error, fatal)
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
# BullMQ workers require REDIS_URL (for example `redis://localhost:6379`) to be set.
|
|
||||||
# BullMQ worker startup is enabled by default outside tests. Set to 0 to disable.
|
|
||||||
# BULLMQ_WORKER_ENABLED=1
|
|
||||||
# Set to 1 on web/API pods that only enqueue jobs while a separate BullMQ worker deployment consumes them.
|
|
||||||
# BULLMQ_EXTERNAL_WORKER_ENABLED=0
|
|
||||||
|
|
||||||
# Number of BullMQ worker instances started per Formbricks server process.
|
|
||||||
# BULLMQ_WORKER_COUNT=1
|
|
||||||
|
|
||||||
# Number of concurrent jobs each BullMQ worker can process.
|
|
||||||
# BULLMQ_WORKER_CONCURRENCY=1
|
|
||||||
|
|
||||||
# Survey publish/close scheduling is configured with public build-time env vars because the editor UI
|
|
||||||
# also needs to render the selected execution time and timezone.
|
|
||||||
# NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE=Europe/Berlin
|
|
||||||
# NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR=0
|
|
||||||
# NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE=0
|
|
||||||
|
|
||||||
##############
|
##############
|
||||||
# DATABASE #
|
# DATABASE #
|
||||||
##############
|
##############
|
||||||
|
|
||||||
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public'
|
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public'
|
||||||
|
|
||||||
#################
|
|
||||||
# HUB (DEV) #
|
|
||||||
#################
|
|
||||||
# The dev stack (pnpm db:up / pnpm go) runs Formbricks Hub on port 8080.
|
|
||||||
# Set explicitly to avoid confusion; override as needed when using docker-compose.dev.yml.
|
|
||||||
HUB_API_KEY=dev-api-key
|
|
||||||
HUB_API_URL=http://localhost:8080
|
|
||||||
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
|
|
||||||
|
|
||||||
################
|
################
|
||||||
# MAIL SETUP #
|
# MAIL SETUP #
|
||||||
################
|
################
|
||||||
@@ -121,12 +94,6 @@ EMAIL_VERIFICATION_DISABLED=1
|
|||||||
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
|
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
|
||||||
PASSWORD_RESET_DISABLED=1
|
PASSWORD_RESET_DISABLED=1
|
||||||
|
|
||||||
# Password reset token lifetime in minutes. Must be between 5 and 120 if set.
|
|
||||||
# PASSWORD_RESET_TOKEN_LIFETIME_MINUTES=30
|
|
||||||
|
|
||||||
# Development-only helper: log the password reset link to the server console instead of sending reset emails.
|
|
||||||
# DEBUG_SHOW_RESET_LINK=1
|
|
||||||
|
|
||||||
# Email login. Disable the ability for users to login with email.
|
# Email login. Disable the ability for users to login with email.
|
||||||
# EMAIL_AUTH_DISABLED=1
|
# EMAIL_AUTH_DISABLED=1
|
||||||
|
|
||||||
@@ -165,31 +132,6 @@ AZUREAD_CLIENT_ID=
|
|||||||
AZUREAD_CLIENT_SECRET=
|
AZUREAD_CLIENT_SECRET=
|
||||||
AZUREAD_TENANT_ID=
|
AZUREAD_TENANT_ID=
|
||||||
|
|
||||||
# Configure Formbricks AI at the instance level
|
|
||||||
# Set the provider used for AI features on this instance.
|
|
||||||
# Accepted values for AI_PROVIDER: aws, gcp, azure
|
|
||||||
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching credentials below.
|
|
||||||
# AI_PROVIDER=gcp
|
|
||||||
# AI_MODEL=gemini-2.5-flash
|
|
||||||
|
|
||||||
# Google Vertex AI credentials
|
|
||||||
# AI_GCP_PROJECT=
|
|
||||||
# AI_GCP_LOCATION=
|
|
||||||
# AI_GCP_CREDENTIALS_JSON=
|
|
||||||
# AI_GCP_APPLICATION_CREDENTIALS=
|
|
||||||
|
|
||||||
# Amazon Bedrock credentials
|
|
||||||
# AI_AWS_REGION=
|
|
||||||
# AI_AWS_ACCESS_KEY_ID=
|
|
||||||
# AI_AWS_SECRET_ACCESS_KEY=
|
|
||||||
# AI_AWS_SESSION_TOKEN=
|
|
||||||
|
|
||||||
# Azure AI / Microsoft Foundry credentials
|
|
||||||
# AI_AZURE_BASE_URL=
|
|
||||||
# AI_AZURE_RESOURCE_NAME=
|
|
||||||
# AI_AZURE_API_KEY=
|
|
||||||
# AI_AZURE_API_VERSION=v1
|
|
||||||
|
|
||||||
# OpenID Connect (OIDC) configuration
|
# OpenID Connect (OIDC) configuration
|
||||||
# OIDC_CLIENT_ID=
|
# OIDC_CLIENT_ID=
|
||||||
# OIDC_CLIENT_SECRET=
|
# OIDC_CLIENT_SECRET=
|
||||||
@@ -208,7 +150,6 @@ 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=
|
||||||
|
|
||||||
@@ -243,14 +184,6 @@ ENTERPRISE_LICENSE_KEY=
|
|||||||
# Ignore Rate Limiting across the Formbricks app
|
# Ignore Rate Limiting across the Formbricks app
|
||||||
# RATE_LIMITING_DISABLED=1
|
# RATE_LIMITING_DISABLED=1
|
||||||
|
|
||||||
# Disable telemetry reporting (usage stats sent to Formbricks). Ignored when an EE license is active.
|
|
||||||
# TELEMETRY_DISABLED=1
|
|
||||||
|
|
||||||
# Allow webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x)
|
|
||||||
# WARNING: Only enable this if you understand the SSRF risks. Useful for self-hosted instances
|
|
||||||
# that need to send webhooks to internal services.
|
|
||||||
# DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
|
|
||||||
|
|
||||||
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
|
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
|
||||||
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||||
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
||||||
@@ -297,4 +230,4 @@ REDIS_URL=redis://localhost:6379
|
|||||||
|
|
||||||
|
|
||||||
# Lingo.dev API key for translation generation
|
# Lingo.dev API key for translation generation
|
||||||
LINGO_API_KEY=your_api_key_here
|
LINGODOTDEV_API_KEY=your_api_key_here
|
||||||
@@ -285,14 +285,12 @@ 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,4 +92,3 @@ 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 }}
|
|
||||||
|
|||||||
@@ -6,9 +6,19 @@ 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:
|
||||||
@@ -23,38 +33,30 @@ jobs:
|
|||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Check for relevant changes
|
|
||||||
id: changes
|
|
||||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
|
||||||
with:
|
with:
|
||||||
filters: |
|
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||||
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 Node.js 22.x
|
- name: Setup Node.js 22.x
|
||||||
if: steps.changes.outputs.translations == 'true'
|
|
||||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
if: steps.changes.outputs.translations == 'true'
|
|
||||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: steps.changes.outputs.translations == 'true'
|
|
||||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||||
|
|
||||||
- name: Validate translation keys
|
- name: Validate translation keys
|
||||||
if: steps.changes.outputs.translations == 'true'
|
run: |
|
||||||
run: pnpm run scan-translations
|
echo ""
|
||||||
|
echo "🔍 Validating translation keys..."
|
||||||
|
echo ""
|
||||||
|
pnpm run scan-translations
|
||||||
|
|
||||||
- name: Skip (no translation-related changes)
|
- name: Summary
|
||||||
if: steps.changes.outputs.translations != 'true'
|
if: success()
|
||||||
run: echo "No translation-related files changed — skipping validation."
|
run: |
|
||||||
|
echo ""
|
||||||
|
echo "✅ Translation validation completed successfully!"
|
||||||
|
echo ""
|
||||||
|
|||||||
+1
-1
@@ -45,7 +45,7 @@ yarn-error.log*
|
|||||||
.direnv
|
.direnv
|
||||||
|
|
||||||
# Playwright
|
# Playwright
|
||||||
**/test-results/
|
/test-results/
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ./branch.json
|
||||||
|
prettier --write ./branch.json
|
||||||
+37
-10
@@ -1,13 +1,40 @@
|
|||||||
#!/usr/bin/env sh
|
# Load environment variables from .env files
|
||||||
|
if [ -f .env ]; then
|
||||||
|
set -a
|
||||||
|
. .env
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
if command -v pnpm >/dev/null 2>&1; then
|
pnpm lint-staged
|
||||||
pnpm lint-staged
|
|
||||||
elif command -v npm >/dev/null 2>&1; then
|
# Run Lingo.dev i18n workflow if LINGODOTDEV_API_KEY is set
|
||||||
npm exec --yes pnpm@10.32.1 lint-staged
|
if [ -n "$LINGODOTDEV_API_KEY" ]; then
|
||||||
elif command -v corepack >/dev/null 2>&1; then
|
echo ""
|
||||||
corepack pnpm lint-staged
|
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
|
else
|
||||||
echo "Error: pnpm, npm, and corepack are unavailable in this Git hook PATH."
|
echo ""
|
||||||
echo "Install Node.js tooling or update your PATH, then retry the commit."
|
echo "⚠️ Skipping translation validation: LINGODOTDEV_API_KEY is not set"
|
||||||
exit 127
|
echo " (This is expected for community contributors)"
|
||||||
|
echo ""
|
||||||
fi
|
fi
|
||||||
@@ -52,14 +52,6 @@ We are using SonarQube to identify code smells and security hotspots.
|
|||||||
- Translations are in `apps/web/locales/`. Default is `en-US.json`.
|
- Translations are in `apps/web/locales/`. Default is `en-US.json`.
|
||||||
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
|
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
|
||||||
|
|
||||||
## Date and Time Rendering
|
|
||||||
|
|
||||||
- All user-facing dates and times must use shared formatting helpers instead of ad hoc `date-fns`, `Intl`, or `toLocale*` calls in components.
|
|
||||||
- Locale for display must come from the app language source of truth (`user.locale`, `getLocale()`, or `i18n.resolvedLanguage`), not browser defaults or implicit `undefined` locale behavior.
|
|
||||||
- Locale and time zone are different concerns: locale controls formatting, time zone controls the represented clock/calendar moment.
|
|
||||||
- Never infer a time zone from locale. If a product-level time zone source of truth exists, use it explicitly; otherwise preserve the existing semantic meaning of the stored value and avoid introducing browser-dependent conversions.
|
|
||||||
- Machine-facing values for storage, APIs, exports, integrations, and logs must remain stable and non-localized (`ISO 8601` / UTC where applicable).
|
|
||||||
|
|
||||||
## Database & Prisma Performance
|
## Database & Prisma Performance
|
||||||
|
|
||||||
- Multi-tenancy: All data must be scoped by Organization or Environment.
|
- Multi-tenancy: All data must be scoped by Organization or Environment.
|
||||||
|
|||||||
@@ -127,10 +127,34 @@ Formbricks has a hosted cloud offering with a generous free plan to get you up a
|
|||||||
|
|
||||||
Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription.
|
Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription.
|
||||||
|
|
||||||
|
If you opt for self-hosting Formbricks, here are a few options to consider:
|
||||||
|
|
||||||
#### Docker
|
#### Docker
|
||||||
|
|
||||||
To get started with self-hosting with Docker, take a look at our [self-hosting docs](https://formbricks.com/docs/self-hosting/deployment).
|
To get started with self-hosting with Docker, take a look at our [self-hosting docs](https://formbricks.com/docs/self-hosting/deployment).
|
||||||
|
|
||||||
|
#### Community-managed One Click Hosting
|
||||||
|
|
||||||
|
##### Railway
|
||||||
|
|
||||||
|
You can deploy Formbricks on [Railway](https://railway.app) using the button below.
|
||||||
|
|
||||||
|
[](https://railway.app/new/template/PPDzCd)
|
||||||
|
|
||||||
|
##### RepoCloud
|
||||||
|
|
||||||
|
Or you can also deploy Formbricks on [RepoCloud](https://repocloud.io) using the button below.
|
||||||
|
|
||||||
|
[](https://repocloud.io/details/?app_id=254)
|
||||||
|
|
||||||
|
##### Zeabur
|
||||||
|
|
||||||
|
Or you can also deploy Formbricks on [Zeabur](https://zeabur.com) using the button below.
|
||||||
|
|
||||||
|
[](https://zeabur.com/templates/G4TUJL)
|
||||||
|
|
||||||
|
<a id="development"></a>
|
||||||
|
|
||||||
## 👨💻 Development
|
## 👨💻 Development
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -223,4 +247,4 @@ We currently do not offer Formbricks white-labeled. That means that we don't sel
|
|||||||
|
|
||||||
The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come.
|
The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come.
|
||||||
|
|
||||||
<a id="readme-de"></a>
|
<p align="right"><a href="#top">🔼 Back to top</a></p>
|
||||||
|
|||||||
+18
-13
@@ -10,20 +10,25 @@
|
|||||||
"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.1",
|
"@chromatic-com/storybook": "^5.0.0",
|
||||||
"@storybook/addon-a11y": "10.2.17",
|
"@storybook/addon-a11y": "10.1.11",
|
||||||
"@storybook/addon-links": "10.2.17",
|
"@storybook/addon-links": "10.1.11",
|
||||||
"@storybook/addon-onboarding": "10.2.17",
|
"@storybook/addon-onboarding": "10.1.11",
|
||||||
"@storybook/react-vite": "10.2.17",
|
"@storybook/react-vite": "10.1.11",
|
||||||
"@typescript-eslint/eslint-plugin": "8.57.0",
|
"@typescript-eslint/eslint-plugin": "8.53.0",
|
||||||
"@tailwindcss/vite": "4.2.1",
|
"@tailwindcss/vite": "4.1.18",
|
||||||
"@typescript-eslint/parser": "8.57.0",
|
"@typescript-eslint/parser": "8.53.0",
|
||||||
"@vitejs/plugin-react": "5.1.4",
|
"@vitejs/plugin-react": "5.1.2",
|
||||||
|
"esbuild": "0.25.12",
|
||||||
"eslint-plugin-react-refresh": "0.4.26",
|
"eslint-plugin-react-refresh": "0.4.26",
|
||||||
"eslint-plugin-storybook": "10.2.17",
|
"eslint-plugin-storybook": "10.1.11",
|
||||||
"storybook": "10.2.17",
|
"prop-types": "15.8.1",
|
||||||
"vite": "7.3.2",
|
"storybook": "10.1.11",
|
||||||
"@storybook/addon-docs": "10.2.17"
|
"vite": "7.3.1",
|
||||||
|
"@storybook/addon-docs": "10.1.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
const baseConfig = require("../../.prettierrc.js");
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
...baseConfig,
|
|
||||||
tailwindConfig: "./tailwind.config.js",
|
|
||||||
};
|
|
||||||
+6
-11
@@ -18,7 +18,7 @@ FROM node:24-alpine3.23 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@10.28.2 --activate
|
RUN corepack prepare pnpm@10.28.2 --activate
|
||||||
|
|
||||||
@@ -67,7 +67,6 @@ 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...
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -75,10 +74,9 @@ RUN --mount=type=secret,id=database_url \
|
|||||||
#
|
#
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|
||||||
# Upgrade Alpine system packages to pick up security patches, update npm to latest, then create user
|
# Update npm to latest, then create user
|
||||||
# Note: npm's bundled tar has a known vulnerability but npm is only used during build, not at runtime
|
# 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 npm install --ignore-scripts -g npm@latest \
|
||||||
&& npm install --ignore-scripts -g npm@latest \
|
|
||||||
&& addgroup -S nextjs \
|
&& addgroup -S nextjs \
|
||||||
&& adduser -S -u 1001 -G nextjs nextjs
|
&& adduser -S -u 1001 -G nextjs nextjs
|
||||||
|
|
||||||
@@ -122,11 +120,8 @@ RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules
|
|||||||
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
|
||||||
|
|
||||||
# Runtime migrations import uuid v7 from the database package, so copy the
|
COPY --from=installer /app/node_modules/uuid ./node_modules/uuid
|
||||||
# database package's resolved install instead of the repo-root hoisted version.
|
RUN chmod -R 755 ./node_modules/uuid
|
||||||
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
|
||||||
@@ -169,4 +164,4 @@ RUN mkdir -p /home/nextjs/apps/web/uploads/ && \
|
|||||||
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"]
|
||||||
+14
-11
@@ -4,20 +4,21 @@ import { ArrowRight } from "lucide-react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TWorkspaceConfigChannel } from "@formbricks/types/workspace";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
|
import { TProjectConfigChannel } from "@formbricks/types/project";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
|
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
|
||||||
|
|
||||||
interface ConnectWithFormbricksProps {
|
interface ConnectWithFormbricksProps {
|
||||||
workspaceId: string;
|
environment: TEnvironment;
|
||||||
publicDomain: string;
|
publicDomain: string;
|
||||||
appSetupCompleted: boolean;
|
appSetupCompleted: boolean;
|
||||||
channel: TWorkspaceConfigChannel;
|
channel: TProjectConfigChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConnectWithFormbricks = ({
|
export const ConnectWithFormbricks = ({
|
||||||
workspaceId,
|
environment,
|
||||||
publicDomain,
|
publicDomain,
|
||||||
appSetupCompleted,
|
appSetupCompleted,
|
||||||
channel,
|
channel,
|
||||||
@@ -25,7 +26,7 @@ export const ConnectWithFormbricks = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const handleFinishOnboarding = async () => {
|
const handleFinishOnboarding = async () => {
|
||||||
router.push(`/workspaces/${workspaceId}/surveys`);
|
router.push(`/environments/${environment.id}/surveys`);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -47,7 +48,7 @@ export const ConnectWithFormbricks = ({
|
|||||||
<div className="flex w-full space-x-10">
|
<div className="flex w-full space-x-10">
|
||||||
<div className="flex w-1/2 flex-col space-y-4">
|
<div className="flex w-1/2 flex-col space-y-4">
|
||||||
<OnboardingSetupInstructions
|
<OnboardingSetupInstructions
|
||||||
workspaceId={workspaceId}
|
environmentId={environment.id}
|
||||||
publicDomain={publicDomain}
|
publicDomain={publicDomain}
|
||||||
channel={channel}
|
channel={channel}
|
||||||
appSetupCompleted={appSetupCompleted}
|
appSetupCompleted={appSetupCompleted}
|
||||||
@@ -60,19 +61,19 @@ export const ConnectWithFormbricks = ({
|
|||||||
)}>
|
)}>
|
||||||
{appSetupCompleted ? (
|
{appSetupCompleted ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-3xl">{t("workspace.connect.congrats")}</p>
|
<p className="text-3xl">{t("environments.connect.congrats")}</p>
|
||||||
<p className="pt-4 text-sm font-medium text-slate-600">
|
<p className="pt-4 text-sm font-medium text-slate-600">
|
||||||
{t("workspace.connect.connection_successful_message")}
|
{t("environments.connect.connection_successful_message")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<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="absolute inline-flex h-full w-full animate-ping-slow rounded-full bg-slate-400 opacity-75"></span>
|
<span className="animate-ping-slow absolute inline-flex h-full w-full 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">
|
||||||
{t("workspace.connect.waiting_for_your_signal")}
|
{t("environments.connect.waiting_for_your_signal")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -82,7 +83,9 @@ export const ConnectWithFormbricks = ({
|
|||||||
id="finishOnboarding"
|
id="finishOnboarding"
|
||||||
variant={appSetupCompleted ? "default" : "ghost"}
|
variant={appSetupCompleted ? "default" : "ghost"}
|
||||||
onClick={handleFinishOnboarding}>
|
onClick={handleFinishOnboarding}>
|
||||||
{appSetupCompleted ? t("workspace.connect.finish_onboarding") : t("workspace.connect.do_it_later")}
|
{appSetupCompleted
|
||||||
|
? t("environments.connect.finish_onboarding")
|
||||||
|
: t("environments.connect.do_it_later")}
|
||||||
<ArrowRight />
|
<ArrowRight />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
+19
-19
@@ -5,7 +5,7 @@ import "prismjs/themes/prism.css";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TWorkspaceConfigChannel } from "@formbricks/types/workspace";
|
import { TProjectConfigChannel } from "@formbricks/types/project";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { CodeBlock } from "@/modules/ui/components/code-block";
|
import { CodeBlock } from "@/modules/ui/components/code-block";
|
||||||
import { Html5Icon, NpmIcon } from "@/modules/ui/components/icons";
|
import { Html5Icon, NpmIcon } from "@/modules/ui/components/icons";
|
||||||
@@ -17,14 +17,14 @@ const tabs = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
interface OnboardingSetupInstructionsProps {
|
interface OnboardingSetupInstructionsProps {
|
||||||
workspaceId: string;
|
environmentId: string;
|
||||||
publicDomain: string;
|
publicDomain: string;
|
||||||
channel: TWorkspaceConfigChannel;
|
channel: TProjectConfigChannel;
|
||||||
appSetupCompleted: boolean;
|
appSetupCompleted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OnboardingSetupInstructions = ({
|
export const OnboardingSetupInstructions = ({
|
||||||
workspaceId,
|
environmentId,
|
||||||
publicDomain,
|
publicDomain,
|
||||||
channel,
|
channel,
|
||||||
appSetupCompleted,
|
appSetupCompleted,
|
||||||
@@ -35,8 +35,8 @@ export const OnboardingSetupInstructions = ({
|
|||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
!function(){
|
!function(){
|
||||||
var appUrl = "${publicDomain}";
|
var appUrl = "${publicDomain}";
|
||||||
var workspaceId = "${workspaceId}";
|
var environmentId = "${environmentId}";
|
||||||
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({workspaceId:workspaceId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
|
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
|
||||||
</script>
|
</script>
|
||||||
<!-- END Formbricks Surveys -->
|
<!-- END Formbricks Surveys -->
|
||||||
`;
|
`;
|
||||||
@@ -45,46 +45,46 @@ export const OnboardingSetupInstructions = ({
|
|||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
!function(){
|
!function(){
|
||||||
var appUrl = "${publicDomain}";
|
var appUrl = "${publicDomain}";
|
||||||
var workspaceId = "${workspaceId}";
|
var environmentId = "${environmentId}";
|
||||||
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({workspaceId:workspaceId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
|
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
|
||||||
</script>
|
</script>
|
||||||
<!-- END Formbricks Surveys -->
|
<!-- END Formbricks Surveys -->
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const npmSnippetForAppSurveys = `
|
const npmSnippetForAppSurveys = `
|
||||||
import formbricks from "@formbricks/js";
|
import formbricks from "@formbricks/js";
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
formbricks.setup({
|
formbricks.setup({
|
||||||
workspaceId: "${workspaceId}",
|
environmentId: "${environmentId}",
|
||||||
appUrl: "${publicDomain}",
|
appUrl: "${publicDomain}",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
// your own app
|
// your own app
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const npmSnippetForWebsiteSurveys = `
|
const npmSnippetForWebsiteSurveys = `
|
||||||
// other imports
|
// other imports
|
||||||
import formbricks from "@formbricks/js";
|
import formbricks from "@formbricks/js";
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
formbricks.setup({
|
formbricks.setup({
|
||||||
workspaceId: "${workspaceId}",
|
environmentId: "${environmentId}",
|
||||||
appUrl: "${publicDomain}",
|
appUrl: "${publicDomain}",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
// your own app
|
// your own app
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -109,7 +109,7 @@ export const OnboardingSetupInstructions = ({
|
|||||||
yarn add @formbricks/js
|
yarn add @formbricks/js
|
||||||
</CodeBlock>
|
</CodeBlock>
|
||||||
<p className="text-sm text-slate-700">
|
<p className="text-sm text-slate-700">
|
||||||
{t("workspace.connect.import_formbricks_and_initialize_the_widget_in_your_component")}
|
{t("environments.connect.import_formbricks_and_initialize_the_widget_in_your_component")}
|
||||||
</p>
|
</p>
|
||||||
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
|
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
|
||||||
{channel === "app" ? npmSnippetForAppSurveys : npmSnippetForWebsiteSurveys}
|
{channel === "app" ? npmSnippetForAppSurveys : npmSnippetForWebsiteSurveys}
|
||||||
@@ -126,7 +126,7 @@ export const OnboardingSetupInstructions = ({
|
|||||||
) : activeTab === "html" ? (
|
) : activeTab === "html" ? (
|
||||||
<div className="prose prose-slate">
|
<div className="prose prose-slate">
|
||||||
<p className="-mb-1 mt-6 text-sm text-slate-700">
|
<p className="-mb-1 mt-6 text-sm text-slate-700">
|
||||||
{t("workspace.connect.insert_this_code_into_the_head_tag_of_your_website")}
|
{t("environments.connect.insert_this_code_into_the_head_tag_of_your_website")}
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
|
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
|
||||||
+18
-13
@@ -1,50 +1,55 @@
|
|||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
||||||
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/connect/components/ConnectWithFormbricks";
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||||
import { getWorkspace } from "@/lib/workspace/service";
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Header } from "@/modules/ui/components/header";
|
import { Header } from "@/modules/ui/components/header";
|
||||||
|
|
||||||
interface ConnectPageProps {
|
interface ConnectPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
workspaceId: string;
|
environmentId: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Page = async (props: ConnectPageProps) => {
|
const Page = async (props: ConnectPageProps) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
|
const environment = await getEnvironment(params.environmentId);
|
||||||
|
|
||||||
const workspace = await getWorkspace(params.workspaceId);
|
if (!environment) {
|
||||||
if (!workspace) {
|
throw new Error(t("common.environment_not_found"));
|
||||||
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = workspace.config.channel || null;
|
const project = await getProjectByEnvironmentId(environment.id);
|
||||||
|
if (!project) {
|
||||||
|
throw new Error(t("common.workspace_not_found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const channel = project.config.channel || null;
|
||||||
|
|
||||||
const publicDomain = getPublicDomain();
|
const publicDomain = getPublicDomain();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full flex-col items-center justify-center py-10">
|
<div className="flex min-h-full flex-col items-center justify-center py-10">
|
||||||
<Header title={t("workspace.connect.headline")} subtitle={t("workspace.connect.subtitle")} />
|
<Header title={t("environments.connect.headline")} subtitle={t("environments.connect.subtitle")} />
|
||||||
<div className="space-y-4 text-center">
|
<div className="space-y-4 text-center">
|
||||||
<p className="text-4xl font-medium text-slate-800"></p>
|
<p className="text-4xl font-medium text-slate-800"></p>
|
||||||
<p className="text-sm text-slate-500"></p>
|
<p className="text-sm text-slate-500"></p>
|
||||||
</div>
|
</div>
|
||||||
<ConnectWithFormbricks
|
<ConnectWithFormbricks
|
||||||
workspaceId={params.workspaceId}
|
environment={environment}
|
||||||
publicDomain={publicDomain}
|
publicDomain={publicDomain}
|
||||||
appSetupCompleted={workspace.appSetupCompleted}
|
appSetupCompleted={environment.appSetupCompleted}
|
||||||
channel={channel}
|
channel={channel}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={`/workspaces/${params.workspaceId}`}>
|
<Link href={`/environments/${environment.id}`}>
|
||||||
<XIcon className="h-7 w-7" strokeWidth={1.5} />
|
<XIcon className="h-7 w-7" strokeWidth={1.5} />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
+4
-7
@@ -1,13 +1,10 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AuthorizationError } from "@formbricks/types/errors";
|
import { AuthorizationError } from "@formbricks/types/errors";
|
||||||
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
|
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
|
|
||||||
const OnboardingLayout = async (props: {
|
const OnboardingLayout = async (props) => {
|
||||||
params: Promise<{ workspaceId: string }>;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) => {
|
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
@@ -17,9 +14,9 @@ const OnboardingLayout = async (props: {
|
|||||||
return redirect(`/auth/login`);
|
return redirect(`/auth/login`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAuthorized = await hasUserWorkspaceAccess(session.user.id, params.workspaceId);
|
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
|
||||||
if (!isAuthorized) {
|
if (!isAuthorized) {
|
||||||
throw new AuthorizationError("User is not authorized to access this workspace");
|
throw new AuthorizationError("User is not authorized to access this environment");
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div className="flex-1 bg-slate-50">{children}</div>;
|
return <div className="flex-1 bg-slate-50">{children}</div>;
|
||||||
+21
-21
@@ -5,23 +5,23 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TProject } from "@formbricks/types/project";
|
||||||
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
|
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
|
||||||
import { TXMTemplate } from "@formbricks/types/templates";
|
import { TXMTemplate } from "@formbricks/types/templates";
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
import { TWorkspace } from "@formbricks/types/workspace";
|
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils";
|
||||||
|
import { getXMTemplates } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates";
|
||||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||||
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/xm-templates/lib/utils";
|
|
||||||
import { getXMTemplates } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/xm-templates/lib/xm-templates";
|
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
|
import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
|
||||||
|
|
||||||
interface XMTemplateListProps {
|
interface XMTemplateListProps {
|
||||||
workspace: TWorkspace;
|
project: TProject;
|
||||||
user: TUser;
|
user: TUser;
|
||||||
workspaceId: string;
|
environmentId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const XMTemplateList = ({ workspace, user, workspaceId }: XMTemplateListProps) => {
|
export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListProps) => {
|
||||||
const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null);
|
const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -33,12 +33,12 @@ export const XMTemplateList = ({ workspace, user, workspaceId }: XMTemplateListP
|
|||||||
createdBy: user.id,
|
createdBy: user.id,
|
||||||
};
|
};
|
||||||
const createSurveyResponse = await createSurveyAction({
|
const createSurveyResponse = await createSurveyAction({
|
||||||
workspaceId: workspaceId,
|
environmentId: environmentId,
|
||||||
surveyBody: augmentedTemplate,
|
surveyBody: augmentedTemplate,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (createSurveyResponse?.data) {
|
if (createSurveyResponse?.data) {
|
||||||
router.push(`/workspaces/${workspaceId}/surveys/${createSurveyResponse.data.id}/edit?mode=cx`);
|
router.push(`/environments/${environmentId}/surveys/${createSurveyResponse.data.id}/edit?mode=cx`);
|
||||||
} else {
|
} else {
|
||||||
const errorMessage = getFormattedErrorMessage(createSurveyResponse);
|
const errorMessage = getFormattedErrorMessage(createSurveyResponse);
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
@@ -48,49 +48,49 @@ export const XMTemplateList = ({ workspace, user, workspaceId }: XMTemplateListP
|
|||||||
const handleTemplateClick = (templateIdx: number) => {
|
const handleTemplateClick = (templateIdx: number) => {
|
||||||
setActiveTemplateId(templateIdx);
|
setActiveTemplateId(templateIdx);
|
||||||
const template = getXMTemplates(t)[templateIdx];
|
const template = getXMTemplates(t)[templateIdx];
|
||||||
const newTemplate = replacePresetPlaceholders(template, workspace);
|
const newTemplate = replacePresetPlaceholders(template, project);
|
||||||
createSurvey(newTemplate);
|
createSurvey(newTemplate);
|
||||||
};
|
};
|
||||||
|
|
||||||
const XMTemplateOptions = [
|
const XMTemplateOptions = [
|
||||||
{
|
{
|
||||||
title: t("workspace.xm-templates.nps"),
|
title: t("environments.xm-templates.nps"),
|
||||||
description: t("workspace.xm-templates.nps_description"),
|
description: t("environments.xm-templates.nps_description"),
|
||||||
icon: ShoppingCartIcon,
|
icon: ShoppingCartIcon,
|
||||||
onClick: () => handleTemplateClick(0),
|
onClick: () => handleTemplateClick(0),
|
||||||
isLoading: activeTemplateId === 0,
|
isLoading: activeTemplateId === 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("workspace.xm-templates.five_star_rating"),
|
title: t("environments.xm-templates.five_star_rating"),
|
||||||
description: t("workspace.xm-templates.five_star_rating_description"),
|
description: t("environments.xm-templates.five_star_rating_description"),
|
||||||
icon: StarIcon,
|
icon: StarIcon,
|
||||||
onClick: () => handleTemplateClick(1),
|
onClick: () => handleTemplateClick(1),
|
||||||
isLoading: activeTemplateId === 1,
|
isLoading: activeTemplateId === 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("workspace.xm-templates.csat"),
|
title: t("environments.xm-templates.csat"),
|
||||||
description: t("workspace.xm-templates.csat_description"),
|
description: t("environments.xm-templates.csat_description"),
|
||||||
icon: ThumbsUpIcon,
|
icon: ThumbsUpIcon,
|
||||||
onClick: () => handleTemplateClick(2),
|
onClick: () => handleTemplateClick(2),
|
||||||
isLoading: activeTemplateId === 2,
|
isLoading: activeTemplateId === 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("workspace.xm-templates.ces"),
|
title: t("environments.xm-templates.ces"),
|
||||||
description: t("workspace.xm-templates.ces_description"),
|
description: t("environments.xm-templates.ces_description"),
|
||||||
icon: ActivityIcon,
|
icon: ActivityIcon,
|
||||||
onClick: () => handleTemplateClick(3),
|
onClick: () => handleTemplateClick(3),
|
||||||
isLoading: activeTemplateId === 3,
|
isLoading: activeTemplateId === 3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("workspace.xm-templates.smileys"),
|
title: t("environments.xm-templates.smileys"),
|
||||||
description: t("workspace.xm-templates.smileys_description"),
|
description: t("environments.xm-templates.smileys_description"),
|
||||||
icon: SmileIcon,
|
icon: SmileIcon,
|
||||||
onClick: () => handleTemplateClick(4),
|
onClick: () => handleTemplateClick(4),
|
||||||
isLoading: activeTemplateId === 4,
|
isLoading: activeTemplateId === 4,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("workspace.xm-templates.enps"),
|
title: t("environments.xm-templates.enps"),
|
||||||
description: t("workspace.xm-templates.enps_description"),
|
description: t("environments.xm-templates.enps_description"),
|
||||||
icon: UsersIcon,
|
icon: UsersIcon,
|
||||||
onClick: () => handleTemplateClick(5),
|
onClick: () => handleTemplateClick(5),
|
||||||
isLoading: activeTemplateId === 5,
|
isLoading: activeTemplateId === 5,
|
||||||
+16
-17
@@ -1,17 +1,16 @@
|
|||||||
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 { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
|
import { TProject } from "@formbricks/types/project";
|
||||||
import { TXMTemplate } from "@formbricks/types/templates";
|
import { TXMTemplate } from "@formbricks/types/templates";
|
||||||
import { TWorkspace } from "@formbricks/types/workspace";
|
|
||||||
import { replacePresetPlaceholders } from "./utils";
|
import { replacePresetPlaceholders } from "./utils";
|
||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
const mockWorkspace: TWorkspace = {
|
const mockProject: TProject = {
|
||||||
id: "workspace1",
|
id: "project1",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
name: "Test Workspace",
|
name: "Test Project",
|
||||||
organizationId: "org1",
|
organizationId: "org1",
|
||||||
styling: {
|
styling: {
|
||||||
allowStyleOverwrite: true,
|
allowStyleOverwrite: true,
|
||||||
@@ -27,12 +26,12 @@ const mockWorkspace: TWorkspace = {
|
|||||||
placement: "bottomRight",
|
placement: "bottomRight",
|
||||||
clickOutsideClose: true,
|
clickOutsideClose: true,
|
||||||
overlay: "none",
|
overlay: "none",
|
||||||
appSetupCompleted: false,
|
environments: [],
|
||||||
languages: [],
|
languages: [],
|
||||||
logo: null,
|
logo: null,
|
||||||
};
|
};
|
||||||
const mockTemplate: TXMTemplate = {
|
const mockTemplate: TXMTemplate = {
|
||||||
name: "$[workspaceName] Survey",
|
name: "$[projectName] Survey",
|
||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
id: "block1",
|
id: "block1",
|
||||||
@@ -40,13 +39,13 @@ const mockTemplate: TXMTemplate = {
|
|||||||
elements: [
|
elements: [
|
||||||
{
|
{
|
||||||
id: "q1",
|
id: "q1",
|
||||||
type: "openText" as TSurveyElementTypeEnum.OpenText,
|
type: "openText" as const,
|
||||||
inputType: "text" as const,
|
inputType: "text" as const,
|
||||||
headline: { default: "$[workspaceName] Question" },
|
headline: { default: "$[projectName] Question" },
|
||||||
subheader: { default: "" },
|
subheader: { default: "" },
|
||||||
required: false,
|
required: false,
|
||||||
placeholder: { default: "" },
|
placeholder: { default: "" },
|
||||||
charLimit: { enabled: true, max: 1000 },
|
charLimit: 1000,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -70,19 +69,19 @@ describe("replacePresetPlaceholders", () => {
|
|||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("replaces workspaceName placeholder in template name", () => {
|
test("replaces projectName placeholder in template name", () => {
|
||||||
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
|
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
||||||
expect(result.name).toBe("Test Workspace Survey");
|
expect(result.name).toBe("Test Project Survey");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("replaces workspaceName placeholder in element headline", () => {
|
test("replaces projectName placeholder in element headline", () => {
|
||||||
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
|
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
||||||
expect(result.blocks[0].elements[0].headline.default).toBe("Test Workspace Question");
|
expect(result.blocks[0].elements[0].headline.default).toBe("Test Project Question");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns a new object without mutating the original template", () => {
|
test("returns a new object without mutating the original template", () => {
|
||||||
const originalTemplate = structuredClone(mockTemplate);
|
const originalTemplate = structuredClone(mockTemplate);
|
||||||
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
|
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
||||||
expect(result).not.toBe(mockTemplate);
|
expect(result).not.toBe(mockTemplate);
|
||||||
expect(mockTemplate).toEqual(originalTemplate);
|
expect(mockTemplate).toEqual(originalTemplate);
|
||||||
});
|
});
|
||||||
+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 workspaceName with the actual workspace name in the current template
|
// replace all occurences of projectName with the actual project name in the current template
|
||||||
export const replacePresetPlaceholders = (template: TXMTemplate, workspace: TWorkspace): TXMTemplate => {
|
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject): TXMTemplate => {
|
||||||
const survey = structuredClone(template);
|
const survey = structuredClone(template);
|
||||||
|
|
||||||
const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({
|
const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({
|
||||||
...block,
|
...block,
|
||||||
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, workspace)),
|
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, project)),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { ...survey, name: survey.name.replace("$[workspaceName]", workspace.name), blocks: modifiedBlocks };
|
return { ...survey, name: survey.name.replace("$[projectName]", project.name), blocks: modifiedBlocks };
|
||||||
};
|
};
|
||||||
+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: string) => key) as unknown as TFunction;
|
const tMock = vi.fn((key) => key) 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: string) => key) as unknown as TFunction;
|
const tMock = vi.fn((key) => key) 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 unknown as TFunction;
|
}) as TFunction;
|
||||||
|
|
||||||
const result = getXMTemplates(tMock);
|
const result = getXMTemplates(tMock);
|
||||||
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
|
||||||
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
|
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
|
||||||
|
import { getUser } from "@/lib/user/service";
|
||||||
|
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||||
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { Header } from "@/modules/ui/components/header";
|
||||||
|
|
||||||
|
interface XMTemplatePageProps {
|
||||||
|
params: Promise<{
|
||||||
|
environmentId: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Page = async (props: XMTemplatePageProps) => {
|
||||||
|
const params = await props.params;
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
const environment = await getEnvironment(params.environmentId);
|
||||||
|
const t = await getTranslate();
|
||||||
|
if (!session) {
|
||||||
|
throw new Error(t("common.session_not_found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUser(session.user.id);
|
||||||
|
if (!user) {
|
||||||
|
throw new Error(t("common.user_not_found"));
|
||||||
|
}
|
||||||
|
if (!environment) {
|
||||||
|
throw new Error(t("common.environment_not_found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||||
|
|
||||||
|
const project = await getProjectByEnvironmentId(environment.id);
|
||||||
|
if (!project) {
|
||||||
|
throw new Error(t("common.workspace_not_found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = await getUserProjects(session.user.id, organizationId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||||
|
<Header title={t("environments.xm-templates.headline")} />
|
||||||
|
<XMTemplateList project={project} user={user} environmentId={environment.id} />
|
||||||
|
{projects.length >= 2 && (
|
||||||
|
<Button
|
||||||
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
|
variant="ghost"
|
||||||
|
asChild>
|
||||||
|
<Link href={`/environments/${environment.id}/surveys`}>
|
||||||
|
<XIcon className="h-7 w-7" strokeWidth={1.5} />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Page;
|
||||||
@@ -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", createdAt: new Date(), updatedAt: new Date(), organizationId: "org1" },
|
{ id: "t1", name: "Team 1" },
|
||||||
{ id: "t2", name: "Team 2", createdAt: new Date(), updatedAt: new Date(), organizationId: "org1" },
|
{ id: "t2", name: "Team 2" },
|
||||||
];
|
];
|
||||||
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,10 +22,12 @@ export const getTeamsByOrganizationId = reactCache(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return teams.map((team: TOrganizationTeam) => ({
|
const projectTeams = teams.map((team) => ({
|
||||||
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);
|
||||||
|
|||||||
+3
-2
@@ -42,9 +42,9 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
|||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-40 flex w-sidebar-collapsed flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
|
"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"
|
||||||
)}>
|
)}>
|
||||||
<Image src={FBLogo} width={160} height={30} alt={t("workspace.formbricks_logo")} />
|
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
|
||||||
|
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -105,6 +105,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
|||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
redirect: true,
|
redirect: true,
|
||||||
callbackUrl: "/auth/login",
|
callbackUrl: "/auth/login",
|
||||||
|
clearEnvironmentId: true,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { getEnvironments } from "@/lib/environment/service";
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getUserWorkspaces } from "@/lib/workspace/service";
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
|
|
||||||
const LandingLayout = async (props: {
|
const LandingLayout = async (props) => {
|
||||||
params: Promise<{ organizationId: string }>;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) => {
|
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
@@ -23,11 +21,16 @@ const LandingLayout = async (props: {
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
|
const projects = await getUserProjects(session.user.id, params.organizationId);
|
||||||
|
|
||||||
if (workspaces.length !== 0) {
|
if (projects.length !== 0) {
|
||||||
const firstWorkspace = workspaces[0];
|
const firstProject = projects[0];
|
||||||
return redirect(`/workspaces/${firstWorkspace.id}/`);
|
const environments = await getEnvironments(firstProject.id);
|
||||||
|
const prodEnvironment = environments.find((e) => e.type === "production");
|
||||||
|
|
||||||
|
if (prodEnvironment) {
|
||||||
|
return redirect(`/environments/${prodEnvironment.id}/`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
@@ -1,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 { WorkspaceAndOrgSwitch } from "@/app/(app)/workspaces/[workspaceId]/components/workspace-and-org-switch";
|
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
|
||||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { 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: { params: Promise<{ organizationId: string }> }) => {
|
const Page = async (props) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
|
|
||||||
@@ -26,8 +26,7 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
|
|||||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||||
|
|
||||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||||
const { isMember, isBilling } = getAccessFlags(membership?.role);
|
const { isMember } = getAccessFlags(membership?.role);
|
||||||
const isMembershipPending = membership?.role === undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-row">
|
<div className="flex min-h-full min-w-full flex-row">
|
||||||
@@ -35,19 +34,18 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
{/* we only need to render organization breadcrumb on this page, organizations/workspaces are lazy-loaded */}
|
{/* we only need to render organization breadcrumb on this page, organizations/projects are lazy-loaded */}
|
||||||
<WorkspaceAndOrgSwitch
|
<ProjectAndOrgSwitch
|
||||||
currentOrganizationId={organization.id}
|
currentOrganizationId={organization.id}
|
||||||
currentOrganizationName={organization.name}
|
currentOrganizationName={organization.name}
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
organizationWorkspacesLimit={0}
|
organizationProjectsLimit={0}
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
isLicenseActive={false}
|
isLicenseActive={false}
|
||||||
isOwnerOrManager={false}
|
isOwnerOrManager={false}
|
||||||
isAccessControlAllowed={false}
|
isAccessControlAllowed={false}
|
||||||
isMember={isMember}
|
isMember={isMember}
|
||||||
isBilling={isBilling}
|
environments={[]}
|
||||||
isMembershipPending={isMembershipPending}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-full flex-col items-center justify-center space-y-12">
|
<div className="flex h-full flex-col items-center justify-center space-y-12">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
import { AuthorizationError } from "@formbricks/types/errors";
|
||||||
import { canUserAccessOrganization } from "@/lib/organization/auth";
|
import { canUserAccessOrganization } from "@/lib/organization/auth";
|
||||||
import { getOrganization } from "@/lib/organization/service";
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
@@ -8,10 +8,7 @@ import { getTranslate } from "@/lingodotdev/server";
|
|||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
||||||
|
|
||||||
const WorkspaceOnboardingLayout = async (props: {
|
const ProjectOnboardingLayout = async (props) => {
|
||||||
params: Promise<{ organizationId: string }>;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) => {
|
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
@@ -25,7 +22,7 @@ const WorkspaceOnboardingLayout = async (props: {
|
|||||||
|
|
||||||
const user = await getUser(session.user.id);
|
const user = await getUser(session.user.id);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
throw new Error(t("common.user_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
|
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
|
||||||
@@ -36,7 +33,7 @@ const WorkspaceOnboardingLayout = async (props: {
|
|||||||
|
|
||||||
const organization = await getOrganization(params.organizationId);
|
const organization = await getOrganization(params.organizationId);
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
|
throw new Error(t("common.organization_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,4 +44,4 @@ const WorkspaceOnboardingLayout = async (props: {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default WorkspaceOnboardingLayout;
|
export default ProjectOnboardingLayout;
|
||||||
|
|||||||
+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 { getUserWorkspaces } from "@/lib/workspace/service";
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
@@ -39,7 +39,7 @@ const Page = async (props: ChannelPageProps) => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
|
const projects = await getUserProjects(session.user.id, params.organizationId);
|
||||||
|
|
||||||
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} />
|
||||||
{workspaces.length >= 1 && (
|
{projects.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
|
|||||||
+7
-13
@@ -1,18 +1,14 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
import { getOrganization } from "@/lib/organization/service";
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
import { getOrganizationWorkspacesCount } from "@/lib/workspace/service";
|
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { getOrganizationWorkspacesLimit } from "@/modules/ee/license-check/lib/utils";
|
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||||
|
|
||||||
const OnboardingLayout = async (props: {
|
const OnboardingLayout = async (props) => {
|
||||||
params: Promise<{ organizationId: string }>;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) => {
|
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
@@ -29,15 +25,13 @@ const OnboardingLayout = async (props: {
|
|||||||
|
|
||||||
const organization = await getOrganization(params.organizationId);
|
const organization = await getOrganization(params.organizationId);
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
|
throw new Error(t("common.organization_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const [organizationWorkspacesLimit, organizationWorkspacesCount] = await Promise.all([
|
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||||
getOrganizationWorkspacesLimit(organization.id),
|
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
|
||||||
getOrganizationWorkspacesCount(organization.id),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (organizationWorkspacesCount >= organizationWorkspacesLimit) {
|
if (organizationProjectsCount >= organizationProjectsLimit) {
|
||||||
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 { getUserWorkspaces } from "@/lib/workspace/service";
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
@@ -39,15 +39,15 @@ const Page = async (props: ModePageProps) => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
|
const projects = await getUserProjects(session.user.id, params.organizationId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||||
<Header title={t("organizations.workspaces.new.mode.what_are_you_here_for")} />
|
<Header title={t("organizations.workspaces.new.mode.what_are_you_here_for")} />
|
||||||
<OnboardingOptionsContainer options={channelOptions} />
|
<OnboardingOptionsContainer options={channelOptions} />
|
||||||
{workspaces.length >= 1 && (
|
{projects.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
|
|||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
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("workspace.settings.billing.select_plan_header_title")}
|
|
||||||
subtitle={t("workspace.settings.billing.select_plan_header_subtitle")}
|
|
||||||
/>
|
|
||||||
<SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-42
@@ -1,42 +0,0 @@
|
|||||||
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
@@ -8,19 +8,19 @@ import { useForm } from "react-hook-form";
|
|||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
TWorkspaceConfigChannel,
|
TProjectConfigChannel,
|
||||||
TWorkspaceConfigIndustry,
|
TProjectConfigIndustry,
|
||||||
TWorkspaceMode,
|
TProjectMode,
|
||||||
TWorkspaceUpdateInput,
|
TProjectUpdateInput,
|
||||||
ZWorkspaceUpdateInput,
|
ZProjectUpdateInput,
|
||||||
} from "@formbricks/types/workspace";
|
} from "@formbricks/types/project";
|
||||||
import { createWorkspaceAction } from "@/app/(app)/workspaces/[workspaceId]/actions";
|
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||||
import { previewSurvey } from "@/app/lib/templates";
|
import { previewSurvey } from "@/app/lib/templates";
|
||||||
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
|
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
|
||||||
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
|
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
|
||||||
import { 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 {
|
||||||
@@ -36,34 +36,34 @@ import { Input } from "@/modules/ui/components/input";
|
|||||||
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
import { MultiSelect } from "@/modules/ui/components/multi-select";
|
||||||
import { SurveyInline } from "@/modules/ui/components/survey";
|
import { SurveyInline } from "@/modules/ui/components/survey";
|
||||||
|
|
||||||
interface WorkspaceSettingsProps {
|
interface ProjectSettingsProps {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
workspaceMode: TWorkspaceMode;
|
projectMode: TProjectMode;
|
||||||
channel: TWorkspaceConfigChannel;
|
channel: TProjectConfigChannel;
|
||||||
industry: TWorkspaceConfigIndustry;
|
industry: TProjectConfigIndustry;
|
||||||
defaultBrandColor: string;
|
defaultBrandColor: string;
|
||||||
organizationTeams: TOrganizationTeam[];
|
organizationTeams: TOrganizationTeam[];
|
||||||
isAccessControlAllowed: boolean;
|
isAccessControlAllowed: boolean;
|
||||||
userWorkspacesCount: number;
|
userProjectsCount: number;
|
||||||
publicDomain: string;
|
publicDomain: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WorkspaceSettings = ({
|
export const ProjectSettings = ({
|
||||||
organizationId,
|
organizationId,
|
||||||
workspaceMode,
|
projectMode,
|
||||||
channel,
|
channel,
|
||||||
industry,
|
industry,
|
||||||
defaultBrandColor,
|
defaultBrandColor,
|
||||||
organizationTeams,
|
organizationTeams,
|
||||||
isAccessControlAllowed = false,
|
isAccessControlAllowed = false,
|
||||||
userWorkspacesCount,
|
userProjectsCount,
|
||||||
publicDomain,
|
publicDomain,
|
||||||
}: WorkspaceSettingsProps) => {
|
}: ProjectSettingsProps) => {
|
||||||
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const addWorkspace = async (data: TWorkspaceUpdateInput) => {
|
const addProject = async (data: TProjectUpdateInput) => {
|
||||||
try {
|
try {
|
||||||
// Build the full styling from the chosen brand color so all derived
|
// Build the full styling from the chosen brand color so all derived
|
||||||
// colours (question, button, input, option, progress, etc.) are persisted.
|
// colours (question, button, input, option, progress, etc.) are persisted.
|
||||||
@@ -71,7 +71,7 @@ export const WorkspaceSettings = ({
|
|||||||
// back to STYLE_DEFAULTS computed from the default brand (#64748b).
|
// back to STYLE_DEFAULTS computed from the default brand (#64748b).
|
||||||
const fullStyling = buildStylingFromBrandColor(data.styling?.brandColor?.light);
|
const fullStyling = buildStylingFromBrandColor(data.styling?.brandColor?.light);
|
||||||
|
|
||||||
const createWorkspaceResponse = await createWorkspaceAction({
|
const createProjectResponse = await createProjectAction({
|
||||||
organizationId,
|
organizationId,
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
@@ -81,21 +81,26 @@ export const WorkspaceSettings = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (createWorkspaceResponse?.data) {
|
if (createProjectResponse?.data) {
|
||||||
if (globalThis.window !== undefined) {
|
// get production environment
|
||||||
// Remove filters when creating a new workspace
|
const productionEnvironment = createProjectResponse.data.environments.find(
|
||||||
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
(environment) => environment.type === "production"
|
||||||
|
);
|
||||||
|
if (productionEnvironment) {
|
||||||
|
if (globalThis.window !== undefined) {
|
||||||
|
// Rmove filters when creating a new project
|
||||||
|
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const workspaceId = createWorkspaceResponse.data.id;
|
|
||||||
if (channel === "app" || channel === "website") {
|
if (channel === "app" || channel === "website") {
|
||||||
router.push(`/workspaces/${workspaceId}/connect`);
|
router.push(`/environments/${productionEnvironment?.id}/connect`);
|
||||||
} else if (channel === "link") {
|
} else if (channel === "link") {
|
||||||
router.push(`/workspaces/${workspaceId}/surveys`);
|
router.push(`/environments/${productionEnvironment?.id}/surveys`);
|
||||||
} else if (workspaceMode === "cx") {
|
} else if (projectMode === "cx") {
|
||||||
router.push(`/workspaces/${workspaceId}/xm-templates`);
|
router.push(`/environments/${productionEnvironment?.id}/xm-templates`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const errorMessage = getFormattedErrorMessage(createWorkspaceResponse);
|
const errorMessage = getFormattedErrorMessage(createProjectResponse);
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -104,15 +109,15 @@ export const WorkspaceSettings = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const form = useForm<TWorkspaceUpdateInput>({
|
const form = useForm<TProjectUpdateInput>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: "",
|
name: "",
|
||||||
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
|
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
|
||||||
teamIds: [],
|
teamIds: [],
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZWorkspaceUpdateInput),
|
resolver: zodResolver(ZProjectUpdateInput),
|
||||||
});
|
});
|
||||||
const workspaceName = form.watch("name");
|
const projectName = form.watch("name");
|
||||||
const logoUrl = form.watch("logo.url");
|
const logoUrl = form.watch("logo.url");
|
||||||
const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
|
const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
|
||||||
const previewStyling = useMemo(() => buildStylingFromBrandColor(brandColor), [brandColor]);
|
const previewStyling = useMemo(() => buildStylingFromBrandColor(brandColor), [brandColor]);
|
||||||
@@ -127,7 +132,7 @@ export const WorkspaceSettings = ({
|
|||||||
<div className="mt-6 flex w-5/6 space-x-10 lg:w-2/3 2xl:w-1/2">
|
<div className="mt-6 flex w-5/6 space-x-10 lg:w-2/3 2xl:w-1/2">
|
||||||
<div className="flex w-1/2 flex-col space-y-4">
|
<div className="flex w-1/2 flex-col space-y-4">
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<form onSubmit={form.handleSubmit(addWorkspace)} className="w-full space-y-4">
|
<form onSubmit={form.handleSubmit(addProject)} className="w-full space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="styling.brandColor.light"
|
name="styling.brandColor.light"
|
||||||
@@ -179,7 +184,7 @@ export const WorkspaceSettings = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isAccessControlAllowed && userWorkspacesCount > 0 && (
|
{isAccessControlAllowed && userProjectsCount > 0 && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="teamIds"
|
name="teamIds"
|
||||||
@@ -223,7 +228,7 @@ export const WorkspaceSettings = ({
|
|||||||
</FormProvider>
|
</FormProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
<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">
|
||||||
{logoUrl && (
|
{logoUrl && (
|
||||||
<Image
|
<Image
|
||||||
src={logoUrl}
|
src={logoUrl}
|
||||||
@@ -234,16 +239,18 @@ export const WorkspaceSettings = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm text-slate-400">{t("common.preview")}</p>
|
<p className="text-sm text-slate-400">{t("common.preview")}</p>
|
||||||
<SurveyInline
|
<div className="z-0 h-3/4 w-3/4">
|
||||||
appUrl={publicDomain}
|
<SurveyInline
|
||||||
isPreviewMode={true}
|
appUrl={publicDomain}
|
||||||
survey={previewSurvey(workspaceName || t("common.my_product"), t)}
|
isPreviewMode={true}
|
||||||
styling={previewStyling}
|
survey={previewSurvey(projectName || "my Product", t)}
|
||||||
isBrandingEnabled={false}
|
styling={previewStyling}
|
||||||
languageCode="default"
|
isBrandingEnabled={false}
|
||||||
onFileUpload={async (file) => file.name}
|
languageCode="default"
|
||||||
autoFocus={false}
|
onFileUpload={async (file) => file.name}
|
||||||
/>
|
autoFocus={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CreateTeamModal
|
<CreateTeamModal
|
||||||
open={createTeamModalOpen}
|
open={createTeamModalOpen}
|
||||||
+16
-21
@@ -1,35 +1,30 @@
|
|||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
|
||||||
import {
|
|
||||||
TWorkspaceConfigChannel,
|
|
||||||
TWorkspaceConfigIndustry,
|
|
||||||
TWorkspaceMode,
|
|
||||||
} from "@formbricks/types/workspace";
|
|
||||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||||
import { WorkspaceSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/WorkspaceSettings";
|
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
|
||||||
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||||
import { getUserWorkspaces } from "@/lib/workspace/service";
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Header } from "@/modules/ui/components/header";
|
import { Header } from "@/modules/ui/components/header";
|
||||||
|
|
||||||
interface WorkspaceSettingsPageProps {
|
interface ProjectSettingsPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
}>;
|
}>;
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
channel?: TWorkspaceConfigChannel;
|
channel?: TProjectConfigChannel;
|
||||||
industry?: TWorkspaceConfigIndustry;
|
industry?: TProjectConfigIndustry;
|
||||||
mode?: TWorkspaceMode;
|
mode?: TProjectMode;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Page = async (props: WorkspaceSettingsPageProps) => {
|
const Page = async (props: ProjectSettingsPageProps) => {
|
||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
@@ -43,14 +38,14 @@ const Page = async (props: WorkspaceSettingsPageProps) => {
|
|||||||
const channel = searchParams.channel ?? null;
|
const channel = searchParams.channel ?? null;
|
||||||
const industry = searchParams.industry ?? null;
|
const industry = searchParams.industry ?? null;
|
||||||
const mode = searchParams.mode ?? "surveys";
|
const mode = searchParams.mode ?? "surveys";
|
||||||
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
|
const projects = await getUserProjects(session.user.id, params.organizationId);
|
||||||
|
|
||||||
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
|
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
|
||||||
|
|
||||||
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
|
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
|
||||||
|
|
||||||
if (!organizationTeams) {
|
if (!organizationTeams) {
|
||||||
throw new ResourceNotFoundError(t("common.team"), null);
|
throw new Error(t("common.organization_teams_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicDomain = getPublicDomain();
|
const publicDomain = getPublicDomain();
|
||||||
@@ -61,20 +56,20 @@ const Page = async (props: WorkspaceSettingsPageProps) => {
|
|||||||
title={t("organizations.workspaces.new.settings.workspace_settings_title")}
|
title={t("organizations.workspaces.new.settings.workspace_settings_title")}
|
||||||
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
|
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
|
||||||
/>
|
/>
|
||||||
<WorkspaceSettings
|
<ProjectSettings
|
||||||
organizationId={params.organizationId}
|
organizationId={params.organizationId}
|
||||||
workspaceMode={mode}
|
projectMode={mode}
|
||||||
channel={channel}
|
channel={channel}
|
||||||
industry={industry}
|
industry={industry}
|
||||||
defaultBrandColor={DEFAULT_BRAND_COLOR}
|
defaultBrandColor={DEFAULT_BRAND_COLOR}
|
||||||
organizationTeams={organizationTeams}
|
organizationTeams={organizationTeams}
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
isAccessControlAllowed={isAccessControlAllowed}
|
||||||
userWorkspacesCount={workspaces.length}
|
userProjectsCount={projects.length}
|
||||||
publicDomain={publicDomain}
|
publicDomain={publicDomain}
|
||||||
/>
|
/>
|
||||||
{workspaces.length >= 1 && (
|
{projects.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-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: OnboardingOptionsContainerProps["options"][number]) => {
|
const getOptionCard = (option) => {
|
||||||
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.cuid2(),
|
id: z.string().cuid2(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
import { XIcon } from "lucide-react";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { XMTemplateList } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/xm-templates/components/XMTemplateList";
|
|
||||||
import { getUser } from "@/lib/user/service";
|
|
||||||
import { getUserWorkspaces, getWorkspace } from "@/lib/workspace/service";
|
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
|
||||||
import { Header } from "@/modules/ui/components/header";
|
|
||||||
|
|
||||||
interface XMTemplatePageProps {
|
|
||||||
params: Promise<{
|
|
||||||
workspaceId: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Page = async (props: XMTemplatePageProps) => {
|
|
||||||
const params = await props.params;
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
const t = await getTranslate();
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await getUser(session.user.id);
|
|
||||||
if (!user) {
|
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspace = await getWorkspace(params.workspaceId);
|
|
||||||
if (!workspace) {
|
|
||||||
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspaces = await getUserWorkspaces(session.user.id, workspace.organizationId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
|
||||||
<Header title={t("workspace.xm-templates.headline")} />
|
|
||||||
<XMTemplateList workspace={workspace} user={user} workspaceId={params.workspaceId} />
|
|
||||||
{workspaces.length >= 2 && (
|
|
||||||
<Button
|
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
|
||||||
variant="ghost"
|
|
||||||
asChild>
|
|
||||||
<Link href={`/workspaces/${params.workspaceId}/surveys`}>
|
|
||||||
<XIcon className="h-7 w-7" strokeWidth={1.5} />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
|
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||||
|
|
||||||
|
const SurveyEditorEnvironmentLayout = async (props) => {
|
||||||
|
const params = await props.params;
|
||||||
|
|
||||||
|
const { children } = props;
|
||||||
|
|
||||||
|
const { t, session, user } = await environmentIdLayoutChecks(params.environmentId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return redirect(`/auth/login`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error(t("common.user_not_found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const environment = await getEnvironment(params.environmentId);
|
||||||
|
|
||||||
|
if (!environment) {
|
||||||
|
throw new Error(t("common.environment_not_found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen flex-col">
|
||||||
|
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SurveyEditorEnvironmentLayout;
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { getWorkspace } from "@/lib/workspace/service";
|
|
||||||
import { workspaceIdLayoutChecks } from "@/modules/workspaces/lib/utils";
|
|
||||||
|
|
||||||
const SurveyEditorWorkspaceLayout = async (props: {
|
|
||||||
params: Promise<{ workspaceId: string }>;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) => {
|
|
||||||
const params = await props.params;
|
|
||||||
|
|
||||||
const { children } = props;
|
|
||||||
|
|
||||||
const { t, session, user } = await workspaceIdLayoutChecks(params.workspaceId);
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return redirect(`/auth/login`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspace = await getWorkspace(params.workspaceId);
|
|
||||||
|
|
||||||
if (!workspace) {
|
|
||||||
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen flex-col">
|
|
||||||
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SurveyEditorWorkspaceLayout;
|
|
||||||
@@ -6,24 +6,15 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Confetti } from "@/modules/ui/components/confetti";
|
import { Confetti } from "@/modules/ui/components/confetti";
|
||||||
|
|
||||||
const BILLING_CONFIRMATION_WORKSPACE_ID_KEY = "billingConfirmationWorkspaceId";
|
interface ConfirmationPageProps {
|
||||||
|
environmentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const ConfirmationPage = () => {
|
export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showConfetti, setShowConfetti] = useState(false);
|
const [showConfetti, setShowConfetti] = useState(false);
|
||||||
const [resolvedWorkspaceId, setResolvedWorkspaceId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowConfetti(true);
|
setShowConfetti(true);
|
||||||
|
|
||||||
if (globalThis.window === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const storedWorkspaceId = globalThis.window.sessionStorage.getItem(BILLING_CONFIRMATION_WORKSPACE_ID_KEY);
|
|
||||||
if (storedWorkspaceId) {
|
|
||||||
setResolvedWorkspaceId(storedWorkspaceId);
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -39,7 +30,7 @@ export const ConfirmationPage = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild className="w-full justify-center">
|
<Button asChild className="w-full justify-center">
|
||||||
<Link href={resolvedWorkspaceId ? `/workspaces/${resolvedWorkspaceId}/settings/billing` : "/"}>
|
<Link href={`/environments/${environmentId}/settings/billing`}>
|
||||||
{t("billing_confirmation.back_to_billing_overview")}
|
{t("billing_confirmation.back_to_billing_overview")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const Page = async () => {
|
const Page = async (props) => {
|
||||||
|
const searchParams = await props.searchParams;
|
||||||
|
const { environmentId } = searchParams;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<ConfirmationPage />
|
<ConfirmationPage environmentId={environmentId?.toString()} />
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
|
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
export const LoadingCard = ({
|
export const LoadingCard = ({
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ZId } from "@formbricks/types/common";
|
||||||
|
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||||
|
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
||||||
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
|
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
||||||
|
import { updateUser } from "@/lib/user/service";
|
||||||
|
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
|
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 {
|
||||||
|
getAccessControlPermission,
|
||||||
|
getOrganizationProjectsLimit,
|
||||||
|
} from "@/modules/ee/license-check/lib/utils";
|
||||||
|
import { createProject } from "@/modules/projects/settings/lib/project";
|
||||||
|
import { getOrganizationsByUserId } from "./lib/organization";
|
||||||
|
import { getProjectsByUserId } from "./lib/project";
|
||||||
|
|
||||||
|
const ZCreateProjectAction = z.object({
|
||||||
|
organizationId: ZId,
|
||||||
|
data: ZProjectUpdateInput,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createProjectAction = authenticatedActionClient.schema(ZCreateProjectAction).action(
|
||||||
|
withAuditLogging(
|
||||||
|
"created",
|
||||||
|
"project",
|
||||||
|
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||||
|
const { user } = ctx;
|
||||||
|
|
||||||
|
const organizationId = parsedInput.organizationId;
|
||||||
|
|
||||||
|
await checkAuthorizationUpdated({
|
||||||
|
userId: user.id,
|
||||||
|
organizationId: parsedInput.organizationId,
|
||||||
|
access: [
|
||||||
|
{
|
||||||
|
data: parsedInput.data,
|
||||||
|
schema: ZProjectUpdateInput,
|
||||||
|
type: "organization",
|
||||||
|
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, {
|
||||||
|
notificationSettings: updatedNotificationSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||||
|
ctx.auditLoggingCtx.projectId = project.id;
|
||||||
|
ctx.auditLoggingCtx.newObject = project;
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const ZGetOrganizationsForSwitcherAction = z.object({
|
||||||
|
organizationId: ZId, // Changed from environmentId to avoid extra query
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches organizations list for switcher dropdown.
|
||||||
|
* Called on-demand when user opens the organization switcher.
|
||||||
|
*/
|
||||||
|
export const getOrganizationsForSwitcherAction = authenticatedActionClient
|
||||||
|
.schema(ZGetOrganizationsForSwitcherAction)
|
||||||
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
|
await checkAuthorizationUpdated({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
organizationId: parsedInput.organizationId,
|
||||||
|
access: [
|
||||||
|
{
|
||||||
|
type: "organization",
|
||||||
|
roles: ["owner", "manager", "member", "billing"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return await getOrganizationsByUserId(ctx.user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ZGetProjectsForSwitcherAction = z.object({
|
||||||
|
organizationId: ZId, // Changed from environmentId to avoid extra query
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches projects list for switcher dropdown.
|
||||||
|
* Called on-demand when user opens the project switcher.
|
||||||
|
*/
|
||||||
|
export const getProjectsForSwitcherAction = authenticatedActionClient
|
||||||
|
.schema(ZGetProjectsForSwitcherAction)
|
||||||
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
|
await checkAuthorizationUpdated({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
organizationId: parsedInput.organizationId,
|
||||||
|
access: [
|
||||||
|
{
|
||||||
|
type: "organization",
|
||||||
|
roles: ["owner", "manager", "member", "billing"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Need membership for getProjectsByUserId (1 DB query)
|
||||||
|
const membership = await getMembershipByUserIdOrganizationId(ctx.user.id, parsedInput.organizationId);
|
||||||
|
if (!membership) {
|
||||||
|
throw new AuthorizationError("Membership not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await getProjectsByUserId(ctx.user.id, membership);
|
||||||
|
});
|
||||||
+28
-21
@@ -1,33 +1,35 @@
|
|||||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
||||||
import { MainNavigation } from "@/app/(app)/workspaces/[workspaceId]/components/MainNavigation";
|
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
||||||
import { TopControlBar } from "@/app/(app)/workspaces/[workspaceId]/components/TopControlBar";
|
|
||||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getOrganizationWorkspacesLimit } from "@/modules/ee/license-check/lib/utils";
|
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||||
|
import { TEnvironmentLayoutData } from "@/modules/environments/types/environment-auth";
|
||||||
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
||||||
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
|
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
|
||||||
import { TWorkspaceLayoutData } from "@/modules/workspaces/types/workspace-auth";
|
|
||||||
|
|
||||||
interface WorkspaceLayoutProps {
|
interface EnvironmentLayoutProps {
|
||||||
layoutData: TWorkspaceLayoutData;
|
layoutData: TEnvironmentLayoutData;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WorkspaceLayout = async ({ layoutData, children }: WorkspaceLayoutProps) => {
|
export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => {
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const publicDomain = getPublicDomain();
|
const publicDomain = getPublicDomain();
|
||||||
|
|
||||||
// Destructure all data from props (NO database queries)
|
// Destructure all data from props (NO database queries)
|
||||||
const {
|
const {
|
||||||
user,
|
user,
|
||||||
|
environment,
|
||||||
organization,
|
organization,
|
||||||
membership,
|
membership,
|
||||||
workspace, // Current workspace details
|
project, // Current project details
|
||||||
|
environments, // All project environments (for environment switcher)
|
||||||
isAccessControlAllowed,
|
isAccessControlAllowed,
|
||||||
workspacePermission,
|
projectPermission,
|
||||||
license,
|
license,
|
||||||
|
peopleCount,
|
||||||
responseCount,
|
responseCount,
|
||||||
} = layoutData;
|
} = layoutData;
|
||||||
|
|
||||||
@@ -36,47 +38,52 @@ export const WorkspaceLayout = async ({ layoutData, children }: WorkspaceLayoutP
|
|||||||
|
|
||||||
const { features, lastChecked, isPendingDowngrade, active, status } = license;
|
const { features, lastChecked, isPendingDowngrade, active, status } = license;
|
||||||
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||||
const organizationWorkspacesLimit = await getOrganizationWorkspacesLimit(organization.id);
|
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||||
const isOwnerOrManager = isOwner || isManager;
|
const isOwnerOrManager = isOwner || isManager;
|
||||||
|
|
||||||
// Validate that workspace permission exists for members
|
// Validate that project permission exists for members
|
||||||
if (isMember && !workspacePermission) {
|
if (isMember && !projectPermission) {
|
||||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
throw new Error(t("common.workspace_permission_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen min-h-screen flex-col overflow-hidden">
|
<div className="flex h-screen min-h-screen flex-col overflow-hidden">
|
||||||
{IS_FORMBRICKS_CLOUD && (
|
{IS_FORMBRICKS_CLOUD && (
|
||||||
<LimitsReachedBanner organization={organization} responseCount={responseCount} />
|
<LimitsReachedBanner
|
||||||
|
organization={organization}
|
||||||
|
environmentId={environment.id}
|
||||||
|
peopleCount={peopleCount}
|
||||||
|
responseCount={responseCount}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PendingDowngradeBanner
|
<PendingDowngradeBanner
|
||||||
lastChecked={lastChecked}
|
lastChecked={lastChecked}
|
||||||
isPendingDowngrade={isPendingDowngrade ?? false}
|
isPendingDowngrade={isPendingDowngrade ?? false}
|
||||||
active={active}
|
active={active}
|
||||||
|
environmentId={environment.id}
|
||||||
locale={user.locale}
|
locale={user.locale}
|
||||||
status={status}
|
status={status}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
<MainNavigation
|
<MainNavigation
|
||||||
|
environment={environment}
|
||||||
organization={organization}
|
organization={organization}
|
||||||
user={user}
|
user={user}
|
||||||
workspace={{ id: workspace.id, name: workspace.name }}
|
project={{ id: project.id, name: project.name }}
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
isDevelopment={IS_DEVELOPMENT}
|
isDevelopment={IS_DEVELOPMENT}
|
||||||
membershipRole={membership.role}
|
membershipRole={membership.role}
|
||||||
publicDomain={publicDomain}
|
publicDomain={publicDomain}
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
|
||||||
organizationWorkspacesLimit={organizationWorkspacesLimit}
|
|
||||||
isLicenseActive={active}
|
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
|
||||||
/>
|
/>
|
||||||
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
||||||
<TopControlBar
|
<TopControlBar
|
||||||
|
environments={environments}
|
||||||
currentOrganizationId={organization.id}
|
currentOrganizationId={organization.id}
|
||||||
|
currentProjectId={project.id}
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
organizationWorkspacesLimit={organizationWorkspacesLimit}
|
organizationProjectsLimit={organizationProjectsLimit}
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
isLicenseActive={active}
|
isLicenseActive={active}
|
||||||
isOwnerOrManager={isOwnerOrManager}
|
isOwnerOrManager={isOwnerOrManager}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||||
|
|
||||||
|
interface EnvironmentStorageHandlerProps {
|
||||||
|
environmentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EnvironmentStorageHandler = ({ environmentId }: EnvironmentStorageHandlerProps) => {
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(FORMBRICKS_ENVIRONMENT_ID_LS, environmentId);
|
||||||
|
}, [environmentId]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EnvironmentStorageHandler;
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { Label } from "@/modules/ui/components/label";
|
||||||
|
import { Switch } from "@/modules/ui/components/switch";
|
||||||
|
|
||||||
|
interface EnvironmentSwitchProps {
|
||||||
|
environment: TEnvironment;
|
||||||
|
environments: TEnvironment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EnvironmentSwitch = ({ environment, environments }: EnvironmentSwitchProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const [isEnvSwitchChecked, setIsEnvSwitchChecked] = useState(environment?.type === "development");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleEnvironmentChange = (environmentType: "production" | "development") => {
|
||||||
|
const newEnvironmentId = environments.find((e) => e.type === environmentType)?.id;
|
||||||
|
if (newEnvironmentId) {
|
||||||
|
router.push(`/environments/${newEnvironmentId}/`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleEnvSwitch = () => {
|
||||||
|
const newEnvironmentType = isEnvSwitchChecked ? "production" : "development";
|
||||||
|
setIsLoading(true);
|
||||||
|
setIsEnvSwitchChecked(!isEnvSwitchChecked);
|
||||||
|
handleEnvironmentChange(newEnvironmentType);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center space-x-2 rounded-lg p-2",
|
||||||
|
isEnvSwitchChecked ? "bg-slate-100 text-orange-800" : "hover:bg-slate-100"
|
||||||
|
)}>
|
||||||
|
<Label
|
||||||
|
htmlFor="development-mode"
|
||||||
|
className={cn("hover:cursor-pointer", isEnvSwitchChecked && "text-orange-800")}>
|
||||||
|
{t("common.dev_env")}
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
className="focus:ring-orange-800 data-[state=checked]:bg-orange-800"
|
||||||
|
id="development-mode"
|
||||||
|
disabled={isLoading}
|
||||||
|
checked={isEnvSwitchChecked}
|
||||||
|
onCheckedChange={toggleEnvSwitch}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ArrowUpRightIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
Cog,
|
||||||
|
LogOutIcon,
|
||||||
|
MessageCircle,
|
||||||
|
PanelLeftCloseIcon,
|
||||||
|
PanelLeftOpenIcon,
|
||||||
|
RocketIcon,
|
||||||
|
UserCircleIcon,
|
||||||
|
UserIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
|
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||||
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
|
import { TUser } from "@formbricks/types/user";
|
||||||
|
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
||||||
|
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
|
||||||
|
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
|
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||||
|
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
||||||
|
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
|
import packageJson from "../../../../../package.json";
|
||||||
|
|
||||||
|
interface NavigationProps {
|
||||||
|
environment: TEnvironment;
|
||||||
|
user: TUser;
|
||||||
|
organization: TOrganization;
|
||||||
|
project: { id: string; name: string };
|
||||||
|
isFormbricksCloud: boolean;
|
||||||
|
isDevelopment: boolean;
|
||||||
|
membershipRole?: TOrganizationRole;
|
||||||
|
publicDomain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MainNavigation = ({
|
||||||
|
environment,
|
||||||
|
organization,
|
||||||
|
user,
|
||||||
|
project,
|
||||||
|
membershipRole,
|
||||||
|
isFormbricksCloud,
|
||||||
|
isDevelopment,
|
||||||
|
publicDomain,
|
||||||
|
}: NavigationProps) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
|
const [isTextVisible, setIsTextVisible] = useState(true);
|
||||||
|
const [latestVersion, setLatestVersion] = useState("");
|
||||||
|
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||||
|
|
||||||
|
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
|
||||||
|
|
||||||
|
const isOwnerOrManager = isManager || isOwner;
|
||||||
|
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
setIsCollapsed(!isCollapsed);
|
||||||
|
localStorage.setItem("isMainNavCollapsed", isCollapsed ? "false" : "true");
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const isCollapsedValueFromLocalStorage = localStorage.getItem("isMainNavCollapsed") === "true";
|
||||||
|
setIsCollapsed(isCollapsedValueFromLocalStorage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const toggleTextOpacity = () => {
|
||||||
|
setIsTextVisible(isCollapsed);
|
||||||
|
};
|
||||||
|
const timeoutId = setTimeout(toggleTextOpacity, 150);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [isCollapsed]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Auto collapse project navbar on org and account settings
|
||||||
|
if (pathname?.includes("/settings")) {
|
||||||
|
setIsCollapsed(true);
|
||||||
|
}
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
const mainNavigation = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
name: t("common.surveys"),
|
||||||
|
href: `/environments/${environment.id}/surveys`,
|
||||||
|
icon: MessageCircle,
|
||||||
|
isActive: pathname?.includes("/surveys"),
|
||||||
|
isHidden: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: `/environments/${environment.id}/contacts`,
|
||||||
|
name: t("common.contacts"),
|
||||||
|
icon: UserIcon,
|
||||||
|
isActive:
|
||||||
|
pathname?.includes("/contacts") ||
|
||||||
|
pathname?.includes("/segments") ||
|
||||||
|
pathname?.includes("/attributes"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t("common.configuration"),
|
||||||
|
href: `/environments/${environment.id}/workspace/general`,
|
||||||
|
icon: Cog,
|
||||||
|
isActive: pathname?.includes("/project"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t, environment.id, pathname]
|
||||||
|
);
|
||||||
|
|
||||||
|
const dropdownNavigation = [
|
||||||
|
{
|
||||||
|
label: t("common.account"),
|
||||||
|
href: `/environments/${environment.id}/settings/profile`,
|
||||||
|
icon: UserCircleIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("common.documentation"),
|
||||||
|
href: "https://formbricks.com/docs",
|
||||||
|
target: "_blank",
|
||||||
|
icon: ArrowUpRightIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("common.share_feedback"),
|
||||||
|
href: "https://github.com/formbricks/formbricks/issues",
|
||||||
|
target: "_blank",
|
||||||
|
icon: ArrowUpRightIcon,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function loadReleases() {
|
||||||
|
const res = await getLatestStableFbReleaseAction();
|
||||||
|
if (res?.data) {
|
||||||
|
const latestVersionTag = res.data;
|
||||||
|
const currentVersionTag = `v${packageJson.version}`;
|
||||||
|
|
||||||
|
if (isNewerVersion(currentVersionTag, latestVersionTag)) {
|
||||||
|
setLatestVersion(latestVersionTag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isOwnerOrManager) loadReleases();
|
||||||
|
}, [isOwnerOrManager]);
|
||||||
|
|
||||||
|
const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{project && (
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
|
||||||
|
isCollapsed ? "w-sidebar-expanded" : "w-sidebar-collapsed"
|
||||||
|
)}>
|
||||||
|
<div>
|
||||||
|
{/* Logo and Toggle */}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between px-3 pb-4">
|
||||||
|
{!isCollapsed && (
|
||||||
|
<Link
|
||||||
|
href={mainNavigationLink}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center transition-opacity duration-100",
|
||||||
|
isTextVisible ? "opacity-0" : "opacity-100"
|
||||||
|
)}>
|
||||||
|
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
|
||||||
|
)}>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||||
|
) : (
|
||||||
|
<PanelLeftCloseIcon strokeWidth={1.5} />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Nav Switch */}
|
||||||
|
{!isBilling && (
|
||||||
|
<ul>
|
||||||
|
{mainNavigation.map(
|
||||||
|
(item) =>
|
||||||
|
!item.isHidden && (
|
||||||
|
<NavigationLink
|
||||||
|
key={item.name}
|
||||||
|
href={item.href}
|
||||||
|
isActive={item.isActive}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
isTextVisible={isTextVisible}
|
||||||
|
linkText={item.name}>
|
||||||
|
<item.icon strokeWidth={1.5} />
|
||||||
|
</NavigationLink>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{/* New Version Available */}
|
||||||
|
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && !isDevelopment && (
|
||||||
|
<Link
|
||||||
|
href="https://github.com/formbricks/formbricks/releases"
|
||||||
|
target="_blank"
|
||||||
|
className="m-2 flex items-center space-x-4 rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-800 hover:border-slate-300 hover:bg-slate-200">
|
||||||
|
<p className="flex items-center justify-center gap-x-2 text-xs">
|
||||||
|
<RocketIcon strokeWidth={1.5} className="mx-1 h-6 w-6 text-slate-900" />
|
||||||
|
{t("common.new_version_available", { version: latestVersion })}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User Switch */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
asChild
|
||||||
|
id="userDropdownTrigger"
|
||||||
|
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-pointer flex-row items-center gap-3",
|
||||||
|
isCollapsed ? "justify-center px-2" : "px-4"
|
||||||
|
)}>
|
||||||
|
<ProfileAvatar userId={user.id} />
|
||||||
|
{!isCollapsed && !isTextVisible && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
|
||||||
|
<p
|
||||||
|
title={user?.email}
|
||||||
|
className={cn(
|
||||||
|
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
|
||||||
|
)}>
|
||||||
|
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-700">{t("common.account")}</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRightIcon
|
||||||
|
className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent
|
||||||
|
id="userDropdownInnerContentWrapper"
|
||||||
|
side="right"
|
||||||
|
sideOffset={10}
|
||||||
|
alignOffset={5}
|
||||||
|
align="end">
|
||||||
|
{/* Dropdown Items */}
|
||||||
|
|
||||||
|
{dropdownNavigation.map((link) => (
|
||||||
|
<Link
|
||||||
|
href={link.href}
|
||||||
|
target={link.target}
|
||||||
|
className="flex w-full items-center"
|
||||||
|
key={link.label}
|
||||||
|
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||||
|
{link.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{/* Logout */}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={async () => {
|
||||||
|
const loginUrl = `${publicDomain}/auth/login`;
|
||||||
|
const route = await signOutWithAudit({
|
||||||
|
reason: "user_initiated",
|
||||||
|
redirectUrl: loginUrl,
|
||||||
|
organizationId: organization.id,
|
||||||
|
redirect: false,
|
||||||
|
callbackUrl: loginUrl,
|
||||||
|
clearEnvironmentId: true,
|
||||||
|
});
|
||||||
|
router.push(route?.url || loginUrl); // NOSONAR // We want to check for empty strings
|
||||||
|
}}
|
||||||
|
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||||
|
{t("common.logout")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import React from "react";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||||
|
|
||||||
|
interface NavigationLinkProps {
|
||||||
|
href: string;
|
||||||
|
isActive: boolean;
|
||||||
|
isCollapsed: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
linkText: string;
|
||||||
|
isTextVisible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NavigationLink = ({
|
||||||
|
href,
|
||||||
|
isActive,
|
||||||
|
isCollapsed = false,
|
||||||
|
children,
|
||||||
|
linkText,
|
||||||
|
isTextVisible = true,
|
||||||
|
}: NavigationLinkProps) => {
|
||||||
|
const activeClass = "bg-slate-50 border-r-4 border-brand-dark font-semibold text-slate-900";
|
||||||
|
const inactiveClass =
|
||||||
|
"hover:bg-slate-50 border-r-4 border-transparent hover:border-slate-300 transition-all duration-150 ease-in-out";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<li
|
||||||
|
className={cn(
|
||||||
|
"mb-1 ml-2 rounded-l-md py-2 pl-2 text-sm text-slate-700 hover:text-slate-900",
|
||||||
|
isActive ? activeClass : inactiveClass
|
||||||
|
)}>
|
||||||
|
<Link href={href} className="flex items-center">
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">{linkText}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : (
|
||||||
|
<li
|
||||||
|
className={cn(
|
||||||
|
"mb-1 rounded-l-md py-2 pl-5 text-sm text-slate-600 hover:text-slate-900",
|
||||||
|
isActive ? activeClass : inactiveClass
|
||||||
|
)}>
|
||||||
|
<Link href={href} className="flex items-center">
|
||||||
|
{children}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-2 flex transition-opacity duration-100",
|
||||||
|
isTextVisible ? "opacity-0" : "opacity-100"
|
||||||
|
)}>
|
||||||
|
{linkText}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
+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 WorkspaceNavItemProps {
|
interface ProjectNavItemProps {
|
||||||
href: string;
|
href: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WorkspaceNavItem = ({ href, children, isActive }: WorkspaceNavItemProps) => {
|
export const ProjectNavItem = ({ href, children, isActive }: ProjectNavItemProps) => {
|
||||||
const activeClass = "bg-slate-50 font-semibold";
|
const activeClass = "bg-slate-50 font-semibold";
|
||||||
const inactiveClass = "hover:bg-slate-50";
|
const inactiveClass = "hover:bg-slate-50";
|
||||||
|
|
||||||
+16
-12
@@ -1,14 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||||
import { WorkspaceAndOrgSwitch } from "@/app/(app)/workspaces/[workspaceId]/components/workspace-and-org-switch";
|
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
|
||||||
import { useWorkspaceContext } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
|
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
|
|
||||||
interface TopControlBarProps {
|
interface TopControlBarProps {
|
||||||
|
environments: TEnvironment[];
|
||||||
currentOrganizationId: string;
|
currentOrganizationId: string;
|
||||||
|
currentProjectId: string;
|
||||||
isMultiOrgEnabled: boolean;
|
isMultiOrgEnabled: boolean;
|
||||||
organizationWorkspacesLimit: number;
|
organizationProjectsLimit: number;
|
||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
isLicenseActive: boolean;
|
isLicenseActive: boolean;
|
||||||
isOwnerOrManager: boolean;
|
isOwnerOrManager: boolean;
|
||||||
@@ -17,34 +20,35 @@ interface TopControlBarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const TopControlBar = ({
|
export const TopControlBar = ({
|
||||||
|
environments,
|
||||||
currentOrganizationId,
|
currentOrganizationId,
|
||||||
|
currentProjectId,
|
||||||
isMultiOrgEnabled,
|
isMultiOrgEnabled,
|
||||||
organizationWorkspacesLimit,
|
organizationProjectsLimit,
|
||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
isLicenseActive,
|
isLicenseActive,
|
||||||
isOwnerOrManager,
|
isOwnerOrManager,
|
||||||
isAccessControlAllowed,
|
isAccessControlAllowed,
|
||||||
membershipRole,
|
membershipRole,
|
||||||
}: TopControlBarProps) => {
|
}: TopControlBarProps) => {
|
||||||
const { isMember, isBilling } = getAccessFlags(membershipRole);
|
const { isMember } = getAccessFlags(membershipRole);
|
||||||
const { workspace } = useWorkspaceContext();
|
const { environment } = useEnvironment();
|
||||||
const isMembershipPending = membershipRole === undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex h-14 w-full items-center justify-between bg-slate-50 px-6"
|
className="flex h-14 w-full items-center justify-between bg-slate-50 px-6"
|
||||||
data-testid="fb__global-top-control-bar">
|
data-testid="fb__global-top-control-bar">
|
||||||
<WorkspaceAndOrgSwitch
|
<ProjectAndOrgSwitch
|
||||||
currentWorkspaceId={workspace.id}
|
currentEnvironmentId={environment.id}
|
||||||
|
environments={environments}
|
||||||
currentOrganizationId={currentOrganizationId}
|
currentOrganizationId={currentOrganizationId}
|
||||||
|
currentProjectId={currentProjectId}
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
organizationWorkspacesLimit={organizationWorkspacesLimit}
|
organizationProjectsLimit={organizationProjectsLimit}
|
||||||
isFormbricksCloud={isFormbricksCloud}
|
isFormbricksCloud={isFormbricksCloud}
|
||||||
isLicenseActive={isLicenseActive}
|
isLicenseActive={isLicenseActive}
|
||||||
isOwnerOrManager={isOwnerOrManager}
|
isOwnerOrManager={isOwnerOrManager}
|
||||||
isMember={isMember}
|
isMember={isMember}
|
||||||
isBilling={isBilling}
|
|
||||||
isMembershipPending={isMembershipPending}
|
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
isAccessControlAllowed={isAccessControlAllowed}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
+10
-9
@@ -3,32 +3,33 @@
|
|||||||
import { AlertTriangleIcon, CheckIcon, RotateCcwIcon } from "lucide-react";
|
import { AlertTriangleIcon, CheckIcon, RotateCcwIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
|
||||||
interface WidgetStatusIndicatorProps {
|
interface WidgetStatusIndicatorProps {
|
||||||
workspace: { appSetupCompleted: boolean };
|
environment: TEnvironment;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WidgetStatusIndicator = ({ workspace }: WidgetStatusIndicatorProps) => {
|
export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const stati = {
|
const stati = {
|
||||||
notImplemented: {
|
notImplemented: {
|
||||||
icon: AlertTriangleIcon,
|
icon: AlertTriangleIcon,
|
||||||
title: t("workspace.app-connection.formbricks_sdk_not_connected"),
|
title: t("environments.workspace.app-connection.formbricks_sdk_not_connected"),
|
||||||
subtitle: t("workspace.app-connection.formbricks_sdk_not_connected_description"),
|
subtitle: t("environments.workspace.app-connection.formbricks_sdk_not_connected_description"),
|
||||||
},
|
},
|
||||||
running: {
|
running: {
|
||||||
icon: CheckIcon,
|
icon: CheckIcon,
|
||||||
title: t("workspace.app-connection.receiving_data"),
|
title: t("environments.workspace.app-connection.receiving_data"),
|
||||||
subtitle: t("workspace.app-connection.formbricks_sdk_connected"),
|
subtitle: t("environments.workspace.app-connection.formbricks_sdk_connected"),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let status: "notImplemented" | "running";
|
let status: "notImplemented" | "running";
|
||||||
|
|
||||||
if (workspace.appSetupCompleted) {
|
if (environment.appSetupCompleted) {
|
||||||
status = "running";
|
status = "running";
|
||||||
} else {
|
} else {
|
||||||
status = "notImplemented";
|
status = "notImplemented";
|
||||||
@@ -52,11 +53,11 @@ export const WidgetStatusIndicator = ({ workspace }: WidgetStatusIndicatorProps)
|
|||||||
<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-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
|
<p className="w-2/3 text-sm text-balance 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 />
|
||||||
{t("workspace.app-connection.recheck")}
|
{t("environments.workspace.app-connection.recheck")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ChevronDownIcon, CircleHelpIcon, Code2Icon, Loader2 } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||||
|
|
||||||
|
export const EnvironmentBreadcrumb = ({
|
||||||
|
environments,
|
||||||
|
currentEnvironment,
|
||||||
|
}: {
|
||||||
|
environments: { id: string; type: string }[];
|
||||||
|
currentEnvironment: { id: string; type: string };
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isEnvironmentDropdownOpen, setIsEnvironmentDropdownOpen] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleEnvironmentChange = (environmentId: string) => {
|
||||||
|
if (environmentId === currentEnvironment.id) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
router.push(`/environments/${environmentId}/`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const developmentTooltip = () => {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip delayDuration={0}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<CircleHelpIcon className="h-3 w-3" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="mt-2 border-none bg-red-800 text-white">
|
||||||
|
{t("common.development_environment_banner")}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BreadcrumbItem
|
||||||
|
isActive={isEnvironmentDropdownOpen}
|
||||||
|
isHighlighted={currentEnvironment.type === "development"}>
|
||||||
|
<DropdownMenu onOpenChange={setIsEnvironmentDropdownOpen}>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
className="flex cursor-pointer items-center gap-1 outline-none"
|
||||||
|
id="environmentDropdownTrigger"
|
||||||
|
asChild>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Code2Icon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
|
<span className="capitalize">{currentEnvironment.type}</span>
|
||||||
|
{isLoading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||||
|
{currentEnvironment.type === "development" && developmentTooltip()}
|
||||||
|
{isEnvironmentDropdownOpen && <ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="mt-2" align="start">
|
||||||
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
|
<Code2Icon className="mr-2 inline h-4 w-4" />
|
||||||
|
{t("common.choose_environment")}
|
||||||
|
</div>
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
{environments.map((env) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={env.id}
|
||||||
|
checked={env.type === currentEnvironment.type}
|
||||||
|
onClick={() => handleEnvironmentChange(env.id)}
|
||||||
|
className="cursor-pointer">
|
||||||
|
<div className="flex items-center gap-2 capitalize">
|
||||||
|
<span>{env.type}</span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
+22
-61
@@ -13,7 +13,7 @@ import { usePathname, useRouter } from "next/navigation";
|
|||||||
import { useEffect, useState, useTransition } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { getOrganizationsForSwitcherAction } from "@/app/(app)/workspaces/[workspaceId]/actions";
|
import { getOrganizationsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
||||||
@@ -25,18 +25,16 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
import { useOrganization } from "../context/environment-context";
|
||||||
import { useOrganization, useWorkspace } from "../context/workspace-context";
|
|
||||||
|
|
||||||
interface OrganizationBreadcrumbProps {
|
interface OrganizationBreadcrumbProps {
|
||||||
currentOrganizationId: string;
|
currentOrganizationId: string;
|
||||||
currentOrganizationName?: string; // Optional: pass directly if context not available
|
currentOrganizationName?: string; // Optional: pass directly if context not available
|
||||||
isMultiOrgEnabled: boolean;
|
isMultiOrgEnabled: boolean;
|
||||||
currentWorkspaceId?: string;
|
currentEnvironmentId?: string;
|
||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
isMember: boolean;
|
isMember: boolean;
|
||||||
isOwnerOrManager: boolean;
|
isOwnerOrManager: boolean;
|
||||||
isMembershipPending: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
|
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
|
||||||
@@ -54,11 +52,10 @@ export const OrganizationBreadcrumb = ({
|
|||||||
currentOrganizationId,
|
currentOrganizationId,
|
||||||
currentOrganizationName,
|
currentOrganizationName,
|
||||||
isMultiOrgEnabled,
|
isMultiOrgEnabled,
|
||||||
currentWorkspaceId,
|
currentEnvironmentId,
|
||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
isMember,
|
isMember,
|
||||||
isOwnerOrManager,
|
isOwnerOrManager,
|
||||||
isMembershipPending,
|
|
||||||
}: OrganizationBreadcrumbProps) => {
|
}: OrganizationBreadcrumbProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
|
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
|
||||||
@@ -73,7 +70,6 @@ export const OrganizationBreadcrumb = ({
|
|||||||
// Get current organization name from context OR prop
|
// Get current organization name from context OR prop
|
||||||
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
|
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
|
||||||
const { organization: currentOrganization } = useOrganization();
|
const { organization: currentOrganization } = useOrganization();
|
||||||
const { workspace } = useWorkspace();
|
|
||||||
const organizationName = currentOrganization?.name || currentOrganizationName || "";
|
const organizationName = currentOrganization?.name || currentOrganizationName || "";
|
||||||
|
|
||||||
// Lazy-load organizations when dropdown opens
|
// Lazy-load organizations when dropdown opens
|
||||||
@@ -85,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].sort((a, b) => a.name.localeCompare(b.name));
|
const sorted = result.data.toSorted((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
|
||||||
@@ -114,15 +110,9 @@ export const OrganizationBreadcrumb = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceBasePath = `/workspaces/${workspace?.id}`;
|
|
||||||
|
|
||||||
const handleOrganizationChange = (organizationId: string) => {
|
const handleOrganizationChange = (organizationId: string) => {
|
||||||
|
if (organizationId === currentOrganizationId) return;
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setIsOrganizationDropdownOpen(false);
|
|
||||||
if (organizationId === currentOrganizationId && currentWorkspaceId) {
|
|
||||||
router.push(`/workspaces/${currentWorkspaceId}/settings/general`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
router.push(`/organizations/${organizationId}/`);
|
router.push(`/organizations/${organizationId}/`);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -141,49 +131,36 @@ export const OrganizationBreadcrumb = ({
|
|||||||
{
|
{
|
||||||
id: "general",
|
id: "general",
|
||||||
label: t("common.general"),
|
label: t("common.general"),
|
||||||
href: `${workspaceBasePath}/settings/general`,
|
href: `/environments/${currentEnvironmentId}/settings/general`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "teams",
|
id: "teams",
|
||||||
label: t("common.members_and_teams"),
|
label: t("common.members_and_teams"),
|
||||||
href: `${workspaceBasePath}/settings/teams`,
|
href: `/environments/${currentEnvironmentId}/settings/teams`,
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "feedback-record-directories",
|
|
||||||
label: t("workspace.settings.feedback_record_directories.nav_label"),
|
|
||||||
href: `${workspaceBasePath}/settings/feedback-record-directories`,
|
|
||||||
hidden: isMember,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "api-keys",
|
id: "api-keys",
|
||||||
label: t("common.api_keys"),
|
label: t("common.api_keys"),
|
||||||
href: `${workspaceBasePath}/settings/api-keys`,
|
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
|
||||||
disabled: isMembershipPending || !isOwnerOrManager,
|
hidden: !isOwnerOrManager,
|
||||||
disabledMessage: isMembershipPending
|
|
||||||
? t("common.loading")
|
|
||||||
: t("common.you_are_not_authorized_to_perform_this_action"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "domain",
|
id: "domain",
|
||||||
label: t("common.domain"),
|
label: t("common.domain"),
|
||||||
href: `${workspaceBasePath}/settings/domain`,
|
href: `/environments/${currentEnvironmentId}/settings/domain`,
|
||||||
hidden: isFormbricksCloud,
|
hidden: isFormbricksCloud,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "billing",
|
id: "billing",
|
||||||
label: t("common.billing"),
|
label: t("common.billing"),
|
||||||
href: `${workspaceBasePath}/settings/billing`,
|
href: `/environments/${currentEnvironmentId}/settings/billing`,
|
||||||
hidden: !isFormbricksCloud,
|
hidden: !isFormbricksCloud,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "enterprise",
|
id: "enterprise",
|
||||||
label: t("common.enterprise_license"),
|
label: t("common.enterprise_license"),
|
||||||
href: `${workspaceBasePath}/settings/enterprise`,
|
href: `/environments/${currentEnvironmentId}/settings/enterprise`,
|
||||||
hidden: isFormbricksCloud || isMember,
|
hidden: isFormbricksCloud || isMember,
|
||||||
disabled: isMembershipPending || isMember,
|
|
||||||
disabledMessage: isMembershipPending
|
|
||||||
? t("common.loading")
|
|
||||||
: t("common.you_are_not_authorized_to_perform_this_action"),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -255,7 +232,7 @@ export const OrganizationBreadcrumb = ({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{currentWorkspaceId && (
|
{currentEnvironmentId && (
|
||||||
<div>
|
<div>
|
||||||
{showOrganizationDropdown && <DropdownMenuSeparator />}
|
{showOrganizationDropdown && <DropdownMenuSeparator />}
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
@@ -265,30 +242,14 @@ export const OrganizationBreadcrumb = ({
|
|||||||
|
|
||||||
{organizationSettings.map((setting) => {
|
{organizationSettings.map((setting) => {
|
||||||
return setting.hidden ? null : (
|
return setting.hidden ? null : (
|
||||||
<div key={setting.id}>
|
<DropdownMenuCheckboxItem
|
||||||
{setting.disabled ? (
|
key={setting.id}
|
||||||
<Popover>
|
checked={isActiveOrganizationSetting(pathname, setting.id)}
|
||||||
<PopoverTrigger asChild>
|
hidden={setting.hidden}
|
||||||
<button
|
onClick={() => handleSettingChange(setting.href)}
|
||||||
type="button"
|
className="cursor-pointer">
|
||||||
aria-disabled="true"
|
{setting.label}
|
||||||
className="relative flex w-full cursor-not-allowed select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
|
</DropdownMenuCheckboxItem>
|
||||||
{setting.label}
|
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
|
|
||||||
{setting.disabledMessage}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
) : (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
checked={isActiveOrganizationSetting(pathname, setting.id)}
|
|
||||||
onClick={() => handleSettingChange(setting.href)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
{setting.label}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb";
|
||||||
|
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
|
||||||
|
import { ProjectBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/project-breadcrumb";
|
||||||
|
import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
|
||||||
|
|
||||||
|
interface ProjectAndOrgSwitchProps {
|
||||||
|
currentOrganizationId: string;
|
||||||
|
currentOrganizationName?: string; // Optional: for pages without context
|
||||||
|
currentProjectId?: string;
|
||||||
|
currentProjectName?: string; // Optional: for pages without context
|
||||||
|
currentEnvironmentId?: string;
|
||||||
|
environments: { id: string; type: string }[];
|
||||||
|
isMultiOrgEnabled: boolean;
|
||||||
|
organizationProjectsLimit: number;
|
||||||
|
isFormbricksCloud: boolean;
|
||||||
|
isLicenseActive: boolean;
|
||||||
|
isOwnerOrManager: boolean;
|
||||||
|
isMember: boolean;
|
||||||
|
isAccessControlAllowed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProjectAndOrgSwitch = ({
|
||||||
|
currentOrganizationId,
|
||||||
|
currentOrganizationName,
|
||||||
|
currentProjectId,
|
||||||
|
currentProjectName,
|
||||||
|
currentEnvironmentId,
|
||||||
|
environments,
|
||||||
|
isMultiOrgEnabled,
|
||||||
|
organizationProjectsLimit,
|
||||||
|
isFormbricksCloud,
|
||||||
|
isLicenseActive,
|
||||||
|
isOwnerOrManager,
|
||||||
|
isAccessControlAllowed,
|
||||||
|
isMember,
|
||||||
|
}: ProjectAndOrgSwitchProps) => {
|
||||||
|
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
||||||
|
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList className="gap-0">
|
||||||
|
<OrganizationBreadcrumb
|
||||||
|
currentOrganizationId={currentOrganizationId}
|
||||||
|
currentOrganizationName={currentOrganizationName}
|
||||||
|
currentEnvironmentId={currentEnvironmentId}
|
||||||
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
|
isFormbricksCloud={isFormbricksCloud}
|
||||||
|
isMember={isMember}
|
||||||
|
isOwnerOrManager={isOwnerOrManager}
|
||||||
|
/>
|
||||||
|
{currentProjectId && currentEnvironmentId && (
|
||||||
|
<ProjectBreadcrumb
|
||||||
|
currentProjectId={currentProjectId}
|
||||||
|
currentProjectName={currentProjectName}
|
||||||
|
currentOrganizationId={currentOrganizationId}
|
||||||
|
currentEnvironmentId={currentEnvironmentId}
|
||||||
|
isOwnerOrManager={isOwnerOrManager}
|
||||||
|
organizationProjectsLimit={organizationProjectsLimit}
|
||||||
|
isFormbricksCloud={isFormbricksCloud}
|
||||||
|
isLicenseActive={isLicenseActive}
|
||||||
|
isAccessControlAllowed={isAccessControlAllowed}
|
||||||
|
isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showEnvironmentBreadcrumb && (
|
||||||
|
<EnvironmentBreadcrumb environments={environments} currentEnvironment={currentEnvironment} />
|
||||||
|
)}
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { ChevronDownIcon, ChevronRightIcon, CogIcon, HotelIcon, Loader2, PlusIcon } from "lucide-react";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState, useTransition } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { logger } from "@formbricks/logger";
|
||||||
|
import { getProjectsForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||||
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
|
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
|
||||||
|
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
|
||||||
|
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
|
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
||||||
|
import { useProject } from "../context/environment-context";
|
||||||
|
|
||||||
|
interface ProjectBreadcrumbProps {
|
||||||
|
currentProjectId: string;
|
||||||
|
currentProjectName?: string; // Optional: pass directly if context not available
|
||||||
|
isOwnerOrManager: boolean;
|
||||||
|
organizationProjectsLimit: number;
|
||||||
|
isFormbricksCloud: boolean;
|
||||||
|
isLicenseActive: boolean;
|
||||||
|
currentOrganizationId: string;
|
||||||
|
currentEnvironmentId: string;
|
||||||
|
isAccessControlAllowed: boolean;
|
||||||
|
isEnvironmentBreadcrumbVisible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
||||||
|
// Match /workspace/{settingId} or /workspace/{settingId}/... but exclude settings paths
|
||||||
|
if (pathname.includes("/settings/")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Check if path matches /workspace/{settingId} (with optional trailing path)
|
||||||
|
const pattern = new RegExp(`/workspace/${settingId}(?:/|$)`);
|
||||||
|
return pattern.test(pathname);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProjectBreadcrumb = ({
|
||||||
|
currentProjectId,
|
||||||
|
currentProjectName,
|
||||||
|
isOwnerOrManager,
|
||||||
|
organizationProjectsLimit,
|
||||||
|
isFormbricksCloud,
|
||||||
|
isLicenseActive,
|
||||||
|
currentOrganizationId,
|
||||||
|
currentEnvironmentId,
|
||||||
|
isAccessControlAllowed,
|
||||||
|
isEnvironmentBreadcrumbVisible,
|
||||||
|
}: ProjectBreadcrumbProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
|
||||||
|
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
|
||||||
|
const [openLimitModal, setOpenLimitModal] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
||||||
|
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// Get current project name from context OR prop
|
||||||
|
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
|
||||||
|
const { project: currentProject } = useProject();
|
||||||
|
const projectName = currentProject?.name || currentProjectName || "";
|
||||||
|
|
||||||
|
// Lazy-load projects when dropdown opens
|
||||||
|
useEffect(() => {
|
||||||
|
// Only fetch when dropdown opened for first time (and no error state)
|
||||||
|
if (isProjectDropdownOpen && projects.length === 0 && !isLoadingProjects && !loadError) {
|
||||||
|
setIsLoadingProjects(true);
|
||||||
|
setLoadError(null); // Clear any previous errors
|
||||||
|
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
||||||
|
if (result?.data) {
|
||||||
|
// Sort projects by name
|
||||||
|
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||||
|
setProjects(sorted);
|
||||||
|
} else {
|
||||||
|
// Handle server errors or validation errors
|
||||||
|
const errorMessage = getFormattedErrorMessage(result);
|
||||||
|
const error = new Error(errorMessage);
|
||||||
|
logger.error(error, "Failed to load projects");
|
||||||
|
Sentry.captureException(error);
|
||||||
|
setLoadError(errorMessage || t("common.failed_to_load_workspaces"));
|
||||||
|
}
|
||||||
|
setIsLoadingProjects(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isProjectDropdownOpen, currentOrganizationId, projects.length, isLoadingProjects, loadError, t]);
|
||||||
|
|
||||||
|
const projectSettings = [
|
||||||
|
{
|
||||||
|
id: "general",
|
||||||
|
label: t("common.general"),
|
||||||
|
href: `/environments/${currentEnvironmentId}/workspace/general`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "look",
|
||||||
|
label: t("common.look_and_feel"),
|
||||||
|
href: `/environments/${currentEnvironmentId}/workspace/look`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "app-connection",
|
||||||
|
label: t("common.website_and_app_connection"),
|
||||||
|
href: `/environments/${currentEnvironmentId}/workspace/app-connection`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "integrations",
|
||||||
|
label: t("common.integrations"),
|
||||||
|
href: `/environments/${currentEnvironmentId}/workspace/integrations`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "teams",
|
||||||
|
label: t("common.team_access"),
|
||||||
|
href: `/environments/${currentEnvironmentId}/workspace/teams`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "languages",
|
||||||
|
label: t("common.survey_languages"),
|
||||||
|
href: `/environments/${currentEnvironmentId}/workspace/languages`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tags",
|
||||||
|
label: t("common.tags"),
|
||||||
|
href: `/environments/${currentEnvironmentId}/workspace/tags`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!currentProject) {
|
||||||
|
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
|
||||||
|
logger.error(errorMessage);
|
||||||
|
Sentry.captureException(new Error(errorMessage));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleProjectChange = (projectId: string) => {
|
||||||
|
if (projectId === currentProjectId) return;
|
||||||
|
startTransition(() => {
|
||||||
|
router.push(`/workspaces/${projectId}/`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddProject = () => {
|
||||||
|
if (projects.length >= organizationProjectsLimit) {
|
||||||
|
setOpenLimitModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpenCreateProjectModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProjectSettingsNavigation = (settingId: string) => {
|
||||||
|
startTransition(() => {
|
||||||
|
router.push(`/environments/${currentEnvironmentId}/workspace/${settingId}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const LimitModalButtons = (): [ModalButton, ModalButton] => {
|
||||||
|
if (isFormbricksCloud) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: t("environments.settings.billing.upgrade"),
|
||||||
|
href: `/environments/${currentEnvironmentId}/settings/billing`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("common.cancel"),
|
||||||
|
onClick: () => setOpenLimitModal(false),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: t("environments.settings.billing.upgrade"),
|
||||||
|
href: isLicenseActive
|
||||||
|
? `/environments/${currentEnvironmentId}/settings/enterprise`
|
||||||
|
: "https://formbricks.com/upgrade-self-hosted-license",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("common.cancel"),
|
||||||
|
onClick: () => setOpenLimitModal(false),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<BreadcrumbItem isActive={isProjectDropdownOpen}>
|
||||||
|
<DropdownMenu onOpenChange={setIsProjectDropdownOpen}>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
className="flex cursor-pointer items-center gap-1 outline-none"
|
||||||
|
id="projectDropdownTrigger"
|
||||||
|
asChild>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<HotelIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
|
<span>{projectName}</span>
|
||||||
|
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||||
|
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
|
||||||
|
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
|
) : (
|
||||||
|
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
|
<DropdownMenuContent align="start" className="mt-2">
|
||||||
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
|
<HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||||
|
{t("common.choose_workspace")}
|
||||||
|
</div>
|
||||||
|
{isLoadingProjects && (
|
||||||
|
<div className="flex items-center justify-center py-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoadingProjects && loadError && (
|
||||||
|
<div className="px-2 py-4">
|
||||||
|
<p className="mb-2 text-sm text-red-600">{loadError}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLoadError(null);
|
||||||
|
setProjects([]);
|
||||||
|
}}
|
||||||
|
className="text-xs text-slate-600 underline hover:text-slate-800">
|
||||||
|
{t("common.try_again")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoadingProjects && !loadError && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||||
|
{projects.map((proj) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={proj.id}
|
||||||
|
checked={proj.id === currentProjectId}
|
||||||
|
onClick={() => handleProjectChange(proj.id)}
|
||||||
|
className="cursor-pointer">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{proj.name}</span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
{isOwnerOrManager && (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
onClick={handleAddProject}
|
||||||
|
className="w-full cursor-pointer justify-between">
|
||||||
|
<span>{t("common.add_new_workspace")}</span>
|
||||||
|
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
|
<CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||||
|
{t("common.workspace_configuration")}
|
||||||
|
</div>
|
||||||
|
{projectSettings.map((setting) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={setting.id}
|
||||||
|
checked={isActiveProjectSetting(pathname, setting.id)}
|
||||||
|
onClick={() => handleProjectSettingsNavigation(setting.id)}
|
||||||
|
className="cursor-pointer">
|
||||||
|
{setting.label}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
{/* Modals */}
|
||||||
|
{openLimitModal && (
|
||||||
|
<ProjectLimitModal
|
||||||
|
open={openLimitModal}
|
||||||
|
setOpen={setOpenLimitModal}
|
||||||
|
buttons={LimitModalButtons()}
|
||||||
|
projectLimit={organizationProjectsLimit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{openCreateProjectModal && (
|
||||||
|
<CreateProjectModal
|
||||||
|
open={openCreateProjectModal}
|
||||||
|
setOpen={setOpenCreateProjectModal}
|
||||||
|
organizationId={currentOrganizationId}
|
||||||
|
isAccessControlAllowed={isAccessControlAllowed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</BreadcrumbItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useMemo } from "react";
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
|
import { TProject } from "@formbricks/types/project";
|
||||||
|
|
||||||
|
export interface EnvironmentContextType {
|
||||||
|
environment: TEnvironment;
|
||||||
|
project: TProject;
|
||||||
|
organization: TOrganization;
|
||||||
|
organizationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EnvironmentContext = createContext<EnvironmentContextType | null>(null);
|
||||||
|
|
||||||
|
export const useEnvironment = () => {
|
||||||
|
const context = useContext(EnvironmentContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useEnvironment must be used within an EnvironmentProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useProject = () => {
|
||||||
|
const context = useContext(EnvironmentContext);
|
||||||
|
if (!context) {
|
||||||
|
return { project: null };
|
||||||
|
}
|
||||||
|
return { project: context.project };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useOrganization = () => {
|
||||||
|
const context = useContext(EnvironmentContext);
|
||||||
|
if (!context) {
|
||||||
|
return { organization: null };
|
||||||
|
}
|
||||||
|
return { organization: context.organization };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Client wrapper component to be used in server components
|
||||||
|
interface EnvironmentContextWrapperProps {
|
||||||
|
environment: TEnvironment;
|
||||||
|
project: TProject;
|
||||||
|
organization: TOrganization;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EnvironmentContextWrapper = ({
|
||||||
|
environment,
|
||||||
|
project,
|
||||||
|
organization,
|
||||||
|
children,
|
||||||
|
}: EnvironmentContextWrapperProps) => {
|
||||||
|
const environmentContextValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
environment,
|
||||||
|
project,
|
||||||
|
organization,
|
||||||
|
organizationId: project.organizationId,
|
||||||
|
}),
|
||||||
|
[environment, project, organization]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EnvironmentContext.Provider value={environmentContextValue}>{children}</EnvironmentContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
||||||
|
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||||
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
|
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
|
||||||
|
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
|
||||||
|
|
||||||
|
const EnvLayout = async (props: {
|
||||||
|
params: Promise<{ environmentId: string }>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => {
|
||||||
|
const params = await props.params;
|
||||||
|
const { children } = props;
|
||||||
|
|
||||||
|
// Check session first (required for userId)
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session?.user) {
|
||||||
|
return redirect(`/auth/login`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single consolidated data fetch (replaces ~12 individual fetches)
|
||||||
|
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
||||||
|
<EnvironmentContextWrapper
|
||||||
|
environment={layoutData.environment}
|
||||||
|
project={layoutData.project}
|
||||||
|
organization={layoutData.organization}>
|
||||||
|
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
|
||||||
|
</EnvironmentContextWrapper>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EnvLayout;
|
||||||
+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 { getWorkspacesByUserId } from "./workspace";
|
import { getProjectsByUserId } from "./project";
|
||||||
|
|
||||||
vi.mock("@formbricks/database", () => ({
|
vi.mock("@formbricks/database", () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
workspace: {
|
project: {
|
||||||
findMany: vi.fn(),
|
findMany: vi.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("Workspace", () => {
|
describe("Project", () => {
|
||||||
describe("getUserWorkspaces", () => {
|
describe("getUserProjects", () => {
|
||||||
const mockAdminMembership: TMembership = {
|
const mockAdminMembership: TMembership = {
|
||||||
role: "manager",
|
role: "manager",
|
||||||
organizationId: "org1",
|
organizationId: "org1",
|
||||||
@@ -29,17 +29,17 @@ describe("Workspace", () => {
|
|||||||
accepted: true,
|
accepted: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
test("should return workspaces for admin role", async () => {
|
test("should return projects for admin role", async () => {
|
||||||
const mockWorkspaces = [
|
const mockProjects = [
|
||||||
{ id: "workspace1", name: "Workspace 1" },
|
{ id: "project1", name: "Project 1" },
|
||||||
{ id: "workspace2", name: "Workspace 2" },
|
{ id: "project2", name: "Project 2" },
|
||||||
];
|
];
|
||||||
|
|
||||||
vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any);
|
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
|
||||||
|
|
||||||
const result = await getWorkspacesByUserId("user1", mockAdminMembership);
|
const result = await getProjectsByUserId("user1", mockAdminMembership);
|
||||||
|
|
||||||
expect(prisma.workspace.findMany).toHaveBeenCalledWith({
|
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||||
where: {
|
where: {
|
||||||
organizationId: "org1",
|
organizationId: "org1",
|
||||||
},
|
},
|
||||||
@@ -48,20 +48,20 @@ describe("Workspace", () => {
|
|||||||
name: true,
|
name: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result).toEqual(mockWorkspaces);
|
expect(result).toEqual(mockProjects);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return workspaces for member role with team restrictions", async () => {
|
test("should return projects for member role with team restrictions", async () => {
|
||||||
const mockWorkspaces = [{ id: "workspace1", name: "Workspace 1" }];
|
const mockProjects = [{ id: "project1", name: "Project 1" }];
|
||||||
|
|
||||||
vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any);
|
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
|
||||||
|
|
||||||
const result = await getWorkspacesByUserId("user1", mockMemberMembership);
|
const result = await getProjectsByUserId("user1", mockMemberMembership);
|
||||||
|
|
||||||
expect(prisma.workspace.findMany).toHaveBeenCalledWith({
|
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||||
where: {
|
where: {
|
||||||
organizationId: "org1",
|
organizationId: "org1",
|
||||||
workspaceTeams: {
|
projectTeams: {
|
||||||
some: {
|
some: {
|
||||||
team: {
|
team: {
|
||||||
teamUsers: {
|
teamUsers: {
|
||||||
@@ -78,13 +78,13 @@ describe("Workspace", () => {
|
|||||||
name: true,
|
name: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result).toEqual(mockWorkspaces);
|
expect(result).toEqual(mockProjects);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return empty array when no workspaces found", async () => {
|
test("should return empty array when no projects found", async () => {
|
||||||
vi.mocked(prisma.workspace.findMany).mockResolvedValue([]);
|
vi.mocked(prisma.project.findMany).mockResolvedValue([]);
|
||||||
|
|
||||||
const result = await getWorkspacesByUserId("user1", mockAdminMembership);
|
const result = await getProjectsByUserId("user1", mockAdminMembership);
|
||||||
|
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
@@ -95,27 +95,27 @@ describe("Workspace", () => {
|
|||||||
clientVersion: "5.0.0",
|
clientVersion: "5.0.0",
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mocked(prisma.workspace.findMany).mockRejectedValue(prismaError);
|
vi.mocked(prisma.project.findMany).mockRejectedValue(prismaError);
|
||||||
|
|
||||||
await expect(getWorkspacesByUserId("user1", mockAdminMembership)).rejects.toThrow(
|
await expect(getProjectsByUserId("user1", mockAdminMembership)).rejects.toThrow(
|
||||||
new DatabaseError("Database error")
|
new DatabaseError("Database error")
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should re-throw unknown errors", async () => {
|
test("should re-throw unknown errors", async () => {
|
||||||
const unknownError = new Error("Unknown error");
|
const unknownError = new Error("Unknown error");
|
||||||
vi.mocked(prisma.workspace.findMany).mockRejectedValue(unknownError);
|
vi.mocked(prisma.project.findMany).mockRejectedValue(unknownError);
|
||||||
|
|
||||||
await expect(getWorkspacesByUserId("user1", mockAdminMembership)).rejects.toThrow(unknownError);
|
await expect(getProjectsByUserId("user1", mockAdminMembership)).rejects.toThrow(unknownError);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should validate inputs correctly", async () => {
|
test("should validate inputs correctly", async () => {
|
||||||
await expect(getWorkspacesByUserId(123 as any, mockAdminMembership)).rejects.toThrow();
|
await expect(getProjectsByUserId(123 as any, mockAdminMembership)).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should validate membership input correctly", async () => {
|
test("should validate membership input correctly", async () => {
|
||||||
const invalidMembership = {} as TMembership;
|
const invalidMembership = {} as TMembership;
|
||||||
await expect(getWorkspacesByUserId("user1", invalidMembership)).rejects.toThrow();
|
await expect(getProjectsByUserId("user1", invalidMembership)).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should handle owner role like manager", async () => {
|
test("should handle owner role like manager", async () => {
|
||||||
@@ -126,12 +126,12 @@ describe("Workspace", () => {
|
|||||||
accepted: true,
|
accepted: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockWorkspaces = [{ id: "workspace1", name: "Workspace 1" }];
|
const mockProjects = [{ id: "project1", name: "Project 1" }];
|
||||||
vi.mocked(prisma.workspace.findMany).mockResolvedValue(mockWorkspaces as any);
|
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
|
||||||
|
|
||||||
const result = await getWorkspacesByUserId("user1", mockOwnerMembership);
|
const result = await getProjectsByUserId("user1", mockOwnerMembership);
|
||||||
|
|
||||||
expect(prisma.workspace.findMany).toHaveBeenCalledWith({
|
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||||
where: {
|
where: {
|
||||||
organizationId: "org1",
|
organizationId: "org1",
|
||||||
},
|
},
|
||||||
@@ -140,7 +140,7 @@ describe("Workspace", () => {
|
|||||||
name: true,
|
name: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result).toEqual(mockWorkspaces);
|
expect(result).toEqual(mockProjects);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+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 getWorkspacesByUserId = reactCache(
|
export const getProjectsByUserId = reactCache(
|
||||||
async (userId: string, orgMembership: TMembership): Promise<{ id: string; name: string }[]> => {
|
async (userId: string, orgMembership: TMembership): Promise<{ id: string; name: string }[]> => {
|
||||||
validateInputs([userId, ZString], [orgMembership, ZMembership]);
|
validateInputs([userId, ZString], [orgMembership, ZMembership]);
|
||||||
|
|
||||||
let workspaceWhereClause: Prisma.WorkspaceWhereInput = {};
|
let projectWhereClause: Prisma.ProjectWhereInput = {};
|
||||||
|
|
||||||
if (orgMembership.role === "member") {
|
if (orgMembership.role === "member") {
|
||||||
workspaceWhereClause = {
|
projectWhereClause = {
|
||||||
workspaceTeams: {
|
projectTeams: {
|
||||||
some: {
|
some: {
|
||||||
team: {
|
team: {
|
||||||
teamUsers: {
|
teamUsers: {
|
||||||
@@ -29,17 +29,17 @@ export const getWorkspacesByUserId = reactCache(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const workspaces = await prisma.workspace.findMany({
|
const projects = await prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
organizationId: orgMembership.organizationId,
|
organizationId: orgMembership.organizationId,
|
||||||
...workspaceWhereClause,
|
...projectWhereClause,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return workspaces;
|
return projects;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
throw new DatabaseError(error.message);
|
throw new DatabaseError(error.message);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
|
|
||||||
|
const EnvironmentPage = async (props) => {
|
||||||
|
const params = await props.params;
|
||||||
|
const { session, organization } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
|
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||||
|
const { isBilling } = getAccessFlags(currentUserMembership?.role);
|
||||||
|
|
||||||
|
if (isBilling) {
|
||||||
|
if (IS_FORMBRICKS_CLOUD) {
|
||||||
|
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
||||||
|
} else {
|
||||||
|
return redirect(`/environments/${params.environmentId}/settings/enterprise`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(`/environments/${params.environmentId}/surveys`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EnvironmentPage;
|
||||||
+4
-6
@@ -2,30 +2,28 @@
|
|||||||
|
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
|
|
||||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||||
|
|
||||||
interface AccountSettingsNavbarProps {
|
interface AccountSettingsNavbarProps {
|
||||||
|
environmentId?: string;
|
||||||
activeId: string;
|
activeId: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AccountSettingsNavbar = ({ activeId, loading }: AccountSettingsNavbarProps) => {
|
export const AccountSettingsNavbar = ({ environmentId, activeId, loading }: AccountSettingsNavbarProps) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { workspace } = useWorkspace();
|
|
||||||
const workspaceBasePath = `/workspaces/${workspace?.id}`;
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{
|
{
|
||||||
id: "profile",
|
id: "profile",
|
||||||
label: t("common.profile"),
|
label: t("common.profile"),
|
||||||
href: `${workspaceBasePath}/settings/profile`,
|
href: `/environments/${environmentId}/settings/profile`,
|
||||||
current: pathname?.includes("/profile"),
|
current: pathname?.includes("/profile"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "notifications",
|
id: "notifications",
|
||||||
label: t("common.notifications"),
|
label: t("common.notifications"),
|
||||||
href: `${workspaceBasePath}/settings/notifications`,
|
href: `/environments/${environmentId}/settings/notifications`,
|
||||||
current: pathname?.includes("/notifications"),
|
current: pathname?.includes("/notifications"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
|
|
||||||
|
const AccountSettingsLayout = async (props) => {
|
||||||
|
const params = await props.params;
|
||||||
|
|
||||||
|
const { children } = props;
|
||||||
|
|
||||||
|
const t = await getTranslate();
|
||||||
|
const [organization, project, session] = await Promise.all([
|
||||||
|
getOrganizationByEnvironmentId(params.environmentId),
|
||||||
|
getProjectByEnvironmentId(params.environmentId),
|
||||||
|
getServerSession(authOptions),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
throw new Error(t("common.organization_not_found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new Error(t("common.workspace_not_found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new Error(t("common.session_not_found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AccountSettingsLayout;
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ZUserNotificationSettings } from "@formbricks/types/user";
|
||||||
|
import { getUser, updateUser } from "@/lib/user/service";
|
||||||
|
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";
|
||||||
|
|
||||||
|
const ZUpdateNotificationSettingsAction = z.object({
|
||||||
|
notificationSettings: ZUserNotificationSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateNotificationSettingsAction = authenticatedActionClient
|
||||||
|
.schema(ZUpdateNotificationSettingsAction)
|
||||||
|
.action(
|
||||||
|
withAuditLogging(
|
||||||
|
"updated",
|
||||||
|
"user",
|
||||||
|
async ({
|
||||||
|
ctx,
|
||||||
|
parsedInput,
|
||||||
|
}: {
|
||||||
|
ctx: AuthenticatedActionClientCtx;
|
||||||
|
parsedInput: Record<string, any>;
|
||||||
|
}) => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
+34
-28
@@ -4,7 +4,6 @@ import { HelpCircleIcon, UsersIcon } from "lucide-react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||||
import { Membership } from "../types";
|
import { Membership } from "../types";
|
||||||
import { NotificationSwitch } from "./NotificationSwitch";
|
import { NotificationSwitch } from "./NotificationSwitch";
|
||||||
@@ -12,6 +11,7 @@ import { NotificationSwitch } from "./NotificationSwitch";
|
|||||||
interface EditAlertsProps {
|
interface EditAlertsProps {
|
||||||
memberships: Membership[];
|
memberships: Membership[];
|
||||||
user: TUser;
|
user: TUser;
|
||||||
|
environmentId: string;
|
||||||
autoDisableNotificationType: string;
|
autoDisableNotificationType: string;
|
||||||
autoDisableNotificationElementId: string;
|
autoDisableNotificationElementId: string;
|
||||||
}
|
}
|
||||||
@@ -19,11 +19,11 @@ interface EditAlertsProps {
|
|||||||
export const EditAlerts = ({
|
export const EditAlerts = ({
|
||||||
memberships,
|
memberships,
|
||||||
user,
|
user,
|
||||||
|
environmentId,
|
||||||
autoDisableNotificationType,
|
autoDisableNotificationType,
|
||||||
autoDisableNotificationElementId,
|
autoDisableNotificationElementId,
|
||||||
}: EditAlertsProps) => {
|
}: EditAlertsProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { workspace: currentWorkspace } = useWorkspace();
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{memberships.map((membership) => (
|
{memberships.map((membership) => (
|
||||||
@@ -37,10 +37,10 @@ export const EditAlerts = ({
|
|||||||
|
|
||||||
<div className="col-span-3 flex items-center justify-end pr-2">
|
<div className="col-span-3 flex items-center justify-end pr-2">
|
||||||
<p className="pr-4 text-sm text-slate-600">
|
<p className="pr-4 text-sm text-slate-600">
|
||||||
{t("workspace.settings.notifications.auto_subscribe_to_new_surveys")}
|
{t("environments.settings.notifications.auto_subscribe_to_new_surveys")}
|
||||||
</p>
|
</p>
|
||||||
<NotificationSwitch
|
<NotificationSwitch
|
||||||
surveyOrWorkspaceOrOrganizationId={membership.organization.id}
|
surveyOrProjectOrOrganizationId={membership.organization.id}
|
||||||
notificationSettings={user.notificationSettings!}
|
notificationSettings={user.notificationSettings!}
|
||||||
notificationType={"unsubscribedOrganizationIds"}
|
notificationType={"unsubscribedOrganizationIds"}
|
||||||
autoDisableNotificationType={autoDisableNotificationType}
|
autoDisableNotificationType={autoDisableNotificationType}
|
||||||
@@ -55,38 +55,44 @@ export const EditAlerts = ({
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<div className="col-span-1 flex cursor-default items-center justify-center space-x-2">
|
<div className="col-span-1 flex cursor-default items-center justify-center space-x-2">
|
||||||
<span>{t("workspace.settings.notifications.every_response")}</span>
|
<span>{t("environments.settings.notifications.every_response")}</span>
|
||||||
<HelpCircleIcon className="h-4 w-4 flex-shrink-0 text-slate-500" />
|
<HelpCircleIcon className="h-4 w-4 flex-shrink-0 text-slate-500" />
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{t("workspace.settings.notifications.every_response_tooltip")}
|
{t("environments.settings.notifications.every_response_tooltip")}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{membership.organization.workspaces.some((workspace) => workspace.surveys.length > 0) ? (
|
{membership.organization.projects.some((project) =>
|
||||||
|
project.environments.some((environment) => environment.surveys.length > 0)
|
||||||
|
) ? (
|
||||||
<div className="grid-cols-8 space-y-1 p-2">
|
<div className="grid-cols-8 space-y-1 p-2">
|
||||||
{membership.organization.workspaces.map((workspace) => (
|
{membership.organization.projects.map((project) => (
|
||||||
<div key={workspace.id}>
|
<div key={project.id}>
|
||||||
{workspace.surveys.map((survey) => (
|
{project.environments.map((environment) => (
|
||||||
<div
|
<div key={environment.id}>
|
||||||
className="grid h-auto w-full cursor-pointer grid-cols-3 place-content-center rounded-lg px-2 py-2 text-left text-sm text-slate-900 hover:bg-slate-50"
|
{environment.surveys.map((survey) => (
|
||||||
key={survey.name}>
|
<div
|
||||||
<div className="col-span-2 text-left">
|
className="grid h-auto w-full cursor-pointer grid-cols-3 place-content-center rounded-lg px-2 py-2 text-left text-sm text-slate-900 hover:bg-slate-50"
|
||||||
<div className="font-medium text-slate-900">{survey.name}</div>
|
key={survey.name}>
|
||||||
<div className="text-xs text-slate-400">{workspace.name}</div>
|
<div className="col-span-2 text-left">
|
||||||
</div>
|
<div className="font-medium text-slate-900">{survey.name}</div>
|
||||||
<div className="col-span-1 text-center">
|
<div className="text-xs text-slate-400">{project.name}</div>
|
||||||
<NotificationSwitch
|
</div>
|
||||||
surveyOrWorkspaceOrOrganizationId={survey.id}
|
<div className="col-span-1 text-center">
|
||||||
notificationSettings={user.notificationSettings!}
|
<NotificationSwitch
|
||||||
notificationType={"alert"}
|
surveyOrProjectOrOrganizationId={survey.id}
|
||||||
autoDisableNotificationType={autoDisableNotificationType}
|
notificationSettings={user.notificationSettings!}
|
||||||
autoDisableNotificationElementId={autoDisableNotificationElementId}
|
notificationType={"alert"}
|
||||||
/>
|
autoDisableNotificationType={autoDisableNotificationType}
|
||||||
</div>
|
autoDisableNotificationElementId={autoDisableNotificationElementId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -98,8 +104,8 @@ export const EditAlerts = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="pb-3 pl-4 text-xs text-slate-400">
|
<p className="pb-3 pl-4 text-xs text-slate-400">
|
||||||
{t("workspace.settings.notifications.want_to_loop_in_organization_mates")}{" "}
|
{t("environments.settings.notifications.want_to_loop_in_organization_mates")}{" "}
|
||||||
<Link className="font-semibold" href={`/workspaces/${currentWorkspace?.id}/settings/general`}>
|
<Link className="font-semibold" href={`/environments/${environmentId}/settings/general`}>
|
||||||
{t("common.invite_them")}
|
{t("common.invite_them")}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
+8
-6
@@ -1,22 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
|
|
||||||
import { SlackIcon } from "@/modules/ui/components/icons";
|
import { SlackIcon } from "@/modules/ui/components/icons";
|
||||||
|
|
||||||
export const IntegrationsTip = () => {
|
interface IntegrationsTipProps {
|
||||||
|
environmentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IntegrationsTip = ({ environmentId }: IntegrationsTipProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { workspace } = useWorkspace();
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex max-w-4xl items-center space-y-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm md:space-y-0 md:text-base">
|
<div className="flex max-w-4xl items-center space-y-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm md:space-y-0 md:text-base">
|
||||||
<SlackIcon className="mr-3 h-4 w-4 text-blue-400" />
|
<SlackIcon className="mr-3 h-4 w-4 text-blue-400" />
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
{t("workspace.settings.notifications.need_slack_or_discord_notifications")}?
|
{t("environments.settings.notifications.need_slack_or_discord_notifications")}?
|
||||||
<a
|
<a
|
||||||
href={`/workspaces/${workspace?.id}/integrations`}
|
href={`/environments/${environmentId}/workspace/integrations`}
|
||||||
className="ml-1 cursor-pointer text-sm underline">
|
className="ml-1 cursor-pointer text-sm underline">
|
||||||
{t("workspace.settings.notifications.use_the_integration")}
|
{t("environments.settings.notifications.use_the_integration")}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
+15
-20
@@ -10,7 +10,7 @@ import { Switch } from "@/modules/ui/components/switch";
|
|||||||
import { updateNotificationSettingsAction } from "../actions";
|
import { updateNotificationSettingsAction } from "../actions";
|
||||||
|
|
||||||
interface NotificationSwitchProps {
|
interface NotificationSwitchProps {
|
||||||
surveyOrWorkspaceOrOrganizationId: string;
|
surveyOrProjectOrOrganizationId: string;
|
||||||
notificationSettings: TUserNotificationSettings;
|
notificationSettings: TUserNotificationSettings;
|
||||||
notificationType: "alert" | "unsubscribedOrganizationIds";
|
notificationType: "alert" | "unsubscribedOrganizationIds";
|
||||||
autoDisableNotificationType?: string;
|
autoDisableNotificationType?: string;
|
||||||
@@ -18,7 +18,7 @@ interface NotificationSwitchProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const NotificationSwitch = ({
|
export const NotificationSwitch = ({
|
||||||
surveyOrWorkspaceOrOrganizationId,
|
surveyOrProjectOrOrganizationId,
|
||||||
notificationSettings,
|
notificationSettings,
|
||||||
notificationType,
|
notificationType,
|
||||||
autoDisableNotificationType,
|
autoDisableNotificationType,
|
||||||
@@ -29,8 +29,8 @@ export const NotificationSwitch = ({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isChecked =
|
const isChecked =
|
||||||
notificationType === "unsubscribedOrganizationIds"
|
notificationType === "unsubscribedOrganizationIds"
|
||||||
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrWorkspaceOrOrganizationId)
|
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
|
||||||
: notificationSettings[notificationType]?.[surveyOrWorkspaceOrOrganizationId] === true;
|
: notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true;
|
||||||
|
|
||||||
const handleSwitchChange = async () => {
|
const handleSwitchChange = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -38,29 +38,26 @@ export const NotificationSwitch = ({
|
|||||||
let updatedNotificationSettings = { ...notificationSettings };
|
let updatedNotificationSettings = { ...notificationSettings };
|
||||||
if (notificationType === "unsubscribedOrganizationIds") {
|
if (notificationType === "unsubscribedOrganizationIds") {
|
||||||
const unsubscribedOrganizationIds = updatedNotificationSettings.unsubscribedOrganizationIds ?? [];
|
const unsubscribedOrganizationIds = updatedNotificationSettings.unsubscribedOrganizationIds ?? [];
|
||||||
if (unsubscribedOrganizationIds.includes(surveyOrWorkspaceOrOrganizationId)) {
|
if (unsubscribedOrganizationIds.includes(surveyOrProjectOrOrganizationId)) {
|
||||||
updatedNotificationSettings.unsubscribedOrganizationIds = unsubscribedOrganizationIds.filter(
|
updatedNotificationSettings.unsubscribedOrganizationIds = unsubscribedOrganizationIds.filter(
|
||||||
(id) => id !== surveyOrWorkspaceOrOrganizationId
|
(id) => id !== surveyOrProjectOrOrganizationId
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
updatedNotificationSettings.unsubscribedOrganizationIds = [
|
updatedNotificationSettings.unsubscribedOrganizationIds = [
|
||||||
...unsubscribedOrganizationIds,
|
...unsubscribedOrganizationIds,
|
||||||
surveyOrWorkspaceOrOrganizationId,
|
surveyOrProjectOrOrganizationId,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
updatedNotificationSettings[notificationType] = {
|
updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId] =
|
||||||
...updatedNotificationSettings[notificationType],
|
!updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId];
|
||||||
[surveyOrWorkspaceOrOrganizationId]:
|
|
||||||
!updatedNotificationSettings[notificationType]?.[surveyOrWorkspaceOrOrganizationId],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
|
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
|
||||||
notificationSettings: updatedNotificationSettings,
|
notificationSettings: updatedNotificationSettings,
|
||||||
});
|
});
|
||||||
if (updatedNotificationSettingsActionResponse?.data) {
|
if (updatedNotificationSettingsActionResponse?.data) {
|
||||||
toast.success(t("workspace.settings.notifications.notification_settings_updated"), {
|
toast.success(t("environments.settings.notifications.notification_settings_updated"), {
|
||||||
id: "notification-switch",
|
id: "notification-switch",
|
||||||
});
|
});
|
||||||
router.refresh();
|
router.refresh();
|
||||||
@@ -76,16 +73,16 @@ export const NotificationSwitch = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
autoDisableNotificationType &&
|
autoDisableNotificationType &&
|
||||||
autoDisableNotificationElementId === surveyOrWorkspaceOrOrganizationId &&
|
autoDisableNotificationElementId === surveyOrProjectOrOrganizationId &&
|
||||||
isChecked
|
isChecked
|
||||||
) {
|
) {
|
||||||
switch (notificationType) {
|
switch (notificationType) {
|
||||||
case "alert":
|
case "alert":
|
||||||
if (notificationSettings[notificationType]?.[surveyOrWorkspaceOrOrganizationId] === true) {
|
if (notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true) {
|
||||||
handleSwitchChange();
|
handleSwitchChange();
|
||||||
toast.success(
|
toast.success(
|
||||||
t(
|
t(
|
||||||
"workspace.settings.notifications.you_will_not_receive_any_more_emails_for_responses_on_this_survey"
|
"environments.settings.notifications.you_will_not_receive_any_more_emails_for_responses_on_this_survey"
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
id: "notification-switch",
|
id: "notification-switch",
|
||||||
@@ -95,13 +92,11 @@ export const NotificationSwitch = ({
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "unsubscribedOrganizationIds":
|
case "unsubscribedOrganizationIds":
|
||||||
if (
|
if (!notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)) {
|
||||||
!notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrWorkspaceOrOrganizationId)
|
|
||||||
) {
|
|
||||||
handleSwitchChange();
|
handleSwitchChange();
|
||||||
toast.success(
|
toast.success(
|
||||||
t(
|
t(
|
||||||
"workspace.settings.notifications.you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore"
|
"environments.settings.notifications.you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore"
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
id: "notification-switch",
|
id: "notification-switch",
|
||||||
+3
-3
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { LoadingCard } from "@/app/(app)/components/LoadingCard";
|
import { LoadingCard } from "@/app/(app)/components/LoadingCard";
|
||||||
import { AccountSettingsNavbar } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/components/AccountSettingsNavbar";
|
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
|
|
||||||
@@ -10,8 +10,8 @@ const Loading = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const cards = [
|
const cards = [
|
||||||
{
|
{
|
||||||
title: t("workspace.settings.notifications.email_alerts_surveys"),
|
title: t("environments.settings.notifications.email_alerts_surveys"),
|
||||||
description: t("workspace.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses"),
|
description: t("environments.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses"),
|
||||||
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }],
|
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
+45
-36
@@ -1,35 +1,35 @@
|
|||||||
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)/workspaces/[workspaceId]/settings/(account)/components/AccountSettingsNavbar";
|
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||||
import { EditAlerts } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/notifications/components/EditAlerts";
|
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||||
import { IntegrationsTip } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/notifications/components/IntegrationsTip";
|
|
||||||
import type { Membership } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/notifications/types";
|
|
||||||
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
|
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
|
import { EditAlerts } from "./components/EditAlerts";
|
||||||
|
import { IntegrationsTip } from "./components/IntegrationsTip";
|
||||||
|
import type { Membership } from "./types";
|
||||||
|
|
||||||
const setCompleteNotificationSettings = (
|
const setCompleteNotificationSettings = (
|
||||||
notificationSettings: TUserNotificationSettings,
|
notificationSettings: TUserNotificationSettings,
|
||||||
memberships: Membership[]
|
memberships: Membership[]
|
||||||
): TUserNotificationSettings => {
|
): TUserNotificationSettings => {
|
||||||
const newNotificationSettings: TUserNotificationSettings = {
|
const newNotificationSettings = {
|
||||||
alert: {} as Record<string, boolean>,
|
alert: {},
|
||||||
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
|
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
|
||||||
};
|
};
|
||||||
for (const membership of memberships) {
|
for (const membership of memberships) {
|
||||||
for (const workspace of membership.organization.workspaces) {
|
for (const project of membership.organization.projects) {
|
||||||
// set default values for alerts
|
// set default values for alerts
|
||||||
for (const survey of workspace.surveys) {
|
for (const environment of project.environments) {
|
||||||
newNotificationSettings.alert[survey.id] =
|
for (const survey of environment.surveys) {
|
||||||
(notificationSettings as unknown as Record<string, Record<string, boolean>>)[survey.id]
|
newNotificationSettings.alert[survey.id] =
|
||||||
?.responseFinished ||
|
notificationSettings[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 +45,17 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
|
|||||||
},
|
},
|
||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
// Fetch all workspaces if user role is owner or manager
|
// Fetch all projects if user role is owner or manager
|
||||||
role: {
|
role: {
|
||||||
in: ["owner", "manager"],
|
in: ["owner", "manager"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Filter workspaces based on team membership if user is not owner or manager
|
// Filter projects based on team membership if user is not owner or manager
|
||||||
organization: {
|
organization: {
|
||||||
workspaces: {
|
projects: {
|
||||||
some: {
|
some: {
|
||||||
workspaceTeams: {
|
projectTeams: {
|
||||||
some: {
|
some: {
|
||||||
team: {
|
team: {
|
||||||
teamUsers: {
|
teamUsers: {
|
||||||
@@ -77,12 +77,12 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
workspaces: {
|
projects: {
|
||||||
// Apply conditional filtering based on user's role
|
// Apply conditional filtering based on user's role
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
{
|
{
|
||||||
// Fetch all workspaces if user is owner or manager
|
// Fetch all projects if user is owner or manager
|
||||||
organization: {
|
organization: {
|
||||||
memberships: {
|
memberships: {
|
||||||
some: {
|
some: {
|
||||||
@@ -95,8 +95,8 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// Only include workspaces accessible through teams if user is not owner or manager
|
// Only include projects accessible through teams if user is not owner or manager
|
||||||
workspaceTeams: {
|
projectTeams: {
|
||||||
some: {
|
some: {
|
||||||
team: {
|
team: {
|
||||||
teamUsers: {
|
teamUsers: {
|
||||||
@@ -113,10 +113,18 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
surveys: {
|
environments: {
|
||||||
|
where: {
|
||||||
|
type: "production",
|
||||||
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
surveys: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -128,26 +136,24 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
|
|||||||
return memberships;
|
return memberships;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Page = async (props: {
|
const Page = async (props) => {
|
||||||
params: Promise<{ workspaceId: string }>;
|
|
||||||
searchParams: Promise<Record<string, string>>;
|
|
||||||
}) => {
|
|
||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
|
const params = await props.params;
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
throw new Error(t("common.session_not_found"));
|
||||||
}
|
}
|
||||||
const autoDisableNotificationType = searchParams["type"];
|
const autoDisableNotificationType = searchParams["type"];
|
||||||
const autoDisableNotificationElementId = searchParams["elementId"];
|
const autoDisableNotificationElementId = searchParams["elementId"];
|
||||||
|
|
||||||
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
|
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
throw new Error(t("common.user_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!memberships) {
|
if (!memberships) {
|
||||||
throw new ResourceNotFoundError(t("common.membership"), null);
|
throw new Error(t("common.membership_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user?.notificationSettings) {
|
if (user?.notificationSettings) {
|
||||||
@@ -156,19 +162,22 @@ const Page = async (props: {
|
|||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<PageHeader pageTitle={t("common.account_settings")}>
|
<PageHeader pageTitle={t("common.account_settings")}>
|
||||||
<AccountSettingsNavbar activeId="notifications" />
|
<AccountSettingsNavbar environmentId={params.environmentId} activeId="notifications" />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title={t("workspace.settings.notifications.email_alerts_surveys")}
|
title={t("environments.settings.notifications.email_alerts_surveys")}
|
||||||
description={t("workspace.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses")}>
|
description={t(
|
||||||
|
"environments.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses"
|
||||||
|
)}>
|
||||||
<EditAlerts
|
<EditAlerts
|
||||||
memberships={memberships}
|
memberships={memberships}
|
||||||
user={user}
|
user={user}
|
||||||
|
environmentId={params.environmentId}
|
||||||
autoDisableNotificationType={autoDisableNotificationType}
|
autoDisableNotificationType={autoDisableNotificationType}
|
||||||
autoDisableNotificationElementId={autoDisableNotificationElementId}
|
autoDisableNotificationElementId={autoDisableNotificationElementId}
|
||||||
/>
|
/>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
<IntegrationsTip />
|
<IntegrationsTip environmentId={params.environmentId} />
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+6
-3
@@ -4,12 +4,15 @@ export interface Membership {
|
|||||||
organization: {
|
organization: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
workspaces: {
|
projects: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
surveys: {
|
environments: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
surveys: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
}[];
|
}[];
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
+44
-35
@@ -9,19 +9,18 @@ import {
|
|||||||
import {
|
import {
|
||||||
getIsEmailUnique,
|
getIsEmailUnique,
|
||||||
verifyUserPassword,
|
verifyUserPassword,
|
||||||
} from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/profile/lib/user";
|
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
|
||||||
import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
|
||||||
import { getUser, updateUser } from "@/lib/user/service";
|
import { getUser, updateUser } from "@/lib/user/service";
|
||||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||||
import { requestPasswordReset } from "@/modules/auth/forgot-password/lib/password-reset-service";
|
|
||||||
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
||||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
import { sendVerificationNewEmail } from "@/modules/email";
|
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
|
||||||
|
|
||||||
function buildUserUpdatePayload(parsedInput: TUserPersonalInfoUpdateInput): TUserUpdateInput {
|
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
|
||||||
return {
|
return {
|
||||||
...(parsedInput.name && { name: parsedInput.name }),
|
...(parsedInput.name && { name: parsedInput.name }),
|
||||||
...(parsedInput.locale && { locale: parsedInput.locale }),
|
...(parsedInput.locale && { locale: parsedInput.locale }),
|
||||||
@@ -64,40 +63,50 @@ async function handleEmailUpdate({
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPersonalInfoUpdateInput).action(
|
export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action(
|
||||||
withAuditLogging("updated", "user", async ({ ctx, parsedInput }) => {
|
withAuditLogging(
|
||||||
const oldObject = await getUser(ctx.user.id);
|
"updated",
|
||||||
let payload = buildUserUpdatePayload(parsedInput);
|
"user",
|
||||||
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
|
async ({
|
||||||
|
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("passwordReset", "user", async ({ ctx }) => {
|
withAuditLogging(
|
||||||
if (PASSWORD_RESET_DISABLED) {
|
"passwordReset",
|
||||||
throw new OperationNotAllowedError("Password reset is disabled");
|
"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 };
|
||||||
}
|
}
|
||||||
|
)
|
||||||
if (ctx.user.identityProvider !== "email") {
|
|
||||||
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await requestPasswordReset(ctx.user, "profile");
|
|
||||||
|
|
||||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
+2
-2
@@ -31,11 +31,11 @@ export const AccountSecurity = ({ user }: AccountSecurityProps) => {
|
|||||||
/>
|
/>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<h1 className="text-sm font-semibold text-slate-800">
|
<h1 className="text-sm font-semibold text-slate-800">
|
||||||
{t("workspace.settings.profile.two_factor_authentication")}
|
{t("environments.settings.profile.two_factor_authentication")}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-xs text-slate-600">
|
<p className="text-xs text-slate-600">
|
||||||
{t("workspace.settings.profile.two_factor_authentication_description")}
|
{t("environments.settings.profile.two_factor_authentication_description")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
+3
-3
@@ -39,18 +39,18 @@ export const DeleteAccount = ({
|
|||||||
organizationsWithSingleOwner={organizationsWithSingleOwner}
|
organizationsWithSingleOwner={organizationsWithSingleOwner}
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-slate-700">
|
<p className="text-sm text-slate-700">
|
||||||
<strong>{t("workspace.settings.profile.warning_cannot_undo")}</strong>
|
<strong>{t("environments.settings.profile.warning_cannot_undo")}</strong>
|
||||||
</p>
|
</p>
|
||||||
<TooltipRenderer
|
<TooltipRenderer
|
||||||
shouldRender={isDeleteDisabled}
|
shouldRender={isDeleteDisabled}
|
||||||
tooltipContent={t("workspace.settings.profile.warning_cannot_delete_account")}>
|
tooltipContent={t("environments.settings.profile.warning_cannot_delete_account")}>
|
||||||
<Button
|
<Button
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setModalOpen(!isModalOpen)}
|
onClick={() => setModalOpen(!isModalOpen)}
|
||||||
disabled={isDeleteDisabled}>
|
disabled={isDeleteDisabled}>
|
||||||
{t("workspace.settings.profile.confirm_delete_my_account")}
|
{t("environments.settings.profile.confirm_delete_my_account")}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipRenderer>
|
</TooltipRenderer>
|
||||||
</div>
|
</div>
|
||||||
+42
-59
@@ -8,8 +8,8 @@ import toast from "react-hot-toast";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
|
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
|
||||||
import { PasswordConfirmationModal } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/profile/components/password-confirmation-modal";
|
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
|
||||||
import { appLanguages, sortedAppLanguages } from "@/lib/i18n/utils";
|
import { appLanguages } 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";
|
||||||
@@ -91,13 +91,13 @@ export const EditProfileDetailsForm = ({
|
|||||||
if (!emailVerificationDisabled) {
|
if (!emailVerificationDisabled) {
|
||||||
toast.success(t("auth.verification-requested.new_email_verification_success"));
|
toast.success(t("auth.verification-requested.new_email_verification_success"));
|
||||||
} else {
|
} else {
|
||||||
toast.success(t("workspace.settings.profile.email_change_initiated"));
|
toast.success(t("environments.settings.profile.email_change_initiated"));
|
||||||
await signOutWithAudit({
|
await signOutWithAudit({
|
||||||
reason: "email_change",
|
reason: "email_change",
|
||||||
redirectUrl: "/email-change-without-verification-success",
|
redirectUrl: "/email-change-without-verification-success",
|
||||||
redirect: true,
|
redirect: true,
|
||||||
callbackUrl: "/email-change-without-verification-success",
|
callbackUrl: "/email-change-without-verification-success",
|
||||||
clearWorkspaceId: true,
|
clearEnvironmentId: true,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -116,15 +116,11 @@ export const EditProfileDetailsForm = ({
|
|||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const result = await updateUserAction({
|
await updateUserAction({
|
||||||
...data,
|
...data,
|
||||||
name: data.name.trim(),
|
name: data.name.trim(),
|
||||||
});
|
});
|
||||||
if (result?.serverError) {
|
toast.success(t("environments.settings.profile.profile_updated_successfully"));
|
||||||
toast.error(getFormattedErrorMessage(result));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toast.success(t("workspace.settings.profile.profile_updated_successfully"));
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
form.reset(data);
|
form.reset(data);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -145,7 +141,7 @@ export const EditProfileDetailsForm = ({
|
|||||||
redirectUrl: "/auth/login",
|
redirectUrl: "/auth/login",
|
||||||
redirect: true,
|
redirect: true,
|
||||||
callbackUrl: "/auth/login",
|
callbackUrl: "/auth/login",
|
||||||
clearWorkspaceId: true,
|
clearEnvironmentId: true,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const errorMessage = getFormattedErrorMessage(result);
|
const errorMessage = getFormattedErrorMessage(result);
|
||||||
@@ -202,54 +198,41 @@ export const EditProfileDetailsForm = ({
|
|||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="locale"
|
name="locale"
|
||||||
render={({ field }) => {
|
render={({ field }) => (
|
||||||
const selectedLanguage = appLanguages.find((l) => l.code === field.value);
|
<FormItem className="mt-4">
|
||||||
|
<FormLabel>{t("common.language")}</FormLabel>
|
||||||
return (
|
<FormControl>
|
||||||
<FormItem className="mt-4">
|
<DropdownMenu>
|
||||||
<FormLabel>{t("common.language")}</FormLabel>
|
<DropdownMenuTrigger asChild>
|
||||||
<FormControl>
|
<Button
|
||||||
<DropdownMenu>
|
type="button"
|
||||||
<DropdownMenuTrigger asChild>
|
variant="ghost"
|
||||||
<Button
|
className="h-10 w-full border border-slate-300 px-3 text-left">
|
||||||
type="button"
|
<div className="flex w-full items-center justify-between">
|
||||||
variant="ghost"
|
{appLanguages.find((l) => l.code === field.value)?.label["en-US"] ?? "NA"}
|
||||||
className="h-10 w-full border border-slate-300 px-3 text-left">
|
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
|
||||||
<div className="flex w-full items-center justify-between">
|
</div>
|
||||||
{selectedLanguage ? (
|
</Button>
|
||||||
<>
|
</DropdownMenuTrigger>
|
||||||
{selectedLanguage.label["en-US"]}
|
<DropdownMenuContent
|
||||||
{selectedLanguage.label.native !== selectedLanguage.label["en-US"] &&
|
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
|
||||||
` (${selectedLanguage.label.native})`}
|
align="start">
|
||||||
</>
|
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
|
||||||
) : (
|
{appLanguages.map((lang) => (
|
||||||
t("common.select")
|
<DropdownMenuRadioItem
|
||||||
)}
|
key={lang.code}
|
||||||
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
|
value={lang.code}
|
||||||
</div>
|
className="min-h-8 cursor-pointer">
|
||||||
</Button>
|
{lang.label["en-US"]}
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuRadioItem>
|
||||||
<DropdownMenuContent
|
))}
|
||||||
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
|
</DropdownMenuRadioGroup>
|
||||||
align="start">
|
</DropdownMenuContent>
|
||||||
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
|
</DropdownMenu>
|
||||||
{sortedAppLanguages.map((lang) => (
|
</FormControl>
|
||||||
<DropdownMenuRadioItem
|
<FormError />
|
||||||
key={lang.code}
|
</FormItem>
|
||||||
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="block w-full rounded-md border-slate-300 shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm"
|
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onChange={(password) => field.onChange(password)}
|
onChange={(password) => field.onChange(password)}
|
||||||
/>
|
/>
|
||||||
+5
-5
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { LoadingCard } from "@/app/(app)/components/LoadingCard";
|
import { LoadingCard } from "@/app/(app)/components/LoadingCard";
|
||||||
import { AccountSettingsNavbar } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/components/AccountSettingsNavbar";
|
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
|
|
||||||
@@ -10,8 +10,8 @@ const Loading = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const cards = [
|
const cards = [
|
||||||
{
|
{
|
||||||
title: t("workspace.settings.profile.personal_information"),
|
title: t("environments.settings.profile.personal_information"),
|
||||||
description: t("workspace.settings.profile.update_personal_info"),
|
description: t("environments.settings.profile.update_personal_info"),
|
||||||
skeletonLines: [
|
skeletonLines: [
|
||||||
{ classes: "h-4 w-28" },
|
{ classes: "h-4 w-28" },
|
||||||
{ classes: "h-6 w-64" },
|
{ classes: "h-6 w-64" },
|
||||||
@@ -20,8 +20,8 @@ const Loading = () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("workspace.settings.profile.delete_account"),
|
title: t("environments.settings.profile.delete_account"),
|
||||||
description: t("workspace.settings.profile.confirm_delete_account"),
|
description: t("environments.settings.profile.confirm_delete_account"),
|
||||||
skeletonLines: [{ classes: "h-4 w-60" }, { classes: "h-8 w-24" }],
|
skeletonLines: [{ classes: "h-4 w-60" }, { classes: "h-8 w-24" }],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
+22
-21
@@ -1,33 +1,34 @@
|
|||||||
import { AuthenticationError } from "@formbricks/types/errors";
|
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||||
import { AccountSettingsNavbar } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/components/AccountSettingsNavbar";
|
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
|
||||||
import { AccountSecurity } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/profile/components/AccountSecurity";
|
|
||||||
import { DeleteAccount } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/profile/components/DeleteAccount";
|
|
||||||
import { EditProfileDetailsForm } from "@/app/(app)/workspaces/[workspaceId]/settings/(account)/profile/components/EditProfileDetailsForm";
|
|
||||||
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
|
|
||||||
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||||
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
import { 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 { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
|
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
|
||||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
import { SettingsCard } from "../../components/SettingsCard";
|
||||||
|
import { DeleteAccount } from "./components/DeleteAccount";
|
||||||
|
import { EditProfileDetailsForm } from "./components/EditProfileDetailsForm";
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||||
const isTwoFactorAuthEnabled = await getIsTwoFactorAuthEnabled();
|
const isTwoFactorAuthEnabled = await getIsTwoFactorAuthEnabled();
|
||||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const { session } = await getWorkspaceAuth(params.workspaceId);
|
const { environmentId } = params;
|
||||||
|
|
||||||
|
const { session } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(session.user.id);
|
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(session.user.id);
|
||||||
|
|
||||||
const user = session?.user ? await getUser(session.user.id) : null;
|
const user = session?.user ? await getUser(session.user.id) : null;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
throw new Error(t("common.user_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
|
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
|
||||||
@@ -35,13 +36,13 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
|||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<PageHeader pageTitle={t("common.account_settings")}>
|
<PageHeader pageTitle={t("common.account_settings")}>
|
||||||
<AccountSettingsNavbar activeId="profile" />
|
<AccountSettingsNavbar environmentId={environmentId} activeId="profile" />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
{user && (
|
{user && (
|
||||||
<div>
|
<div>
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title={t("workspace.settings.profile.personal_information")}
|
title={t("environments.settings.profile.personal_information")}
|
||||||
description={t("workspace.settings.profile.update_personal_info")}>
|
description={t("environments.settings.profile.update_personal_info")}>
|
||||||
<EditProfileDetailsForm
|
<EditProfileDetailsForm
|
||||||
user={user}
|
user={user}
|
||||||
emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED}
|
emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED}
|
||||||
@@ -51,24 +52,24 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
|||||||
{user.identityProvider === "email" && (
|
{user.identityProvider === "email" && (
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title={t("common.security")}
|
title={t("common.security")}
|
||||||
description={t("workspace.settings.profile.security_description")}>
|
description={t("environments.settings.profile.security_description")}>
|
||||||
{!isTwoFactorAuthEnabled && !user.twoFactorEnabled ? (
|
{!isTwoFactorAuthEnabled && !user.twoFactorEnabled ? (
|
||||||
<UpgradePrompt
|
<UpgradePrompt
|
||||||
title={t("workspace.settings.profile.unlock_two_factor_authentication")}
|
title={t("environments.settings.profile.unlock_two_factor_authentication")}
|
||||||
description={t("workspace.settings.profile.two_factor_authentication_description")}
|
description={t("environments.settings.profile.two_factor_authentication_description")}
|
||||||
buttons={[
|
buttons={[
|
||||||
{
|
{
|
||||||
text: IS_FORMBRICKS_CLOUD
|
text: IS_FORMBRICKS_CLOUD
|
||||||
? t("common.upgrade_plan")
|
? t("common.start_free_trial")
|
||||||
: t("common.request_trial_license"),
|
: t("common.request_trial_license"),
|
||||||
href: IS_FORMBRICKS_CLOUD
|
href: IS_FORMBRICKS_CLOUD
|
||||||
? `/workspaces/${params.workspaceId}/settings/billing`
|
? `/environments/${params.environmentId}/settings/billing`
|
||||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: t("common.learn_more"),
|
text: t("common.learn_more"),
|
||||||
href: IS_FORMBRICKS_CLOUD
|
href: IS_FORMBRICKS_CLOUD
|
||||||
? `/workspaces/${params.workspaceId}/settings/billing`
|
? `/environments/${params.environmentId}/settings/billing`
|
||||||
: "https://formbricks.com/learn-more-self-hosting-license",
|
: "https://formbricks.com/learn-more-self-hosting-license",
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
@@ -80,8 +81,8 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title={t("workspace.settings.profile.delete_account")}
|
title={t("environments.settings.profile.delete_account")}
|
||||||
description={t("workspace.settings.profile.confirm_delete_account")}>
|
description={t("environments.settings.profile.confirm_delete_account")}>
|
||||||
<DeleteAccount
|
<DeleteAccount
|
||||||
session={session}
|
session={session}
|
||||||
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
|
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
import { OrganizationSettingsNavbar } from "@/app/(app)/workspaces/[workspaceId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||||
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 { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
@@ -8,7 +8,7 @@ const Loading = async () => {
|
|||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<PageHeader pageTitle={t("workspace.settings.general.organization_settings")}>
|
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
|
||||||
<OrganizationSettingsNavbar isFormbricksCloud={IS_FORMBRICKS_CLOUD} activeId="billing" loading />
|
<OrganizationSettingsNavbar isFormbricksCloud={IS_FORMBRICKS_CLOUD} activeId="billing" loading />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<div className="my-8 h-64 animate-pulse rounded-xl bg-slate-200"></div>
|
<div className="my-8 h-64 animate-pulse rounded-xl bg-slate-200"></div>
|
||||||
+13
-29
@@ -3,11 +3,11 @@
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||||
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
|
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||||
|
|
||||||
interface OrganizationSettingsNavbarProps {
|
interface OrganizationSettingsNavbarProps {
|
||||||
|
environmentId?: string;
|
||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
membershipRole?: TOrganizationRole;
|
membershipRole?: TOrganizationRole;
|
||||||
activeId: string;
|
activeId: string;
|
||||||
@@ -15,73 +15,57 @@ interface OrganizationSettingsNavbarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const OrganizationSettingsNavbar = ({
|
export const OrganizationSettingsNavbar = ({
|
||||||
|
environmentId,
|
||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
membershipRole,
|
membershipRole,
|
||||||
activeId,
|
activeId,
|
||||||
loading,
|
loading,
|
||||||
}: OrganizationSettingsNavbarProps) => {
|
}: OrganizationSettingsNavbarProps) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { isMember, isOwner, isManager } = getAccessFlags(membershipRole);
|
const { isMember, isOwner } = getAccessFlags(membershipRole);
|
||||||
const isOwnerOrManager = isOwner || isManager;
|
const isPricingDisabled = isMember;
|
||||||
const isMembershipPending = membershipRole === undefined || loading;
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { workspace } = useWorkspace();
|
|
||||||
const workspaceBasePath = `/workspaces/${workspace?.id}`;
|
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{
|
{
|
||||||
id: "general",
|
id: "general",
|
||||||
label: t("common.general"),
|
label: t("common.general"),
|
||||||
href: `${workspaceBasePath}/settings/general`,
|
href: `/environments/${environmentId}/settings/general`,
|
||||||
current: pathname?.includes("/general"),
|
current: pathname?.includes("/general"),
|
||||||
hidden: false,
|
hidden: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "teams",
|
id: "teams",
|
||||||
label: t("common.members_and_teams"),
|
label: t("common.members_and_teams"),
|
||||||
href: `${workspaceBasePath}/settings/teams`,
|
href: `/environments/${environmentId}/settings/teams`,
|
||||||
current: pathname?.includes("/teams"),
|
current: pathname?.includes("/teams"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "feedback-record-directories",
|
|
||||||
label: t("workspace.settings.feedback_record_directories.nav_label"),
|
|
||||||
href: `${workspaceBasePath}/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"),
|
||||||
href: `${workspaceBasePath}/settings/api-keys`,
|
href: `/environments/${environmentId}/settings/api-keys`,
|
||||||
current: pathname?.includes("/api-keys"),
|
current: pathname?.includes("/api-keys"),
|
||||||
disabled: isMembershipPending || !isOwnerOrManager,
|
hidden: !isOwner,
|
||||||
disabledMessage: isMembershipPending
|
|
||||||
? t("common.loading")
|
|
||||||
: t("common.you_are_not_authorized_to_perform_this_action"),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "domain",
|
id: "domain",
|
||||||
label: t("common.domain"),
|
label: t("common.domain"),
|
||||||
href: `${workspaceBasePath}/settings/domain`,
|
href: `/environments/${environmentId}/settings/domain`,
|
||||||
current: pathname?.includes("/domain"),
|
current: pathname?.includes("/domain"),
|
||||||
hidden: isFormbricksCloud,
|
hidden: isFormbricksCloud,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "billing",
|
id: "billing",
|
||||||
label: t("common.billing"),
|
label: t("common.billing"),
|
||||||
href: `${workspaceBasePath}/settings/billing`,
|
href: `/environments/${environmentId}/settings/billing`,
|
||||||
hidden: !isFormbricksCloud,
|
hidden: !isFormbricksCloud || loading,
|
||||||
current: pathname?.includes("/billing"),
|
current: pathname?.includes("/billing"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "enterprise",
|
id: "enterprise",
|
||||||
label: t("common.enterprise_license"),
|
label: t("common.enterprise_license"),
|
||||||
href: `${workspaceBasePath}/settings/enterprise`,
|
href: `/environments/${environmentId}/settings/enterprise`,
|
||||||
hidden: isFormbricksCloud,
|
hidden: isFormbricksCloud || isPricingDisabled,
|
||||||
disabled: isMembershipPending || isMember,
|
|
||||||
disabledMessage: isMembershipPending
|
|
||||||
? t("common.loading")
|
|
||||||
: t("common.you_are_not_authorized_to_perform_this_action"),
|
|
||||||
current: pathname?.includes("/enterprise"),
|
current: pathname?.includes("/enterprise"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
+30
-11
@@ -11,10 +11,13 @@ interface SurveyWithSlug {
|
|||||||
name: string;
|
name: string;
|
||||||
slug: string | null;
|
slug: string | null;
|
||||||
status: TSurveyStatus;
|
status: TSurveyStatus;
|
||||||
workspace: {
|
environment: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
type: "production" | "development";
|
||||||
organizationId: string;
|
project: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
@@ -26,19 +29,27 @@ interface PrettyUrlsTableProps {
|
|||||||
export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
|
export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const getEnvironmentBadgeColor = (type: string) => {
|
||||||
|
return type === "production" ? "bg-green-100 text-green-800" : "bg-blue-100 text-blue-800";
|
||||||
|
};
|
||||||
|
|
||||||
const tableHeaders = [
|
const tableHeaders = [
|
||||||
{
|
{
|
||||||
label: t("workspace.settings.domain.survey_name"),
|
label: t("environments.settings.domain.survey_name"),
|
||||||
key: "name",
|
key: "name",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("workspace.settings.domain.workspace"),
|
label: t("environments.settings.domain.workspace"),
|
||||||
key: "workspace",
|
key: "project",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("workspace.settings.domain.pretty_url"),
|
label: t("environments.settings.domain.pretty_url"),
|
||||||
key: "slug",
|
key: "slug",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t("common.environment"),
|
||||||
|
key: "environment",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -56,8 +67,8 @@ export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
|
|||||||
<TableBody className="[&_tr:last-child]:border-b">
|
<TableBody className="[&_tr:last-child]:border-b">
|
||||||
{surveys.length === 0 && (
|
{surveys.length === 0 && (
|
||||||
<TableRow className="hover:bg-transparent">
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableCell colSpan={3} className="text-center text-slate-500">
|
<TableCell colSpan={4} className="text-center text-slate-500">
|
||||||
{t("workspace.settings.domain.no_pretty_urls")}
|
{t("environments.settings.domain.no_pretty_urls")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
@@ -65,15 +76,23 @@ export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
|
|||||||
<TableRow key={survey.id} className="border-slate-200 hover:bg-transparent">
|
<TableRow key={survey.id} className="border-slate-200 hover:bg-transparent">
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
<Link
|
<Link
|
||||||
href={`/workspaces/${survey.workspace.id}/surveys/${survey.id}/summary`}
|
href={`/environments/${survey.environment.id}/surveys/${survey.id}/summary`}
|
||||||
className="text-slate-900 hover:text-slate-700 hover:underline">
|
className="text-slate-900 hover:text-slate-700 hover:underline">
|
||||||
{survey.name}
|
{survey.name}
|
||||||
</Link>
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{survey.workspace.name}</TableCell>
|
<TableCell>{survey.environment.project.name}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<IdBadge id={survey.slug ?? ""} />
|
<IdBadge id={survey.slug ?? ""} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span
|
||||||
|
className={`rounded px-2 py-1 text-xs font-medium ${getEnvironmentBadgeColor(survey.environment.type)}`}>
|
||||||
|
{survey.environment.type === "production"
|
||||||
|
? t("common.production")
|
||||||
|
: t("common.development")}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
+14
-14
@@ -1,19 +1,18 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { AuthenticationError } from "@formbricks/types/errors";
|
|
||||||
import { OrganizationSettingsNavbar } from "@/app/(app)/workspaces/[workspaceId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
|
||||||
import { PrettyUrlsTable } from "@/app/(app)/workspaces/[workspaceId]/settings/(organization)/domain/components/pretty-urls-table";
|
|
||||||
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
|
|
||||||
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";
|
||||||
import { FaviconCustomizationSettings } from "@/modules/ee/whitelabel/favicon-customization/components/favicon-customization-settings";
|
import { FaviconCustomizationSettings } from "@/modules/ee/whitelabel/favicon-customization/components/favicon-customization-settings";
|
||||||
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
import { getSurveysWithSlugsByOrganizationId } from "@/modules/survey/lib/slug";
|
import { getSurveysWithSlugsByOrganizationId } from "@/modules/survey/lib/slug";
|
||||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||||
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 { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
import { SettingsCard } from "../../components/SettingsCard";
|
||||||
|
import { OrganizationSettingsNavbar } from "../components/OrganizationSettingsNavbar";
|
||||||
|
import { PrettyUrlsTable } from "./components/pretty-urls-table";
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
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();
|
||||||
|
|
||||||
@@ -21,23 +20,24 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { session, currentUserMembership, organization, isOwner, isManager } = await getWorkspaceAuth(
|
const { session, currentUserMembership, organization, isOwner, isManager } = await getEnvironmentAuth(
|
||||||
params.workspaceId
|
params.environmentId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
throw new Error(t("common.session_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
|
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan);
|
||||||
const isOwnerOrManager = isManager || isOwner;
|
const isOwnerOrManager = isManager || isOwner;
|
||||||
|
|
||||||
const surveys = await getSurveysWithSlugsByOrganizationId(organization.id);
|
const surveys = await getSurveysWithSlugsByOrganizationId(organization.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<PageHeader pageTitle={t("workspace.settings.general.organization_settings")}>
|
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
|
||||||
<OrganizationSettingsNavbar
|
<OrganizationSettingsNavbar
|
||||||
|
environmentId={params.environmentId}
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
membershipRole={currentUserMembership?.role}
|
membershipRole={currentUserMembership?.role}
|
||||||
activeId="domain"
|
activeId="domain"
|
||||||
@@ -55,14 +55,14 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
|||||||
<FaviconCustomizationSettings
|
<FaviconCustomizationSettings
|
||||||
organization={organization}
|
organization={organization}
|
||||||
hasWhiteLabelPermission={hasWhiteLabelPermission}
|
hasWhiteLabelPermission={hasWhiteLabelPermission}
|
||||||
workspaceId={params.workspaceId}
|
environmentId={params.environmentId}
|
||||||
isReadOnly={!isOwnerOrManager}
|
isReadOnly={!isOwnerOrManager}
|
||||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title={t("workspace.settings.domain.title")}
|
title={t("environments.settings.domain.title")}
|
||||||
description={t("workspace.settings.domain.description")}>
|
description={t("environments.settings.domain.description")}>
|
||||||
<PrettyUrlsTable surveys={surveys} />
|
<PrettyUrlsTable surveys={surveys} />
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user