mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-06 19:35:53 -05:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6547c1ada | |||
| fca5a808fb | |||
| 57a5c3ce76 | |||
| 71cca557fc | |||
| 1500b6f7f3 | |||
| 2c9fbf83e4 |
@@ -1 +0,0 @@
|
||||
{"sessionId":"f77248e2-8840-41c6-968b-c3b7d8a9e913","pid":49125,"acquiredAt":1776168010367}
|
||||
@@ -1,9 +0,0 @@
|
||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||
version = 1
|
||||
name = "formbricks"
|
||||
|
||||
[setup]
|
||||
script = '''
|
||||
pnpm install
|
||||
pnpm dev:setup
|
||||
'''
|
||||
@@ -32,24 +32,6 @@ CRON_SECRET=
|
||||
# Set the minimum log level(debug, info, warn, error, fatal)
|
||||
LOG_LEVEL=info
|
||||
|
||||
# BullMQ workers require REDIS_URL (for example `redis://localhost:6379`) to be set.
|
||||
# BullMQ worker startup is enabled by default outside tests. Set to 0 to disable.
|
||||
# BULLMQ_WORKER_ENABLED=1
|
||||
# Set to 1 on web/API pods that only enqueue jobs while a separate BullMQ worker deployment consumes them.
|
||||
# BULLMQ_EXTERNAL_WORKER_ENABLED=0
|
||||
|
||||
# Number of BullMQ worker instances started per Formbricks server process.
|
||||
# BULLMQ_WORKER_COUNT=1
|
||||
|
||||
# Number of concurrent jobs each BullMQ worker can process.
|
||||
# BULLMQ_WORKER_CONCURRENCY=1
|
||||
|
||||
# Survey publish/close scheduling is configured with public build-time env vars because the editor UI
|
||||
# also needs to render the selected execution time and timezone.
|
||||
# NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE=Europe/Berlin
|
||||
# NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR=0
|
||||
# NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE=0
|
||||
|
||||
##############
|
||||
# DATABASE #
|
||||
##############
|
||||
@@ -64,21 +46,6 @@ DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=pu
|
||||
HUB_API_KEY=dev-api-key
|
||||
HUB_API_URL=http://localhost:8080
|
||||
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
|
||||
# Hub image tag used by docker-compose.dev.yml (hub + hub-migrate). Leave unset to use the
|
||||
# pinned default in the compose file; override here when testing a specific Hub release.
|
||||
# HUB_IMAGE_TAG=0.2.0
|
||||
|
||||
###########################
|
||||
# CUBE ANALYTICS (XM V5) #
|
||||
###########################
|
||||
# XM Suite v5 analysis features require Cube.js. The optional xm dev profile exposes Cube on port 4000.
|
||||
# Uncomment COMPOSE_PROFILES=xm to run the optional Cube analytics service.
|
||||
# COMPOSE_PROFILES=xm
|
||||
CUBEJS_API_URL=http://localhost:4000
|
||||
# Generate with: openssl rand -hex 32. `pnpm dev:setup` will create/preserve this automatically.
|
||||
CUBEJS_API_SECRET=
|
||||
CUBEJS_JWT_ISSUER=formbricks-web
|
||||
CUBEJS_JWT_AUDIENCE=formbricks-cube
|
||||
|
||||
################
|
||||
# MAIL SETUP #
|
||||
@@ -136,12 +103,6 @@ EMAIL_VERIFICATION_DISABLED=1
|
||||
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
|
||||
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_AUTH_DISABLED=1
|
||||
|
||||
@@ -180,31 +141,6 @@ AZUREAD_CLIENT_ID=
|
||||
AZUREAD_CLIENT_SECRET=
|
||||
AZUREAD_TENANT_ID=
|
||||
|
||||
# Configure Formbricks AI at the instance level
|
||||
# Set the provider used for AI features on this instance.
|
||||
# Accepted values for AI_PROVIDER: aws, google, azure
|
||||
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching credentials below.
|
||||
# AI_PROVIDER=google
|
||||
# AI_MODEL=gemini-2.5-flash
|
||||
|
||||
# Google Cloud credentials for Gemini models
|
||||
# AI_GOOGLE_CLOUD_PROJECT=
|
||||
# AI_GOOGLE_CLOUD_LOCATION=
|
||||
# AI_GOOGLE_CLOUD_CREDENTIALS_JSON=
|
||||
# AI_GOOGLE_CLOUD_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
|
||||
# OIDC_CLIENT_ID=
|
||||
# OIDC_CLIENT_SECRET=
|
||||
@@ -258,14 +194,6 @@ ENTERPRISE_LICENSE_KEY=
|
||||
# Ignore Rate Limiting across the Formbricks app
|
||||
# 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)
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
||||
@@ -310,13 +238,6 @@ REDIS_URL=redis://localhost:6379
|
||||
# If the ip should be added in the log or not. Default 0
|
||||
# AUDIT_LOG_GET_USER_IP=0
|
||||
|
||||
# Optional Cube.js database overrides. The official local docker-compose.dev.yml stack points Cube at the
|
||||
# local `postgres` service automatically; set these only when running Cube yourself or changing bundled defaults.
|
||||
# CUBEJS_DB_HOST=postgres
|
||||
# CUBEJS_DB_PORT=5432
|
||||
# CUBEJS_DB_NAME=postgres
|
||||
# CUBEJS_DB_USER=postgres
|
||||
# CUBEJS_DB_PASS=postgres
|
||||
|
||||
# Lingo.dev API key for translation generation
|
||||
LINGO_API_KEY=your_api_key_here
|
||||
|
||||
@@ -57,14 +57,16 @@ runs:
|
||||
if: steps.cache-build.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
|
||||
- name: Create .env
|
||||
run: pnpm dev:setup
|
||||
- name: create .env
|
||||
run: cp .env.example .env
|
||||
shell: bash
|
||||
|
||||
- name: Fill E2E_TESTING in .env
|
||||
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
|
||||
env:
|
||||
E2E_TESTING_MODE: ${{ inputs.e2e_testing_mode }}
|
||||
run: |
|
||||
RANDOM_KEY=$(openssl rand -hex 32)
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
echo "E2E_TESTING=$E2E_TESTING_MODE" >> .env
|
||||
shell: bash
|
||||
|
||||
|
||||
@@ -68,12 +68,16 @@ jobs:
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
shell: bash
|
||||
|
||||
- name: Create .env
|
||||
run: pnpm dev:setup
|
||||
- name: create .env
|
||||
run: cp .env.example .env
|
||||
shell: bash
|
||||
|
||||
- name: Fill ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
|
||||
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
|
||||
run: |
|
||||
RANDOM_KEY=$(openssl rand -hex 32)
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
|
||||
sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env
|
||||
echo "" >> .env
|
||||
|
||||
@@ -31,8 +31,15 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: Create .env
|
||||
run: pnpm dev:setup
|
||||
- name: create .env
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
|
||||
run: |
|
||||
RANDOM_KEY=$(openssl rand -hex 32)
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
@@ -35,11 +35,15 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: Create .env
|
||||
run: pnpm dev:setup
|
||||
- name: create .env
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Adjust CI-specific env values
|
||||
- name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
|
||||
run: |
|
||||
RANDOM_KEY=$(openssl rand -hex 32)
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
|
||||
|
||||
- name: Run tests with coverage
|
||||
|
||||
@@ -32,11 +32,15 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: Create .env
|
||||
run: pnpm dev:setup
|
||||
- name: create .env
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Adjust CI-specific env values
|
||||
- name: Generate Random ENCRYPTION_KEY, CRON_SECRET & NEXTAUTH_SECRET and fill in .env
|
||||
run: |
|
||||
RANDOM_KEY=$(openssl rand -hex 32)
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
|
||||
|
||||
- name: Test
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ yarn-error.log*
|
||||
.direnv
|
||||
|
||||
# Playwright
|
||||
**/test-results/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
+1
-13
@@ -1,13 +1 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
if command -v pnpm >/dev/null 2>&1; then
|
||||
pnpm lint-staged
|
||||
elif command -v npm >/dev/null 2>&1; then
|
||||
npm exec --yes pnpm@10.32.1 lint-staged
|
||||
elif command -v corepack >/dev/null 2>&1; then
|
||||
corepack pnpm lint-staged
|
||||
else
|
||||
echo "Error: pnpm, npm, and corepack are unavailable in this Git hook PATH."
|
||||
echo "Install Node.js tooling or update your PATH, then retry the commit."
|
||||
exit 127
|
||||
fi
|
||||
pnpm lint-staged
|
||||
@@ -32,7 +32,6 @@ The `@formbricks/surveys` package is pre-compiled (Vite → UMD + ESM) and the b
|
||||
|
||||
TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable.
|
||||
We are using SonarQube to identify code smells and security hotspots.
|
||||
Always mark React component props as `Readonly<>` (e.g., `({ children }: Readonly<MyProps>)`).
|
||||
|
||||
## Architecture & Patterns
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
If you opt for self-hosting Formbricks, here are a few options to consider:
|
||||
|
||||
#### Docker
|
||||
|
||||
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
|
||||
|
||||
### 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.
|
||||
|
||||
<a id="readme-de"></a>
|
||||
<p align="right"><a href="#top">🔼 Back to top</a></p>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"eslint-plugin-react-refresh": "0.4.26",
|
||||
"eslint-plugin-storybook": "10.2.17",
|
||||
"storybook": "10.2.17",
|
||||
"vite": "7.3.2",
|
||||
"vite": "7.3.1",
|
||||
"@storybook/addon-docs": "10.2.17"
|
||||
}
|
||||
}
|
||||
|
||||
+11
-8
@@ -4,20 +4,21 @@ import { ArrowRight } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TWorkspaceConfigChannel } from "@formbricks/types/workspace";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
|
||||
|
||||
interface ConnectWithFormbricksProps {
|
||||
workspaceId: string;
|
||||
environment: TEnvironment;
|
||||
publicDomain: string;
|
||||
appSetupCompleted: boolean;
|
||||
channel: TWorkspaceConfigChannel;
|
||||
}
|
||||
|
||||
export const ConnectWithFormbricks = ({
|
||||
workspaceId,
|
||||
environment,
|
||||
publicDomain,
|
||||
appSetupCompleted,
|
||||
channel,
|
||||
@@ -25,7 +26,7 @@ export const ConnectWithFormbricks = ({
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const handleFinishOnboarding = async () => {
|
||||
router.push(`/workspaces/${workspaceId}/surveys`);
|
||||
router.push(`/environments/${environment.id}/surveys`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -47,7 +48,7 @@ export const ConnectWithFormbricks = ({
|
||||
<div className="flex w-full space-x-10">
|
||||
<div className="flex w-1/2 flex-col space-y-4">
|
||||
<OnboardingSetupInstructions
|
||||
workspaceId={workspaceId}
|
||||
environmentId={environment.id}
|
||||
publicDomain={publicDomain}
|
||||
channel={channel}
|
||||
appSetupCompleted={appSetupCompleted}
|
||||
@@ -60,9 +61,9 @@ export const ConnectWithFormbricks = ({
|
||||
)}>
|
||||
{appSetupCompleted ? (
|
||||
<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">
|
||||
{t("workspace.connect.connection_successful_message")}
|
||||
{t("environments.connect.connection_successful_message")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -72,7 +73,7 @@ export const ConnectWithFormbricks = ({
|
||||
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -82,7 +83,9 @@ export const ConnectWithFormbricks = ({
|
||||
id="finishOnboarding"
|
||||
variant={appSetupCompleted ? "default" : "ghost"}
|
||||
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 />
|
||||
</Button>
|
||||
</div>
|
||||
+17
-17
@@ -17,14 +17,14 @@ const tabs = [
|
||||
];
|
||||
|
||||
interface OnboardingSetupInstructionsProps {
|
||||
workspaceId: string;
|
||||
environmentId: string;
|
||||
publicDomain: string;
|
||||
channel: TWorkspaceConfigChannel;
|
||||
appSetupCompleted: boolean;
|
||||
}
|
||||
|
||||
export const OnboardingSetupInstructions = ({
|
||||
workspaceId,
|
||||
environmentId,
|
||||
publicDomain,
|
||||
channel,
|
||||
appSetupCompleted,
|
||||
@@ -35,8 +35,8 @@ export const OnboardingSetupInstructions = ({
|
||||
<script type="text/javascript">
|
||||
!function(){
|
||||
var appUrl = "${publicDomain}";
|
||||
var workspaceId = "${workspaceId}";
|
||||
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 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({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>
|
||||
<!-- END Formbricks Surveys -->
|
||||
`;
|
||||
@@ -45,46 +45,46 @@ export const OnboardingSetupInstructions = ({
|
||||
<script type="text/javascript">
|
||||
!function(){
|
||||
var appUrl = "${publicDomain}";
|
||||
var workspaceId = "${workspaceId}";
|
||||
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 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({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>
|
||||
<!-- END Formbricks Surveys -->
|
||||
`;
|
||||
|
||||
const npmSnippetForAppSurveys = `
|
||||
import formbricks from "@formbricks/js";
|
||||
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
formbricks.setup({
|
||||
workspaceId: "${workspaceId}",
|
||||
environmentId: "${environmentId}",
|
||||
appUrl: "${publicDomain}",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function App() {
|
||||
// your own app
|
||||
}
|
||||
|
||||
|
||||
export default App;
|
||||
`;
|
||||
|
||||
const npmSnippetForWebsiteSurveys = `
|
||||
// other imports
|
||||
import formbricks from "@formbricks/js";
|
||||
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
formbricks.setup({
|
||||
workspaceId: "${workspaceId}",
|
||||
environmentId: "${environmentId}",
|
||||
appUrl: "${publicDomain}",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function App() {
|
||||
// your own app
|
||||
}
|
||||
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
`;
|
||||
|
||||
return (
|
||||
@@ -109,7 +109,7 @@ export const OnboardingSetupInstructions = ({
|
||||
yarn add @formbricks/js
|
||||
</CodeBlock>
|
||||
<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>
|
||||
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
|
||||
{channel === "app" ? npmSnippetForAppSurveys : npmSnippetForWebsiteSurveys}
|
||||
@@ -126,7 +126,7 @@ export const OnboardingSetupInstructions = ({
|
||||
) : activeTab === "html" ? (
|
||||
<div className="prose prose-slate">
|
||||
<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>
|
||||
<div>
|
||||
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
|
||||
+15
-9
@@ -1,26 +1,32 @@
|
||||
import { XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/connect/components/ConnectWithFormbricks";
|
||||
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getWorkspace } from "@/lib/workspace/service";
|
||||
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
|
||||
interface ConnectPageProps {
|
||||
params: Promise<{
|
||||
workspaceId: string;
|
||||
environmentId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const Page = async (props: ConnectPageProps) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
|
||||
const workspace = await getWorkspace(params.workspaceId);
|
||||
if (!environment) {
|
||||
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
||||
}
|
||||
|
||||
const workspace = await getWorkspaceByEnvironmentId(environment.id);
|
||||
if (!workspace) {
|
||||
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
const channel = workspace.config.channel || null;
|
||||
@@ -29,22 +35,22 @@ const Page = async (props: ConnectPageProps) => {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<p className="text-4xl font-medium text-slate-800"></p>
|
||||
<p className="text-sm text-slate-500"></p>
|
||||
</div>
|
||||
<ConnectWithFormbricks
|
||||
workspaceId={params.workspaceId}
|
||||
environment={environment}
|
||||
publicDomain={publicDomain}
|
||||
appSetupCompleted={workspace.appSetupCompleted}
|
||||
appSetupCompleted={environment.appSetupCompleted}
|
||||
channel={channel}
|
||||
/>
|
||||
<Button
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={`/workspaces/${params.workspaceId}`}>
|
||||
<Link href={`/environments/${environment.id}`}>
|
||||
<XIcon className="h-7 w-7" strokeWidth={1.5} />
|
||||
</Link>
|
||||
</Button>
|
||||
+4
-4
@@ -1,11 +1,11 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
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";
|
||||
|
||||
const OnboardingLayout = async (props: {
|
||||
params: Promise<{ workspaceId: string }>;
|
||||
params: Promise<{ environmentId: string }>;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
@@ -17,9 +17,9 @@ const OnboardingLayout = async (props: {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
const isAuthorized = await hasUserWorkspaceAccess(session.user.id, params.workspaceId);
|
||||
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
|
||||
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>;
|
||||
+18
-18
@@ -9,19 +9,19 @@ import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
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 { 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 { createSurveyAction } from "@/modules/survey/components/template-list/actions";
|
||||
|
||||
interface XMTemplateListProps {
|
||||
workspace: TWorkspace;
|
||||
user: TUser;
|
||||
workspaceId: string;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const XMTemplateList = ({ workspace, user, workspaceId }: XMTemplateListProps) => {
|
||||
export const XMTemplateList = ({ workspace, user, environmentId }: XMTemplateListProps) => {
|
||||
const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null);
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -33,12 +33,12 @@ export const XMTemplateList = ({ workspace, user, workspaceId }: XMTemplateListP
|
||||
createdBy: user.id,
|
||||
};
|
||||
const createSurveyResponse = await createSurveyAction({
|
||||
workspaceId: workspaceId,
|
||||
environmentId: environmentId,
|
||||
surveyBody: augmentedTemplate,
|
||||
});
|
||||
|
||||
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 {
|
||||
const errorMessage = getFormattedErrorMessage(createSurveyResponse);
|
||||
toast.error(errorMessage);
|
||||
@@ -54,43 +54,43 @@ export const XMTemplateList = ({ workspace, user, workspaceId }: XMTemplateListP
|
||||
|
||||
const XMTemplateOptions = [
|
||||
{
|
||||
title: t("workspace.xm-templates.nps"),
|
||||
description: t("workspace.xm-templates.nps_description"),
|
||||
title: t("environments.xm-templates.nps"),
|
||||
description: t("environments.xm-templates.nps_description"),
|
||||
icon: ShoppingCartIcon,
|
||||
onClick: () => handleTemplateClick(0),
|
||||
isLoading: activeTemplateId === 0,
|
||||
},
|
||||
{
|
||||
title: t("workspace.xm-templates.five_star_rating"),
|
||||
description: t("workspace.xm-templates.five_star_rating_description"),
|
||||
title: t("environments.xm-templates.five_star_rating"),
|
||||
description: t("environments.xm-templates.five_star_rating_description"),
|
||||
icon: StarIcon,
|
||||
onClick: () => handleTemplateClick(1),
|
||||
isLoading: activeTemplateId === 1,
|
||||
},
|
||||
{
|
||||
title: t("workspace.xm-templates.csat"),
|
||||
description: t("workspace.xm-templates.csat_description"),
|
||||
title: t("environments.xm-templates.csat"),
|
||||
description: t("environments.xm-templates.csat_description"),
|
||||
icon: ThumbsUpIcon,
|
||||
onClick: () => handleTemplateClick(2),
|
||||
isLoading: activeTemplateId === 2,
|
||||
},
|
||||
{
|
||||
title: t("workspace.xm-templates.ces"),
|
||||
description: t("workspace.xm-templates.ces_description"),
|
||||
title: t("environments.xm-templates.ces"),
|
||||
description: t("environments.xm-templates.ces_description"),
|
||||
icon: ActivityIcon,
|
||||
onClick: () => handleTemplateClick(3),
|
||||
isLoading: activeTemplateId === 3,
|
||||
},
|
||||
{
|
||||
title: t("workspace.xm-templates.smileys"),
|
||||
description: t("workspace.xm-templates.smileys_description"),
|
||||
title: t("environments.xm-templates.smileys"),
|
||||
description: t("environments.xm-templates.smileys_description"),
|
||||
icon: SmileIcon,
|
||||
onClick: () => handleTemplateClick(4),
|
||||
isLoading: activeTemplateId === 4,
|
||||
},
|
||||
{
|
||||
title: t("workspace.xm-templates.enps"),
|
||||
description: t("workspace.xm-templates.enps_description"),
|
||||
title: t("environments.xm-templates.enps"),
|
||||
description: t("environments.xm-templates.enps_description"),
|
||||
icon: UsersIcon,
|
||||
onClick: () => handleTemplateClick(5),
|
||||
isLoading: activeTemplateId === 5,
|
||||
+3
-3
@@ -27,7 +27,7 @@ const mockWorkspace: TWorkspace = {
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
overlay: "none",
|
||||
appSetupCompleted: false,
|
||||
environments: [],
|
||||
languages: [],
|
||||
logo: null,
|
||||
};
|
||||
@@ -60,8 +60,8 @@ const mockTemplate: TXMTemplate = {
|
||||
],
|
||||
styling: {
|
||||
brandColor: { light: "#0000FF" },
|
||||
elementHeadlineColor: { light: "#00FF00" },
|
||||
inputBgColor: { light: "#FF0000" },
|
||||
questionColor: { light: "#00FF00" },
|
||||
inputColor: { light: "#FF0000" },
|
||||
},
|
||||
};
|
||||
|
||||
+19
-12
@@ -2,9 +2,11 @@ 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 { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getUserWorkspaces, getWorkspace } from "@/lib/workspace/service";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { getUserWorkspaces, getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -12,15 +14,15 @@ import { Header } from "@/modules/ui/components/header";
|
||||
|
||||
interface XMTemplatePageProps {
|
||||
params: Promise<{
|
||||
workspaceId: string;
|
||||
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 AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
@@ -29,24 +31,29 @@ const Page = async (props: XMTemplatePageProps) => {
|
||||
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);
|
||||
if (!environment) {
|
||||
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
||||
}
|
||||
|
||||
const workspaces = await getUserWorkspaces(session.user.id, workspace.organizationId);
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||
|
||||
const workspace = await getWorkspaceByEnvironmentId(environment.id);
|
||||
if (!workspace) {
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
const workspaces = await getUserWorkspaces(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("workspace.xm-templates.headline")} />
|
||||
<XMTemplateList workspace={workspace} user={user} workspaceId={params.workspaceId} />
|
||||
<Header title={t("environments.xm-templates.headline")} />
|
||||
<XMTemplateList workspace={workspace} user={user} environmentId={environment.id} />
|
||||
{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`}>
|
||||
<Link href={`/environments/${environment.id}/surveys`}>
|
||||
<XIcon className="h-7 w-7" strokeWidth={1.5} />
|
||||
</Link>
|
||||
</Button>
|
||||
+31
-149
@@ -1,32 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
Building2Icon,
|
||||
ChevronRightIcon,
|
||||
Loader2,
|
||||
LogOutIcon,
|
||||
PlusIcon,
|
||||
} from "lucide-react";
|
||||
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState, useTransition } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { getOrganizationsForSwitcherAction } from "@/app/(app)/workspaces/[workspaceId]/actions";
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
@@ -34,65 +22,14 @@ import {
|
||||
interface LandingSidebarProps {
|
||||
user: TUser;
|
||||
organization: TOrganization;
|
||||
isMultiOrgEnabled: boolean;
|
||||
}
|
||||
|
||||
export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: LandingSidebarProps) => {
|
||||
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
|
||||
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
|
||||
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
|
||||
const [organizationLoadError, setOrganizationLoadError] = useState<string | null>(null);
|
||||
const [isOrgDropdownOpen, setIsOrgDropdownOpen] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
||||
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
|
||||
const loadOrganizations = useCallback(async () => {
|
||||
setIsLoadingOrganizations(true);
|
||||
setOrganizationLoadError(null);
|
||||
try {
|
||||
const result = await getOrganizationsForSwitcherAction({ organizationId: organization.id });
|
||||
if (result?.data) {
|
||||
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
|
||||
setOrganizations(sorted);
|
||||
} else {
|
||||
setOrganizationLoadError(
|
||||
getFormattedErrorMessage(result) || t("common.failed_to_load_organizations")
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
setOrganizationLoadError(t("common.failed_to_load_organizations"));
|
||||
} finally {
|
||||
setIsLoadingOrganizations(false);
|
||||
}
|
||||
}, [organization.id, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isOrgDropdownOpen &&
|
||||
organizations.length === 0 &&
|
||||
!isLoadingOrganizations &&
|
||||
!organizationLoadError
|
||||
) {
|
||||
loadOrganizations();
|
||||
}
|
||||
}, [
|
||||
isOrgDropdownOpen,
|
||||
organizations.length,
|
||||
isLoadingOrganizations,
|
||||
organizationLoadError,
|
||||
loadOrganizations,
|
||||
]);
|
||||
|
||||
const handleOrganizationChange = (orgId: string) => {
|
||||
startTransition(() => {
|
||||
setIsOrgDropdownOpen(false);
|
||||
router.push(`/organizations/${orgId}/`);
|
||||
});
|
||||
};
|
||||
|
||||
const dropdownNavigation = [
|
||||
{
|
||||
label: t("common.documentation"),
|
||||
@@ -102,109 +39,52 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
|
||||
},
|
||||
];
|
||||
|
||||
const switcherTriggerClasses =
|
||||
"w-full border-t px-3 py-3 text-left transition-colors duration-200 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-inset";
|
||||
const switcherIconClasses =
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600";
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"z-40 flex w-sidebar-collapsed flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
|
||||
)}>
|
||||
<Image src={FBLogo} width={160} height={30} alt={t("workspace.formbricks_logo")} />
|
||||
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
|
||||
|
||||
<div className="flex flex-col">
|
||||
{/* Organization Switcher */}
|
||||
<DropdownMenu onOpenChange={setIsOrgDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild className={switcherTriggerClasses}>
|
||||
<button type="button" className="flex w-full items-center gap-3">
|
||||
<span className={switcherIconClasses}>
|
||||
<Building2Icon className="h-4 w-4" strokeWidth={1.5} />
|
||||
</span>
|
||||
<div className="grow overflow-hidden">
|
||||
<p className="truncate text-sm font-bold text-slate-700">{organization.name}</p>
|
||||
<p className="text-sm text-slate-500">{t("common.organization")}</p>
|
||||
</div>
|
||||
{isPending && <Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />}
|
||||
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<Building2Icon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.change_organization")}
|
||||
</div>
|
||||
{isLoadingOrganizations && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingOrganizations && organizationLoadError && (
|
||||
<div className="px-2 py-4">
|
||||
<p className="mb-2 text-sm text-red-600">{organizationLoadError}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setOrganizationLoadError(null);
|
||||
setOrganizations([]);
|
||||
}}
|
||||
className="text-xs text-slate-600 underline hover:text-slate-800">
|
||||
{t("common.try_again")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isLoadingOrganizations && !organizationLoadError && (
|
||||
<>
|
||||
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||
{organizations.map((org) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={org.id}
|
||||
checked={org.id === organization.id}
|
||||
onClick={() => handleOrganizationChange(org.id)}
|
||||
className="cursor-pointer">
|
||||
{org.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isMultiOrgEnabled && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={() => setOpenCreateOrganizationModal(true)}
|
||||
className="w-full cursor-pointer justify-between">
|
||||
<span>{t("common.create_new_organization")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* User Dropdown */}
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
id="userDropdownTrigger"
|
||||
className={cn(switcherTriggerClasses, "rounded-br-xl")}>
|
||||
<button type="button" className="flex w-full items-center gap-3">
|
||||
<span className={switcherIconClasses}>
|
||||
<ProfileAvatar userId={user.id} />
|
||||
</span>
|
||||
className="w-full rounded-br-xl border-t p-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
||||
<button
|
||||
type="button"
|
||||
className={cn("flex w-full cursor-pointer flex-row items-center gap-3 text-left")}
|
||||
aria-haspopup="menu">
|
||||
<ProfileAvatar userId={user.id} />
|
||||
<div className="grow overflow-hidden">
|
||||
<p
|
||||
title={user?.email}
|
||||
className="ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700">
|
||||
className={cn(
|
||||
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
|
||||
)}>
|
||||
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">{t("common.account")}</p>
|
||||
<p title={organization?.name} className="truncate text-sm text-slate-500">
|
||||
{organization?.name}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
|
||||
<ChevronRightIcon className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
|
||||
<DropdownMenuContent
|
||||
id="userDropdownInnerContentWrapper"
|
||||
side="right"
|
||||
sideOffset={10}
|
||||
alignOffset={5}
|
||||
align="end">
|
||||
{/* Dropdown Items */}
|
||||
|
||||
{dropdownNavigation.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
id={link.href}
|
||||
href={link.href}
|
||||
target={link.target}
|
||||
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}
|
||||
@@ -215,6 +95,8 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Logout */}
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await signOutWithAudit({
|
||||
@@ -223,6 +105,7 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
|
||||
organizationId: organization.id,
|
||||
redirect: true,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
}}
|
||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||
@@ -231,7 +114,6 @@ export const LandingSidebar = ({ user, organization, isMultiOrgEnabled }: Landin
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<CreateOrganizationModal open={openCreateOrganizationModal} setOpen={setOpenCreateOrganizationModal} />
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getEnvironments } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getUserWorkspaces } from "@/lib/workspace/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
@@ -27,7 +28,12 @@ const LandingLayout = async (props: {
|
||||
|
||||
if (workspaces.length !== 0) {
|
||||
const firstWorkspace = workspaces[0];
|
||||
return redirect(`/workspaces/${firstWorkspace.id}/`);
|
||||
const environments = await getEnvironments(firstWorkspace.id);
|
||||
const prodEnvironment = environments.find((e) => e.type === "production");
|
||||
|
||||
if (prodEnvironment) {
|
||||
return redirect(`/environments/${prodEnvironment.id}/`);
|
||||
}
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
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 { WorkspaceAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/workspace-and-org-switch";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -25,11 +26,11 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
|
||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||
const isMembershipPending = membership?.role === undefined;
|
||||
const { isMember } = getAccessFlags(membership?.role);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-row">
|
||||
<LandingSidebar user={user} organization={organization} isMultiOrgEnabled={isMultiOrgEnabled} />
|
||||
<LandingSidebar user={user} organization={organization} />
|
||||
<div className="flex-1">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="p-6">
|
||||
@@ -43,7 +44,8 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
|
||||
isLicenseActive={false}
|
||||
isOwnerOrManager={false}
|
||||
isAccessControlAllowed={false}
|
||||
isMembershipPending={isMembershipPending}
|
||||
isMember={isMember}
|
||||
environments={[]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-12">
|
||||
|
||||
+2
-2
@@ -13,8 +13,8 @@ export const SelectPlanOnboarding = async ({ organizationId }: SelectPlanOnboard
|
||||
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")}
|
||||
title={t("environments.settings.billing.select_plan_header_title")}
|
||||
subtitle={t("environments.settings.billing.select_plan_header_subtitle")}
|
||||
/>
|
||||
<SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} />
|
||||
</div>
|
||||
|
||||
+13
-8
@@ -14,7 +14,7 @@ import {
|
||||
TWorkspaceUpdateInput,
|
||||
ZWorkspaceUpdateInput,
|
||||
} from "@formbricks/types/workspace";
|
||||
import { createWorkspaceAction } from "@/app/(app)/workspaces/[workspaceId]/actions";
|
||||
import { createWorkspaceAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import { previewSurvey } from "@/app/lib/templates";
|
||||
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
|
||||
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
|
||||
@@ -82,17 +82,22 @@ export const WorkspaceSettings = ({
|
||||
});
|
||||
|
||||
if (createWorkspaceResponse?.data) {
|
||||
if (globalThis.window !== undefined) {
|
||||
// Remove filters when creating a new workspace
|
||||
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
||||
// get production environment
|
||||
const productionEnvironment = createWorkspaceResponse.data.environments.find(
|
||||
(environment: { type: string }) => environment.type === "production"
|
||||
);
|
||||
if (productionEnvironment) {
|
||||
if (globalThis.window !== undefined) {
|
||||
// Remove filters when creating a new workspace
|
||||
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
||||
}
|
||||
}
|
||||
const workspaceId = createWorkspaceResponse.data.id;
|
||||
if (channel === "app" || channel === "website") {
|
||||
router.push(`/workspaces/${workspaceId}/connect`);
|
||||
router.push(`/environments/${productionEnvironment?.id}/connect`);
|
||||
} else if (channel === "link") {
|
||||
router.push(`/workspaces/${workspaceId}/surveys`);
|
||||
router.push(`/environments/${productionEnvironment?.id}/surveys`);
|
||||
} else if (workspaceMode === "cx") {
|
||||
router.push(`/workspaces/${workspaceId}/xm-templates`);
|
||||
router.push(`/environments/${productionEnvironment?.id}/xm-templates`);
|
||||
}
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(createWorkspaceResponse);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||
|
||||
const SurveyEditorEnvironmentLayout = async (props: {
|
||||
params: Promise<{ environmentId: string }>;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
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 AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
|
||||
if (!environment) {
|
||||
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
||||
}
|
||||
|
||||
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,12 +6,12 @@ import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Confetti } from "@/modules/ui/components/confetti";
|
||||
|
||||
const BILLING_CONFIRMATION_WORKSPACE_ID_KEY = "billingConfirmationWorkspaceId";
|
||||
const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId";
|
||||
|
||||
export const ConfirmationPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
const [resolvedWorkspaceId, setResolvedWorkspaceId] = useState<string | null>(null);
|
||||
const [resolvedEnvironmentId, setResolvedEnvironmentId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setShowConfetti(true);
|
||||
@@ -20,9 +20,11 @@ export const ConfirmationPage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const storedWorkspaceId = globalThis.window.sessionStorage.getItem(BILLING_CONFIRMATION_WORKSPACE_ID_KEY);
|
||||
if (storedWorkspaceId) {
|
||||
setResolvedWorkspaceId(storedWorkspaceId);
|
||||
const storedEnvironmentId = globalThis.window.sessionStorage.getItem(
|
||||
BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY
|
||||
);
|
||||
if (storedEnvironmentId) {
|
||||
setResolvedEnvironmentId(storedEnvironmentId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -41,7 +43,9 @@ export const ConfirmationPage = () => {
|
||||
<Button asChild className="w-full justify-center">
|
||||
<Link
|
||||
href={
|
||||
resolvedWorkspaceId ? `/workspaces/${resolvedWorkspaceId}/settings/organization/billing` : "/"
|
||||
resolvedEnvironmentId
|
||||
? `/environments/${resolvedEnvironmentId}/settings/billing`
|
||||
: "/environments"
|
||||
}>
|
||||
{t("billing_confirmation.back_to_billing_overview")}
|
||||
</Link>
|
||||
|
||||
@@ -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";
|
||||
|
||||
export const LoadingCard = ({
|
||||
|
||||
+5
-9
@@ -23,13 +23,9 @@ import { createWorkspace } from "@/modules/workspaces/settings/lib/workspace";
|
||||
import { getOrganizationsByUserId } from "./lib/organization";
|
||||
import { getWorkspacesByUserId } from "./lib/workspace";
|
||||
|
||||
const ZCreateWorkspaceInput = ZWorkspaceUpdateInput.extend({
|
||||
feedbackDirectoryId: ZId.optional(),
|
||||
});
|
||||
|
||||
const ZCreateWorkspaceAction = z.object({
|
||||
organizationId: ZId,
|
||||
data: ZCreateWorkspaceInput,
|
||||
data: ZWorkspaceUpdateInput,
|
||||
});
|
||||
|
||||
export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCreateWorkspaceAction).action(
|
||||
@@ -44,7 +40,7 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
|
||||
access: [
|
||||
{
|
||||
data: parsedInput.data,
|
||||
schema: ZCreateWorkspaceInput,
|
||||
schema: ZWorkspaceUpdateInput,
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
@@ -92,7 +88,7 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
|
||||
);
|
||||
|
||||
const ZGetOrganizationsForSwitcherAction = z.object({
|
||||
organizationId: ZId, // Changed from workspaceId to avoid extra query
|
||||
organizationId: ZId, // Changed from environmentId to avoid extra query
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -117,11 +113,11 @@ export const getOrganizationsForSwitcherAction = authenticatedActionClient
|
||||
});
|
||||
|
||||
const ZGetWorkspacesForSwitcherAction = z.object({
|
||||
organizationId: ZId, // Changed from workspaceId to avoid extra query
|
||||
organizationId: ZId, // Changed from environmentId to avoid extra query
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetches workspaces list for switcher dropdown.
|
||||
* Fetches projects list for switcher dropdown.
|
||||
* Called on-demand when user opens the workspace switcher.
|
||||
*/
|
||||
export const getWorkspacesForSwitcherAction = authenticatedActionClient
|
||||
+17
-11
@@ -1,30 +1,32 @@
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { MainNavigation } from "@/app/(app)/workspaces/[workspaceId]/components/MainNavigation";
|
||||
import { TopControlBar } from "@/app/(app)/workspaces/[workspaceId]/components/TopControlBar";
|
||||
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
||||
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getOrganizationWorkspacesLimit } 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 { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
|
||||
import { TWorkspaceLayoutData } from "@/modules/workspaces/types/workspace-auth";
|
||||
|
||||
interface WorkspaceLayoutProps {
|
||||
layoutData: TWorkspaceLayoutData;
|
||||
interface EnvironmentLayoutProps {
|
||||
layoutData: TEnvironmentLayoutData;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const WorkspaceLayout = async ({ layoutData, children }: WorkspaceLayoutProps) => {
|
||||
export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => {
|
||||
const t = await getTranslate();
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
// Destructure all data from props (NO database queries)
|
||||
const {
|
||||
user,
|
||||
environment,
|
||||
organization,
|
||||
membership,
|
||||
workspace, // Current workspace details
|
||||
environments, // All workspace environments (for environment switcher)
|
||||
isAccessControlAllowed,
|
||||
workspacePermission,
|
||||
license,
|
||||
@@ -47,19 +49,25 @@ export const WorkspaceLayout = async ({ layoutData, children }: WorkspaceLayoutP
|
||||
return (
|
||||
<div className="flex h-screen min-h-screen flex-col overflow-hidden">
|
||||
{IS_FORMBRICKS_CLOUD && (
|
||||
<LimitsReachedBanner organization={organization} responseCount={responseCount} />
|
||||
<LimitsReachedBanner
|
||||
organization={organization}
|
||||
environmentId={environment.id}
|
||||
responseCount={responseCount}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PendingDowngradeBanner
|
||||
lastChecked={lastChecked}
|
||||
isPendingDowngrade={isPendingDowngrade ?? false}
|
||||
active={active}
|
||||
environmentId={environment.id}
|
||||
locale={user.locale}
|
||||
status={status}
|
||||
/>
|
||||
|
||||
<div className="flex h-full">
|
||||
<MainNavigation
|
||||
environment={environment}
|
||||
organization={organization}
|
||||
user={user}
|
||||
workspace={{ id: workspace.id, name: workspace.name }}
|
||||
@@ -67,14 +75,12 @@ export const WorkspaceLayout = async ({ layoutData, children }: WorkspaceLayoutP
|
||||
isDevelopment={IS_DEVELOPMENT}
|
||||
membershipRole={membership.role}
|
||||
publicDomain={publicDomain}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationWorkspacesLimit={organizationWorkspacesLimit}
|
||||
isLicenseActive={active}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
/>
|
||||
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
||||
<TopControlBar
|
||||
environments={environments}
|
||||
currentOrganizationId={organization.id}
|
||||
currentWorkspaceId={workspace.id}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationWorkspacesLimit={organizationWorkspacesLimit}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
+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,346 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
ChevronRightIcon,
|
||||
Cog,
|
||||
LogOutIcon,
|
||||
MessageCircle,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
RocketIcon,
|
||||
UserCircleIcon,
|
||||
UserIcon,
|
||||
WorkflowIcon,
|
||||
} 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 { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
|
||||
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 { getLatestStableFbReleaseAction } from "@/modules/workspaces/settings/(setup)/app-connection/actions";
|
||||
import packageJson from "../../../../../package.json";
|
||||
|
||||
interface NavigationProps {
|
||||
environment: TEnvironment;
|
||||
user: TUser;
|
||||
organization: TOrganization;
|
||||
workspace: { id: string; name: string };
|
||||
isFormbricksCloud: boolean;
|
||||
isDevelopment: boolean;
|
||||
membershipRole?: TOrganizationRole;
|
||||
publicDomain: string;
|
||||
}
|
||||
|
||||
export const MainNavigation = ({
|
||||
environment,
|
||||
organization,
|
||||
user,
|
||||
workspace,
|
||||
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 workspace 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.workflows"),
|
||||
href: `/environments/${environment.id}/workflows`,
|
||||
icon: WorkflowIcon,
|
||||
isActive: pathname?.includes("/workflows"),
|
||||
isHidden: !isFormbricksCloud,
|
||||
},
|
||||
{
|
||||
name: t("common.configuration"),
|
||||
href: `/environments/${environment.id}/workspace/general`,
|
||||
icon: Cog,
|
||||
isActive: pathname?.includes("/workspace"),
|
||||
},
|
||||
],
|
||||
[t, environment.id, pathname, isFormbricksCloud]
|
||||
);
|
||||
|
||||
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 trialDaysRemaining = useMemo(() => {
|
||||
if (!isFormbricksCloud || organization.billing?.stripe?.subscriptionStatus !== "trialing") return null;
|
||||
const trialEnd = organization.billing.stripe.trialEnd;
|
||||
if (!trialEnd) return null;
|
||||
const ts = new Date(trialEnd).getTime();
|
||||
if (!Number.isFinite(ts)) return null;
|
||||
const msPerDay = 86_400_000;
|
||||
return Math.ceil((ts - Date.now()) / msPerDay);
|
||||
}, [
|
||||
isFormbricksCloud,
|
||||
organization.billing?.stripe?.subscriptionStatus,
|
||||
organization.billing?.stripe?.trialEnd,
|
||||
]);
|
||||
|
||||
const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
{workspace && (
|
||||
<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:outline-none focus:ring-0 focus:ring-transparent"
|
||||
)}>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Trial Days Remaining */}
|
||||
{!isCollapsed && isFormbricksCloud && trialDaysRemaining !== null && (
|
||||
<Link href={`/environments/${environment.id}/settings/billing`} className="m-2 block">
|
||||
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* User Switch */}
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+14
-6
@@ -1,11 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { WorkspaceAndOrgSwitch } from "@/app/(app)/workspaces/[workspaceId]/components/workspace-and-org-switch";
|
||||
import { useWorkspaceContext } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
|
||||
import { WorkspaceAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/workspace-and-org-switch";
|
||||
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
|
||||
interface TopControlBarProps {
|
||||
environments: TEnvironment[];
|
||||
currentOrganizationId: string;
|
||||
currentWorkspaceId: string;
|
||||
isMultiOrgEnabled: boolean;
|
||||
organizationWorkspacesLimit: number;
|
||||
isFormbricksCloud: boolean;
|
||||
@@ -16,7 +20,9 @@ interface TopControlBarProps {
|
||||
}
|
||||
|
||||
export const TopControlBar = ({
|
||||
environments,
|
||||
currentOrganizationId,
|
||||
currentWorkspaceId,
|
||||
isMultiOrgEnabled,
|
||||
organizationWorkspacesLimit,
|
||||
isFormbricksCloud,
|
||||
@@ -25,22 +31,24 @@ export const TopControlBar = ({
|
||||
isAccessControlAllowed,
|
||||
membershipRole,
|
||||
}: TopControlBarProps) => {
|
||||
const { workspace } = useWorkspaceContext();
|
||||
const isMembershipPending = membershipRole === undefined;
|
||||
const { isMember } = getAccessFlags(membershipRole);
|
||||
const { environment } = useEnvironment();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-14 w-full items-center justify-between bg-slate-50 px-6"
|
||||
data-testid="fb__global-top-control-bar">
|
||||
<WorkspaceAndOrgSwitch
|
||||
currentWorkspaceId={workspace.id}
|
||||
currentEnvironmentId={environment.id}
|
||||
environments={environments}
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
currentWorkspaceId={currentWorkspaceId}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationWorkspacesLimit={organizationWorkspacesLimit}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isLicenseActive={isLicenseActive}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
isMembershipPending={isMembershipPending}
|
||||
isMember={isMember}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
/>
|
||||
</div>
|
||||
+9
-8
@@ -3,32 +3,33 @@
|
||||
import { AlertTriangleIcon, CheckIcon, RotateCcwIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface WidgetStatusIndicatorProps {
|
||||
workspace: { appSetupCompleted: boolean };
|
||||
environment: TEnvironment;
|
||||
}
|
||||
|
||||
export const WidgetStatusIndicator = ({ workspace }: WidgetStatusIndicatorProps) => {
|
||||
export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const stati = {
|
||||
notImplemented: {
|
||||
icon: AlertTriangleIcon,
|
||||
title: t("workspace.app-connection.formbricks_sdk_not_connected"),
|
||||
subtitle: t("workspace.app-connection.formbricks_sdk_not_connected_description"),
|
||||
title: t("environments.workspace.app-connection.formbricks_sdk_not_connected"),
|
||||
subtitle: t("environments.workspace.app-connection.formbricks_sdk_not_connected_description"),
|
||||
},
|
||||
running: {
|
||||
icon: CheckIcon,
|
||||
title: t("workspace.app-connection.receiving_data"),
|
||||
subtitle: t("workspace.app-connection.formbricks_sdk_connected"),
|
||||
title: t("environments.workspace.app-connection.receiving_data"),
|
||||
subtitle: t("environments.workspace.app-connection.formbricks_sdk_connected"),
|
||||
},
|
||||
};
|
||||
|
||||
let status: "notImplemented" | "running";
|
||||
|
||||
if (workspace.appSetupCompleted) {
|
||||
if (environment.appSetupCompleted) {
|
||||
status = "running";
|
||||
} else {
|
||||
status = "notImplemented";
|
||||
@@ -56,7 +57,7 @@ export const WidgetStatusIndicator = ({ workspace }: WidgetStatusIndicatorProps)
|
||||
{status === "notImplemented" && (
|
||||
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
|
||||
<RotateCcwIcon />
|
||||
{t("workspace.app-connection.recheck")}
|
||||
{t("environments.workspace.app-connection.recheck")}
|
||||
</Button>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
+87
-22
@@ -9,11 +9,11 @@ import {
|
||||
PlusIcon,
|
||||
SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
||||
@@ -25,24 +25,42 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { useOrganization, useWorkspace } from "../context/workspace-context";
|
||||
import { useOrganization } from "../context/environment-context";
|
||||
|
||||
interface OrganizationBreadcrumbProps {
|
||||
currentOrganizationId: string;
|
||||
currentOrganizationName?: string; // Optional: pass directly if context not available
|
||||
isMultiOrgEnabled: boolean;
|
||||
currentWorkspaceId?: string;
|
||||
currentEnvironmentId?: string;
|
||||
isFormbricksCloud: boolean;
|
||||
isMember: boolean;
|
||||
isOwnerOrManager: boolean;
|
||||
}
|
||||
|
||||
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
|
||||
// Match /settings/{settingId} or /settings/{settingId}/... but exclude account settings
|
||||
// Exclude paths with /(account)/
|
||||
if (pathname.includes("/(account)/")) {
|
||||
return false;
|
||||
}
|
||||
// Check if path matches /settings/{settingId} (with optional trailing path)
|
||||
const pattern = new RegExp(`/settings/${settingId}(?:/|$)`);
|
||||
return pattern.test(pathname);
|
||||
};
|
||||
|
||||
export const OrganizationBreadcrumb = ({
|
||||
currentOrganizationId,
|
||||
currentOrganizationName,
|
||||
isMultiOrgEnabled,
|
||||
currentWorkspaceId,
|
||||
currentEnvironmentId,
|
||||
isFormbricksCloud,
|
||||
isMember,
|
||||
isOwnerOrManager,
|
||||
}: OrganizationBreadcrumbProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
|
||||
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
|
||||
@@ -52,7 +70,6 @@ export const OrganizationBreadcrumb = ({
|
||||
// Get current organization name from context OR prop
|
||||
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
|
||||
const { organization: currentOrganization } = useOrganization();
|
||||
const { workspace } = useWorkspace();
|
||||
const organizationName = currentOrganization?.name || currentOrganizationName || "";
|
||||
|
||||
// Lazy-load organizations when dropdown opens
|
||||
@@ -93,15 +110,9 @@ export const OrganizationBreadcrumb = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceBasePath = `/workspaces/${workspace?.id}`;
|
||||
|
||||
const handleOrganizationChange = (organizationId: string) => {
|
||||
if (organizationId === currentOrganizationId) return;
|
||||
startTransition(() => {
|
||||
setIsOrganizationDropdownOpen(false);
|
||||
if (organizationId === currentOrganizationId && currentWorkspaceId) {
|
||||
router.push(`/workspaces/${currentWorkspaceId}/settings/organization/general`);
|
||||
return;
|
||||
}
|
||||
router.push(`/organizations/${organizationId}/`);
|
||||
});
|
||||
};
|
||||
@@ -116,6 +127,49 @@ export const OrganizationBreadcrumb = ({
|
||||
});
|
||||
};
|
||||
|
||||
const organizationSettings = [
|
||||
{
|
||||
id: "general",
|
||||
label: t("common.general"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/general`,
|
||||
},
|
||||
{
|
||||
id: "teams",
|
||||
label: t("common.members_and_teams"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/teams`,
|
||||
},
|
||||
{
|
||||
id: "feedback-record-directories",
|
||||
label: t("environments.settings.feedback_record_directories.nav_label"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/feedback-record-directories`,
|
||||
hidden: isMember,
|
||||
},
|
||||
{
|
||||
id: "api-keys",
|
||||
label: t("common.api_keys"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
|
||||
hidden: !isOwnerOrManager,
|
||||
},
|
||||
{
|
||||
id: "domain",
|
||||
label: t("common.domain"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/domain`,
|
||||
hidden: isFormbricksCloud,
|
||||
},
|
||||
{
|
||||
id: "billing",
|
||||
label: t("common.billing"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/billing`,
|
||||
hidden: !isFormbricksCloud,
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
label: t("common.enterprise_license"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/enterprise`,
|
||||
hidden: isFormbricksCloud || isMember,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<BreadcrumbItem isActive={isOrganizationDropdownOpen}>
|
||||
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
|
||||
@@ -184,16 +238,27 @@ export const OrganizationBreadcrumb = ({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{currentWorkspaceId && (
|
||||
<>
|
||||
{currentEnvironmentId && (
|
||||
<div>
|
||||
{showOrganizationDropdown && <DropdownMenuSeparator />}
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={() => handleSettingChange(`${workspaceBasePath}/settings/organization/general`)}
|
||||
className="cursor-pointer">
|
||||
<SettingsIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.settings")}
|
||||
</DropdownMenuCheckboxItem>
|
||||
</>
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<SettingsIcon className="mr-2 inline h-4 w-4" />
|
||||
{t("common.organization_settings")}
|
||||
</div>
|
||||
|
||||
{organizationSettings.map((setting) => {
|
||||
return setting.hidden ? null : (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={setting.id}
|
||||
checked={isActiveOrganizationSetting(pathname, setting.id)}
|
||||
hidden={setting.hidden}
|
||||
onClick={() => handleSettingChange(setting.href)}
|
||||
className="cursor-pointer">
|
||||
{setting.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
+22
-8
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { OrganizationBreadcrumb } from "@/app/(app)/workspaces/[workspaceId]/components/organization-breadcrumb";
|
||||
import { WorkspaceBreadcrumb } from "@/app/(app)/workspaces/[workspaceId]/components/workspace-breadcrumb";
|
||||
import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb";
|
||||
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
|
||||
import { WorkspaceBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/workspace-breadcrumb";
|
||||
import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
|
||||
|
||||
interface WorkspaceAndOrgSwitchProps {
|
||||
@@ -9,13 +10,15 @@ interface WorkspaceAndOrgSwitchProps {
|
||||
currentOrganizationName?: string; // Optional: for pages without context
|
||||
currentWorkspaceId?: string;
|
||||
currentWorkspaceName?: string; // Optional: for pages without context
|
||||
currentEnvironmentId?: string;
|
||||
environments: { id: string; type: string }[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
organizationWorkspacesLimit: number;
|
||||
isFormbricksCloud: boolean;
|
||||
isLicenseActive: boolean;
|
||||
isOwnerOrManager: boolean;
|
||||
isMember: boolean;
|
||||
isAccessControlAllowed: boolean;
|
||||
isMembershipPending: boolean;
|
||||
}
|
||||
|
||||
export const WorkspaceAndOrgSwitch = ({
|
||||
@@ -23,37 +26,48 @@ export const WorkspaceAndOrgSwitch = ({
|
||||
currentOrganizationName,
|
||||
currentWorkspaceId,
|
||||
currentWorkspaceName,
|
||||
currentEnvironmentId,
|
||||
environments,
|
||||
isMultiOrgEnabled,
|
||||
organizationWorkspacesLimit,
|
||||
isFormbricksCloud,
|
||||
isLicenseActive,
|
||||
isOwnerOrManager,
|
||||
isAccessControlAllowed,
|
||||
isMembershipPending,
|
||||
isMember,
|
||||
}: WorkspaceAndOrgSwitchProps) => {
|
||||
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
||||
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
|
||||
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList className="gap-0">
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
currentOrganizationName={currentOrganizationName}
|
||||
currentWorkspaceId={currentWorkspaceId}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isMember={isMember}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
/>
|
||||
{currentWorkspaceId && (
|
||||
{currentWorkspaceId && currentEnvironmentId && (
|
||||
<WorkspaceBreadcrumb
|
||||
currentWorkspaceId={currentWorkspaceId}
|
||||
currentWorkspaceName={currentWorkspaceName}
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
organizationWorkspacesLimit={organizationWorkspacesLimit}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isLicenseActive={isLicenseActive}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
isEnvironmentBreadcrumbVisible={false}
|
||||
isMembershipPending={isMembershipPending}
|
||||
isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb}
|
||||
/>
|
||||
)}
|
||||
{showEnvironmentBreadcrumb && (
|
||||
<EnvironmentBreadcrumb environments={environments} currentEnvironment={currentEnvironment} />
|
||||
)}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
+91
-57
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FoldersIcon, Loader2, PlusIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
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 { getWorkspacesForSwitcherAction } from "@/app/(app)/workspaces/[workspaceId]/actions";
|
||||
import { getWorkspacesForSwitcherAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
||||
import {
|
||||
@@ -17,11 +17,10 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
||||
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { CreateWorkspaceModal } from "@/modules/workspaces/components/create-workspace-modal";
|
||||
import { WorkspaceLimitModal } from "@/modules/workspaces/components/workspace-limit-modal";
|
||||
import { useWorkspace } from "../context/workspace-context";
|
||||
import { useWorkspace } from "../context/environment-context";
|
||||
|
||||
interface WorkspaceBreadcrumbProps {
|
||||
currentWorkspaceId: string;
|
||||
@@ -31,11 +30,21 @@ interface WorkspaceBreadcrumbProps {
|
||||
isFormbricksCloud: boolean;
|
||||
isLicenseActive: boolean;
|
||||
currentOrganizationId: string;
|
||||
currentEnvironmentId: string;
|
||||
isAccessControlAllowed: boolean;
|
||||
isEnvironmentBreadcrumbVisible: boolean;
|
||||
isMembershipPending: boolean;
|
||||
}
|
||||
|
||||
const isActiveWorkspaceSetting = (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 WorkspaceBreadcrumb = ({
|
||||
currentWorkspaceId,
|
||||
currentWorkspaceName,
|
||||
@@ -44,9 +53,9 @@ export const WorkspaceBreadcrumb = ({
|
||||
isFormbricksCloud,
|
||||
isLicenseActive,
|
||||
currentOrganizationId,
|
||||
currentEnvironmentId,
|
||||
isAccessControlAllowed,
|
||||
isEnvironmentBreadcrumbVisible,
|
||||
isMembershipPending,
|
||||
}: WorkspaceBreadcrumbProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = useState(false);
|
||||
@@ -57,14 +66,13 @@ export const WorkspaceBreadcrumb = ({
|
||||
const [workspaces, setWorkspaces] = useState<{ id: string; name: string }[]>([]);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const pathname = usePathname();
|
||||
|
||||
// Get current workspace name from context OR prop
|
||||
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
|
||||
const { workspace: currentWorkspace } = useWorkspace();
|
||||
const workspaceName = currentWorkspace?.name || currentWorkspaceName || "";
|
||||
|
||||
const workspaceBasePath = `/workspaces/${currentWorkspace?.id}`;
|
||||
|
||||
// Lazy-load workspaces when dropdown opens
|
||||
useEffect(() => {
|
||||
// Only fetch when dropdown opened for first time (and no error state)
|
||||
@@ -89,6 +97,44 @@ export const WorkspaceBreadcrumb = ({
|
||||
}
|
||||
}, [isWorkspaceDropdownOpen, currentOrganizationId, workspaces.length, isLoadingWorkspaces, loadError, t]);
|
||||
|
||||
const workspaceSettings = [
|
||||
{
|
||||
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 (!currentWorkspace) {
|
||||
const errorMessage = `Workspace not found for workspace id: ${currentWorkspaceId}`;
|
||||
logger.error(errorMessage);
|
||||
@@ -97,13 +143,9 @@ export const WorkspaceBreadcrumb = ({
|
||||
}
|
||||
|
||||
const handleWorkspaceChange = (workspaceId: string) => {
|
||||
const targetPath =
|
||||
workspaceId === currentWorkspaceId
|
||||
? `/workspaces/${currentWorkspaceId}/surveys`
|
||||
: `/workspaces/${workspaceId}/`;
|
||||
if (workspaceId === currentWorkspaceId) return;
|
||||
startTransition(() => {
|
||||
setIsWorkspaceDropdownOpen(false);
|
||||
router.push(targetPath);
|
||||
router.push(`/workspaces/${workspaceId}/`);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -115,9 +157,9 @@ export const WorkspaceBreadcrumb = ({
|
||||
setOpenCreateWorkspaceModal(true);
|
||||
};
|
||||
|
||||
const handleWorkspaceSettingsNavigation = (href: string) => {
|
||||
const handleWorkspaceSettingsNavigation = (settingId: string) => {
|
||||
startTransition(() => {
|
||||
router.push(href);
|
||||
router.push(`/environments/${currentEnvironmentId}/workspace/${settingId}`);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -125,8 +167,8 @@ export const WorkspaceBreadcrumb = ({
|
||||
if (isFormbricksCloud) {
|
||||
return [
|
||||
{
|
||||
text: t("workspace.settings.billing.upgrade"),
|
||||
href: `${workspaceBasePath}/settings/organization/billing`,
|
||||
text: t("environments.settings.billing.upgrade"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/billing`,
|
||||
},
|
||||
{
|
||||
text: t("common.cancel"),
|
||||
@@ -137,9 +179,9 @@ export const WorkspaceBreadcrumb = ({
|
||||
|
||||
return [
|
||||
{
|
||||
text: t("workspace.settings.billing.upgrade"),
|
||||
text: t("environments.settings.billing.upgrade"),
|
||||
href: isLicenseActive
|
||||
? `${workspaceBasePath}/settings/organization/enterprise`
|
||||
? `/environments/${currentEnvironmentId}/settings/enterprise`
|
||||
: "https://formbricks.com/upgrade-self-hosted-license",
|
||||
},
|
||||
{
|
||||
@@ -148,13 +190,15 @@ export const WorkspaceBreadcrumb = ({
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
return (
|
||||
<BreadcrumbItem isActive={isWorkspaceDropdownOpen}>
|
||||
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
|
||||
<DropdownMenuTrigger className="flex cursor-pointer items-center gap-1 outline-none" asChild>
|
||||
<DropdownMenuTrigger
|
||||
className="flex cursor-pointer items-center gap-1 outline-none"
|
||||
id="workspaceDropdownTrigger"
|
||||
asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
<FoldersIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<HotelIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<span>{workspaceName}</span>
|
||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
{isEnvironmentBreadcrumbVisible && !isWorkspaceDropdownOpen ? (
|
||||
@@ -167,7 +211,7 @@ export const WorkspaceBreadcrumb = ({
|
||||
|
||||
<DropdownMenuContent align="start" className="mt-2">
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
<HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.choose_workspace")}
|
||||
</div>
|
||||
{isLoadingWorkspaces && (
|
||||
@@ -191,36 +235,19 @@ export const WorkspaceBreadcrumb = ({
|
||||
{!isLoadingWorkspaces && !loadError && (
|
||||
<>
|
||||
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||
{workspaces.map((ws) => (
|
||||
{workspaces.map((proj) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={ws.id}
|
||||
checked={ws.id === currentWorkspaceId}
|
||||
onClick={() => handleWorkspaceChange(ws.id)}
|
||||
key={proj.id}
|
||||
checked={proj.id === currentWorkspaceId}
|
||||
onClick={() => handleWorkspaceChange(proj.id)}
|
||||
className="cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{ws.name}</span>
|
||||
<span>{proj.name}</span>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isMembershipPending || !isOwnerOrManager ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-disabled="true"
|
||||
className="relative flex w-full cursor-not-allowed select-none items-center justify-between rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
|
||||
<span>{t("common.add_new_workspace")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
|
||||
{isMembershipPending
|
||||
? t("common.loading")
|
||||
: t("common.you_are_not_authorized_to_perform_this_action")}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
{isOwnerOrManager && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={handleAddWorkspace}
|
||||
className="w-full cursor-pointer justify-between">
|
||||
@@ -230,15 +257,22 @@ export const WorkspaceBreadcrumb = ({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={() =>
|
||||
handleWorkspaceSettingsNavigation(`${workspaceBasePath}/settings/workspace/general`)
|
||||
}
|
||||
className="cursor-pointer">
|
||||
<CogIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.settings")}
|
||||
</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>
|
||||
{workspaceSettings.map((setting) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={setting.id}
|
||||
checked={isActiveWorkspaceSetting(pathname, setting.id)}
|
||||
onClick={() => handleWorkspaceSettingsNavigation(setting.id)}
|
||||
className="cursor-pointer">
|
||||
{setting.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{/* Modals */}
|
||||
+20
-13
@@ -1,27 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TWorkspace } from "@formbricks/types/workspace";
|
||||
|
||||
export interface WorkspaceContextType {
|
||||
export interface EnvironmentContextType {
|
||||
environment: TEnvironment;
|
||||
workspace: TWorkspace;
|
||||
organization: TOrganization;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
const WorkspaceContext = createContext<WorkspaceContextType | null>(null);
|
||||
const EnvironmentContext = createContext<EnvironmentContextType | null>(null);
|
||||
|
||||
export const useWorkspaceContext = () => {
|
||||
const context = useContext(WorkspaceContext);
|
||||
export const useEnvironment = () => {
|
||||
const context = useContext(EnvironmentContext);
|
||||
if (!context) {
|
||||
throw new Error("useWorkspaceContext must be used within a WorkspaceContextWrapper");
|
||||
throw new Error("useEnvironment must be used within an EnvironmentProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const useWorkspace = () => {
|
||||
const context = useContext(WorkspaceContext);
|
||||
const context = useContext(EnvironmentContext);
|
||||
if (!context) {
|
||||
return { workspace: null };
|
||||
}
|
||||
@@ -29,7 +31,7 @@ export const useWorkspace = () => {
|
||||
};
|
||||
|
||||
export const useOrganization = () => {
|
||||
const context = useContext(WorkspaceContext);
|
||||
const context = useContext(EnvironmentContext);
|
||||
if (!context) {
|
||||
return { organization: null };
|
||||
}
|
||||
@@ -37,25 +39,30 @@ export const useOrganization = () => {
|
||||
};
|
||||
|
||||
// Client wrapper component to be used in server components
|
||||
interface WorkspaceContextWrapperProps {
|
||||
interface EnvironmentContextWrapperProps {
|
||||
environment: TEnvironment;
|
||||
workspace: TWorkspace;
|
||||
organization: TOrganization;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const WorkspaceContextWrapper = ({
|
||||
export const EnvironmentContextWrapper = ({
|
||||
environment,
|
||||
workspace,
|
||||
organization,
|
||||
children,
|
||||
}: WorkspaceContextWrapperProps) => {
|
||||
const workspaceContextValue = useMemo(
|
||||
}: EnvironmentContextWrapperProps) => {
|
||||
const environmentContextValue = useMemo(
|
||||
() => ({
|
||||
environment,
|
||||
workspace,
|
||||
organization,
|
||||
organizationId: workspace.organizationId,
|
||||
}),
|
||||
[workspace, organization]
|
||||
[environment, workspace, organization]
|
||||
);
|
||||
|
||||
return <WorkspaceContext.Provider value={workspaceContextValue}>{children}</WorkspaceContext.Provider>;
|
||||
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}
|
||||
workspace={layoutData.workspace}
|
||||
organization={layoutData.organization}>
|
||||
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
|
||||
</EnvironmentContextWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnvLayout;
|
||||
@@ -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: { params: Promise<{ environmentId: string }> }) => {
|
||||
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 { useTranslation } from "react-i18next";
|
||||
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
|
||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||
|
||||
interface AccountSettingsNavbarProps {
|
||||
environmentId?: string;
|
||||
activeId: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const AccountSettingsNavbar = ({ activeId, loading }: AccountSettingsNavbarProps) => {
|
||||
export const AccountSettingsNavbar = ({ environmentId, activeId, loading }: AccountSettingsNavbarProps) => {
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslation();
|
||||
const { workspace } = useWorkspace();
|
||||
const workspaceBasePath = `/workspaces/${workspace?.id}`;
|
||||
const navigation = [
|
||||
{
|
||||
id: "profile",
|
||||
label: t("common.profile"),
|
||||
href: `${workspaceBasePath}/settings/account/profile`,
|
||||
href: `/environments/${environmentId}/settings/profile`,
|
||||
current: pathname?.includes("/profile"),
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
label: t("common.notifications"),
|
||||
href: `${workspaceBasePath}/settings/account/notifications`,
|
||||
href: `/environments/${environmentId}/settings/notifications`,
|
||||
current: pathname?.includes("/notifications"),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,38 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
const AccountSettingsLayout = async (props: {
|
||||
params: Promise<{ environmentId: string }>;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
|
||||
const t = await getTranslate();
|
||||
const [organization, workspace, session] = await Promise.all([
|
||||
getOrganizationByEnvironmentId(params.environmentId),
|
||||
getWorkspaceByEnvironmentId(params.environmentId),
|
||||
getServerSession(authOptions),
|
||||
]);
|
||||
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError(t("common.organization"), null);
|
||||
}
|
||||
|
||||
if (!workspace) {
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default AccountSettingsLayout;
|
||||
+31
-27
@@ -4,7 +4,6 @@ import { HelpCircleIcon, UsersIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { Membership } from "../types";
|
||||
import { NotificationSwitch } from "./NotificationSwitch";
|
||||
@@ -12,6 +11,7 @@ import { NotificationSwitch } from "./NotificationSwitch";
|
||||
interface EditAlertsProps {
|
||||
memberships: Membership[];
|
||||
user: TUser;
|
||||
environmentId: string;
|
||||
autoDisableNotificationType: string;
|
||||
autoDisableNotificationElementId: string;
|
||||
}
|
||||
@@ -19,11 +19,11 @@ interface EditAlertsProps {
|
||||
export const EditAlerts = ({
|
||||
memberships,
|
||||
user,
|
||||
environmentId,
|
||||
autoDisableNotificationType,
|
||||
autoDisableNotificationElementId,
|
||||
}: EditAlertsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { workspace: currentWorkspace } = useWorkspace();
|
||||
return (
|
||||
<>
|
||||
{memberships.map((membership) => (
|
||||
@@ -37,7 +37,7 @@ export const EditAlerts = ({
|
||||
|
||||
<div className="col-span-3 flex items-center justify-end pr-2">
|
||||
<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>
|
||||
<NotificationSwitch
|
||||
surveyOrWorkspaceOrOrganizationId={membership.organization.id}
|
||||
@@ -55,38 +55,44 @@ export const EditAlerts = ({
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<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" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("workspace.settings.notifications.every_response_tooltip")}
|
||||
{t("environments.settings.notifications.every_response_tooltip")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
{membership.organization.workspaces.some((workspace) => workspace.surveys.length > 0) ? (
|
||||
{membership.organization.workspaces.some((workspace) =>
|
||||
workspace.environments.some((environment) => environment.surveys.length > 0)
|
||||
) ? (
|
||||
<div className="grid-cols-8 space-y-1 p-2">
|
||||
{membership.organization.workspaces.map((workspace) => (
|
||||
<div key={workspace.id}>
|
||||
{workspace.surveys.map((survey) => (
|
||||
<div
|
||||
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"
|
||||
key={survey.name}>
|
||||
<div className="col-span-2 text-left">
|
||||
<div className="font-medium text-slate-900">{survey.name}</div>
|
||||
<div className="text-xs text-slate-400">{workspace.name}</div>
|
||||
</div>
|
||||
<div className="col-span-1 text-center">
|
||||
<NotificationSwitch
|
||||
surveyOrWorkspaceOrOrganizationId={survey.id}
|
||||
notificationSettings={user.notificationSettings!}
|
||||
notificationType={"alert"}
|
||||
autoDisableNotificationType={autoDisableNotificationType}
|
||||
autoDisableNotificationElementId={autoDisableNotificationElementId}
|
||||
/>
|
||||
</div>
|
||||
{workspace.environments.map((environment) => (
|
||||
<div key={environment.id}>
|
||||
{environment.surveys.map((survey) => (
|
||||
<div
|
||||
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"
|
||||
key={survey.name}>
|
||||
<div className="col-span-2 text-left">
|
||||
<div className="font-medium text-slate-900">{survey.name}</div>
|
||||
<div className="text-xs text-slate-400">{workspace.name}</div>
|
||||
</div>
|
||||
<div className="col-span-1 text-center">
|
||||
<NotificationSwitch
|
||||
surveyOrWorkspaceOrOrganizationId={survey.id}
|
||||
notificationSettings={user.notificationSettings!}
|
||||
notificationType={"alert"}
|
||||
autoDisableNotificationType={autoDisableNotificationType}
|
||||
autoDisableNotificationElementId={autoDisableNotificationElementId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -98,10 +104,8 @@ export const EditAlerts = ({
|
||||
</div>
|
||||
)}
|
||||
<p className="pb-3 pl-4 text-xs text-slate-400">
|
||||
{t("workspace.settings.notifications.want_to_loop_in_organization_mates")}{" "}
|
||||
<Link
|
||||
className="font-semibold"
|
||||
href={`/workspaces/${currentWorkspace?.id}/settings/organization/general`}>
|
||||
{t("environments.settings.notifications.want_to_loop_in_organization_mates")}{" "}
|
||||
<Link className="font-semibold" href={`/environments/${environmentId}/settings/general`}>
|
||||
{t("common.invite_them")}
|
||||
</Link>
|
||||
</p>
|
||||
+8
-6
@@ -1,22 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
|
||||
import { SlackIcon } from "@/modules/ui/components/icons";
|
||||
|
||||
export const IntegrationsTip = () => {
|
||||
interface IntegrationsTipProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const IntegrationsTip = ({ environmentId }: IntegrationsTipProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { workspace } = useWorkspace();
|
||||
return (
|
||||
<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">
|
||||
<SlackIcon className="mr-3 h-4 w-4 text-blue-400" />
|
||||
<p className="text-sm">
|
||||
{t("workspace.settings.notifications.need_slack_or_discord_notifications")}?
|
||||
{t("environments.settings.notifications.need_slack_or_discord_notifications")}?
|
||||
<a
|
||||
href={`/workspaces/${workspace?.id}/settings/workspace/integrations`}
|
||||
href={`/environments/${environmentId}/workspace/integrations`}
|
||||
className="ml-1 cursor-pointer text-sm underline">
|
||||
{t("workspace.settings.notifications.use_the_integration")}
|
||||
{t("environments.settings.notifications.use_the_integration")}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
+3
-3
@@ -60,7 +60,7 @@ export const NotificationSwitch = ({
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
if (updatedNotificationSettingsActionResponse?.data) {
|
||||
toast.success(t("workspace.settings.notifications.notification_settings_updated"), {
|
||||
toast.success(t("environments.settings.notifications.notification_settings_updated"), {
|
||||
id: "notification-switch",
|
||||
});
|
||||
router.refresh();
|
||||
@@ -85,7 +85,7 @@ export const NotificationSwitch = ({
|
||||
handleSwitchChange();
|
||||
toast.success(
|
||||
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",
|
||||
@@ -101,7 +101,7 @@ export const NotificationSwitch = ({
|
||||
handleSwitchChange();
|
||||
toast.success(
|
||||
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",
|
||||
+6
-3
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingCard } from "@/app/(app)/components/LoadingCard";
|
||||
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
|
||||
@@ -9,15 +10,17 @@ const Loading = () => {
|
||||
const { t } = useTranslation();
|
||||
const cards = [
|
||||
{
|
||||
title: t("workspace.settings.notifications.email_alerts_surveys"),
|
||||
description: t("workspace.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses"),
|
||||
title: t("environments.settings.notifications.email_alerts_surveys"),
|
||||
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" }],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.notifications")} />
|
||||
<PageHeader pageTitle={t("common.account_settings")}>
|
||||
<AccountSettingsNavbar activeId="notifications" loading />
|
||||
</PageHeader>
|
||||
{cards.map((card, index) => (
|
||||
<LoadingCard key={index} {...card} />
|
||||
))}
|
||||
+37
-17
@@ -2,15 +2,16 @@ import { getServerSession } from "next-auth";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||
import { EditAlerts } from "@/app/(app)/workspaces/[workspaceId]/settings/account/notifications/components/EditAlerts";
|
||||
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 { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
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 = (
|
||||
notificationSettings: TUserNotificationSettings,
|
||||
@@ -23,12 +24,14 @@ const setCompleteNotificationSettings = (
|
||||
for (const membership of memberships) {
|
||||
for (const workspace of membership.organization.workspaces) {
|
||||
// set default values for alerts
|
||||
for (const survey of workspace.surveys) {
|
||||
newNotificationSettings.alert[survey.id] =
|
||||
(notificationSettings as unknown as Record<string, Record<string, boolean>>)[survey.id]
|
||||
?.responseFinished ||
|
||||
(notificationSettings.alert && notificationSettings.alert[survey.id]) ||
|
||||
false; // check for legacy notification settings w/o "alerts" key
|
||||
for (const environment of workspace.environments) {
|
||||
for (const survey of environment.surveys) {
|
||||
newNotificationSettings.alert[survey.id] =
|
||||
(notificationSettings as unknown as Record<string, Record<string, boolean>>)[survey.id]
|
||||
?.responseFinished ||
|
||||
(notificationSettings.alert && notificationSettings.alert[survey.id]) ||
|
||||
false; // check for legacy notification settings w/o "alerts" key
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,10 +115,18 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
surveys: {
|
||||
environments: {
|
||||
where: {
|
||||
type: "production",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
surveys: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -127,8 +138,12 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
|
||||
return memberships;
|
||||
};
|
||||
|
||||
const Page = async (props: { searchParams: Promise<Record<string, string>> }) => {
|
||||
const Page = async (props: {
|
||||
params: Promise<{ environmentId: string }>;
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}) => {
|
||||
const searchParams = await props.searchParams;
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
@@ -151,18 +166,23 @@ const Page = async (props: { searchParams: Promise<Record<string, string>> }) =>
|
||||
}
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.notifications")} />
|
||||
<PageHeader pageTitle={t("common.account_settings")}>
|
||||
<AccountSettingsNavbar environmentId={params.environmentId} activeId="notifications" />
|
||||
</PageHeader>
|
||||
<SettingsCard
|
||||
title={t("workspace.settings.notifications.email_alerts_surveys")}
|
||||
description={t("workspace.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses")}>
|
||||
title={t("environments.settings.notifications.email_alerts_surveys")}
|
||||
description={t(
|
||||
"environments.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses"
|
||||
)}>
|
||||
<EditAlerts
|
||||
memberships={memberships}
|
||||
user={user}
|
||||
environmentId={params.environmentId}
|
||||
autoDisableNotificationType={autoDisableNotificationType}
|
||||
autoDisableNotificationElementId={autoDisableNotificationElementId}
|
||||
/>
|
||||
</SettingsCard>
|
||||
<IntegrationsTip />
|
||||
<IntegrationsTip environmentId={params.environmentId} />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
+5
-2
@@ -7,9 +7,12 @@ export interface Membership {
|
||||
workspaces: {
|
||||
id: string;
|
||||
name: string;
|
||||
surveys: {
|
||||
environments: {
|
||||
id: string;
|
||||
name: string;
|
||||
surveys: {
|
||||
id: string;
|
||||
name: string;
|
||||
}[];
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
+4
-9
@@ -9,17 +9,16 @@ import {
|
||||
import {
|
||||
getIsEmailUnique,
|
||||
verifyUserPassword,
|
||||
} from "@/app/(app)/workspaces/[workspaceId]/settings/account/profile/lib/user";
|
||||
import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
|
||||
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
|
||||
import { getUser, updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
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 { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
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 {
|
||||
return {
|
||||
@@ -86,15 +85,11 @@ export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPerso
|
||||
|
||||
export const resetPasswordAction = authenticatedActionClient.action(
|
||||
withAuditLogging("passwordReset", "user", async ({ ctx }) => {
|
||||
if (PASSWORD_RESET_DISABLED) {
|
||||
throw new OperationNotAllowedError("Password reset is disabled");
|
||||
}
|
||||
|
||||
if (ctx.user.identityProvider !== "email") {
|
||||
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
|
||||
}
|
||||
|
||||
await requestPasswordReset(ctx.user, "profile");
|
||||
await sendForgotPasswordEmail(ctx.user);
|
||||
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
|
||||
+2
-2
@@ -31,11 +31,11 @@ export const AccountSecurity = ({ user }: AccountSecurityProps) => {
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-sm font-semibold text-slate-800">
|
||||
{t("workspace.settings.profile.two_factor_authentication")}
|
||||
{t("environments.settings.profile.two_factor_authentication")}
|
||||
</h1>
|
||||
|
||||
<p className="text-xs text-slate-600">
|
||||
{t("workspace.settings.profile.two_factor_authentication_description")}
|
||||
{t("environments.settings.profile.two_factor_authentication_description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
+3
-3
@@ -39,18 +39,18 @@ export const DeleteAccount = ({
|
||||
organizationsWithSingleOwner={organizationsWithSingleOwner}
|
||||
/>
|
||||
<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>
|
||||
<TooltipRenderer
|
||||
shouldRender={isDeleteDisabled}
|
||||
tooltipContent={t("workspace.settings.profile.warning_cannot_delete_account")}>
|
||||
tooltipContent={t("environments.settings.profile.warning_cannot_delete_account")}>
|
||||
<Button
|
||||
className="mt-4"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setModalOpen(!isModalOpen)}
|
||||
disabled={isDeleteDisabled}>
|
||||
{t("workspace.settings.profile.confirm_delete_my_account")}
|
||||
{t("environments.settings.profile.confirm_delete_my_account")}
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
</div>
|
||||
+6
-10
@@ -8,7 +8,7 @@ import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
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 { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
@@ -91,13 +91,13 @@ export const EditProfileDetailsForm = ({
|
||||
if (!emailVerificationDisabled) {
|
||||
toast.success(t("auth.verification-requested.new_email_verification_success"));
|
||||
} else {
|
||||
toast.success(t("workspace.settings.profile.email_change_initiated"));
|
||||
toast.success(t("environments.settings.profile.email_change_initiated"));
|
||||
await signOutWithAudit({
|
||||
reason: "email_change",
|
||||
redirectUrl: "/email-change-without-verification-success",
|
||||
redirect: true,
|
||||
callbackUrl: "/email-change-without-verification-success",
|
||||
clearWorkspaceId: true,
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -116,15 +116,11 @@ export const EditProfileDetailsForm = ({
|
||||
setShowModal(true);
|
||||
} else {
|
||||
try {
|
||||
const result = await updateUserAction({
|
||||
await updateUserAction({
|
||||
...data,
|
||||
name: data.name.trim(),
|
||||
});
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
toast.success(t("workspace.settings.profile.profile_updated_successfully"));
|
||||
toast.success(t("environments.settings.profile.profile_updated_successfully"));
|
||||
window.location.reload();
|
||||
form.reset(data);
|
||||
} catch (error: any) {
|
||||
@@ -145,7 +141,7 @@ export const EditProfileDetailsForm = ({
|
||||
redirectUrl: "/auth/login",
|
||||
redirect: true,
|
||||
callbackUrl: "/auth/login",
|
||||
clearWorkspaceId: true,
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
+8
-5
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingCard } from "@/app/(app)/components/LoadingCard";
|
||||
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
|
||||
@@ -9,8 +10,8 @@ const Loading = () => {
|
||||
const { t } = useTranslation();
|
||||
const cards = [
|
||||
{
|
||||
title: t("workspace.settings.profile.personal_information"),
|
||||
description: t("workspace.settings.profile.update_personal_info"),
|
||||
title: t("environments.settings.profile.personal_information"),
|
||||
description: t("environments.settings.profile.update_personal_info"),
|
||||
skeletonLines: [
|
||||
{ classes: "h-4 w-28" },
|
||||
{ classes: "h-6 w-64" },
|
||||
@@ -19,15 +20,17 @@ const Loading = () => {
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t("workspace.settings.profile.delete_account"),
|
||||
description: t("workspace.settings.profile.confirm_delete_account"),
|
||||
title: t("environments.settings.profile.delete_account"),
|
||||
description: t("environments.settings.profile.confirm_delete_account"),
|
||||
skeletonLines: [{ classes: "h-4 w-60" }, { classes: "h-8 w-24" }],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.profile")} />
|
||||
<PageHeader pageTitle={t("common.account_settings")}>
|
||||
<AccountSettingsNavbar activeId="profile" loading />
|
||||
</PageHeader>
|
||||
{cards.map((card, index) => (
|
||||
<LoadingCard key={index} {...card} />
|
||||
))}
|
||||
+22
-17
@@ -1,25 +1,28 @@
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
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 { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
|
||||
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
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 { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
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 isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
const params = await props.params;
|
||||
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);
|
||||
|
||||
@@ -33,12 +36,14 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.profile")} />
|
||||
<PageHeader pageTitle={t("common.account_settings")}>
|
||||
<AccountSettingsNavbar environmentId={environmentId} activeId="profile" />
|
||||
</PageHeader>
|
||||
{user && (
|
||||
<div>
|
||||
<SettingsCard
|
||||
title={t("workspace.settings.profile.personal_information")}
|
||||
description={t("workspace.settings.profile.update_personal_info")}>
|
||||
title={t("environments.settings.profile.personal_information")}
|
||||
description={t("environments.settings.profile.update_personal_info")}>
|
||||
<EditProfileDetailsForm
|
||||
user={user}
|
||||
emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED}
|
||||
@@ -48,24 +53,24 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
{user.identityProvider === "email" && (
|
||||
<SettingsCard
|
||||
title={t("common.security")}
|
||||
description={t("workspace.settings.profile.security_description")}>
|
||||
description={t("environments.settings.profile.security_description")}>
|
||||
{!isTwoFactorAuthEnabled && !user.twoFactorEnabled ? (
|
||||
<UpgradePrompt
|
||||
title={t("workspace.settings.profile.unlock_two_factor_authentication")}
|
||||
description={t("workspace.settings.profile.two_factor_authentication_description")}
|
||||
title={t("environments.settings.profile.unlock_two_factor_authentication")}
|
||||
description={t("environments.settings.profile.two_factor_authentication_description")}
|
||||
buttons={[
|
||||
{
|
||||
text: IS_FORMBRICKS_CLOUD
|
||||
? t("common.upgrade_plan")
|
||||
: t("common.request_trial_license"),
|
||||
href: IS_FORMBRICKS_CLOUD
|
||||
? `/workspaces/${params.workspaceId}/settings/organization/billing`
|
||||
? `/environments/${params.environmentId}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
href: IS_FORMBRICKS_CLOUD
|
||||
? `/workspaces/${params.workspaceId}/settings/organization/billing`
|
||||
? `/environments/${params.environmentId}/settings/billing`
|
||||
: "https://formbricks.com/learn-more-self-hosting-license",
|
||||
},
|
||||
]}
|
||||
@@ -77,8 +82,8 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
)}
|
||||
|
||||
<SettingsCard
|
||||
title={t("workspace.settings.profile.delete_account")}
|
||||
description={t("workspace.settings.profile.confirm_delete_account")}>
|
||||
title={t("environments.settings.profile.delete_account")}
|
||||
description={t("environments.settings.profile.confirm_delete_account")}>
|
||||
<DeleteAccount
|
||||
session={session}
|
||||
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import Loading from "@/modules/organization/settings/api-keys/loading";
|
||||
|
||||
export default function LoadingPage() {
|
||||
return <Loading isFormbricksCloud={IS_FORMBRICKS_CLOUD} />;
|
||||
}
|
||||
+5
-1
@@ -1,3 +1,5 @@
|
||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
@@ -6,7 +8,9 @@ const Loading = async () => {
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.billing")} />
|
||||
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
|
||||
<OrganizationSettingsNavbar isFormbricksCloud={IS_FORMBRICKS_CLOUD} activeId="billing" loading />
|
||||
</PageHeader>
|
||||
<div className="my-8 h-64 animate-pulse rounded-xl bg-slate-200"></div>
|
||||
<div className="my-8 h-96 animate-pulse rounded-md bg-slate-200"></div>
|
||||
</PageContentWrapper>
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||
|
||||
interface OrganizationSettingsNavbarProps {
|
||||
environmentId?: string;
|
||||
isFormbricksCloud: boolean;
|
||||
membershipRole?: TOrganizationRole;
|
||||
activeId: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const OrganizationSettingsNavbar = ({
|
||||
environmentId,
|
||||
isFormbricksCloud,
|
||||
membershipRole,
|
||||
activeId,
|
||||
loading,
|
||||
}: OrganizationSettingsNavbarProps) => {
|
||||
const pathname = usePathname();
|
||||
const { isMember, isOwner } = getAccessFlags(membershipRole);
|
||||
const isPricingDisabled = isMember;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
id: "general",
|
||||
label: t("common.general"),
|
||||
href: `/environments/${environmentId}/settings/general`,
|
||||
current: pathname?.includes("/general"),
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
id: "teams",
|
||||
label: t("common.members_and_teams"),
|
||||
href: `/environments/${environmentId}/settings/teams`,
|
||||
current: pathname?.includes("/teams"),
|
||||
},
|
||||
{
|
||||
id: "feedback-record-directories",
|
||||
label: t("environments.settings.feedback_record_directories.nav_label"),
|
||||
href: `/environments/${environmentId}/settings/feedback-record-directories`,
|
||||
current: pathname?.includes("/feedback-record-directories"),
|
||||
hidden: isMember,
|
||||
},
|
||||
{
|
||||
id: "api-keys",
|
||||
label: t("common.api_keys"),
|
||||
href: `/environments/${environmentId}/settings/api-keys`,
|
||||
current: pathname?.includes("/api-keys"),
|
||||
hidden: !isOwner,
|
||||
},
|
||||
{
|
||||
id: "domain",
|
||||
label: t("common.domain"),
|
||||
href: `/environments/${environmentId}/settings/domain`,
|
||||
current: pathname?.includes("/domain"),
|
||||
hidden: isFormbricksCloud,
|
||||
},
|
||||
{
|
||||
id: "billing",
|
||||
label: t("common.billing"),
|
||||
href: `/environments/${environmentId}/settings/billing`,
|
||||
hidden: !isFormbricksCloud || loading,
|
||||
current: pathname?.includes("/billing"),
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
label: t("common.enterprise_license"),
|
||||
href: `/environments/${environmentId}/settings/enterprise`,
|
||||
hidden: isFormbricksCloud || isPricingDisabled,
|
||||
current: pathname?.includes("/enterprise"),
|
||||
},
|
||||
];
|
||||
|
||||
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
|
||||
};
|
||||
+29
-10
@@ -11,10 +11,13 @@ interface SurveyWithSlug {
|
||||
name: string;
|
||||
slug: string | null;
|
||||
status: TSurveyStatus;
|
||||
workspace: {
|
||||
environment: {
|
||||
id: string;
|
||||
name: string;
|
||||
organizationId: string;
|
||||
type: "production" | "development";
|
||||
workspace: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -26,19 +29,27 @@ interface PrettyUrlsTableProps {
|
||||
export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getEnvironmentBadgeColor = (type: string) => {
|
||||
return type === "production" ? "bg-green-100 text-green-800" : "bg-blue-100 text-blue-800";
|
||||
};
|
||||
|
||||
const tableHeaders = [
|
||||
{
|
||||
label: t("workspace.settings.domain.survey_name"),
|
||||
label: t("environments.settings.domain.survey_name"),
|
||||
key: "name",
|
||||
},
|
||||
{
|
||||
label: t("workspace.settings.domain.workspace"),
|
||||
label: t("environments.settings.domain.workspace"),
|
||||
key: "workspace",
|
||||
},
|
||||
{
|
||||
label: t("workspace.settings.domain.pretty_url"),
|
||||
label: t("environments.settings.domain.pretty_url"),
|
||||
key: "slug",
|
||||
},
|
||||
{
|
||||
label: t("common.environment"),
|
||||
key: "environment",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -56,8 +67,8 @@ export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
|
||||
<TableBody className="[&_tr:last-child]:border-b">
|
||||
{surveys.length === 0 && (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell colSpan={3} className="text-center text-slate-500">
|
||||
{t("workspace.settings.domain.no_pretty_urls")}
|
||||
<TableCell colSpan={4} className="text-center text-slate-500">
|
||||
{t("environments.settings.domain.no_pretty_urls")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
@@ -65,15 +76,23 @@ export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
|
||||
<TableRow key={survey.id} className="border-slate-200 hover:bg-transparent">
|
||||
<TableCell className="font-medium">
|
||||
<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">
|
||||
{survey.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>{survey.workspace.name}</TableCell>
|
||||
<TableCell>{survey.environment.workspace.name}</TableCell>
|
||||
<TableCell>
|
||||
<IdBadge id={survey.slug ?? ""} />
|
||||
</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>
|
||||
))}
|
||||
</TableBody>
|
||||
+19
-9
@@ -1,18 +1,19 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
|
||||
import { PrettyUrlsTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/domain/components/pretty-urls-table";
|
||||
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
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 { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
import { 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 t = await getTranslate();
|
||||
|
||||
@@ -20,7 +21,9 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const { session, organization, isOwner, isManager } = await getWorkspaceAuth(params.workspaceId);
|
||||
const { session, currentUserMembership, organization, isOwner, isManager } = await getEnvironmentAuth(
|
||||
params.environmentId
|
||||
);
|
||||
|
||||
if (!session) {
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
@@ -33,7 +36,14 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.domain")} />
|
||||
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
|
||||
<OrganizationSettingsNavbar
|
||||
environmentId={params.environmentId}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
membershipRole={currentUserMembership?.role}
|
||||
activeId="domain"
|
||||
/>
|
||||
</PageHeader>
|
||||
|
||||
{!IS_STORAGE_CONFIGURED && (
|
||||
<div className="max-w-4xl">
|
||||
@@ -46,14 +56,14 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
<FaviconCustomizationSettings
|
||||
organization={organization}
|
||||
hasWhiteLabelPermission={hasWhiteLabelPermission}
|
||||
workspaceId={params.workspaceId}
|
||||
environmentId={params.environmentId}
|
||||
isReadOnly={!isOwnerOrManager}
|
||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||
/>
|
||||
|
||||
<SettingsCard
|
||||
title={t("workspace.settings.domain.title")}
|
||||
description={t("workspace.settings.domain.description")}>
|
||||
title={t("environments.settings.domain.title")}
|
||||
description={t("environments.settings.domain.description")}>
|
||||
<PrettyUrlsTable surveys={surveys} />
|
||||
</SettingsCard>
|
||||
</PageContentWrapper>
|
||||
+21
-31
@@ -3,12 +3,12 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import type { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
|
||||
type TPublicLicenseFeatureKey = Exclude<keyof TEnterpriseLicenseFeatures, "isMultiOrgEnabled">;
|
||||
type TPublicLicenseFeatureKey = Exclude<keyof TEnterpriseLicenseFeatures, "isMultiOrgEnabled" | "ai">;
|
||||
|
||||
type TFeatureDefinition = {
|
||||
key: TPublicLicenseFeatureKey;
|
||||
@@ -20,70 +20,60 @@ const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
|
||||
return [
|
||||
{
|
||||
key: "contacts",
|
||||
labelKey: t("workspace.settings.enterprise.license_feature_contacts"),
|
||||
labelKey: t("environments.settings.enterprise.license_feature_contacts"),
|
||||
docsUrl:
|
||||
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/contact-management-segments",
|
||||
},
|
||||
{
|
||||
key: "workspaces",
|
||||
labelKey: t("workspace.settings.enterprise.license_feature_workspaces"),
|
||||
labelKey: t("environments.settings.enterprise.license_feature_workspaces"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/license",
|
||||
},
|
||||
{
|
||||
key: "whitelabel",
|
||||
labelKey: t("workspace.settings.enterprise.license_feature_whitelabel"),
|
||||
labelKey: t("environments.settings.enterprise.license_feature_whitelabel"),
|
||||
docsUrl:
|
||||
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/whitelabel-email-follow-ups",
|
||||
},
|
||||
{
|
||||
key: "removeBranding",
|
||||
labelKey: t("workspace.settings.enterprise.license_feature_remove_branding"),
|
||||
labelKey: t("environments.settings.enterprise.license_feature_remove_branding"),
|
||||
docsUrl:
|
||||
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/hide-powered-by-formbricks",
|
||||
},
|
||||
{
|
||||
key: "twoFactorAuth",
|
||||
labelKey: t("workspace.settings.enterprise.license_feature_two_factor_auth"),
|
||||
labelKey: t("environments.settings.enterprise.license_feature_two_factor_auth"),
|
||||
docsUrl: "https://formbricks.com/docs/xm-and-surveys/core-features/user-management/two-factor-auth",
|
||||
},
|
||||
{
|
||||
key: "sso",
|
||||
labelKey: t("workspace.settings.enterprise.license_feature_sso"),
|
||||
labelKey: t("environments.settings.enterprise.license_feature_sso"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/oidc-sso",
|
||||
},
|
||||
{
|
||||
key: "saml",
|
||||
labelKey: t("workspace.settings.enterprise.license_feature_saml"),
|
||||
labelKey: t("environments.settings.enterprise.license_feature_saml"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/saml-sso",
|
||||
},
|
||||
{
|
||||
key: "spamProtection",
|
||||
labelKey: t("workspace.settings.enterprise.license_feature_spam_protection"),
|
||||
labelKey: t("environments.settings.enterprise.license_feature_spam_protection"),
|
||||
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/spam-protection",
|
||||
},
|
||||
{
|
||||
key: "aiSmartTools",
|
||||
labelKey: t("workspace.settings.general.ai_smart_tools_enabled"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/configuration/ai",
|
||||
},
|
||||
{
|
||||
key: "aiDataAnalysis",
|
||||
labelKey: t("workspace.settings.general.ai_data_analysis_enabled"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/configuration/ai",
|
||||
},
|
||||
{
|
||||
key: "auditLogs",
|
||||
labelKey: t("workspace.settings.enterprise.license_feature_audit_logs"),
|
||||
labelKey: t("environments.settings.enterprise.license_feature_audit_logs"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/audit-logging",
|
||||
},
|
||||
{
|
||||
key: "accessControl",
|
||||
labelKey: t("workspace.settings.enterprise.license_feature_access_control"),
|
||||
labelKey: t("environments.settings.enterprise.license_feature_access_control"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/team-access",
|
||||
},
|
||||
{
|
||||
key: "quotas",
|
||||
labelKey: t("workspace.settings.enterprise.license_feature_quotas"),
|
||||
labelKey: t("environments.settings.enterprise.license_feature_quotas"),
|
||||
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/quota-management",
|
||||
},
|
||||
];
|
||||
@@ -98,15 +88,15 @@ export const EnterpriseLicenseFeaturesTable = ({ features }: EnterpriseLicenseFe
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
title={t("workspace.settings.enterprise.license_features_table_title")}
|
||||
description={t("workspace.settings.enterprise.license_features_table_description")}
|
||||
title={t("environments.settings.enterprise.license_features_table_title")}
|
||||
description={t("environments.settings.enterprise.license_features_table_description")}
|
||||
noPadding>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-white">
|
||||
<TableHead>{t("workspace.settings.enterprise.license_features_table_feature")}</TableHead>
|
||||
<TableHead>{t("workspace.settings.enterprise.license_features_table_access")}</TableHead>
|
||||
<TableHead>{t("workspace.settings.enterprise.license_features_table_value")}</TableHead>
|
||||
<TableHead>{t("environments.settings.enterprise.license_features_table_feature")}</TableHead>
|
||||
<TableHead>{t("environments.settings.enterprise.license_features_table_access")}</TableHead>
|
||||
<TableHead>{t("environments.settings.enterprise.license_features_table_value")}</TableHead>
|
||||
<TableHead>{t("common.documentation")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -119,7 +109,7 @@ export const EnterpriseLicenseFeaturesTable = ({ features }: EnterpriseLicenseFe
|
||||
if (typeof value === "number") {
|
||||
displayValue = value;
|
||||
} else if (value === null) {
|
||||
displayValue = t("workspace.settings.enterprise.license_features_table_unlimited");
|
||||
displayValue = t("environments.settings.enterprise.license_features_table_unlimited");
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -131,8 +121,8 @@ export const EnterpriseLicenseFeaturesTable = ({ features }: EnterpriseLicenseFe
|
||||
size="normal"
|
||||
text={
|
||||
isEnabled
|
||||
? t("workspace.settings.enterprise.license_features_table_enabled")
|
||||
: t("workspace.settings.enterprise.license_features_table_disabled")
|
||||
? t("environments.settings.enterprise.license_features_table_enabled")
|
||||
: t("environments.settings.enterprise.license_features_table_disabled")
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
+24
-24
@@ -18,7 +18,7 @@ interface EnterpriseLicenseStatusProps {
|
||||
status: TLicenseStatus;
|
||||
lastChecked: Date;
|
||||
gracePeriodEnd?: Date;
|
||||
workspaceId: string;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
const getBadgeConfig = (
|
||||
@@ -27,20 +27,20 @@ const getBadgeConfig = (
|
||||
): { type: "success" | "error" | "warning" | "gray"; label: string } => {
|
||||
switch (status) {
|
||||
case "active":
|
||||
return { type: "success", label: t("workspace.settings.enterprise.license_status_active") };
|
||||
return { type: "success", label: t("environments.settings.enterprise.license_status_active") };
|
||||
case "expired":
|
||||
return { type: "error", label: t("workspace.settings.enterprise.license_status_expired") };
|
||||
return { type: "error", label: t("environments.settings.enterprise.license_status_expired") };
|
||||
case "instance_mismatch":
|
||||
return {
|
||||
type: "error",
|
||||
label: t("workspace.settings.enterprise.license_status_instance_mismatch"),
|
||||
label: t("environments.settings.enterprise.license_status_instance_mismatch"),
|
||||
};
|
||||
case "unreachable":
|
||||
return { type: "warning", label: t("workspace.settings.enterprise.license_status_unreachable") };
|
||||
return { type: "warning", label: t("environments.settings.enterprise.license_status_unreachable") };
|
||||
case "invalid_license":
|
||||
return { type: "error", label: t("workspace.settings.enterprise.license_status_invalid") };
|
||||
return { type: "error", label: t("environments.settings.enterprise.license_status_invalid") };
|
||||
default:
|
||||
return { type: "gray", label: t("workspace.settings.enterprise.license_status") };
|
||||
return { type: "gray", label: t("environments.settings.enterprise.license_status") };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -48,7 +48,7 @@ export const EnterpriseLicenseStatus = ({
|
||||
status,
|
||||
lastChecked,
|
||||
gracePeriodEnd,
|
||||
workspaceId,
|
||||
environmentId,
|
||||
}: EnterpriseLicenseStatusProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
|
||||
@@ -58,29 +58,29 @@ export const EnterpriseLicenseStatus = ({
|
||||
const handleRecheck = async () => {
|
||||
setIsRechecking(true);
|
||||
try {
|
||||
const result = await recheckLicenseAction({ workspaceId });
|
||||
const result = await recheckLicenseAction({ environmentId });
|
||||
if (result?.serverError) {
|
||||
toast.error(result.serverError || t("workspace.settings.enterprise.recheck_license_failed"));
|
||||
toast.error(result.serverError || t("environments.settings.enterprise.recheck_license_failed"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.data) {
|
||||
if (result.data.status === "unreachable") {
|
||||
toast.error(t("workspace.settings.enterprise.recheck_license_unreachable"));
|
||||
toast.error(t("environments.settings.enterprise.recheck_license_unreachable"));
|
||||
} else if (result.data.status === "instance_mismatch") {
|
||||
toast.error(t("workspace.settings.enterprise.recheck_license_instance_mismatch"));
|
||||
toast.error(t("environments.settings.enterprise.recheck_license_instance_mismatch"));
|
||||
} else if (result.data.status === "invalid_license") {
|
||||
toast.error(t("workspace.settings.enterprise.recheck_license_invalid"));
|
||||
toast.error(t("environments.settings.enterprise.recheck_license_invalid"));
|
||||
} else {
|
||||
toast.success(t("workspace.settings.enterprise.recheck_license_success"));
|
||||
toast.success(t("environments.settings.enterprise.recheck_license_success"));
|
||||
}
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(t("workspace.settings.enterprise.recheck_license_failed"));
|
||||
toast.error(t("environments.settings.enterprise.recheck_license_failed"));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("workspace.settings.enterprise.recheck_license_failed")
|
||||
error instanceof Error ? error.message : t("environments.settings.enterprise.recheck_license_failed")
|
||||
);
|
||||
} finally {
|
||||
setIsRechecking(false);
|
||||
@@ -91,8 +91,8 @@ export const EnterpriseLicenseStatus = ({
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
title={t("workspace.settings.enterprise.license_status")}
|
||||
description={t("workspace.settings.enterprise.license_status_description")}>
|
||||
title={t("environments.settings.enterprise.license_status")}
|
||||
description={t("environments.settings.enterprise.license_status_description")}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -113,12 +113,12 @@ export const EnterpriseLicenseStatus = ({
|
||||
{isRechecking ? (
|
||||
<>
|
||||
<RotateCcwIcon className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("workspace.settings.enterprise.rechecking")}
|
||||
{t("environments.settings.enterprise.rechecking")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RotateCcwIcon className="mr-2 h-4 w-4" />
|
||||
{t("workspace.settings.enterprise.recheck_license")}
|
||||
{t("environments.settings.enterprise.recheck_license")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
@@ -126,7 +126,7 @@ export const EnterpriseLicenseStatus = ({
|
||||
{status === "unreachable" && gracePeriodEnd && (
|
||||
<Alert variant="warning" size="small">
|
||||
<AlertDescription className="overflow-visible whitespace-normal">
|
||||
{t("workspace.settings.enterprise.license_unreachable_grace_period", {
|
||||
{t("environments.settings.enterprise.license_unreachable_grace_period", {
|
||||
gracePeriodEnd: formatDateForDisplay(new Date(gracePeriodEnd), locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
@@ -139,19 +139,19 @@ export const EnterpriseLicenseStatus = ({
|
||||
{status === "invalid_license" && (
|
||||
<Alert variant="error" size="small">
|
||||
<AlertDescription className="overflow-visible whitespace-normal">
|
||||
{t("workspace.settings.enterprise.license_invalid_description")}
|
||||
{t("environments.settings.enterprise.license_invalid_description")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{status === "instance_mismatch" && (
|
||||
<Alert variant="error" size="small">
|
||||
<AlertDescription className="overflow-visible whitespace-normal">
|
||||
{t("workspace.settings.enterprise.license_instance_mismatch_description")}
|
||||
{t("environments.settings.enterprise.license_instance_mismatch_description")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<p className="border-t border-slate-100 pt-4 text-sm text-slate-500">
|
||||
{t("workspace.settings.enterprise.questions_please_reach_out_to")}{" "}
|
||||
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
|
||||
<a
|
||||
className="font-medium text-slate-700 underline hover:text-slate-900"
|
||||
href="mailto:hola@formbricks.com">
|
||||
+5
-1
@@ -1,3 +1,5 @@
|
||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
@@ -6,7 +8,9 @@ const Loading = async () => {
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.enterprise_license")} />
|
||||
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
|
||||
<OrganizationSettingsNavbar isFormbricksCloud={IS_FORMBRICKS_CLOUD} activeId="enterprise" loading />
|
||||
</PageHeader>
|
||||
<div className="my-8 h-64 animate-pulse rounded-xl bg-slate-200"></div>
|
||||
<div className="my-8 h-96 animate-pulse rounded-md bg-slate-200"></div>
|
||||
</PageContentWrapper>
|
||||
+36
-26
@@ -1,24 +1,25 @@
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { EnterpriseLicenseFeaturesTable } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseFeaturesTable";
|
||||
import { EnterpriseLicenseStatus } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/enterprise/components/EnterpriseLicenseStatus";
|
||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||
import { EnterpriseLicenseStatus } from "@/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/components/EnterpriseLicenseStatus";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { GRACE_PERIOD_MS, getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
import { EnterpriseLicenseFeaturesTable } from "./components/EnterpriseLicenseFeaturesTable";
|
||||
|
||||
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const { isMember } = await getWorkspaceAuth(params.workspaceId);
|
||||
const { isMember, currentUserMembership } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const isPricingDisabled = isMember;
|
||||
|
||||
@@ -31,52 +32,52 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
|
||||
const paidFeatures = [
|
||||
{
|
||||
title: t("workspace.settings.billing.remove_branding"),
|
||||
title: t("environments.settings.billing.remove_branding"),
|
||||
comingSoon: false,
|
||||
onRequest: false,
|
||||
},
|
||||
{
|
||||
title: t("workspace.settings.enterprise.sso"),
|
||||
title: t("environments.settings.enterprise.sso"),
|
||||
comingSoon: false,
|
||||
onRequest: false,
|
||||
},
|
||||
{
|
||||
title: t("workspace.languages.multi_language_surveys"),
|
||||
title: t("environments.workspace.languages.multi_language_surveys"),
|
||||
comingSoon: false,
|
||||
onRequest: false,
|
||||
},
|
||||
{
|
||||
title: t("workspace.settings.enterprise.organization_roles"),
|
||||
title: t("environments.settings.enterprise.organization_roles"),
|
||||
comingSoon: false,
|
||||
onRequest: false,
|
||||
},
|
||||
{
|
||||
title: t("workspace.settings.enterprise.teams"),
|
||||
title: t("environments.settings.enterprise.teams"),
|
||||
comingSoon: false,
|
||||
onRequest: false,
|
||||
},
|
||||
{
|
||||
title: t("workspace.settings.enterprise.contacts_and_segments"),
|
||||
title: t("environments.settings.enterprise.contacts_and_segments"),
|
||||
comingSoon: false,
|
||||
onRequest: false,
|
||||
},
|
||||
{
|
||||
title: t("workspace.settings.enterprise.audit_logs"),
|
||||
title: t("environments.settings.enterprise.audit_logs"),
|
||||
comingSoon: false,
|
||||
onRequest: true,
|
||||
},
|
||||
{
|
||||
title: t("workspace.settings.enterprise.saml_sso"),
|
||||
title: t("environments.settings.enterprise.saml_sso"),
|
||||
comingSoon: false,
|
||||
onRequest: true,
|
||||
},
|
||||
{
|
||||
title: t("workspace.settings.enterprise.service_level_agreement"),
|
||||
title: t("environments.settings.enterprise.service_level_agreement"),
|
||||
comingSoon: false,
|
||||
onRequest: true,
|
||||
},
|
||||
{
|
||||
title: t("workspace.settings.enterprise.soc2_hipaa_iso_27001_compliance_check"),
|
||||
title: t("environments.settings.enterprise.soc2_hipaa_iso_27001_compliance_check"),
|
||||
comingSoon: false,
|
||||
onRequest: true,
|
||||
},
|
||||
@@ -84,7 +85,14 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.enterprise_license")} />
|
||||
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
|
||||
<OrganizationSettingsNavbar
|
||||
environmentId={params.environmentId}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
membershipRole={currentUserMembership?.role}
|
||||
activeId="enterprise"
|
||||
/>
|
||||
</PageHeader>
|
||||
{hasLicense ? (
|
||||
<>
|
||||
<EnterpriseLicenseStatus
|
||||
@@ -95,7 +103,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
|
||||
: undefined
|
||||
}
|
||||
workspaceId={params.workspaceId}
|
||||
environmentId={params.environmentId}
|
||||
/>
|
||||
{licenseState.features && <EnterpriseLicenseFeaturesTable features={licenseState.features} />}
|
||||
</>
|
||||
@@ -122,19 +130,21 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
</svg>
|
||||
<div className="mx-auto text-center lg:mx-0 lg:flex-auto lg:py-16 lg:text-left">
|
||||
<h2 className="text-2xl font-bold text-white sm:text-3xl">
|
||||
{t("workspace.settings.enterprise.unlock_the_full_power_of_formbricks_free_for_30_days")}
|
||||
{t("environments.settings.enterprise.unlock_the_full_power_of_formbricks_free_for_30_days")}
|
||||
</h2>
|
||||
<p className="text-md mt-6 leading-8 text-slate-300">
|
||||
{t("workspace.settings.enterprise.keep_full_control_over_your_data_privacy_and_security")}
|
||||
{t("environments.settings.enterprise.keep_full_control_over_your_data_privacy_and_security")}
|
||||
<br />
|
||||
{t("workspace.settings.enterprise.get_an_enterprise_license_to_get_access_to_all_features")}
|
||||
{t(
|
||||
"environments.settings.enterprise.get_an_enterprise_license_to_get_access_to_all_features"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 rounded-lg border border-slate-300 bg-slate-100 shadow-sm">
|
||||
<div className="p-8">
|
||||
<h2 className="mr-2 inline-flex text-2xl font-bold text-slate-700">
|
||||
{t("workspace.settings.enterprise.enterprise_features")}
|
||||
{t("environments.settings.enterprise.enterprise_features")}
|
||||
</h2>
|
||||
<ul className="my-4 space-y-4">
|
||||
{paidFeatures.map((feature) => (
|
||||
@@ -145,12 +155,12 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
<span className="ml-2 text-sm text-slate-500 dark:text-slate-400">{feature.title}</span>
|
||||
{feature.comingSoon && (
|
||||
<span className="mx-2 rounded-full bg-blue-100 px-3 py-1 text-xs text-blue-700 dark:bg-slate-700 dark:text-teal-500">
|
||||
{t("workspace.settings.enterprise.coming_soon")}
|
||||
{t("environments.settings.enterprise.coming_soon")}
|
||||
</span>
|
||||
)}
|
||||
{feature.onRequest && (
|
||||
<span className="mx-2 rounded-full bg-violet-100 px-3 py-1 text-xs text-violet-700 dark:bg-slate-700 dark:text-teal-500">
|
||||
{t("workspace.settings.enterprise.on_request")}
|
||||
{t("environments.settings.enterprise.on_request")}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
@@ -158,7 +168,7 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
</ul>
|
||||
<p className="my-6 text-sm text-slate-700">
|
||||
{t(
|
||||
"workspace.settings.enterprise.no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form"
|
||||
"environments.settings.enterprise.no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form"
|
||||
)}
|
||||
</p>
|
||||
<Button asChild>
|
||||
@@ -167,11 +177,11 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
referrerPolicy="no-referrer">
|
||||
{t("workspace.settings.enterprise.request_30_day_trial_license")}
|
||||
{t("environments.settings.enterprise.request_30_day_trial_license")}
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
{t("workspace.settings.enterprise.no_credit_card_no_sales_call_just_test_it")}
|
||||
{t("environments.settings.enterprise.no_credit_card_no_sales_call_just_test_it")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { FeedbackRecordDirectoriesPage as default } from "@/modules/ee/feedback-record-directory/page";
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
|
||||
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
|
||||
const ZUpdateOrganizationNameAction = z.object({
|
||||
organizationId: ZId,
|
||||
data: ZOrganizationUpdateInput.pick({ name: true }),
|
||||
});
|
||||
|
||||
export const updateOrganizationNameAction = authenticatedActionClient
|
||||
.inputSchema(ZUpdateOrganizationNameAction)
|
||||
.action(
|
||||
withAuditLogging("updated", "organization", async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
schema: ZOrganizationUpdateInput.pick({ name: true }),
|
||||
data: parsedInput.data,
|
||||
roles: ["owner"],
|
||||
},
|
||||
],
|
||||
});
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
const oldObject = await getOrganization(parsedInput.organizationId);
|
||||
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = result;
|
||||
return result;
|
||||
})
|
||||
);
|
||||
|
||||
const ZDeleteOrganizationAction = z.object({
|
||||
organizationId: ZId,
|
||||
});
|
||||
|
||||
export const deleteOrganizationAction = authenticatedActionClient
|
||||
.inputSchema(ZDeleteOrganizationAction)
|
||||
.action(
|
||||
withAuditLogging("deleted", "organization", async ({ ctx, parsedInput }) => {
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner"],
|
||||
},
|
||||
],
|
||||
});
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
const oldObject = await getOrganization(parsedInput.organizationId);
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
return await deleteOrganization(parsedInput.organizationId);
|
||||
})
|
||||
);
|
||||
+12
-10
@@ -5,7 +5,7 @@ import { Dispatch, SetStateAction, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { deleteOrganizationAction } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/general/actions";
|
||||
import { deleteOrganizationAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
@@ -39,13 +39,13 @@ export const DeleteOrganization = ({
|
||||
setIsDeleting(false);
|
||||
return;
|
||||
}
|
||||
toast.success(t("workspace.settings.general.organization_deleted_successfully"));
|
||||
toast.success(t("environments.settings.general.organization_deleted_successfully"));
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
}
|
||||
router.push("/");
|
||||
} catch (err) {
|
||||
toast.error(t("workspace.settings.general.error_deleting_organization_please_try_again"));
|
||||
toast.error(t("environments.settings.general.error_deleting_organization_please_try_again"));
|
||||
}
|
||||
|
||||
setIsDeleteDialogOpen(false);
|
||||
@@ -53,14 +53,16 @@ export const DeleteOrganization = ({
|
||||
};
|
||||
|
||||
const deleteDisabledWarning = isUserOwner
|
||||
? t("workspace.settings.general.cannot_delete_only_organization")
|
||||
: t("workspace.settings.general.only_org_owner_can_perform_action");
|
||||
? t("environments.settings.general.cannot_delete_only_organization")
|
||||
: t("environments.settings.general.only_org_owner_can_perform_action");
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!isDeleteDisabled && (
|
||||
<div>
|
||||
<p className="text-sm text-slate-900">{t("workspace.settings.general.once_its_gone_its_gone")}</p>
|
||||
<p className="text-sm text-slate-900">
|
||||
{t("environments.settings.general.once_its_gone_its_gone")}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isDeleteDisabled}
|
||||
@@ -115,17 +117,17 @@ const DeleteOrganizationModal = ({
|
||||
setOpen={setOpen}
|
||||
deleteWhat={t("common.organization")}
|
||||
onDelete={deleteOrganization}
|
||||
text={t("workspace.settings.general.delete_organization_warning")}
|
||||
text={t("environments.settings.general.delete_organization_warning")}
|
||||
disabled={inputValue !== organizationData?.name}
|
||||
isDeleting={isDeleting}>
|
||||
<div className="py-5" data-i18n="[html]content.body">
|
||||
<ul className="list-disc pb-6 pl-6">
|
||||
<li>{t("workspace.settings.general.delete_organization_warning_1")}</li>
|
||||
<li>{t("workspace.settings.general.delete_organization_warning_2")}</li>
|
||||
<li>{t("environments.settings.general.delete_organization_warning_1")}</li>
|
||||
<li>{t("environments.settings.general.delete_organization_warning_2")}</li>
|
||||
</ul>
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
<label htmlFor="deleteOrganizationConfirmation">
|
||||
{t("workspace.settings.general.delete_organization_warning_3", {
|
||||
{t("environments.settings.general.delete_organization_warning_3", {
|
||||
organizationName: organizationData?.name,
|
||||
})}
|
||||
</label>
|
||||
+6
-5
@@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization, ZOrganization } from "@formbricks/types/organizations";
|
||||
import { updateOrganizationNameAction } from "@/app/(app)/workspaces/[workspaceId]/settings/organization/general/actions";
|
||||
import { updateOrganizationNameAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
|
||||
interface EditOrganizationNameProps {
|
||||
environmentId: string;
|
||||
organization: TOrganization;
|
||||
membershipRole?: TOrganizationRole;
|
||||
}
|
||||
@@ -53,7 +54,7 @@ export const EditOrganizationNameForm = ({ organization, membershipRole }: EditO
|
||||
});
|
||||
|
||||
if (updatedOrganizationResponse?.data) {
|
||||
toast.success(t("workspace.settings.general.organization_name_updated_successfully"));
|
||||
toast.success(t("environments.settings.general.organization_name_updated_successfully"));
|
||||
form.reset({ name: updatedOrganizationResponse.data.name });
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updatedOrganizationResponse);
|
||||
@@ -75,14 +76,14 @@ export const EditOrganizationNameForm = ({ organization, membershipRole }: EditO
|
||||
name="name"
|
||||
render={({ field, fieldState }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.settings.general.organization_name")}</FormLabel>
|
||||
<FormLabel>{t("environments.settings.general.organization_name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
disabled={!isOwner}
|
||||
isInvalid={!!fieldState.error?.message}
|
||||
placeholder={t("workspace.settings.general.organization_name_placeholder")}
|
||||
placeholder={t("environments.settings.general.organization_name_placeholder")}
|
||||
required
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -105,7 +106,7 @@ export const EditOrganizationNameForm = ({ organization, membershipRole }: EditO
|
||||
{!isOwner && (
|
||||
<Alert variant="warning" className="mt-4">
|
||||
<AlertDescription>
|
||||
{t("workspace.settings.general.only_org_owner_can_perform_action")}
|
||||
{t("environments.settings.general.only_org_owner_can_perform_action")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
+2
-2
@@ -11,13 +11,13 @@ export const SecurityListTip = () => {
|
||||
<div className="flex items-center space-x-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm">
|
||||
<ShieldCheckIcon className="h-5 w-5 flex-shrink-0 text-blue-400" />
|
||||
<p className="text-sm">
|
||||
{t("workspace.settings.general.security_list_tip")}{" "}
|
||||
{t("environments.settings.general.security_list_tip")}{" "}
|
||||
<Link
|
||||
href="https://formbricks.com/security#stay-informed-with-formbricks-security-updates"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-blue-700">
|
||||
{t("workspace.settings.general.security_list_tip_link")}
|
||||
{t("environments.settings.general.security_list_tip_link")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { LoadingCard } from "@/app/(app)/components/LoadingCard";
|
||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
|
||||
const Loading = async () => {
|
||||
const t = await getTranslate();
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: t("environments.settings.general.organization_name"),
|
||||
description: t("environments.settings.general.organization_name_description"),
|
||||
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-8 w-80" }],
|
||||
},
|
||||
{
|
||||
title: t("environments.settings.general.delete_organization"),
|
||||
description: t("environments.settings.general.delete_organization_description"),
|
||||
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-8 w-80" }],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
|
||||
<OrganizationSettingsNavbar isFormbricksCloud={IS_FORMBRICKS_CLOUD} activeId="general" loading />
|
||||
</PageHeader>
|
||||
{cards.map((card, index) => (
|
||||
<LoadingCard key={index} {...card} />
|
||||
))}
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
+23
-35
@@ -1,44 +1,32 @@
|
||||
import { isInstanceAIConfigured } from "@/lib/ai/service";
|
||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import {
|
||||
getIsAIDataAnalysisEnabled,
|
||||
getIsAISmartToolsEnabled,
|
||||
getIsMultiOrgEnabled,
|
||||
getWhiteLabelPermission,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
import packageJson from "@/package.json";
|
||||
import { SettingsCard } from "../../components/SettingsCard";
|
||||
import { AISettingsToggle } from "./components/AISettingsToggle";
|
||||
import { DeleteOrganization } from "./components/DeleteOrganization";
|
||||
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
||||
import { SecurityListTip } from "./components/SecurityListTip";
|
||||
|
||||
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
|
||||
const { session, currentUserMembership, organization, isOwner, isManager } = await getWorkspaceAuth(
|
||||
params.workspaceId
|
||||
const { session, currentUserMembership, organization, isOwner, isManager } = await getEnvironmentAuth(
|
||||
params.environmentId
|
||||
);
|
||||
|
||||
const user = session?.user?.id ? await getUser(session.user.id) : null;
|
||||
|
||||
const [isMultiOrgEnabled, hasWhiteLabelPermission, hasAISmartToolsPermission, hasAIDataAnalysisPermission] =
|
||||
await Promise.all([
|
||||
getIsMultiOrgEnabled(),
|
||||
getWhiteLabelPermission(organization.id),
|
||||
getIsAISmartToolsEnabled(organization.id),
|
||||
getIsAIDataAnalysisEnabled(organization.id),
|
||||
]);
|
||||
const hasAIPermission = hasAISmartToolsPermission || hasAIDataAnalysisPermission;
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
|
||||
|
||||
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
|
||||
const currentUserRole = currentUserMembership?.role;
|
||||
@@ -47,7 +35,14 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("workspace.settings.general.organization_settings")} />
|
||||
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
|
||||
<OrganizationSettingsNavbar
|
||||
environmentId={params.environmentId}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
membershipRole={currentUserMembership?.role}
|
||||
activeId="general"
|
||||
/>
|
||||
</PageHeader>
|
||||
{!IS_STORAGE_CONFIGURED && (
|
||||
<div className="max-w-4xl">
|
||||
<Alert variant="warning">
|
||||
@@ -57,25 +52,18 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
)}
|
||||
{!IS_FORMBRICKS_CLOUD && <SecurityListTip />}
|
||||
<SettingsCard
|
||||
title={t("workspace.settings.general.organization_name")}
|
||||
description={t("workspace.settings.general.organization_name_description")}>
|
||||
<EditOrganizationNameForm organization={organization} membershipRole={currentUserMembership?.role} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title={t("workspace.settings.general.ai_enabled")}
|
||||
description={t("workspace.settings.general.ai_enabled_description")}>
|
||||
<AISettingsToggle
|
||||
title={t("environments.settings.general.organization_name")}
|
||||
description={t("environments.settings.general.organization_name_description")}>
|
||||
<EditOrganizationNameForm
|
||||
organization={organization}
|
||||
environmentId={params.environmentId}
|
||||
membershipRole={currentUserMembership?.role}
|
||||
isInstanceAIConfigured={isInstanceAIConfigured()}
|
||||
hasAIPermission={hasAIPermission}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
/>
|
||||
</SettingsCard>
|
||||
<EmailCustomizationSettings
|
||||
organization={organization}
|
||||
hasWhiteLabelPermission={hasWhiteLabelPermission}
|
||||
workspaceId={params.workspaceId}
|
||||
environmentId={params.environmentId}
|
||||
isReadOnly={!isOwnerOrManager}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
fbLogoUrl={FB_LOGO_URL}
|
||||
@@ -84,8 +72,8 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
/>
|
||||
{isMultiOrgEnabled && (
|
||||
<SettingsCard
|
||||
title={t("workspace.settings.general.delete_organization")}
|
||||
description={t("workspace.settings.general.delete_organization_description")}>
|
||||
title={t("environments.settings.general.delete_organization")}
|
||||
description={t("environments.settings.general.delete_organization_description")}>
|
||||
<DeleteOrganization
|
||||
organization={organization}
|
||||
isDeleteDisabled={isDeleteDisabled}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getWorkspaceByEnvironmentId } from "@/lib/workspace/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
const Layout = async (props: { params: Promise<{ environmentId: string }>; children: React.ReactNode }) => {
|
||||
const params = await props.params;
|
||||
|
||||
const { children } = props;
|
||||
|
||||
const t = await getTranslate();
|
||||
const [organization, workspace, session] = await Promise.all([
|
||||
getOrganizationByEnvironmentId(params.environmentId),
|
||||
getWorkspaceByEnvironmentId(params.environmentId),
|
||||
getServerSession(authOptions),
|
||||
]);
|
||||
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError(t("common.organization"), null);
|
||||
}
|
||||
|
||||
if (!workspace) {
|
||||
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
throw new AuthenticationError(t("common.not_authenticated"));
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user