mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-19 11:11:05 -05:00
Compare commits
2 Commits
feat/unify
...
codex/depe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
122de0121e | ||
|
|
eb416d7ff7 |
@@ -1,9 +0,0 @@
|
|||||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
|
||||||
version = 1
|
|
||||||
name = "formbricks"
|
|
||||||
|
|
||||||
[setup]
|
|
||||||
script = '''
|
|
||||||
pnpm install
|
|
||||||
pnpm dev:setup
|
|
||||||
'''
|
|
||||||
34
.env.example
34
.env.example
@@ -94,12 +94,6 @@ EMAIL_VERIFICATION_DISABLED=1
|
|||||||
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
|
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
|
||||||
PASSWORD_RESET_DISABLED=1
|
PASSWORD_RESET_DISABLED=1
|
||||||
|
|
||||||
# Password reset token lifetime in minutes. Must be between 5 and 120 if set.
|
|
||||||
# PASSWORD_RESET_TOKEN_LIFETIME_MINUTES=30
|
|
||||||
|
|
||||||
# Development-only helper: log the password reset link to the server console instead of sending reset emails.
|
|
||||||
# DEBUG_SHOW_RESET_LINK=1
|
|
||||||
|
|
||||||
# Email login. Disable the ability for users to login with email.
|
# Email login. Disable the ability for users to login with email.
|
||||||
# EMAIL_AUTH_DISABLED=1
|
# EMAIL_AUTH_DISABLED=1
|
||||||
|
|
||||||
@@ -138,31 +132,6 @@ AZUREAD_CLIENT_ID=
|
|||||||
AZUREAD_CLIENT_SECRET=
|
AZUREAD_CLIENT_SECRET=
|
||||||
AZUREAD_TENANT_ID=
|
AZUREAD_TENANT_ID=
|
||||||
|
|
||||||
# Configure Formbricks AI at the instance level
|
|
||||||
# Set the provider used for AI features on this instance.
|
|
||||||
# Accepted values for AI_PROVIDER: aws, gcp, azure
|
|
||||||
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching credentials below.
|
|
||||||
# AI_PROVIDER=gcp
|
|
||||||
# AI_MODEL=gemini-2.5-flash
|
|
||||||
|
|
||||||
# Google Vertex AI credentials
|
|
||||||
# AI_GCP_PROJECT=
|
|
||||||
# AI_GCP_LOCATION=
|
|
||||||
# AI_GCP_CREDENTIALS_JSON=
|
|
||||||
# AI_GCP_APPLICATION_CREDENTIALS=
|
|
||||||
|
|
||||||
# Amazon Bedrock credentials
|
|
||||||
# AI_AWS_REGION=
|
|
||||||
# AI_AWS_ACCESS_KEY_ID=
|
|
||||||
# AI_AWS_SECRET_ACCESS_KEY=
|
|
||||||
# AI_AWS_SESSION_TOKEN=
|
|
||||||
|
|
||||||
# Azure AI / Microsoft Foundry credentials
|
|
||||||
# AI_AZURE_BASE_URL=
|
|
||||||
# AI_AZURE_RESOURCE_NAME=
|
|
||||||
# AI_AZURE_API_KEY=
|
|
||||||
# AI_AZURE_API_VERSION=v1
|
|
||||||
|
|
||||||
# OpenID Connect (OIDC) configuration
|
# OpenID Connect (OIDC) configuration
|
||||||
# OIDC_CLIENT_ID=
|
# OIDC_CLIENT_ID=
|
||||||
# OIDC_CLIENT_SECRET=
|
# OIDC_CLIENT_SECRET=
|
||||||
@@ -216,9 +185,6 @@ ENTERPRISE_LICENSE_KEY=
|
|||||||
# Ignore Rate Limiting across the Formbricks app
|
# Ignore Rate Limiting across the Formbricks app
|
||||||
# RATE_LIMITING_DISABLED=1
|
# RATE_LIMITING_DISABLED=1
|
||||||
|
|
||||||
# Disable telemetry reporting (usage stats sent to Formbricks). Ignored when an EE license is active.
|
|
||||||
# TELEMETRY_DISABLED=1
|
|
||||||
|
|
||||||
# Allow webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x)
|
# 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
|
# WARNING: Only enable this if you understand the SSRF risks. Useful for self-hosted instances
|
||||||
# that need to send webhooks to internal services.
|
# that need to send webhooks to internal services.
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -45,7 +45,7 @@ yarn-error.log*
|
|||||||
.direnv
|
.direnv
|
||||||
|
|
||||||
# Playwright
|
# Playwright
|
||||||
**/test-results/
|
/test-results/
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
|
|||||||
26
README.md
26
README.md
@@ -127,10 +127,34 @@ Formbricks has a hosted cloud offering with a generous free plan to get you up a
|
|||||||
|
|
||||||
Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription.
|
Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription.
|
||||||
|
|
||||||
|
If you opt for self-hosting Formbricks, here are a few options to consider:
|
||||||
|
|
||||||
#### Docker
|
#### Docker
|
||||||
|
|
||||||
To get started with self-hosting with Docker, take a look at our [self-hosting docs](https://formbricks.com/docs/self-hosting/deployment).
|
To get started with self-hosting with Docker, take a look at our [self-hosting docs](https://formbricks.com/docs/self-hosting/deployment).
|
||||||
|
|
||||||
|
#### Community-managed One Click Hosting
|
||||||
|
|
||||||
|
##### Railway
|
||||||
|
|
||||||
|
You can deploy Formbricks on [Railway](https://railway.app) using the button below.
|
||||||
|
|
||||||
|
[](https://railway.app/new/template/PPDzCd)
|
||||||
|
|
||||||
|
##### RepoCloud
|
||||||
|
|
||||||
|
Or you can also deploy Formbricks on [RepoCloud](https://repocloud.io) using the button below.
|
||||||
|
|
||||||
|
[](https://repocloud.io/details/?app_id=254)
|
||||||
|
|
||||||
|
##### Zeabur
|
||||||
|
|
||||||
|
Or you can also deploy Formbricks on [Zeabur](https://zeabur.com) using the button below.
|
||||||
|
|
||||||
|
[](https://zeabur.com/templates/G4TUJL)
|
||||||
|
|
||||||
|
<a id="development"></a>
|
||||||
|
|
||||||
## 👨💻 Development
|
## 👨💻 Development
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -223,4 +247,4 @@ We currently do not offer Formbricks white-labeled. That means that we don't sel
|
|||||||
|
|
||||||
The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come.
|
The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come.
|
||||||
|
|
||||||
<a id="readme-de"></a>
|
<p align="right"><a href="#top">🔼 Back to top</a></p>
|
||||||
|
|||||||
@@ -26,8 +26,7 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
|
|||||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||||
|
|
||||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||||
const { isBilling } = getAccessFlags(membership?.role);
|
const { isMember } = getAccessFlags(membership?.role);
|
||||||
const isMembershipPending = membership?.role === undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-row">
|
<div className="flex min-h-full min-w-full flex-row">
|
||||||
@@ -45,8 +44,7 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
|
|||||||
isLicenseActive={false}
|
isLicenseActive={false}
|
||||||
isOwnerOrManager={false}
|
isOwnerOrManager={false}
|
||||||
isAccessControlAllowed={false}
|
isAccessControlAllowed={false}
|
||||||
isBilling={isBilling}
|
isMember={isMember}
|
||||||
isMembershipPending={isMembershipPending}
|
|
||||||
environments={[]}
|
environments={[]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
|
||||||
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
|
|
||||||
|
|
||||||
const MainNavLayout = async (props: {
|
|
||||||
params: Promise<{ environmentId: string }>;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) => {
|
|
||||||
const params = await props.params;
|
|
||||||
const { children } = props;
|
|
||||||
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
if (!session?.user) {
|
|
||||||
return redirect("/auth/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
|
|
||||||
|
|
||||||
return <EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MainNavLayout;
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
|
||||||
import { SettingsSidebar } from "@/modules/settings/components/settings-sidebar";
|
|
||||||
|
|
||||||
const SettingsLayout = async (props: {
|
|
||||||
params: Promise<{ environmentId: string }>;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) => {
|
|
||||||
const params = await props.params;
|
|
||||||
const { children } = props;
|
|
||||||
|
|
||||||
const { project, organization, currentUserMembership } = await getEnvironmentAuth(params.environmentId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen min-h-screen overflow-hidden">
|
|
||||||
<SettingsSidebar
|
|
||||||
environmentId={params.environmentId}
|
|
||||||
projectName={project.name}
|
|
||||||
organizationName={organization.name}
|
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
|
||||||
membershipRole={currentUserMembership?.role}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-1 flex-col overflow-y-auto bg-slate-50">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SettingsLayout;
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
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)/environments/[environmentId]/settings/(account)/notifications/components/EditAlerts";
|
|
||||||
import { IntegrationsTip } from "@/app/(app)/environments/[environmentId]/settings/(account)/notifications/components/IntegrationsTip";
|
|
||||||
import type { Membership } from "@/app/(app)/environments/[environmentId]/settings/(account)/notifications/types";
|
|
||||||
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";
|
|
||||||
|
|
||||||
const setCompleteNotificationSettings = (
|
|
||||||
notificationSettings: TUserNotificationSettings,
|
|
||||||
memberships: Membership[]
|
|
||||||
): TUserNotificationSettings => {
|
|
||||||
const newNotificationSettings: TUserNotificationSettings = {
|
|
||||||
alert: {} as Record<string, boolean>,
|
|
||||||
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
|
|
||||||
};
|
|
||||||
for (const membership of memberships) {
|
|
||||||
for (const project of membership.organization.projects) {
|
|
||||||
for (const environment of project.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return newNotificationSettings;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getMemberships = async (userId: string): Promise<Membership[]> => {
|
|
||||||
const memberships = await prisma.membership.findMany({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
role: { not: "billing" },
|
|
||||||
OR: [
|
|
||||||
{ role: { in: ["owner", "manager"] } },
|
|
||||||
{
|
|
||||||
organization: {
|
|
||||||
projects: {
|
|
||||||
some: {
|
|
||||||
projectTeams: { some: { team: { teamUsers: { some: { userId } } } } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
organization: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
projects: {
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{
|
|
||||||
organization: {
|
|
||||||
memberships: { some: { userId, role: { in: ["owner", "manager"] } } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ projectTeams: { some: { team: { teamUsers: { some: { userId } } } } } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
environments: {
|
|
||||||
where: { type: "production" },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
surveys: { select: { id: true, name: true } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return memberships;
|
|
||||||
};
|
|
||||||
|
|
||||||
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) {
|
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
|
||||||
}
|
|
||||||
const autoDisableNotificationType = searchParams["type"];
|
|
||||||
const autoDisableNotificationElementId = searchParams["elementId"];
|
|
||||||
|
|
||||||
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
|
|
||||||
if (!user) {
|
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!memberships) {
|
|
||||||
throw new ResourceNotFoundError(t("common.membership"), null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user?.notificationSettings) {
|
|
||||||
user.notificationSettings = setCompleteNotificationSettings(user.notificationSettings, memberships);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<PageContentWrapper>
|
|
||||||
<PageHeader pageTitle={t("common.account_settings")} />
|
|
||||||
<SettingsCard
|
|
||||||
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 environmentId={params.environmentId} />
|
|
||||||
</PageContentWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import { AuthenticationError } from "@formbricks/types/errors";
|
|
||||||
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
|
|
||||||
import { DeleteAccount } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/DeleteAccount";
|
|
||||||
import { EditProfileDetailsForm } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/EditProfileDetailsForm";
|
|
||||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
|
||||||
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";
|
|
||||||
|
|
||||||
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 { environmentId } = params;
|
|
||||||
|
|
||||||
const { session } = await getEnvironmentAuth(params.environmentId);
|
|
||||||
|
|
||||||
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(session.user.id);
|
|
||||||
|
|
||||||
const user = session?.user ? await getUser(session.user.id) : null;
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContentWrapper>
|
|
||||||
<PageHeader pageTitle={t("common.account_settings")} />
|
|
||||||
{user && (
|
|
||||||
<div>
|
|
||||||
<SettingsCard
|
|
||||||
title={t("environments.settings.profile.personal_information")}
|
|
||||||
description={t("environments.settings.profile.update_personal_info")}>
|
|
||||||
<EditProfileDetailsForm
|
|
||||||
user={user}
|
|
||||||
emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED}
|
|
||||||
isPasswordResetEnabled={isPasswordResetEnabled}
|
|
||||||
/>
|
|
||||||
</SettingsCard>
|
|
||||||
{user.identityProvider === "email" && (
|
|
||||||
<SettingsCard
|
|
||||||
title={t("common.security")}
|
|
||||||
description={t("environments.settings.profile.security_description")}>
|
|
||||||
{!isTwoFactorAuthEnabled && !user.twoFactorEnabled ? (
|
|
||||||
<UpgradePrompt
|
|
||||||
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
|
|
||||||
? `/environments/${environmentId}/settings/organization/billing`
|
|
||||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("common.learn_more"),
|
|
||||||
href: IS_FORMBRICKS_CLOUD
|
|
||||||
? `/environments/${environmentId}/settings/organization/billing`
|
|
||||||
: "https://formbricks.com/learn-more-self-hosting-license",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<AccountSecurity user={user} />
|
|
||||||
)}
|
|
||||||
</SettingsCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<SettingsCard
|
|
||||||
title={t("environments.settings.profile.delete_account")}
|
|
||||||
description={t("environments.settings.profile.confirm_delete_account")}>
|
|
||||||
<DeleteAccount
|
|
||||||
session={session}
|
|
||||||
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
|
|
||||||
user={user}
|
|
||||||
organizationsWithSingleOwner={organizationsWithSingleOwner}
|
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
|
||||||
/>
|
|
||||||
</SettingsCard>
|
|
||||||
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</PageContentWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
|
||||||
import { DEFAULT_LOCALE, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
|
||||||
import { getUserLocale } from "@/lib/user/service";
|
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
|
||||||
import { ApiKeyList } from "@/modules/organization/settings/api-keys/components/api-key-list";
|
|
||||||
import { getProjectsByOrganizationId } from "@/modules/organization/settings/api-keys/lib/projects";
|
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|
||||||
const params = await props.params;
|
|
||||||
const t = await getTranslate();
|
|
||||||
|
|
||||||
const { currentUserMembership, organization, session } = await getEnvironmentAuth(params.environmentId);
|
|
||||||
|
|
||||||
const [projects, locale] = await Promise.all([
|
|
||||||
getProjectsByOrganizationId(organization.id),
|
|
||||||
getUserLocale(session.user.id),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const canAccessApiKeys = currentUserMembership.role === "owner" || currentUserMembership.role === "manager";
|
|
||||||
|
|
||||||
if (!canAccessApiKeys) throw new Error(t("common.not_authorized"));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContentWrapper>
|
|
||||||
<PageHeader pageTitle={t("environments.settings.general.organization_settings")} />
|
|
||||||
<SettingsCard
|
|
||||||
title={t("common.api_keys")}
|
|
||||||
description={t("environments.settings.api_keys.api_keys_description")}>
|
|
||||||
<ApiKeyList
|
|
||||||
organizationId={organization.id}
|
|
||||||
locale={locale ?? DEFAULT_LOCALE}
|
|
||||||
isReadOnly={!canAccessApiKeys}
|
|
||||||
projects={projects}
|
|
||||||
/>
|
|
||||||
</SettingsCard>
|
|
||||||
</PageContentWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { notFound } from "next/navigation";
|
|
||||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
|
||||||
import { getMonthlyOrganizationResponseCount } from "@/lib/organization/service";
|
|
||||||
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
|
||||||
import { PricingTable } from "@/modules/ee/billing/components/pricing-table";
|
|
||||||
import { getCloudBillingDisplayContext } from "@/modules/ee/billing/lib/cloud-billing-display";
|
|
||||||
import { getStripeBillingCatalogDisplay } from "@/modules/ee/billing/lib/stripe-billing-catalog";
|
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|
||||||
const params = await props.params;
|
|
||||||
const t = await getTranslate();
|
|
||||||
|
|
||||||
const { organization, isMember, currentUserMembership } = await getEnvironmentAuth(params.environmentId);
|
|
||||||
|
|
||||||
if (!IS_FORMBRICKS_CLOUD) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const [cloudBillingDisplayContext, billingCatalog] = await Promise.all([
|
|
||||||
getCloudBillingDisplayContext(organization.id),
|
|
||||||
getStripeBillingCatalogDisplay(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const organizationWithSyncedBilling = {
|
|
||||||
...organization,
|
|
||||||
billing: cloudBillingDisplayContext.billing,
|
|
||||||
};
|
|
||||||
|
|
||||||
const [responseCount, projectCount] = await Promise.all([
|
|
||||||
getMonthlyOrganizationResponseCount(organization.id),
|
|
||||||
getOrganizationProjectsCount(organization.id),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const hasBillingRights = !isMember;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContentWrapper>
|
|
||||||
<PageHeader pageTitle={t("environments.settings.general.organization_settings")} />
|
|
||||||
|
|
||||||
<PricingTable
|
|
||||||
organization={organizationWithSyncedBilling}
|
|
||||||
environmentId={params.environmentId}
|
|
||||||
responseCount={responseCount}
|
|
||||||
projectCount={projectCount}
|
|
||||||
hasBillingRights={hasBillingRights}
|
|
||||||
currentCloudPlan={cloudBillingDisplayContext.currentCloudPlan}
|
|
||||||
currentBillingInterval={cloudBillingDisplayContext.currentBillingInterval}
|
|
||||||
currentSubscriptionStatus={cloudBillingDisplayContext.currentSubscriptionStatus}
|
|
||||||
pendingChange={cloudBillingDisplayContext.pendingChange}
|
|
||||||
usageCycleStart={cloudBillingDisplayContext.usageCycleStart}
|
|
||||||
usageCycleEnd={cloudBillingDisplayContext.usageCycleEnd}
|
|
||||||
isStripeSetupIncomplete={!organizationWithSyncedBilling.billing.stripeCustomerId}
|
|
||||||
trialDaysRemaining={cloudBillingDisplayContext.trialDaysRemaining}
|
|
||||||
billingCatalog={billingCatalog}
|
|
||||||
/>
|
|
||||||
</PageContentWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { notFound } from "next/navigation";
|
|
||||||
import { AuthenticationError } from "@formbricks/types/errors";
|
|
||||||
import { PrettyUrlsTable } from "@/app/(app)/environments/[environmentId]/settings/(organization)/domain/components/pretty-urls-table";
|
|
||||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
|
||||||
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";
|
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|
||||||
const params = await props.params;
|
|
||||||
const t = await getTranslate();
|
|
||||||
|
|
||||||
if (IS_FORMBRICKS_CLOUD) {
|
|
||||||
return notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { session, currentUserMembership, organization, isOwner, isManager } = await getEnvironmentAuth(
|
|
||||||
params.environmentId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
throw new AuthenticationError(t("common.not_authenticated"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
|
|
||||||
const isOwnerOrManager = isManager || isOwner;
|
|
||||||
|
|
||||||
const surveys = await getSurveysWithSlugsByOrganizationId(organization.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContentWrapper>
|
|
||||||
<PageHeader pageTitle={t("environments.settings.general.organization_settings")} />
|
|
||||||
|
|
||||||
{!IS_STORAGE_CONFIGURED && (
|
|
||||||
<div className="max-w-4xl">
|
|
||||||
<Alert variant="warning">
|
|
||||||
<AlertDescription>{t("common.storage_not_configured")}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FaviconCustomizationSettings
|
|
||||||
organization={organization}
|
|
||||||
hasWhiteLabelPermission={hasWhiteLabelPermission}
|
|
||||||
environmentId={params.environmentId}
|
|
||||||
isReadOnly={!isOwnerOrManager}
|
|
||||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SettingsCard
|
|
||||||
title={t("environments.settings.domain.title")}
|
|
||||||
description={t("environments.settings.domain.description")}>
|
|
||||||
<PrettyUrlsTable surveys={surveys} />
|
|
||||||
</SettingsCard>
|
|
||||||
</PageContentWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
import { CheckIcon } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import { EnterpriseLicenseFeaturesTable } from "@/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/components/EnterpriseLicenseFeaturesTable";
|
|
||||||
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";
|
|
||||||
|
|
||||||
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, currentUserMembership } = await getEnvironmentAuth(params.environmentId);
|
|
||||||
|
|
||||||
const isPricingDisabled = isMember;
|
|
||||||
|
|
||||||
if (isPricingDisabled) {
|
|
||||||
return notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const licenseState = await getEnterpriseLicense();
|
|
||||||
const hasLicense = licenseState.status !== "no-license";
|
|
||||||
|
|
||||||
const paidFeatures = [
|
|
||||||
{ title: t("environments.settings.billing.remove_branding"), comingSoon: false, onRequest: false },
|
|
||||||
{ title: t("environments.settings.enterprise.sso"), comingSoon: false, onRequest: false },
|
|
||||||
{
|
|
||||||
title: t("environments.workspace.languages.multi_language_surveys"),
|
|
||||||
comingSoon: false,
|
|
||||||
onRequest: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("environments.settings.enterprise.organization_roles"),
|
|
||||||
comingSoon: false,
|
|
||||||
onRequest: false,
|
|
||||||
},
|
|
||||||
{ title: t("environments.settings.enterprise.teams"), comingSoon: false, onRequest: false },
|
|
||||||
{
|
|
||||||
title: t("environments.settings.enterprise.contacts_and_segments"),
|
|
||||||
comingSoon: false,
|
|
||||||
onRequest: false,
|
|
||||||
},
|
|
||||||
{ title: t("environments.settings.enterprise.audit_logs"), comingSoon: false, onRequest: true },
|
|
||||||
{ title: t("environments.settings.enterprise.saml_sso"), comingSoon: false, onRequest: true },
|
|
||||||
{
|
|
||||||
title: t("environments.settings.enterprise.service_level_agreement"),
|
|
||||||
comingSoon: false,
|
|
||||||
onRequest: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t("environments.settings.enterprise.soc2_hipaa_iso_27001_compliance_check"),
|
|
||||||
comingSoon: false,
|
|
||||||
onRequest: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContentWrapper>
|
|
||||||
<PageHeader pageTitle={t("environments.settings.general.organization_settings")} />
|
|
||||||
{hasLicense ? (
|
|
||||||
<>
|
|
||||||
<EnterpriseLicenseStatus
|
|
||||||
status={licenseState.status}
|
|
||||||
lastChecked={licenseState.lastChecked}
|
|
||||||
gracePeriodEnd={
|
|
||||||
licenseState.status === "unreachable"
|
|
||||||
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
environmentId={params.environmentId}
|
|
||||||
/>
|
|
||||||
{licenseState.features && <EnterpriseLicenseFeaturesTable features={licenseState.features} />}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 1024 1024"
|
|
||||||
className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
|
|
||||||
aria-hidden="true">
|
|
||||||
<circle
|
|
||||||
cx={512}
|
|
||||||
cy={512}
|
|
||||||
r={512}
|
|
||||||
fill="url(#759c1415-0410-454c-8f7c-9a820de03641)"
|
|
||||||
fillOpacity="0.7"
|
|
||||||
/>
|
|
||||||
<defs>
|
|
||||||
<radialGradient id="759c1415-0410-454c-8f7c-9a820de03641">
|
|
||||||
<stop stopColor="#00E6CA" />
|
|
||||||
<stop offset={0} stopColor="#00C4B8" />
|
|
||||||
</radialGradient>
|
|
||||||
</defs>
|
|
||||||
</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("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("environments.settings.enterprise.keep_full_control_over_your_data_privacy_and_security")}
|
|
||||||
<br />
|
|
||||||
{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("environments.settings.enterprise.enterprise_features")}
|
|
||||||
</h2>
|
|
||||||
<ul className="my-4 space-y-4">
|
|
||||||
{paidFeatures.map((feature) => (
|
|
||||||
<li key={feature.title} className="flex items-center">
|
|
||||||
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800">
|
|
||||||
<CheckIcon className="h-5 w-5 p-0.5 text-green-500 dark:text-green-400" />
|
|
||||||
</div>
|
|
||||||
<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("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("environments.settings.enterprise.on_request")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<p className="my-6 text-sm text-slate-700">
|
|
||||||
{t(
|
|
||||||
"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>
|
|
||||||
<Link
|
|
||||||
href="https://app.formbricks.com/s/clvupq3y205i5yrm3sm9v1xt5"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer nofollow"
|
|
||||||
referrerPolicy="no-referrer">
|
|
||||||
{t("environments.settings.enterprise.request_30_day_trial_license")}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
<p className="mt-2 text-xs text-slate-500">
|
|
||||||
{t("environments.settings.enterprise.no_credit_card_no_sales_call_just_test_it")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</PageContentWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import { AISettingsToggle } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AISettingsToggle";
|
|
||||||
import { DeleteOrganization } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/DeleteOrganization";
|
|
||||||
import { EditOrganizationNameForm } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/EditOrganizationNameForm";
|
|
||||||
import { SecurityListTip } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/SecurityListTip";
|
|
||||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
|
||||||
import { isInstanceAIConfigured } from "@/lib/ai/service";
|
|
||||||
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 { 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 packageJson from "@/package.json";
|
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|
||||||
const params = await props.params;
|
|
||||||
const t = await getTranslate();
|
|
||||||
|
|
||||||
const { session, currentUserMembership, organization, isOwner, isManager } = await getEnvironmentAuth(
|
|
||||||
params.environmentId
|
|
||||||
);
|
|
||||||
|
|
||||||
const user = session?.user?.id ? await getUser(session.user.id) : null;
|
|
||||||
|
|
||||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
|
||||||
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
|
|
||||||
|
|
||||||
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
|
|
||||||
const currentUserRole = currentUserMembership?.role;
|
|
||||||
|
|
||||||
const isOwnerOrManager = isManager || isOwner;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContentWrapper>
|
|
||||||
<PageHeader pageTitle={t("environments.settings.general.organization_settings")} />
|
|
||||||
{!IS_STORAGE_CONFIGURED && (
|
|
||||||
<div className="max-w-4xl">
|
|
||||||
<Alert variant="warning">
|
|
||||||
<AlertDescription>{t("common.storage_not_configured")}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!IS_FORMBRICKS_CLOUD && <SecurityListTip />}
|
|
||||||
<SettingsCard
|
|
||||||
title={t("environments.settings.general.organization_name")}
|
|
||||||
description={t("environments.settings.general.organization_name_description")}>
|
|
||||||
<EditOrganizationNameForm
|
|
||||||
organization={organization}
|
|
||||||
environmentId={params.environmentId}
|
|
||||||
membershipRole={currentUserMembership?.role}
|
|
||||||
/>
|
|
||||||
</SettingsCard>
|
|
||||||
<SettingsCard
|
|
||||||
title={t("environments.settings.general.ai_enabled")}
|
|
||||||
description={t("environments.settings.general.ai_enabled_description")}>
|
|
||||||
<AISettingsToggle
|
|
||||||
organization={organization}
|
|
||||||
membershipRole={currentUserMembership?.role}
|
|
||||||
isInstanceAIConfigured={isInstanceAIConfigured()}
|
|
||||||
/>
|
|
||||||
</SettingsCard>
|
|
||||||
<EmailCustomizationSettings
|
|
||||||
organization={organization}
|
|
||||||
hasWhiteLabelPermission={hasWhiteLabelPermission}
|
|
||||||
environmentId={params.environmentId}
|
|
||||||
isReadOnly={!isOwnerOrManager}
|
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
|
||||||
fbLogoUrl={FB_LOGO_URL}
|
|
||||||
user={user}
|
|
||||||
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
|
||||||
/>
|
|
||||||
{isMultiOrgEnabled && (
|
|
||||||
<SettingsCard
|
|
||||||
title={t("environments.settings.general.delete_organization")}
|
|
||||||
description={t("environments.settings.general.delete_organization_description")}>
|
|
||||||
<DeleteOrganization
|
|
||||||
organization={organization}
|
|
||||||
isDeleteDisabled={isDeleteDisabled}
|
|
||||||
isUserOwner={currentUserRole === "owner"}
|
|
||||||
/>
|
|
||||||
</SettingsCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
|
|
||||||
<IdBadge id={packageJson.version} label={t("common.formbricks_version")} variant="column" />
|
|
||||||
</div>
|
|
||||||
</PageContentWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { IS_FORMBRICKS_CLOUD, USER_MANAGEMENT_MINIMUM_ROLE } from "@/lib/constants";
|
|
||||||
import { getUserManagementAccess } from "@/lib/membership/utils";
|
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
|
||||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
|
||||||
import { getTeamsWhereUserIsAdmin } from "@/modules/ee/teams/lib/roles";
|
|
||||||
import { TeamsView } from "@/modules/ee/teams/team-list/components/teams-view";
|
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
|
||||||
import { MembersView } from "@/modules/organization/settings/teams/components/members-view";
|
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|
||||||
const params = await props.params;
|
|
||||||
const t = await getTranslate();
|
|
||||||
|
|
||||||
const { session, currentUserMembership, organization } = await getEnvironmentAuth(params.environmentId);
|
|
||||||
|
|
||||||
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
|
|
||||||
|
|
||||||
const hasStandardUserManagementAccess = getUserManagementAccess(
|
|
||||||
currentUserMembership?.role,
|
|
||||||
USER_MANAGEMENT_MINIMUM_ROLE
|
|
||||||
);
|
|
||||||
|
|
||||||
const userAdminTeamIds = await getTeamsWhereUserIsAdmin(session.user.id, organization.id);
|
|
||||||
const isTeamAdminUser = userAdminTeamIds.length > 0;
|
|
||||||
|
|
||||||
const hasUserManagementAccess =
|
|
||||||
hasStandardUserManagementAccess || (isAccessControlAllowed && isTeamAdminUser);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContentWrapper>
|
|
||||||
<PageHeader pageTitle={t("environments.settings.general.organization_settings")} />
|
|
||||||
<MembersView
|
|
||||||
membershipRole={currentUserMembership?.role}
|
|
||||||
organization={organization}
|
|
||||||
currentUserId={session.user.id}
|
|
||||||
environmentId={params.environmentId}
|
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
|
||||||
isUserManagementDisabledFromUi={!hasUserManagementAccess}
|
|
||||||
/>
|
|
||||||
<TeamsView
|
|
||||||
organizationId={organization.id}
|
|
||||||
membershipRole={currentUserMembership?.role}
|
|
||||||
currentUserId={session.user.id}
|
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
|
||||||
environmentId={params.environmentId}
|
|
||||||
/>
|
|
||||||
</PageContentWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { ProjectLookSettingsPage } from "@/modules/projects/settings/look/page";
|
|
||||||
|
|
||||||
export default ProjectLookSettingsPage;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { AppConnectionPage } from "@/modules/projects/settings/(setup)/app-connection/page";
|
|
||||||
|
|
||||||
export default AppConnectionPage;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { GeneralSettingsPage } from "@/modules/projects/settings/general/page";
|
|
||||||
|
|
||||||
export default GeneralSettingsPage;
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
|
||||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
|
||||||
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
|
|
||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
|
||||||
import { getAirtableTables } from "@/lib/airtable/service";
|
|
||||||
import { AIRTABLE_CLIENT_ID, DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
|
|
||||||
import { getIntegrations } from "@/lib/integration/service";
|
|
||||||
import { getUserLocale } from "@/lib/user/service";
|
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
|
||||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|
||||||
const params = await props.params;
|
|
||||||
const t = await getTranslate();
|
|
||||||
const isEnabled = !!AIRTABLE_CLIENT_ID;
|
|
||||||
|
|
||||||
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
|
|
||||||
|
|
||||||
const [surveys, integrations, locale] = await Promise.all([
|
|
||||||
getSurveys(params.environmentId),
|
|
||||||
getIntegrations(params.environmentId),
|
|
||||||
getUserLocale(session.user.id),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find(
|
|
||||||
(integration): integration is TIntegrationAirtable => integration.type === "airtable"
|
|
||||||
);
|
|
||||||
|
|
||||||
let airtableArray: TIntegrationItem[] = [];
|
|
||||||
if (airtableIntegration?.config.key) {
|
|
||||||
airtableArray = await getAirtableTables(params.environmentId);
|
|
||||||
}
|
|
||||||
if (isReadOnly) {
|
|
||||||
return redirect("./");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContentWrapper>
|
|
||||||
<GoBackButton
|
|
||||||
url={`${WEBAPP_URL}/environments/${params.environmentId}/settings/workspace/integrations`}
|
|
||||||
/>
|
|
||||||
<PageHeader pageTitle={t("environments.integrations.airtable.airtable_integration")} />
|
|
||||||
<div className="h-[75vh] w-full">
|
|
||||||
<AirtableWrapper
|
|
||||||
isEnabled={isEnabled}
|
|
||||||
airtableIntegration={airtableIntegration}
|
|
||||||
airtableArray={airtableArray}
|
|
||||||
environmentId={environment.id}
|
|
||||||
surveys={surveys}
|
|
||||||
webAppUrl={WEBAPP_URL}
|
|
||||||
locale={locale ?? DEFAULT_LOCALE}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</PageContentWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
|
||||||
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/GoogleSheetWrapper";
|
|
||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
|
||||||
import {
|
|
||||||
DEFAULT_LOCALE,
|
|
||||||
GOOGLE_SHEETS_CLIENT_ID,
|
|
||||||
GOOGLE_SHEETS_CLIENT_SECRET,
|
|
||||||
GOOGLE_SHEETS_REDIRECT_URL,
|
|
||||||
WEBAPP_URL,
|
|
||||||
} from "@/lib/constants";
|
|
||||||
import { getIntegrations } from "@/lib/integration/service";
|
|
||||||
import { getUserLocale } from "@/lib/user/service";
|
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
|
||||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|
||||||
const params = await props.params;
|
|
||||||
const t = await getTranslate();
|
|
||||||
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
|
|
||||||
|
|
||||||
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
|
|
||||||
|
|
||||||
const [surveys, integrations, locale] = await Promise.all([
|
|
||||||
getSurveys(params.environmentId),
|
|
||||||
getIntegrations(params.environmentId),
|
|
||||||
getUserLocale(session.user.id),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const googleSheetIntegration: TIntegrationGoogleSheets | undefined = integrations?.find(
|
|
||||||
(integration): integration is TIntegrationGoogleSheets => integration.type === "googleSheets"
|
|
||||||
);
|
|
||||||
if (isReadOnly) {
|
|
||||||
return redirect("./");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContentWrapper>
|
|
||||||
<GoBackButton
|
|
||||||
url={`${WEBAPP_URL}/environments/${params.environmentId}/settings/workspace/integrations`}
|
|
||||||
/>
|
|
||||||
<PageHeader pageTitle={t("environments.integrations.google_sheets.google_sheets_integration")} />
|
|
||||||
<div className="h-[75vh] w-full">
|
|
||||||
<GoogleSheetWrapper
|
|
||||||
isEnabled={isEnabled}
|
|
||||||
environment={environment}
|
|
||||||
surveys={surveys}
|
|
||||||
googleSheetIntegration={googleSheetIntegration}
|
|
||||||
webAppUrl={WEBAPP_URL}
|
|
||||||
locale={locale ?? DEFAULT_LOCALE}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</PageContentWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
|
|
||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
|
||||||
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/NotionWrapper";
|
|
||||||
import {
|
|
||||||
DEFAULT_LOCALE,
|
|
||||||
NOTION_AUTH_URL,
|
|
||||||
NOTION_OAUTH_CLIENT_ID,
|
|
||||||
NOTION_OAUTH_CLIENT_SECRET,
|
|
||||||
NOTION_REDIRECT_URI,
|
|
||||||
WEBAPP_URL,
|
|
||||||
} from "@/lib/constants";
|
|
||||||
import { getIntegrationByType } from "@/lib/integration/service";
|
|
||||||
import { getNotionDatabases } from "@/lib/notion/service";
|
|
||||||
import { getUserLocale } from "@/lib/user/service";
|
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
|
||||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|
||||||
const params = await props.params;
|
|
||||||
const t = await getTranslate();
|
|
||||||
const enabled = !!(
|
|
||||||
NOTION_OAUTH_CLIENT_ID &&
|
|
||||||
NOTION_OAUTH_CLIENT_SECRET &&
|
|
||||||
NOTION_AUTH_URL &&
|
|
||||||
NOTION_REDIRECT_URI
|
|
||||||
);
|
|
||||||
|
|
||||||
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
|
|
||||||
|
|
||||||
const [surveys, notionIntegration, locale] = await Promise.all([
|
|
||||||
getSurveys(params.environmentId),
|
|
||||||
getIntegrationByType(params.environmentId, "notion"),
|
|
||||||
getUserLocale(session.user.id),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let databasesArray: TIntegrationNotionDatabase[] = [];
|
|
||||||
if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) {
|
|
||||||
databasesArray = (await getNotionDatabases(environment.id)) ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isReadOnly) {
|
|
||||||
return redirect("./");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContentWrapper>
|
|
||||||
<GoBackButton
|
|
||||||
url={`${WEBAPP_URL}/environments/${params.environmentId}/settings/workspace/integrations`}
|
|
||||||
/>
|
|
||||||
<PageHeader pageTitle={t("environments.integrations.notion.notion_integration")} />
|
|
||||||
<NotionWrapper
|
|
||||||
enabled={enabled}
|
|
||||||
surveys={surveys}
|
|
||||||
environment={environment}
|
|
||||||
notionIntegration={notionIntegration as TIntegrationNotion}
|
|
||||||
webAppUrl={WEBAPP_URL}
|
|
||||||
databasesArray={databasesArray}
|
|
||||||
locale={locale ?? DEFAULT_LOCALE}
|
|
||||||
/>
|
|
||||||
</PageContentWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
import { TFunction } from "i18next";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { TIntegrationType } from "@formbricks/types/integration";
|
|
||||||
import { getWebhookCountBySource } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/webhook";
|
|
||||||
import ActivePiecesLogo from "@/images/activepieces.webp";
|
|
||||||
import AirtableLogo from "@/images/airtableLogo.svg";
|
|
||||||
import GoogleSheetsLogo from "@/images/googleSheetsLogo.png";
|
|
||||||
import JsLogo from "@/images/jslogo.png";
|
|
||||||
import MakeLogo from "@/images/make-small.png";
|
|
||||||
import n8nLogo from "@/images/n8n.png";
|
|
||||||
import notionLogo from "@/images/notion.png";
|
|
||||||
import SlackLogo from "@/images/slacklogo.png";
|
|
||||||
import WebhookLogo from "@/images/webhook.png";
|
|
||||||
import ZapierLogo from "@/images/zapier-small.png";
|
|
||||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
|
||||||
import { getIntegrations } from "@/lib/integration/service";
|
|
||||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
|
||||||
import { Card } from "@/modules/ui/components/integration-card";
|
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
|
||||||
|
|
||||||
const getStatusText = (count: number, t: TFunction, type: string) => {
|
|
||||||
if (count === 1) return `1 ${type}`;
|
|
||||||
if (count === 0) return t("common.not_connected");
|
|
||||||
return `${count} ${type}s`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|
||||||
const params = await props.params;
|
|
||||||
const t = await getTranslate();
|
|
||||||
|
|
||||||
const { isReadOnly, environment, isBilling } = await getEnvironmentAuth(params.environmentId);
|
|
||||||
|
|
||||||
const [
|
|
||||||
integrations,
|
|
||||||
userWebhookCount,
|
|
||||||
zapierWebhookCount,
|
|
||||||
makeWebhookCount,
|
|
||||||
n8nwebhookCount,
|
|
||||||
activePiecesWebhookCount,
|
|
||||||
] = await Promise.all([
|
|
||||||
getIntegrations(params.environmentId),
|
|
||||||
getWebhookCountBySource(params.environmentId, "user"),
|
|
||||||
getWebhookCountBySource(params.environmentId, "zapier"),
|
|
||||||
getWebhookCountBySource(params.environmentId, "make"),
|
|
||||||
getWebhookCountBySource(params.environmentId, "n8n"),
|
|
||||||
getWebhookCountBySource(params.environmentId, "activepieces"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const isIntegrationConnected = (type: TIntegrationType) =>
|
|
||||||
integrations.some((integration) => integration.type === type);
|
|
||||||
|
|
||||||
if (isBilling) {
|
|
||||||
return redirect(getBillingFallbackPath(params.environmentId, IS_FORMBRICKS_CLOUD));
|
|
||||||
}
|
|
||||||
|
|
||||||
const isGoogleSheetsIntegrationConnected = isIntegrationConnected("googleSheets");
|
|
||||||
const isNotionIntegrationConnected = isIntegrationConnected("notion");
|
|
||||||
const isAirtableIntegrationConnected = isIntegrationConnected("airtable");
|
|
||||||
const isN8nIntegrationConnected = isIntegrationConnected("n8n");
|
|
||||||
const isSlackIntegrationConnected = isIntegrationConnected("slack");
|
|
||||||
|
|
||||||
const appSetupCompleted = !!environment?.appSetupCompleted;
|
|
||||||
const integrationCards = [
|
|
||||||
{
|
|
||||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/zapier",
|
|
||||||
docsText: t("common.docs"),
|
|
||||||
docsNewTab: true,
|
|
||||||
connectHref: "https://zapier.com/apps/formbricks/integrations",
|
|
||||||
connectText: t("common.connect"),
|
|
||||||
connectNewTab: true,
|
|
||||||
label: "Zapier",
|
|
||||||
description: t("environments.integrations.zapier_integration_description"),
|
|
||||||
icon: <Image src={ZapierLogo} alt="Zapier Logo" />,
|
|
||||||
connected: zapierWebhookCount > 0,
|
|
||||||
statusText: getStatusText(zapierWebhookCount, t, "zap"),
|
|
||||||
disabled: isReadOnly,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
connectHref: `/environments/${params.environmentId}/settings/workspace/integrations/webhooks`,
|
|
||||||
connectText: t("environments.integrations.manage_webhooks"),
|
|
||||||
connectNewTab: false,
|
|
||||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks",
|
|
||||||
docsText: t("common.docs"),
|
|
||||||
docsNewTab: true,
|
|
||||||
label: "Webhooks",
|
|
||||||
description: t("environments.integrations.webhook_integration_description"),
|
|
||||||
icon: <Image src={WebhookLogo} alt="Webhook Logo" />,
|
|
||||||
connected: userWebhookCount > 0,
|
|
||||||
statusText: getStatusText(userWebhookCount, t, "webhook"),
|
|
||||||
disabled: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
connectHref: `/environments/${params.environmentId}/settings/workspace/integrations/google-sheets`,
|
|
||||||
connectText: `${isGoogleSheetsIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
|
||||||
connectNewTab: false,
|
|
||||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/google-sheets",
|
|
||||||
docsText: t("common.docs"),
|
|
||||||
docsNewTab: true,
|
|
||||||
label: "Google Sheets",
|
|
||||||
description: t("environments.integrations.google_sheet_integration_description"),
|
|
||||||
icon: <Image src={GoogleSheetsLogo} alt="Google sheets Logo" />,
|
|
||||||
connected: isGoogleSheetsIntegrationConnected,
|
|
||||||
statusText: isGoogleSheetsIntegrationConnected ? t("common.connected") : t("common.not_connected"),
|
|
||||||
disabled: isReadOnly,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
connectHref: `/environments/${params.environmentId}/settings/workspace/integrations/airtable`,
|
|
||||||
connectText: `${isAirtableIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
|
||||||
connectNewTab: false,
|
|
||||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/airtable",
|
|
||||||
docsText: t("common.docs"),
|
|
||||||
docsNewTab: true,
|
|
||||||
label: "Airtable",
|
|
||||||
description: t("environments.integrations.airtable_integration_description"),
|
|
||||||
icon: <Image src={AirtableLogo} alt="Airtable Logo" />,
|
|
||||||
connected: isAirtableIntegrationConnected,
|
|
||||||
statusText: isAirtableIntegrationConnected ? t("common.connected") : t("common.not_connected"),
|
|
||||||
disabled: isReadOnly,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
connectHref: `/environments/${params.environmentId}/settings/workspace/integrations/slack`,
|
|
||||||
connectText: `${isSlackIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
|
||||||
connectNewTab: false,
|
|
||||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/slack",
|
|
||||||
docsText: t("common.docs"),
|
|
||||||
docsNewTab: true,
|
|
||||||
label: "Slack",
|
|
||||||
description: t("environments.integrations.slack_integration_description"),
|
|
||||||
icon: <Image src={SlackLogo} alt="Slack Logo" />,
|
|
||||||
connected: isSlackIntegrationConnected,
|
|
||||||
statusText: isSlackIntegrationConnected ? t("common.connected") : t("common.not_connected"),
|
|
||||||
disabled: isReadOnly,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/n8n",
|
|
||||||
connectText: `${isN8nIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
|
||||||
docsText: t("common.docs"),
|
|
||||||
docsNewTab: true,
|
|
||||||
connectHref: "https://n8n.io",
|
|
||||||
connectNewTab: true,
|
|
||||||
label: "n8n",
|
|
||||||
description: t("environments.integrations.n8n_integration_description"),
|
|
||||||
icon: <Image src={n8nLogo} alt="n8n Logo" />,
|
|
||||||
connected: n8nwebhookCount > 0,
|
|
||||||
statusText: getStatusText(n8nwebhookCount, t, t("common.integration")),
|
|
||||||
disabled: isReadOnly,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/make",
|
|
||||||
docsText: t("common.docs"),
|
|
||||||
docsNewTab: true,
|
|
||||||
connectHref: "https://www.make.com/en/integrations/formbricks",
|
|
||||||
connectText: t("common.connect"),
|
|
||||||
connectNewTab: true,
|
|
||||||
label: "Make.com",
|
|
||||||
description: t("environments.integrations.make_integration_description"),
|
|
||||||
icon: <Image src={MakeLogo} alt="Make Logo" />,
|
|
||||||
connected: makeWebhookCount > 0,
|
|
||||||
statusText: getStatusText(makeWebhookCount, t, t("common.integration")),
|
|
||||||
disabled: isReadOnly,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
connectHref: `/environments/${params.environmentId}/settings/workspace/integrations/notion`,
|
|
||||||
connectText: `${isNotionIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
|
||||||
connectNewTab: false,
|
|
||||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/notion",
|
|
||||||
docsText: t("common.docs"),
|
|
||||||
docsNewTab: true,
|
|
||||||
label: "Notion",
|
|
||||||
description: t("environments.integrations.notion_integration_description"),
|
|
||||||
icon: <Image src={notionLogo} alt="Notion Logo" />,
|
|
||||||
connected: isNotionIntegrationConnected,
|
|
||||||
statusText: isNotionIntegrationConnected ? t("common.connected") : t("common.not_connected"),
|
|
||||||
disabled: isReadOnly,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/activepieces",
|
|
||||||
docsText: t("common.docs"),
|
|
||||||
docsNewTab: true,
|
|
||||||
connectHref: "https://www.activepieces.com/pieces/formbricks",
|
|
||||||
connectText: t("common.connect"),
|
|
||||||
connectNewTab: true,
|
|
||||||
label: "Activepieces",
|
|
||||||
description: t("environments.integrations.activepieces_integration_description"),
|
|
||||||
icon: <Image src={ActivePiecesLogo} alt="ActivePieces Logo" />,
|
|
||||||
connected: activePiecesWebhookCount > 0,
|
|
||||||
statusText: getStatusText(activePiecesWebhookCount, t, t("common.integration")),
|
|
||||||
disabled: isReadOnly,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
integrationCards.unshift({
|
|
||||||
docsHref: "https://formbricks.com/docs/app-surveys/quickstart",
|
|
||||||
docsText: t("common.docs"),
|
|
||||||
docsNewTab: true,
|
|
||||||
connectHref: `/environments/${params.environmentId}/settings/workspace/connect`,
|
|
||||||
connectText: t("common.connect"),
|
|
||||||
connectNewTab: false,
|
|
||||||
label: "Javascript SDK",
|
|
||||||
description: t("environments.integrations.website_or_app_integration_description"),
|
|
||||||
icon: <Image src={JsLogo} alt="Javascript Logo" />,
|
|
||||||
connected: appSetupCompleted,
|
|
||||||
statusText: appSetupCompleted ? t("common.connected") : t("common.not_connected"),
|
|
||||||
disabled: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContentWrapper>
|
|
||||||
<PageHeader pageTitle={t("common.integrations")} />
|
|
||||||
<div className="grid grid-cols-3 place-content-stretch gap-4 lg:grid-cols-3">
|
|
||||||
{integrationCards.map((card) => (
|
|
||||||
<Card
|
|
||||||
key={card.label}
|
|
||||||
docsHref={card.docsHref}
|
|
||||||
docsText={card.docsText}
|
|
||||||
docsNewTab={card.docsNewTab}
|
|
||||||
connectHref={card.connectHref}
|
|
||||||
connectText={card.connectText}
|
|
||||||
connectNewTab={card.connectNewTab}
|
|
||||||
label={card.label}
|
|
||||||
description={card.description}
|
|
||||||
icon={card.icon}
|
|
||||||
connected={card.connected}
|
|
||||||
statusText={card.statusText}
|
|
||||||
disabled={card.disabled}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</PageContentWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
|
|
||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
|
||||||
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/SlackWrapper";
|
|
||||||
import { DEFAULT_LOCALE, SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
|
|
||||||
import { getIntegrationByType } from "@/lib/integration/service";
|
|
||||||
import { getUserLocale } from "@/lib/user/service";
|
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
|
||||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|
||||||
const params = await props.params;
|
|
||||||
const isEnabled = !!(SLACK_CLIENT_ID && SLACK_CLIENT_SECRET);
|
|
||||||
|
|
||||||
const t = await getTranslate();
|
|
||||||
|
|
||||||
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
|
|
||||||
|
|
||||||
const [surveys, slackIntegration, locale] = await Promise.all([
|
|
||||||
getSurveys(params.environmentId),
|
|
||||||
getIntegrationByType(params.environmentId, "slack"),
|
|
||||||
getUserLocale(session.user.id),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (isReadOnly) {
|
|
||||||
return redirect("./");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContentWrapper>
|
|
||||||
<GoBackButton
|
|
||||||
url={`${WEBAPP_URL}/environments/${params.environmentId}/settings/workspace/integrations`}
|
|
||||||
/>
|
|
||||||
<PageHeader pageTitle={t("environments.integrations.slack.slack_integration")} />
|
|
||||||
<div className="h-[75vh] w-full">
|
|
||||||
<SlackWrapper
|
|
||||||
isEnabled={isEnabled}
|
|
||||||
environment={environment}
|
|
||||||
surveys={surveys}
|
|
||||||
slackIntegration={slackIntegration as TIntegrationSlack}
|
|
||||||
webAppUrl={WEBAPP_URL}
|
|
||||||
locale={locale ?? DEFAULT_LOCALE}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</PageContentWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { WebhooksPage } from "@/modules/integrations/webhooks/page";
|
|
||||||
|
|
||||||
export default WebhooksPage;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { LanguagesPage } from "@/modules/projects/settings/languages/page";
|
|
||||||
|
|
||||||
export default LanguagesPage;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { TagsPage } from "@/modules/projects/settings/tags/page";
|
|
||||||
|
|
||||||
export default TagsPage;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { ProjectTeams } from "@/modules/ee/teams/project-teams/page";
|
|
||||||
|
|
||||||
export default ProjectTeams;
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import { getActionClasses } from "@/lib/actionClass/service";
|
|
||||||
import { DEFAULT_LOCALE } from "@/lib/constants";
|
|
||||||
import { getEnvironments } from "@/lib/environment/service";
|
|
||||||
import { getUserLocale } from "@/lib/user/service";
|
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
|
||||||
import { ActionSettingsCard } from "@/modules/projects/settings/(setup)/components/action-settings-card";
|
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|
||||||
const params = await props.params;
|
|
||||||
const t = await getTranslate();
|
|
||||||
const { environmentId } = params;
|
|
||||||
|
|
||||||
const { environment, isReadOnly, session } = await getEnvironmentAuth(environmentId);
|
|
||||||
|
|
||||||
const [environments, actionClasses, locale] = await Promise.all([
|
|
||||||
getEnvironments(environment.projectId),
|
|
||||||
getActionClasses(environmentId),
|
|
||||||
getUserLocale(session.user.id),
|
|
||||||
]);
|
|
||||||
const otherEnvironment = environments.filter((env) => env.id !== environmentId)[0];
|
|
||||||
const otherEnvActionClasses = otherEnvironment ? await getActionClasses(otherEnvironment.id) : [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContentWrapper>
|
|
||||||
<PageHeader pageTitle={t("common.actions")} />
|
|
||||||
<ActionSettingsCard
|
|
||||||
environment={environment}
|
|
||||||
otherEnvironment={otherEnvironment}
|
|
||||||
otherEnvActionClasses={otherEnvActionClasses}
|
|
||||||
environmentId={environmentId}
|
|
||||||
actionClasses={actionClasses}
|
|
||||||
isReadOnly={isReadOnly}
|
|
||||||
locale={locale ?? DEFAULT_LOCALE}
|
|
||||||
/>
|
|
||||||
</PageContentWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -75,10 +75,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
|||||||
isDevelopment={IS_DEVELOPMENT}
|
isDevelopment={IS_DEVELOPMENT}
|
||||||
membershipRole={membership.role}
|
membershipRole={membership.role}
|
||||||
publicDomain={publicDomain}
|
publicDomain={publicDomain}
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
|
||||||
organizationProjectsLimit={organizationProjectsLimit}
|
|
||||||
isLicenseActive={active}
|
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
|
||||||
/>
|
/>
|
||||||
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
||||||
<TopControlBar
|
<TopControlBar
|
||||||
|
|||||||
@@ -2,59 +2,42 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ArrowUpRightIcon,
|
ArrowUpRightIcon,
|
||||||
Building2Icon,
|
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
Cog,
|
Cog,
|
||||||
FoldersIcon,
|
|
||||||
Loader2,
|
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
PanelLeftCloseIcon,
|
PanelLeftCloseIcon,
|
||||||
PanelLeftOpenIcon,
|
PanelLeftOpenIcon,
|
||||||
PlusIcon,
|
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
SettingsIcon,
|
|
||||||
UserCircleIcon,
|
UserCircleIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
|
WorkflowIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useState, useTransition } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
import {
|
|
||||||
getOrganizationsForSwitcherAction,
|
|
||||||
getProjectsForSwitcherAction,
|
|
||||||
} from "@/app/(app)/environments/[environmentId]/actions";
|
|
||||||
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
||||||
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
|
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
|
||||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
|
||||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||||
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
|
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
|
||||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
|
||||||
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
|
|
||||||
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
|
|
||||||
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
||||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
|
||||||
import packageJson from "../../../../../package.json";
|
import packageJson from "../../../../../package.json";
|
||||||
|
|
||||||
interface NavigationProps {
|
interface NavigationProps {
|
||||||
@@ -66,10 +49,6 @@ interface NavigationProps {
|
|||||||
isDevelopment: boolean;
|
isDevelopment: boolean;
|
||||||
membershipRole?: TOrganizationRole;
|
membershipRole?: TOrganizationRole;
|
||||||
publicDomain: string;
|
publicDomain: string;
|
||||||
isMultiOrgEnabled: boolean;
|
|
||||||
organizationProjectsLimit: number;
|
|
||||||
isLicenseActive: boolean;
|
|
||||||
isAccessControlAllowed: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MainNavigation = ({
|
export const MainNavigation = ({
|
||||||
@@ -81,10 +60,6 @@ export const MainNavigation = ({
|
|||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
isDevelopment,
|
isDevelopment,
|
||||||
publicDomain,
|
publicDomain,
|
||||||
isMultiOrgEnabled,
|
|
||||||
organizationProjectsLimit,
|
|
||||||
isLicenseActive,
|
|
||||||
isAccessControlAllowed,
|
|
||||||
}: NavigationProps) => {
|
}: NavigationProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -94,12 +69,7 @@ export const MainNavigation = ({
|
|||||||
const [latestVersion, setLatestVersion] = useState("");
|
const [latestVersion, setLatestVersion] = useState("");
|
||||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
|
||||||
const { isManager, isOwner, isBilling, isMember } = getAccessFlags(membershipRole);
|
|
||||||
const isMembershipPending = membershipRole === undefined;
|
|
||||||
const disabledNavigationMessage = isMembershipPending
|
|
||||||
? t("common.loading")
|
|
||||||
: t("common.you_are_not_authorized_to_perform_this_action");
|
|
||||||
|
|
||||||
const isOwnerOrManager = isManager || isOwner;
|
const isOwnerOrManager = isManager || isOwner;
|
||||||
|
|
||||||
@@ -136,7 +106,6 @@ export const MainNavigation = ({
|
|||||||
icon: MessageCircle,
|
icon: MessageCircle,
|
||||||
isActive: pathname?.includes("/surveys"),
|
isActive: pathname?.includes("/surveys"),
|
||||||
isHidden: false,
|
isHidden: false,
|
||||||
disabled: isMembershipPending || isBilling,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: `/environments/${environment.id}/contacts`,
|
href: `/environments/${environment.id}/contacts`,
|
||||||
@@ -146,24 +115,29 @@ export const MainNavigation = ({
|
|||||||
pathname?.includes("/contacts") ||
|
pathname?.includes("/contacts") ||
|
||||||
pathname?.includes("/segments") ||
|
pathname?.includes("/segments") ||
|
||||||
pathname?.includes("/attributes"),
|
pathname?.includes("/attributes"),
|
||||||
disabled: isMembershipPending || isBilling,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t("common.settings"),
|
name: t("common.workflows"),
|
||||||
href: `/environments/${environment.id}/settings/workspace/general`,
|
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,
|
icon: Cog,
|
||||||
isActive: pathname?.includes("/settings/"),
|
isActive: pathname?.includes("/workspace"),
|
||||||
disabled: isMembershipPending || isBilling,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t, environment.id, pathname, isMembershipPending, isBilling]
|
[t, environment.id, pathname, isFormbricksCloud]
|
||||||
);
|
);
|
||||||
|
|
||||||
const dropdownNavigation = [
|
const dropdownNavigation = [
|
||||||
{
|
{
|
||||||
label: t("common.settings"),
|
label: t("common.account"),
|
||||||
href: `/environments/${environment.id}/settings/account/profile`,
|
href: `/environments/${environment.id}/settings/profile`,
|
||||||
icon: SettingsIcon,
|
icon: UserCircleIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("common.documentation"),
|
label: t("common.documentation"),
|
||||||
@@ -179,111 +153,6 @@ export const MainNavigation = ({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = useState(false);
|
|
||||||
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
|
|
||||||
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
|
|
||||||
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
|
|
||||||
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
|
||||||
const [hasInitializedProjects, setHasInitializedProjects] = useState(false);
|
|
||||||
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
|
|
||||||
const [workspaceLoadError, setWorkspaceLoadError] = useState<string | null>(null);
|
|
||||||
const [organizationLoadError, setOrganizationLoadError] = useState<string | null>(null);
|
|
||||||
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
|
|
||||||
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
|
|
||||||
const [openProjectLimitModal, setOpenProjectLimitModal] = useState(false);
|
|
||||||
|
|
||||||
const renderSwitcherError = (error: string, onRetry: () => void, retryLabel: string) => (
|
|
||||||
<div className="px-2 py-4">
|
|
||||||
<p className="mb-2 text-sm text-red-600">{error}</p>
|
|
||||||
<button onClick={onRetry} className="text-xs text-slate-600 underline hover:text-slate-800">
|
|
||||||
{retryLabel}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const workspaceSettingsHref = `/environments/${environment.id}/settings/workspace/general`;
|
|
||||||
const organizationSettingsHref = `/environments/${environment.id}/settings/organization/general`;
|
|
||||||
|
|
||||||
const loadProjects = useCallback(async () => {
|
|
||||||
setIsLoadingProjects(true);
|
|
||||||
setWorkspaceLoadError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await getProjectsForSwitcherAction({ organizationId: organization.id });
|
|
||||||
if (result?.data) {
|
|
||||||
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
setProjects(sorted);
|
|
||||||
} else {
|
|
||||||
setWorkspaceLoadError(getFormattedErrorMessage(result) || t("common.failed_to_load_workspaces"));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const formattedError =
|
|
||||||
typeof error === "object" && error !== null
|
|
||||||
? getFormattedErrorMessage(error as { serverError?: string; validationErrors?: unknown })
|
|
||||||
: "";
|
|
||||||
setWorkspaceLoadError(
|
|
||||||
formattedError || (error instanceof Error ? error.message : t("common.failed_to_load_workspaces"))
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingProjects(false);
|
|
||||||
setHasInitializedProjects(true);
|
|
||||||
}
|
|
||||||
}, [organization.id, t]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isWorkspaceDropdownOpen || projects.length > 0 || isLoadingProjects || workspaceLoadError) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadProjects();
|
|
||||||
}, [isWorkspaceDropdownOpen, projects.length, isLoadingProjects, workspaceLoadError, loadProjects]);
|
|
||||||
|
|
||||||
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 (error) {
|
|
||||||
const formattedError =
|
|
||||||
typeof error === "object" && error !== null
|
|
||||||
? getFormattedErrorMessage(error as { serverError?: string; validationErrors?: unknown })
|
|
||||||
: "";
|
|
||||||
setOrganizationLoadError(
|
|
||||||
formattedError || (error instanceof Error ? error.message : t("common.failed_to_load_organizations"))
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingOrganizations(false);
|
|
||||||
}
|
|
||||||
}, [organization.id, t]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!isOrganizationDropdownOpen ||
|
|
||||||
organizations.length > 0 ||
|
|
||||||
isLoadingOrganizations ||
|
|
||||||
organizationLoadError
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadOrganizations();
|
|
||||||
}, [
|
|
||||||
isOrganizationDropdownOpen,
|
|
||||||
organizations.length,
|
|
||||||
isLoadingOrganizations,
|
|
||||||
organizationLoadError,
|
|
||||||
loadOrganizations,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadReleases() {
|
async function loadReleases() {
|
||||||
const res = await getLatestStableFbReleaseAction();
|
const res = await getLatestStableFbReleaseAction();
|
||||||
@@ -313,79 +182,7 @@ export const MainNavigation = ({
|
|||||||
organization.billing?.stripe?.trialEnd,
|
organization.billing?.stripe?.trialEnd,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const mainNavigationLink = isBilling
|
const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`;
|
||||||
? getBillingFallbackPath(environment.id, isFormbricksCloud)
|
|
||||||
: `/environments/${environment.id}/surveys/`;
|
|
||||||
|
|
||||||
const handleProjectChange = (projectId: string) => {
|
|
||||||
if (projectId === project.id) return;
|
|
||||||
startTransition(() => {
|
|
||||||
router.push(`/workspaces/${projectId}/`);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOrganizationChange = (organizationId: string) => {
|
|
||||||
if (organizationId === organization.id) return;
|
|
||||||
startTransition(() => {
|
|
||||||
router.push(`/organizations/${organizationId}/`);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSettingNavigation = (href: string) => {
|
|
||||||
startTransition(() => {
|
|
||||||
router.push(href);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleProjectCreate = () => {
|
|
||||||
if (!hasInitializedProjects || isLoadingProjects) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (projects.length >= organizationProjectsLimit) {
|
|
||||||
setOpenProjectLimitModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpenCreateProjectModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const projectLimitModalButtons = (): [ModalButton, ModalButton] => {
|
|
||||||
if (isFormbricksCloud) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
text: t("environments.settings.billing.upgrade"),
|
|
||||||
href: `/environments/${environment.id}/settings/billing`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("common.cancel"),
|
|
||||||
onClick: () => setOpenProjectLimitModal(false),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
text: t("environments.settings.billing.upgrade"),
|
|
||||||
href: isLicenseActive
|
|
||||||
? `/environments/${environment.id}/settings/enterprise`
|
|
||||||
: "https://formbricks.com/upgrade-self-hosted-license",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("common.cancel"),
|
|
||||||
onClick: () => setOpenProjectLimitModal(false),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
const switcherTriggerClasses = cn(
|
|
||||||
"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",
|
|
||||||
isCollapsed ? "flex items-center justify-center" : ""
|
|
||||||
);
|
|
||||||
|
|
||||||
const switcherIconClasses =
|
|
||||||
"flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-slate-600";
|
|
||||||
const isInitialProjectsLoading = isWorkspaceDropdownOpen && !hasInitializedProjects && !workspaceLoadError;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -425,24 +222,24 @@ export const MainNavigation = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Nav Switch */}
|
{/* Main Nav Switch */}
|
||||||
<ul>
|
{!isBilling && (
|
||||||
{mainNavigation.map(
|
<ul>
|
||||||
(item) =>
|
{mainNavigation.map(
|
||||||
!item.isHidden && (
|
(item) =>
|
||||||
<NavigationLink
|
!item.isHidden && (
|
||||||
key={item.name}
|
<NavigationLink
|
||||||
href={item.href}
|
key={item.name}
|
||||||
isActive={item.isActive}
|
href={item.href}
|
||||||
isCollapsed={isCollapsed}
|
isActive={item.isActive}
|
||||||
isTextVisible={isTextVisible}
|
isCollapsed={isCollapsed}
|
||||||
disabled={item.disabled}
|
isTextVisible={isTextVisible}
|
||||||
disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
|
linkText={item.name}>
|
||||||
linkText={item.name}>
|
<item.icon strokeWidth={1.5} />
|
||||||
<item.icon strokeWidth={1.5} />
|
</NavigationLink>
|
||||||
</NavigationLink>
|
)
|
||||||
)
|
)}
|
||||||
)}
|
</ul>
|
||||||
</ul>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -466,193 +263,38 @@ export const MainNavigation = ({
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col">
|
{/* User Switch */}
|
||||||
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
|
<div className="flex items-center">
|
||||||
<DropdownMenuTrigger asChild id="workspaceDropdownTrigger" className={switcherTriggerClasses}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={isCollapsed ? t("common.change_workspace") : undefined}
|
|
||||||
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
|
|
||||||
<span className={switcherIconClasses}>
|
|
||||||
<FoldersIcon className="h-4 w-4" strokeWidth={1.5} />
|
|
||||||
</span>
|
|
||||||
{!isCollapsed && !isTextVisible && (
|
|
||||||
<>
|
|
||||||
<div className="grow overflow-hidden">
|
|
||||||
<p className="truncate text-sm font-bold text-slate-700">{project.name}</p>
|
|
||||||
<p className="text-sm text-slate-500">{t("common.workspace")}</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">
|
|
||||||
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
|
||||||
{t("common.change_workspace")}
|
|
||||||
</div>
|
|
||||||
{(isLoadingProjects || isInitialProjectsLoading) && (
|
|
||||||
<div className="flex items-center justify-center py-2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isLoadingProjects &&
|
|
||||||
!isInitialProjectsLoading &&
|
|
||||||
workspaceLoadError &&
|
|
||||||
renderSwitcherError(
|
|
||||||
workspaceLoadError,
|
|
||||||
() => {
|
|
||||||
setWorkspaceLoadError(null);
|
|
||||||
setProjects([]);
|
|
||||||
},
|
|
||||||
t("common.try_again")
|
|
||||||
)}
|
|
||||||
{!isLoadingProjects && !isInitialProjectsLoading && !workspaceLoadError && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
|
||||||
{projects.map((proj) => (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={proj.id}
|
|
||||||
checked={proj.id === project.id}
|
|
||||||
onClick={() => handleProjectChange(proj.id)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
{proj.name}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
{isOwnerOrManager && (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
onClick={handleProjectCreate}
|
|
||||||
className="w-full cursor-pointer justify-between">
|
|
||||||
<span>{t("common.add_new_workspace")}</span>
|
|
||||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleSettingNavigation(workspaceSettingsHref)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
<SettingsIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
|
||||||
{t("common.settings")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
asChild
|
|
||||||
id="organizationDropdownTriggerSidebar"
|
|
||||||
className={switcherTriggerClasses}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={isCollapsed ? t("common.change_organization") : undefined}
|
|
||||||
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
|
|
||||||
<span className={switcherIconClasses}>
|
|
||||||
<Building2Icon className="h-4 w-4" strokeWidth={1.5} />
|
|
||||||
</span>
|
|
||||||
{!isCollapsed && !isTextVisible && (
|
|
||||||
<>
|
|
||||||
<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 &&
|
|
||||||
renderSwitcherError(
|
|
||||||
organizationLoadError,
|
|
||||||
() => {
|
|
||||||
setOrganizationLoadError(null);
|
|
||||||
setOrganizations([]);
|
|
||||||
},
|
|
||||||
t("common.try_again")
|
|
||||||
)}
|
|
||||||
{!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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleSettingNavigation(organizationSettingsHref)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
<SettingsIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
|
||||||
{t("common.settings")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
asChild
|
asChild
|
||||||
id="userDropdownTrigger"
|
id="userDropdownTrigger"
|
||||||
className={cn(switcherTriggerClasses, "rounded-br-xl")}>
|
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
||||||
<button
|
<div
|
||||||
type="button"
|
className={cn(
|
||||||
aria-label={isCollapsed ? t("common.account_settings") : undefined}
|
"flex cursor-pointer flex-row items-center gap-3",
|
||||||
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
|
isCollapsed ? "justify-center px-2" : "px-4"
|
||||||
<span className={switcherIconClasses}>
|
)}>
|
||||||
<ProfileAvatar userId={user.id} />
|
<ProfileAvatar userId={user.id} />
|
||||||
</span>
|
|
||||||
{!isCollapsed && !isTextVisible && (
|
{!isCollapsed && !isTextVisible && (
|
||||||
<>
|
<>
|
||||||
<div className="grow overflow-hidden">
|
<div
|
||||||
|
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
|
||||||
<p
|
<p
|
||||||
title={user?.email}
|
title={user?.email}
|
||||||
className="ph-no-capture 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>}
|
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-slate-500">{t("common.account")}</p>
|
<p className="text-sm text-slate-700">{t("common.account")}</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
@@ -661,6 +303,8 @@ export const MainNavigation = ({
|
|||||||
sideOffset={10}
|
sideOffset={10}
|
||||||
alignOffset={5}
|
alignOffset={5}
|
||||||
align="end">
|
align="end">
|
||||||
|
{/* Dropdown Items */}
|
||||||
|
|
||||||
{dropdownNavigation.map((link) => (
|
{dropdownNavigation.map((link) => (
|
||||||
<Link
|
<Link
|
||||||
href={link.href}
|
href={link.href}
|
||||||
@@ -674,6 +318,7 @@ export const MainNavigation = ({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
{/* Logout */}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const loginUrl = `${publicDomain}/auth/login`;
|
const loginUrl = `${publicDomain}/auth/login`;
|
||||||
@@ -696,28 +341,6 @@ export const MainNavigation = ({
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
{openProjectLimitModal && (
|
|
||||||
<ProjectLimitModal
|
|
||||||
open={openProjectLimitModal}
|
|
||||||
setOpen={setOpenProjectLimitModal}
|
|
||||||
buttons={projectLimitModalButtons()}
|
|
||||||
projectLimit={organizationProjectsLimit}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{openCreateProjectModal && (
|
|
||||||
<CreateProjectModal
|
|
||||||
open={openCreateProjectModal}
|
|
||||||
setOpen={setOpenCreateProjectModal}
|
|
||||||
organizationId={organization.id}
|
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{openCreateOrganizationModal && (
|
|
||||||
<CreateOrganizationModal
|
|
||||||
open={openCreateOrganizationModal}
|
|
||||||
setOpen={setOpenCreateOrganizationModal}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||||
|
|
||||||
interface NavigationLinkProps {
|
interface NavigationLinkProps {
|
||||||
@@ -11,8 +10,6 @@ interface NavigationLinkProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
linkText: string;
|
linkText: string;
|
||||||
isTextVisible: boolean;
|
isTextVisible: boolean;
|
||||||
disabled?: boolean;
|
|
||||||
disabledMessage?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NavigationLink = ({
|
export const NavigationLink = ({
|
||||||
@@ -22,34 +19,10 @@ export const NavigationLink = ({
|
|||||||
children,
|
children,
|
||||||
linkText,
|
linkText,
|
||||||
isTextVisible = true,
|
isTextVisible = true,
|
||||||
disabled = false,
|
|
||||||
disabledMessage,
|
|
||||||
}: NavigationLinkProps) => {
|
}: NavigationLinkProps) => {
|
||||||
const tooltipText = disabled ? disabledMessage || linkText : linkText;
|
|
||||||
const activeClass = "bg-slate-50 border-r-4 border-brand-dark font-semibold text-slate-900";
|
const activeClass = "bg-slate-50 border-r-4 border-brand-dark font-semibold text-slate-900";
|
||||||
const inactiveClass =
|
const inactiveClass =
|
||||||
"hover:bg-slate-50 border-r-4 border-transparent hover:border-slate-300 transition-all duration-150 ease-in-out";
|
"hover:bg-slate-50 border-r-4 border-transparent hover:border-slate-300 transition-all duration-150 ease-in-out";
|
||||||
const disabledClass = "cursor-not-allowed border-r-4 border-transparent text-slate-400";
|
|
||||||
const getColorClass = (baseClass: string) => {
|
|
||||||
if (disabled) {
|
|
||||||
return disabledClass;
|
|
||||||
}
|
|
||||||
|
|
||||||
return cn(baseClass, isActive ? activeClass : inactiveClass);
|
|
||||||
};
|
|
||||||
|
|
||||||
const collapsedColorClass = getColorClass("text-slate-700 hover:text-slate-900");
|
|
||||||
const expandedColorClass = getColorClass("text-slate-600 hover:text-slate-900");
|
|
||||||
|
|
||||||
const label = (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"ml-2 flex transition-opacity duration-100",
|
|
||||||
isTextVisible ? "opacity-0" : "opacity-100"
|
|
||||||
)}>
|
|
||||||
{linkText}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -57,37 +30,35 @@ export const NavigationLink = ({
|
|||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<li className={cn("mb-1 ml-2 rounded-l-md py-2 pl-2 text-sm", collapsedColorClass)}>
|
<li
|
||||||
{disabled ? (
|
className={cn(
|
||||||
<div className="flex items-center">{children}</div>
|
"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}>{children}</Link>
|
)}>
|
||||||
)}
|
<Link href={href} className="flex items-center">
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">{tooltipText}</TooltipContent>
|
<TooltipContent side="right">{linkText}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
) : (
|
) : (
|
||||||
<li className={cn("mb-1 rounded-l-md py-2 pl-5 text-sm", expandedColorClass)}>
|
<li
|
||||||
{disabled ? (
|
className={cn(
|
||||||
<Popover>
|
"mb-1 rounded-l-md py-2 pl-5 text-sm text-slate-600 hover:text-slate-900",
|
||||||
<PopoverTrigger asChild>
|
isActive ? activeClass : inactiveClass
|
||||||
<div className="flex items-center">
|
)}>
|
||||||
{children}
|
<Link href={href} className="flex items-center">
|
||||||
{label}
|
{children}
|
||||||
</div>
|
<span
|
||||||
</PopoverTrigger>
|
className={cn(
|
||||||
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
|
"ml-2 flex transition-opacity duration-100",
|
||||||
{disabledMessage || linkText}
|
isTextVisible ? "opacity-0" : "opacity-100"
|
||||||
</PopoverContent>
|
)}>
|
||||||
</Popover>
|
{linkText}
|
||||||
) : (
|
</span>
|
||||||
<Link href={href} className="flex items-center">
|
</Link>
|
||||||
{children}
|
|
||||||
{label}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -31,8 +31,7 @@ export const TopControlBar = ({
|
|||||||
isAccessControlAllowed,
|
isAccessControlAllowed,
|
||||||
membershipRole,
|
membershipRole,
|
||||||
}: TopControlBarProps) => {
|
}: TopControlBarProps) => {
|
||||||
const { isBilling } = getAccessFlags(membershipRole);
|
const { isMember } = getAccessFlags(membershipRole);
|
||||||
const isMembershipPending = membershipRole === undefined;
|
|
||||||
const { environment } = useEnvironment();
|
const { environment } = useEnvironment();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -49,8 +48,7 @@ export const TopControlBar = ({
|
|||||||
isFormbricksCloud={isFormbricksCloud}
|
isFormbricksCloud={isFormbricksCloud}
|
||||||
isLicenseActive={isLicenseActive}
|
isLicenseActive={isLicenseActive}
|
||||||
isOwnerOrManager={isOwnerOrManager}
|
isOwnerOrManager={isOwnerOrManager}
|
||||||
isBilling={isBilling}
|
isMember={isMember}
|
||||||
isMembershipPending={isMembershipPending}
|
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
isAccessControlAllowed={isAccessControlAllowed}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { Building2Icon, ChevronDownIcon, ChevronRightIcon, Loader2, PlusIcon } from "lucide-react";
|
import {
|
||||||
import { useRouter } from "next/navigation";
|
Building2Icon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
Loader2,
|
||||||
|
PlusIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
@@ -15,46 +22,69 @@ import {
|
|||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuGroup,
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
import { useOrganization } from "../context/environment-context";
|
import { useOrganization } from "../context/environment-context";
|
||||||
|
|
||||||
interface OrganizationBreadcrumbProps {
|
interface OrganizationBreadcrumbProps {
|
||||||
currentOrganizationId: string;
|
currentOrganizationId: string;
|
||||||
currentOrganizationName?: string;
|
currentOrganizationName?: string; // Optional: pass directly if context not available
|
||||||
isMultiOrgEnabled: boolean;
|
isMultiOrgEnabled: boolean;
|
||||||
currentEnvironmentId?: string;
|
currentEnvironmentId?: string;
|
||||||
isMembershipPending: boolean;
|
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 = ({
|
export const OrganizationBreadcrumb = ({
|
||||||
currentOrganizationId,
|
currentOrganizationId,
|
||||||
currentOrganizationName,
|
currentOrganizationName,
|
||||||
isMultiOrgEnabled,
|
isMultiOrgEnabled,
|
||||||
currentEnvironmentId,
|
currentEnvironmentId,
|
||||||
isMembershipPending,
|
isFormbricksCloud,
|
||||||
|
isMember,
|
||||||
|
isOwnerOrManager,
|
||||||
}: OrganizationBreadcrumbProps) => {
|
}: OrganizationBreadcrumbProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
|
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
|
||||||
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
|
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
|
||||||
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
|
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
|
||||||
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
|
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Get current organization name from context OR prop
|
||||||
|
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
|
||||||
const { organization: currentOrganization } = useOrganization();
|
const { organization: currentOrganization } = useOrganization();
|
||||||
const organizationName = currentOrganization?.name || currentOrganizationName || "";
|
const organizationName = currentOrganization?.name || currentOrganizationName || "";
|
||||||
|
|
||||||
|
// Lazy-load organizations when dropdown opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Only fetch when dropdown opened for first time (and no error state)
|
||||||
if (isOrganizationDropdownOpen && organizations.length === 0 && !isLoadingOrganizations && !loadError) {
|
if (isOrganizationDropdownOpen && organizations.length === 0 && !isLoadingOrganizations && !loadError) {
|
||||||
setIsLoadingOrganizations(true);
|
setIsLoadingOrganizations(true);
|
||||||
setLoadError(null);
|
setLoadError(null); // Clear any previous errors
|
||||||
getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
||||||
if (result?.data) {
|
if (result?.data) {
|
||||||
|
// Sort organizations by name
|
||||||
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
|
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
setOrganizations(sorted);
|
setOrganizations(sorted);
|
||||||
} else {
|
} else {
|
||||||
|
// Handle server errors or validation errors
|
||||||
const errorMessage = getFormattedErrorMessage(result);
|
const errorMessage = getFormattedErrorMessage(result);
|
||||||
const error = new Error(errorMessage);
|
const error = new Error(errorMessage);
|
||||||
logger.error(error, "Failed to load organizations");
|
logger.error(error, "Failed to load organizations");
|
||||||
@@ -87,8 +117,53 @@ export const OrganizationBreadcrumb = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Hide organization dropdown for single org setups (on-premise)
|
||||||
const showOrganizationDropdown = isMultiOrgEnabled || organizations.length > 1;
|
const showOrganizationDropdown = isMultiOrgEnabled || organizations.length > 1;
|
||||||
|
|
||||||
|
const handleSettingChange = (href: string) => {
|
||||||
|
startTransition(() => {
|
||||||
|
setIsOrganizationDropdownOpen(false);
|
||||||
|
router.push(href);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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: "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 (
|
return (
|
||||||
<BreadcrumbItem isActive={isOrganizationDropdownOpen}>
|
<BreadcrumbItem isActive={isOrganizationDropdownOpen}>
|
||||||
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
|
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
|
||||||
@@ -157,6 +232,28 @@ export const OrganizationBreadcrumb = ({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{currentEnvironmentId && (
|
||||||
|
<div>
|
||||||
|
{showOrganizationDropdown && <DropdownMenuSeparator />}
|
||||||
|
<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>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
{openCreateOrganizationModal && (
|
{openCreateOrganizationModal && (
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ interface ProjectAndOrgSwitchProps {
|
|||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
isLicenseActive: boolean;
|
isLicenseActive: boolean;
|
||||||
isOwnerOrManager: boolean;
|
isOwnerOrManager: boolean;
|
||||||
isBilling: boolean;
|
isMember: boolean;
|
||||||
isMembershipPending: boolean;
|
|
||||||
isAccessControlAllowed: boolean;
|
isAccessControlAllowed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,8 +34,7 @@ export const ProjectAndOrgSwitch = ({
|
|||||||
isLicenseActive,
|
isLicenseActive,
|
||||||
isOwnerOrManager,
|
isOwnerOrManager,
|
||||||
isAccessControlAllowed,
|
isAccessControlAllowed,
|
||||||
isBilling,
|
isMember,
|
||||||
isMembershipPending,
|
|
||||||
}: ProjectAndOrgSwitchProps) => {
|
}: ProjectAndOrgSwitchProps) => {
|
||||||
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
||||||
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
|
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
|
||||||
@@ -49,7 +47,9 @@ export const ProjectAndOrgSwitch = ({
|
|||||||
currentOrganizationName={currentOrganizationName}
|
currentOrganizationName={currentOrganizationName}
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
currentEnvironmentId={currentEnvironmentId}
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
isMembershipPending={isMembershipPending}
|
isFormbricksCloud={isFormbricksCloud}
|
||||||
|
isMember={isMember}
|
||||||
|
isOwnerOrManager={isOwnerOrManager}
|
||||||
/>
|
/>
|
||||||
{currentProjectId && currentEnvironmentId && (
|
{currentProjectId && currentEnvironmentId && (
|
||||||
<ProjectBreadcrumb
|
<ProjectBreadcrumb
|
||||||
@@ -63,8 +63,6 @@ export const ProjectAndOrgSwitch = ({
|
|||||||
isLicenseActive={isLicenseActive}
|
isLicenseActive={isLicenseActive}
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
isAccessControlAllowed={isAccessControlAllowed}
|
||||||
isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb}
|
isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb}
|
||||||
isBilling={isBilling}
|
|
||||||
isMembershipPending={isMembershipPending}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showEnvironmentBreadcrumb && (
|
{showEnvironmentBreadcrumb && (
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { ChevronDownIcon, ChevronRightIcon, FoldersIcon, Loader2, PlusIcon } from "lucide-react";
|
import { ChevronDownIcon, ChevronRightIcon, CogIcon, HotelIcon, Loader2, PlusIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
@@ -16,15 +16,15 @@ import {
|
|||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuGroup,
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
|
||||||
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
||||||
import { useProject } from "../context/environment-context";
|
import { useProject } from "../context/environment-context";
|
||||||
|
|
||||||
interface ProjectBreadcrumbProps {
|
interface ProjectBreadcrumbProps {
|
||||||
currentProjectId: string;
|
currentProjectId: string;
|
||||||
currentProjectName?: string;
|
currentProjectName?: string; // Optional: pass directly if context not available
|
||||||
isOwnerOrManager: boolean;
|
isOwnerOrManager: boolean;
|
||||||
organizationProjectsLimit: number;
|
organizationProjectsLimit: number;
|
||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
@@ -33,10 +33,18 @@ interface ProjectBreadcrumbProps {
|
|||||||
currentEnvironmentId: string;
|
currentEnvironmentId: string;
|
||||||
isAccessControlAllowed: boolean;
|
isAccessControlAllowed: boolean;
|
||||||
isEnvironmentBreadcrumbVisible: boolean;
|
isEnvironmentBreadcrumbVisible: boolean;
|
||||||
isBilling: boolean;
|
|
||||||
isMembershipPending: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
||||||
|
// Match /workspace/{settingId} or /workspace/{settingId}/... but exclude settings paths
|
||||||
|
if (pathname.includes("/settings/")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Check if path matches /workspace/{settingId} (with optional trailing path)
|
||||||
|
const pattern = new RegExp(`/workspace/${settingId}(?:/|$)`);
|
||||||
|
return pattern.test(pathname);
|
||||||
|
};
|
||||||
|
|
||||||
export const ProjectBreadcrumb = ({
|
export const ProjectBreadcrumb = ({
|
||||||
currentProjectId,
|
currentProjectId,
|
||||||
currentProjectName,
|
currentProjectName,
|
||||||
@@ -48,7 +56,6 @@ export const ProjectBreadcrumb = ({
|
|||||||
currentEnvironmentId,
|
currentEnvironmentId,
|
||||||
isAccessControlAllowed,
|
isAccessControlAllowed,
|
||||||
isEnvironmentBreadcrumbVisible,
|
isEnvironmentBreadcrumbVisible,
|
||||||
isMembershipPending,
|
|
||||||
}: ProjectBreadcrumbProps) => {
|
}: ProjectBreadcrumbProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
|
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
|
||||||
@@ -59,19 +66,26 @@ export const ProjectBreadcrumb = ({
|
|||||||
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
|
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// Get current project name from context OR prop
|
||||||
|
// Context is preferred, but prop is fallback for pages without EnvironmentContextWrapper
|
||||||
const { project: currentProject } = useProject();
|
const { project: currentProject } = useProject();
|
||||||
const projectName = currentProject?.name || currentProjectName || "";
|
const projectName = currentProject?.name || currentProjectName || "";
|
||||||
|
|
||||||
|
// Lazy-load projects when dropdown opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Only fetch when dropdown opened for first time (and no error state)
|
||||||
if (isProjectDropdownOpen && projects.length === 0 && !isLoadingProjects && !loadError) {
|
if (isProjectDropdownOpen && projects.length === 0 && !isLoadingProjects && !loadError) {
|
||||||
setIsLoadingProjects(true);
|
setIsLoadingProjects(true);
|
||||||
setLoadError(null);
|
setLoadError(null); // Clear any previous errors
|
||||||
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
||||||
if (result?.data) {
|
if (result?.data) {
|
||||||
|
// Sort projects by name
|
||||||
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
|
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
setProjects(sorted);
|
setProjects(sorted);
|
||||||
} else {
|
} else {
|
||||||
|
// Handle server errors or validation errors
|
||||||
const errorMessage = getFormattedErrorMessage(result);
|
const errorMessage = getFormattedErrorMessage(result);
|
||||||
const error = new Error(errorMessage);
|
const error = new Error(errorMessage);
|
||||||
logger.error(error, "Failed to load projects");
|
logger.error(error, "Failed to load projects");
|
||||||
@@ -83,6 +97,44 @@ export const ProjectBreadcrumb = ({
|
|||||||
}
|
}
|
||||||
}, [isProjectDropdownOpen, currentOrganizationId, projects.length, isLoadingProjects, loadError, t]);
|
}, [isProjectDropdownOpen, currentOrganizationId, projects.length, isLoadingProjects, loadError, t]);
|
||||||
|
|
||||||
|
const projectSettings = [
|
||||||
|
{
|
||||||
|
id: "general",
|
||||||
|
label: t("common.general"),
|
||||||
|
href: `/environments/${currentEnvironmentId}/workspace/general`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "look",
|
||||||
|
label: t("common.look_and_feel"),
|
||||||
|
href: `/environments/${currentEnvironmentId}/workspace/look`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "app-connection",
|
||||||
|
label: t("common.website_and_app_connection"),
|
||||||
|
href: `/environments/${currentEnvironmentId}/workspace/app-connection`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "integrations",
|
||||||
|
label: t("common.integrations"),
|
||||||
|
href: `/environments/${currentEnvironmentId}/workspace/integrations`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "teams",
|
||||||
|
label: t("common.team_access"),
|
||||||
|
href: `/environments/${currentEnvironmentId}/workspace/teams`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "languages",
|
||||||
|
label: t("common.survey_languages"),
|
||||||
|
href: `/environments/${currentEnvironmentId}/workspace/languages`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tags",
|
||||||
|
label: t("common.tags"),
|
||||||
|
href: `/environments/${currentEnvironmentId}/workspace/tags`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
if (!currentProject) {
|
if (!currentProject) {
|
||||||
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
|
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
|
||||||
logger.error(errorMessage);
|
logger.error(errorMessage);
|
||||||
@@ -105,12 +157,18 @@ export const ProjectBreadcrumb = ({
|
|||||||
setOpenCreateProjectModal(true);
|
setOpenCreateProjectModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleProjectSettingsNavigation = (settingId: string) => {
|
||||||
|
startTransition(() => {
|
||||||
|
router.push(`/environments/${currentEnvironmentId}/workspace/${settingId}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const LimitModalButtons = (): [ModalButton, ModalButton] => {
|
const LimitModalButtons = (): [ModalButton, ModalButton] => {
|
||||||
if (isFormbricksCloud) {
|
if (isFormbricksCloud) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
text: t("environments.settings.billing.upgrade"),
|
text: t("environments.settings.billing.upgrade"),
|
||||||
href: `/environments/${currentEnvironmentId}/settings/organization/billing`,
|
href: `/environments/${currentEnvironmentId}/settings/billing`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: t("common.cancel"),
|
text: t("common.cancel"),
|
||||||
@@ -123,7 +181,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
{
|
{
|
||||||
text: t("environments.settings.billing.upgrade"),
|
text: t("environments.settings.billing.upgrade"),
|
||||||
href: isLicenseActive
|
href: isLicenseActive
|
||||||
? `/environments/${currentEnvironmentId}/settings/organization/enterprise`
|
? `/environments/${currentEnvironmentId}/settings/enterprise`
|
||||||
: "https://formbricks.com/upgrade-self-hosted-license",
|
: "https://formbricks.com/upgrade-self-hosted-license",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -132,7 +190,6 @@ export const ProjectBreadcrumb = ({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BreadcrumbItem isActive={isProjectDropdownOpen}>
|
<BreadcrumbItem isActive={isProjectDropdownOpen}>
|
||||||
<DropdownMenu onOpenChange={setIsProjectDropdownOpen}>
|
<DropdownMenu onOpenChange={setIsProjectDropdownOpen}>
|
||||||
@@ -141,7 +198,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
id="projectDropdownTrigger"
|
id="projectDropdownTrigger"
|
||||||
asChild>
|
asChild>
|
||||||
<div className="flex items-center gap-1">
|
<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>{projectName}</span>
|
<span>{projectName}</span>
|
||||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||||
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
|
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
|
||||||
@@ -154,7 +211,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
|
|
||||||
<DropdownMenuContent align="start" className="mt-2">
|
<DropdownMenuContent align="start" className="mt-2">
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
<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")}
|
{t("common.choose_workspace")}
|
||||||
</div>
|
</div>
|
||||||
{isLoadingProjects && (
|
{isLoadingProjects && (
|
||||||
@@ -190,24 +247,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
</DropdownMenuCheckboxItem>
|
</DropdownMenuCheckboxItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
{isMembershipPending || !isOwnerOrManager ? (
|
{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>
|
|
||||||
) : (
|
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
onClick={handleAddProject}
|
onClick={handleAddProject}
|
||||||
className="w-full cursor-pointer justify-between">
|
className="w-full cursor-pointer justify-between">
|
||||||
@@ -217,8 +257,25 @@ export const ProjectBreadcrumb = ({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
|
<CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||||
|
{t("common.workspace_configuration")}
|
||||||
|
</div>
|
||||||
|
{projectSettings.map((setting) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={setting.id}
|
||||||
|
checked={isActiveProjectSetting(pathname, setting.id)}
|
||||||
|
onClick={() => handleProjectSettingsNavigation(setting.id)}
|
||||||
|
className="cursor-pointer">
|
||||||
|
{setting.label}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
{/* Modals */}
|
||||||
{openLimitModal && (
|
{openLimitModal && (
|
||||||
<ProjectLimitModal
|
<ProjectLimitModal
|
||||||
open={openLimitModal}
|
open={openLimitModal}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { redirect } from "next/navigation";
|
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 { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
|
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
|
||||||
@@ -12,11 +13,13 @@ const EnvLayout = async (props: {
|
|||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
|
|
||||||
|
// Check session first (required for userId)
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return redirect(`/auth/login`);
|
return redirect(`/auth/login`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Single consolidated data fetch (replaces ~12 individual fetches)
|
||||||
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
|
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -26,7 +29,7 @@ const EnvLayout = async (props: {
|
|||||||
environment={layoutData.environment}
|
environment={layoutData.environment}
|
||||||
project={layoutData.project}
|
project={layoutData.project}
|
||||||
organization={layoutData.organization}>
|
organization={layoutData.organization}>
|
||||||
{children}
|
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
|
||||||
</EnvironmentContextWrapper>
|
</EnvironmentContextWrapper>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
@@ -13,7 +12,11 @@ const EnvironmentPage = async (props: { params: Promise<{ environmentId: string
|
|||||||
const { isBilling } = getAccessFlags(currentUserMembership?.role);
|
const { isBilling } = getAccessFlags(currentUserMembership?.role);
|
||||||
|
|
||||||
if (isBilling) {
|
if (isBilling) {
|
||||||
return redirect(getBillingFallbackPath(params.environmentId, IS_FORMBRICKS_CLOUD));
|
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`);
|
return redirect(`/environments/${params.environmentId}/surveys`);
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||||
|
|
||||||
|
interface AccountSettingsNavbarProps {
|
||||||
|
environmentId?: string;
|
||||||
|
activeId: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AccountSettingsNavbar = ({ environmentId, activeId, loading }: AccountSettingsNavbarProps) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigation = [
|
||||||
|
{
|
||||||
|
id: "profile",
|
||||||
|
label: t("common.profile"),
|
||||||
|
href: `/environments/${environmentId}/settings/profile`,
|
||||||
|
current: pathname?.includes("/profile"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notifications",
|
||||||
|
label: t("common.notifications"),
|
||||||
|
href: `/environments/${environmentId}/settings/notifications`,
|
||||||
|
current: pathname?.includes("/notifications"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { LoadingCard } from "@/app/(app)/components/LoadingCard";
|
import { LoadingCard } from "@/app/(app)/components/LoadingCard";
|
||||||
|
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
|
|
||||||
@@ -17,7 +18,9 @@ const Loading = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<PageHeader pageTitle={t("common.account_settings")} />
|
<PageHeader pageTitle={t("common.account_settings")}>
|
||||||
|
<AccountSettingsNavbar activeId="notifications" loading />
|
||||||
|
</PageHeader>
|
||||||
{cards.map((card, index) => (
|
{cards.map((card, index) => (
|
||||||
<LoadingCard key={index} {...card} />
|
<LoadingCard key={index} {...card} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,8 +1,190 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { getServerSession } from "next-auth";
|
||||||
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
|
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||||
|
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 Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
const setCompleteNotificationSettings = (
|
||||||
|
notificationSettings: TUserNotificationSettings,
|
||||||
|
memberships: Membership[]
|
||||||
|
): TUserNotificationSettings => {
|
||||||
|
const newNotificationSettings: TUserNotificationSettings = {
|
||||||
|
alert: {} as Record<string, boolean>,
|
||||||
|
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
|
||||||
|
};
|
||||||
|
for (const membership of memberships) {
|
||||||
|
for (const project of membership.organization.projects) {
|
||||||
|
// set default values for alerts
|
||||||
|
for (const environment of project.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newNotificationSettings;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMemberships = async (userId: string): Promise<Membership[]> => {
|
||||||
|
const memberships = await prisma.membership.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
role: {
|
||||||
|
not: "billing",
|
||||||
|
},
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
// Fetch all projects if user role is owner or manager
|
||||||
|
role: {
|
||||||
|
in: ["owner", "manager"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Filter projects based on team membership if user is not owner or manager
|
||||||
|
organization: {
|
||||||
|
projects: {
|
||||||
|
some: {
|
||||||
|
projectTeams: {
|
||||||
|
some: {
|
||||||
|
team: {
|
||||||
|
teamUsers: {
|
||||||
|
some: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
organization: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
projects: {
|
||||||
|
// Apply conditional filtering based on user's role
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
// Fetch all projects if user is owner or manager
|
||||||
|
organization: {
|
||||||
|
memberships: {
|
||||||
|
some: {
|
||||||
|
userId,
|
||||||
|
role: {
|
||||||
|
in: ["owner", "manager"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Only include projects accessible through teams if user is not owner or manager
|
||||||
|
projectTeams: {
|
||||||
|
some: {
|
||||||
|
team: {
|
||||||
|
teamUsers: {
|
||||||
|
some: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
environments: {
|
||||||
|
where: {
|
||||||
|
type: "production",
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
surveys: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return memberships;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Page = async (props: {
|
||||||
|
params: Promise<{ environmentId: string }>;
|
||||||
|
searchParams: Promise<Record<string, string>>;
|
||||||
|
}) => {
|
||||||
|
const searchParams = await props.searchParams;
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
return redirect(`/environments/${params.environmentId}/settings/account/notifications`);
|
const t = await getTranslate();
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
|
if (!session) {
|
||||||
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
|
}
|
||||||
|
const autoDisableNotificationType = searchParams["type"];
|
||||||
|
const autoDisableNotificationElementId = searchParams["elementId"];
|
||||||
|
|
||||||
|
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
|
||||||
|
if (!user) {
|
||||||
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!memberships) {
|
||||||
|
throw new ResourceNotFoundError(t("common.membership"), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user?.notificationSettings) {
|
||||||
|
user.notificationSettings = setCompleteNotificationSettings(user.notificationSettings, memberships);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<PageContentWrapper>
|
||||||
|
<PageHeader pageTitle={t("common.account_settings")}>
|
||||||
|
<AccountSettingsNavbar environmentId={params.environmentId} activeId="notifications" />
|
||||||
|
</PageHeader>
|
||||||
|
<SettingsCard
|
||||||
|
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 environmentId={params.environmentId} />
|
||||||
|
</PageContentWrapper>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Page;
|
export default Page;
|
||||||
|
|||||||
@@ -10,16 +10,15 @@ import {
|
|||||||
getIsEmailUnique,
|
getIsEmailUnique,
|
||||||
verifyUserPassword,
|
verifyUserPassword,
|
||||||
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
|
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
|
||||||
import { EMAIL_VERIFICATION_DISABLED, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
|
||||||
import { getUser, updateUser } from "@/lib/user/service";
|
import { getUser, updateUser } from "@/lib/user/service";
|
||||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||||
import { requestPasswordReset } from "@/modules/auth/forgot-password/lib/password-reset-service";
|
|
||||||
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
||||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
import { sendVerificationNewEmail } from "@/modules/email";
|
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
|
||||||
|
|
||||||
function buildUserUpdatePayload(parsedInput: TUserPersonalInfoUpdateInput): TUserUpdateInput {
|
function buildUserUpdatePayload(parsedInput: TUserPersonalInfoUpdateInput): TUserUpdateInput {
|
||||||
return {
|
return {
|
||||||
@@ -86,15 +85,11 @@ export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPerso
|
|||||||
|
|
||||||
export const resetPasswordAction = authenticatedActionClient.action(
|
export const resetPasswordAction = authenticatedActionClient.action(
|
||||||
withAuditLogging("passwordReset", "user", async ({ ctx }) => {
|
withAuditLogging("passwordReset", "user", async ({ ctx }) => {
|
||||||
if (PASSWORD_RESET_DISABLED) {
|
|
||||||
throw new OperationNotAllowedError("Password reset is disabled");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ctx.user.identityProvider !== "email") {
|
if (ctx.user.identityProvider !== "email") {
|
||||||
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
|
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;
|
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||||
|
|
||||||
|
|||||||
@@ -116,14 +116,10 @@ export const EditProfileDetailsForm = ({
|
|||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const result = await updateUserAction({
|
await updateUserAction({
|
||||||
...data,
|
...data,
|
||||||
name: data.name.trim(),
|
name: data.name.trim(),
|
||||||
});
|
});
|
||||||
if (result?.serverError) {
|
|
||||||
toast.error(getFormattedErrorMessage(result));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toast.success(t("environments.settings.profile.profile_updated_successfully"));
|
toast.success(t("environments.settings.profile.profile_updated_successfully"));
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
form.reset(data);
|
form.reset(data);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { LoadingCard } from "@/app/(app)/components/LoadingCard";
|
import { LoadingCard } from "@/app/(app)/components/LoadingCard";
|
||||||
|
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
|
|
||||||
@@ -27,7 +28,9 @@ const Loading = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<PageHeader pageTitle={t("common.account_settings")} />
|
<PageHeader pageTitle={t("common.account_settings")}>
|
||||||
|
<AccountSettingsNavbar activeId="profile" loading />
|
||||||
|
</PageHeader>
|
||||||
{cards.map((card, index) => (
|
{cards.map((card, index) => (
|
||||||
<LoadingCard key={index} {...card} />
|
<LoadingCard key={index} {...card} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,8 +1,102 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { AuthenticationError } from "@formbricks/types/errors";
|
||||||
|
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 { SettingsCard } from "../../components/SettingsCard";
|
||||||
|
import { DeleteAccount } from "./components/DeleteAccount";
|
||||||
|
import { EditProfileDetailsForm } from "./components/EditProfileDetailsForm";
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||||
|
const isTwoFactorAuthEnabled = await getIsTwoFactorAuthEnabled();
|
||||||
|
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
return redirect(`/environments/${params.environmentId}/settings/account/profile`);
|
const t = await getTranslate();
|
||||||
|
const { environmentId } = params;
|
||||||
|
|
||||||
|
const { session } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
|
const organizationsWithSingleOwner = await getOrganizationsWhereUserIsSingleOwner(session.user.id);
|
||||||
|
|
||||||
|
const user = session?.user ? await getUser(session.user.id) : null;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContentWrapper>
|
||||||
|
<PageHeader pageTitle={t("common.account_settings")}>
|
||||||
|
<AccountSettingsNavbar environmentId={environmentId} activeId="profile" />
|
||||||
|
</PageHeader>
|
||||||
|
{user && (
|
||||||
|
<div>
|
||||||
|
<SettingsCard
|
||||||
|
title={t("environments.settings.profile.personal_information")}
|
||||||
|
description={t("environments.settings.profile.update_personal_info")}>
|
||||||
|
<EditProfileDetailsForm
|
||||||
|
user={user}
|
||||||
|
emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED}
|
||||||
|
isPasswordResetEnabled={isPasswordResetEnabled}
|
||||||
|
/>
|
||||||
|
</SettingsCard>
|
||||||
|
{user.identityProvider === "email" && (
|
||||||
|
<SettingsCard
|
||||||
|
title={t("common.security")}
|
||||||
|
description={t("environments.settings.profile.security_description")}>
|
||||||
|
{!isTwoFactorAuthEnabled && !user.twoFactorEnabled ? (
|
||||||
|
<UpgradePrompt
|
||||||
|
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
|
||||||
|
? `/environments/${params.environmentId}/settings/billing`
|
||||||
|
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("common.learn_more"),
|
||||||
|
href: IS_FORMBRICKS_CLOUD
|
||||||
|
? `/environments/${params.environmentId}/settings/billing`
|
||||||
|
: "https://formbricks.com/learn-more-self-hosting-license",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<AccountSecurity user={user} />
|
||||||
|
)}
|
||||||
|
</SettingsCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SettingsCard
|
||||||
|
title={t("environments.settings.profile.delete_account")}
|
||||||
|
description={t("environments.settings.profile.confirm_delete_account")}>
|
||||||
|
<DeleteAccount
|
||||||
|
session={session}
|
||||||
|
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
|
||||||
|
user={user}
|
||||||
|
organizationsWithSingleOwner={organizationsWithSingleOwner}
|
||||||
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
|
/>
|
||||||
|
</SettingsCard>
|
||||||
|
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageContentWrapper>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Page;
|
export default Page;
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { APIKeysPage } from "@/modules/organization/settings/api-keys/page";
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
export default APIKeysPage;
|
||||||
const params = await props.params;
|
|
||||||
return redirect(`/environments/${params.environmentId}/settings/organization/api-keys`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
|
|||||||
@@ -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 { getTranslate } from "@/lingodotdev/server";
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
@@ -6,7 +8,9 @@ const Loading = async () => {
|
|||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<PageHeader pageTitle={t("environments.settings.general.organization_settings")} />
|
<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-64 animate-pulse rounded-xl bg-slate-200"></div>
|
||||||
<div className="my-8 h-96 animate-pulse rounded-md bg-slate-200"></div>
|
<div className="my-8 h-96 animate-pulse rounded-md bg-slate-200"></div>
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { PricingPage } from "@/modules/ee/billing/page";
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
export default PricingPage;
|
||||||
const params = await props.params;
|
|
||||||
return redirect(`/environments/${params.environmentId}/settings/organization/billing`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"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: "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} />;
|
||||||
|
};
|
||||||
@@ -1,8 +1,73 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { AuthenticationError } from "@formbricks/types/errors";
|
||||||
|
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||||
|
import { 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 { SettingsCard } from "../../components/SettingsCard";
|
||||||
|
import { OrganizationSettingsNavbar } from "../components/OrganizationSettingsNavbar";
|
||||||
|
import { PrettyUrlsTable } from "./components/pretty-urls-table";
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
return redirect(`/environments/${params.environmentId}/settings/organization/domain`);
|
const t = await getTranslate();
|
||||||
|
|
||||||
|
if (IS_FORMBRICKS_CLOUD) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { session, currentUserMembership, organization, isOwner, isManager } = await getEnvironmentAuth(
|
||||||
|
params.environmentId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
|
||||||
|
const isOwnerOrManager = isManager || isOwner;
|
||||||
|
|
||||||
|
const surveys = await getSurveysWithSlugsByOrganizationId(organization.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContentWrapper>
|
||||||
|
<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">
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertDescription>{t("common.storage_not_configured")}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FaviconCustomizationSettings
|
||||||
|
organization={organization}
|
||||||
|
hasWhiteLabelPermission={hasWhiteLabelPermission}
|
||||||
|
environmentId={params.environmentId}
|
||||||
|
isReadOnly={!isOwnerOrManager}
|
||||||
|
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingsCard
|
||||||
|
title={t("environments.settings.domain.title")}
|
||||||
|
description={t("environments.settings.domain.description")}>
|
||||||
|
<PrettyUrlsTable surveys={surveys} />
|
||||||
|
</SettingsCard>
|
||||||
|
</PageContentWrapper>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Page;
|
export default Page;
|
||||||
|
|||||||
@@ -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 { getTranslate } from "@/lingodotdev/server";
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
@@ -6,7 +8,9 @@ const Loading = async () => {
|
|||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<PageHeader pageTitle={t("environments.settings.general.organization_settings")} />
|
<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-64 animate-pulse rounded-xl bg-slate-200"></div>
|
||||||
<div className="my-8 h-96 animate-pulse rounded-md bg-slate-200"></div>
|
<div className="my-8 h-96 animate-pulse rounded-md bg-slate-200"></div>
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
|
|||||||
@@ -1,8 +1,194 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { CheckIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
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 { EnterpriseLicenseFeaturesTable } from "./components/EnterpriseLicenseFeaturesTable";
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
return redirect(`/environments/${params.environmentId}/settings/organization/enterprise`);
|
const t = await getTranslate();
|
||||||
|
if (IS_FORMBRICKS_CLOUD) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isMember, currentUserMembership } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
|
const isPricingDisabled = isMember;
|
||||||
|
|
||||||
|
if (isPricingDisabled) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const licenseState = await getEnterpriseLicense();
|
||||||
|
const hasLicense = licenseState.status !== "no-license";
|
||||||
|
|
||||||
|
const paidFeatures = [
|
||||||
|
{
|
||||||
|
title: t("environments.settings.billing.remove_branding"),
|
||||||
|
comingSoon: false,
|
||||||
|
onRequest: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("environments.settings.enterprise.sso"),
|
||||||
|
comingSoon: false,
|
||||||
|
onRequest: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("environments.workspace.languages.multi_language_surveys"),
|
||||||
|
comingSoon: false,
|
||||||
|
onRequest: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("environments.settings.enterprise.organization_roles"),
|
||||||
|
comingSoon: false,
|
||||||
|
onRequest: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("environments.settings.enterprise.teams"),
|
||||||
|
comingSoon: false,
|
||||||
|
onRequest: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("environments.settings.enterprise.contacts_and_segments"),
|
||||||
|
comingSoon: false,
|
||||||
|
onRequest: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("environments.settings.enterprise.audit_logs"),
|
||||||
|
comingSoon: false,
|
||||||
|
onRequest: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("environments.settings.enterprise.saml_sso"),
|
||||||
|
comingSoon: false,
|
||||||
|
onRequest: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("environments.settings.enterprise.service_level_agreement"),
|
||||||
|
comingSoon: false,
|
||||||
|
onRequest: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("environments.settings.enterprise.soc2_hipaa_iso_27001_compliance_check"),
|
||||||
|
comingSoon: false,
|
||||||
|
onRequest: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContentWrapper>
|
||||||
|
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
|
||||||
|
<OrganizationSettingsNavbar
|
||||||
|
environmentId={params.environmentId}
|
||||||
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
|
membershipRole={currentUserMembership?.role}
|
||||||
|
activeId="enterprise"
|
||||||
|
/>
|
||||||
|
</PageHeader>
|
||||||
|
{hasLicense ? (
|
||||||
|
<>
|
||||||
|
<EnterpriseLicenseStatus
|
||||||
|
status={licenseState.status}
|
||||||
|
lastChecked={licenseState.lastChecked}
|
||||||
|
gracePeriodEnd={
|
||||||
|
licenseState.status === "unreachable"
|
||||||
|
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
environmentId={params.environmentId}
|
||||||
|
/>
|
||||||
|
{licenseState.features && <EnterpriseLicenseFeaturesTable features={licenseState.features} />}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 1024 1024"
|
||||||
|
className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
|
||||||
|
aria-hidden="true">
|
||||||
|
<circle
|
||||||
|
cx={512}
|
||||||
|
cy={512}
|
||||||
|
r={512}
|
||||||
|
fill="url(#759c1415-0410-454c-8f7c-9a820de03641)"
|
||||||
|
fillOpacity="0.7"
|
||||||
|
/>
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="759c1415-0410-454c-8f7c-9a820de03641">
|
||||||
|
<stop stopColor="#00E6CA" />
|
||||||
|
<stop offset={0} stopColor="#00C4B8" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
</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("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("environments.settings.enterprise.keep_full_control_over_your_data_privacy_and_security")}
|
||||||
|
<br />
|
||||||
|
{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("environments.settings.enterprise.enterprise_features")}
|
||||||
|
</h2>
|
||||||
|
<ul className="my-4 space-y-4">
|
||||||
|
{paidFeatures.map((feature) => (
|
||||||
|
<li key={feature.title} className="flex items-center">
|
||||||
|
<div className="rounded-full border border-green-300 bg-green-100 p-0.5 dark:bg-green-800">
|
||||||
|
<CheckIcon className="h-5 w-5 p-0.5 text-green-500 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<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("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("environments.settings.enterprise.on_request")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="my-6 text-sm text-slate-700">
|
||||||
|
{t(
|
||||||
|
"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>
|
||||||
|
<Link
|
||||||
|
href="https://app.formbricks.com/s/clvupq3y205i5yrm3sm9v1xt5"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer nofollow"
|
||||||
|
referrerPolicy="no-referrer">
|
||||||
|
{t("environments.settings.enterprise.request_30_day_trial_license")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<p className="mt-2 text-xs text-slate-500">
|
||||||
|
{t("environments.settings.enterprise.no_credit_card_no_sales_call_just_test_it")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageContentWrapper>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Page;
|
export default Page;
|
||||||
|
|||||||
@@ -1,218 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
|
||||||
import { updateOrganizationAISettingsAction } from "./actions";
|
|
||||||
import { ZOrganizationAISettingsInput } from "./schemas";
|
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
isInstanceAIConfigured: vi.fn(),
|
|
||||||
checkAuthorizationUpdated: vi.fn(),
|
|
||||||
deleteOrganization: vi.fn(),
|
|
||||||
getOrganization: vi.fn(),
|
|
||||||
getIsMultiOrgEnabled: vi.fn(),
|
|
||||||
getTranslate: vi.fn(),
|
|
||||||
updateOrganization: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/utils/action-client", () => ({
|
|
||||||
authenticatedActionClient: {
|
|
||||||
inputSchema: vi.fn(() => ({
|
|
||||||
action: vi.fn((fn) => fn),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
|
|
||||||
checkAuthorizationUpdated: mocks.checkAuthorizationUpdated,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/organization/service", () => ({
|
|
||||||
deleteOrganization: mocks.deleteOrganization,
|
|
||||||
getOrganization: mocks.getOrganization,
|
|
||||||
updateOrganization: mocks.updateOrganization,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/ai/service", () => ({
|
|
||||||
isInstanceAIConfigured: mocks.isInstanceAIConfigured,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lingodotdev/server", () => ({
|
|
||||||
getTranslate: mocks.getTranslate,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
|
||||||
withAuditLogging: vi.fn((_eventName, _objectType, fn) => fn),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
|
||||||
getIsMultiOrgEnabled: mocks.getIsMultiOrgEnabled,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const organizationId = "cm9gptbhg0000192zceq9ayuc";
|
|
||||||
|
|
||||||
describe("organization AI settings actions", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
|
|
||||||
mocks.checkAuthorizationUpdated.mockResolvedValue(undefined);
|
|
||||||
mocks.getOrganization.mockResolvedValue({
|
|
||||||
id: organizationId,
|
|
||||||
isAISmartToolsEnabled: false,
|
|
||||||
isAIDataAnalysisEnabled: false,
|
|
||||||
});
|
|
||||||
mocks.isInstanceAIConfigured.mockReturnValue(true);
|
|
||||||
mocks.getTranslate.mockResolvedValue((key: string, values?: Record<string, string>) =>
|
|
||||||
values ? `${key}:${JSON.stringify(values)}` : key
|
|
||||||
);
|
|
||||||
mocks.updateOrganization.mockResolvedValue({
|
|
||||||
id: organizationId,
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
isAIDataAnalysisEnabled: false,
|
|
||||||
});
|
|
||||||
mocks.getIsMultiOrgEnabled.mockResolvedValue(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("accepts AI toggle updates", () => {
|
|
||||||
expect(
|
|
||||||
ZOrganizationAISettingsInput.parse({
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
})
|
|
||||||
).toEqual({
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("passes owner and manager roles to the authorization check and updates organization settings", async () => {
|
|
||||||
const ctx = {
|
|
||||||
user: { id: "user_1", locale: "en-US" },
|
|
||||||
auditLoggingCtx: {},
|
|
||||||
};
|
|
||||||
const parsedInput = {
|
|
||||||
organizationId,
|
|
||||||
data: {
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await updateOrganizationAISettingsAction({ ctx, parsedInput } as any);
|
|
||||||
|
|
||||||
expect(mocks.checkAuthorizationUpdated).toHaveBeenCalledWith({
|
|
||||||
userId: "user_1",
|
|
||||||
organizationId,
|
|
||||||
access: [
|
|
||||||
{
|
|
||||||
type: "organization",
|
|
||||||
schema: ZOrganizationAISettingsInput,
|
|
||||||
data: parsedInput.data,
|
|
||||||
roles: ["owner", "manager"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(mocks.getOrganization).toHaveBeenCalledWith(organizationId);
|
|
||||||
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, parsedInput.data);
|
|
||||||
expect(ctx.auditLoggingCtx).toMatchObject({
|
|
||||||
organizationId,
|
|
||||||
oldObject: {
|
|
||||||
id: organizationId,
|
|
||||||
isAISmartToolsEnabled: false,
|
|
||||||
isAIDataAnalysisEnabled: false,
|
|
||||||
},
|
|
||||||
newObject: {
|
|
||||||
id: organizationId,
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
isAIDataAnalysisEnabled: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(result).toEqual({
|
|
||||||
id: organizationId,
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
isAIDataAnalysisEnabled: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("propagates authorization failures so members cannot update AI settings", async () => {
|
|
||||||
mocks.checkAuthorizationUpdated.mockRejectedValueOnce(new AuthorizationError("Not authorized"));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
updateOrganizationAISettingsAction({
|
|
||||||
ctx: {
|
|
||||||
user: { id: "user_member", locale: "en-US" },
|
|
||||||
auditLoggingCtx: {},
|
|
||||||
},
|
|
||||||
parsedInput: {
|
|
||||||
organizationId,
|
|
||||||
data: {
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as any)
|
|
||||||
).rejects.toThrow(AuthorizationError);
|
|
||||||
|
|
||||||
expect(mocks.updateOrganization).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("rejects enabling AI when the instance AI provider is not configured", async () => {
|
|
||||||
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
updateOrganizationAISettingsAction({
|
|
||||||
ctx: {
|
|
||||||
user: { id: "user_owner", locale: "en-US" },
|
|
||||||
auditLoggingCtx: {},
|
|
||||||
},
|
|
||||||
parsedInput: {
|
|
||||||
organizationId,
|
|
||||||
data: {
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as any)
|
|
||||||
).rejects.toThrow(OperationNotAllowedError);
|
|
||||||
|
|
||||||
expect(mocks.updateOrganization).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("allows enabling AI when the instance configuration is valid", async () => {
|
|
||||||
await updateOrganizationAISettingsAction({
|
|
||||||
ctx: {
|
|
||||||
user: { id: "user_owner", locale: "en-US" },
|
|
||||||
auditLoggingCtx: {},
|
|
||||||
},
|
|
||||||
parsedInput: {
|
|
||||||
organizationId,
|
|
||||||
data: {
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, {
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("allows disabling AI when the instance configuration later becomes invalid", async () => {
|
|
||||||
mocks.getOrganization.mockResolvedValueOnce({
|
|
||||||
id: organizationId,
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
isAIDataAnalysisEnabled: false,
|
|
||||||
});
|
|
||||||
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
|
|
||||||
|
|
||||||
await updateOrganizationAISettingsAction({
|
|
||||||
ctx: {
|
|
||||||
user: { id: "user_owner", locale: "en-US" },
|
|
||||||
auditLoggingCtx: {},
|
|
||||||
},
|
|
||||||
parsedInput: {
|
|
||||||
organizationId,
|
|
||||||
data: {
|
|
||||||
isAISmartToolsEnabled: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, {
|
|
||||||
isAISmartToolsEnabled: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -2,44 +2,13 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||||
import type { TOrganizationRole } from "@formbricks/types/memberships";
|
|
||||||
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
|
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
|
||||||
import { isInstanceAIConfigured } from "@/lib/ai/service";
|
|
||||||
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
|
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
|
||||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
|
||||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { ZOrganizationAISettingsInput, ZUpdateOrganizationAISettingsAction } from "./schemas";
|
|
||||||
|
|
||||||
async function updateOrganizationAction<T extends z.ZodRawShape>({
|
|
||||||
ctx,
|
|
||||||
organizationId,
|
|
||||||
schema,
|
|
||||||
data,
|
|
||||||
roles,
|
|
||||||
}: {
|
|
||||||
ctx: AuthenticatedActionClientCtx;
|
|
||||||
organizationId: string;
|
|
||||||
schema: z.ZodObject<T>;
|
|
||||||
data: z.infer<z.ZodObject<T>>;
|
|
||||||
roles: TOrganizationRole[];
|
|
||||||
}) {
|
|
||||||
await checkAuthorizationUpdated({
|
|
||||||
userId: ctx.user.id,
|
|
||||||
organizationId,
|
|
||||||
access: [{ type: "organization", schema, data, roles }],
|
|
||||||
});
|
|
||||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
|
||||||
const oldObject = await getOrganization(organizationId);
|
|
||||||
const result = await updateOrganization(organizationId, data);
|
|
||||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
|
||||||
ctx.auditLoggingCtx.newObject = result;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ZUpdateOrganizationNameAction = z.object({
|
const ZUpdateOrganizationNameAction = z.object({
|
||||||
organizationId: ZId,
|
organizationId: ZId,
|
||||||
@@ -49,114 +18,26 @@ const ZUpdateOrganizationNameAction = z.object({
|
|||||||
export const updateOrganizationNameAction = authenticatedActionClient
|
export const updateOrganizationNameAction = authenticatedActionClient
|
||||||
.inputSchema(ZUpdateOrganizationNameAction)
|
.inputSchema(ZUpdateOrganizationNameAction)
|
||||||
.action(
|
.action(
|
||||||
withAuditLogging(
|
withAuditLogging("updated", "organization", async ({ ctx, parsedInput }) => {
|
||||||
"updated",
|
await checkAuthorizationUpdated({
|
||||||
"organization",
|
userId: ctx.user.id,
|
||||||
async ({
|
organizationId: parsedInput.organizationId,
|
||||||
ctx,
|
access: [
|
||||||
parsedInput,
|
{
|
||||||
}: {
|
type: "organization",
|
||||||
ctx: AuthenticatedActionClientCtx;
|
schema: ZOrganizationUpdateInput.pick({ name: true }),
|
||||||
parsedInput: z.infer<typeof ZUpdateOrganizationNameAction>;
|
data: parsedInput.data,
|
||||||
}) =>
|
roles: ["owner"],
|
||||||
updateOrganizationAction({
|
},
|
||||||
ctx,
|
],
|
||||||
organizationId: parsedInput.organizationId,
|
});
|
||||||
schema: ZOrganizationUpdateInput.pick({ name: true }),
|
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||||
data: parsedInput.data,
|
const oldObject = await getOrganization(parsedInput.organizationId);
|
||||||
roles: ["owner"],
|
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
|
||||||
})
|
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||||
)
|
ctx.auditLoggingCtx.newObject = result;
|
||||||
);
|
return result;
|
||||||
|
})
|
||||||
type TOrganizationAISettings = Pick<
|
|
||||||
NonNullable<Awaited<ReturnType<typeof getOrganization>>>,
|
|
||||||
"isAISmartToolsEnabled" | "isAIDataAnalysisEnabled"
|
|
||||||
>;
|
|
||||||
|
|
||||||
type TResolvedOrganizationAISettings = {
|
|
||||||
smartToolsEnabled: boolean;
|
|
||||||
dataAnalysisEnabled: boolean;
|
|
||||||
isEnablingAnyAISetting: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveOrganizationAISettings = ({
|
|
||||||
data,
|
|
||||||
organization,
|
|
||||||
}: {
|
|
||||||
data: z.infer<typeof ZOrganizationAISettingsInput>;
|
|
||||||
organization: TOrganizationAISettings;
|
|
||||||
}): TResolvedOrganizationAISettings => {
|
|
||||||
const smartToolsEnabled = Object.hasOwn(data, "isAISmartToolsEnabled")
|
|
||||||
? (data.isAISmartToolsEnabled ?? organization.isAISmartToolsEnabled)
|
|
||||||
: organization.isAISmartToolsEnabled;
|
|
||||||
const dataAnalysisEnabled = Object.hasOwn(data, "isAIDataAnalysisEnabled")
|
|
||||||
? (data.isAIDataAnalysisEnabled ?? organization.isAIDataAnalysisEnabled)
|
|
||||||
: organization.isAIDataAnalysisEnabled;
|
|
||||||
|
|
||||||
return {
|
|
||||||
smartToolsEnabled,
|
|
||||||
dataAnalysisEnabled,
|
|
||||||
isEnablingAnyAISetting:
|
|
||||||
(smartToolsEnabled && !organization.isAISmartToolsEnabled) ||
|
|
||||||
(dataAnalysisEnabled && !organization.isAIDataAnalysisEnabled),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const assertOrganizationAISettingsUpdateAllowed = ({
|
|
||||||
isInstanceAIConfigured,
|
|
||||||
resolvedSettings,
|
|
||||||
t,
|
|
||||||
}: {
|
|
||||||
isInstanceAIConfigured: boolean;
|
|
||||||
resolvedSettings: TResolvedOrganizationAISettings;
|
|
||||||
t: Awaited<ReturnType<typeof getTranslate>>;
|
|
||||||
}) => {
|
|
||||||
if (resolvedSettings.isEnablingAnyAISetting && !isInstanceAIConfigured) {
|
|
||||||
throw new OperationNotAllowedError(t("environments.settings.general.ai_instance_not_configured"));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateOrganizationAISettingsAction = authenticatedActionClient
|
|
||||||
.inputSchema(ZUpdateOrganizationAISettingsAction)
|
|
||||||
.action(
|
|
||||||
withAuditLogging(
|
|
||||||
"updated",
|
|
||||||
"organization",
|
|
||||||
async ({
|
|
||||||
ctx,
|
|
||||||
parsedInput,
|
|
||||||
}: {
|
|
||||||
ctx: AuthenticatedActionClientCtx;
|
|
||||||
parsedInput: z.infer<typeof ZUpdateOrganizationAISettingsAction>;
|
|
||||||
}) => {
|
|
||||||
const t = await getTranslate(ctx.user.locale);
|
|
||||||
const organization = await getOrganization(parsedInput.organizationId);
|
|
||||||
|
|
||||||
if (!organization) {
|
|
||||||
throw new ResourceNotFoundError("Organization", parsedInput.organizationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedSettings = resolveOrganizationAISettings({
|
|
||||||
data: parsedInput.data,
|
|
||||||
organization,
|
|
||||||
});
|
|
||||||
|
|
||||||
assertOrganizationAISettingsUpdateAllowed({
|
|
||||||
isInstanceAIConfigured: isInstanceAIConfigured(),
|
|
||||||
resolvedSettings,
|
|
||||||
t,
|
|
||||||
});
|
|
||||||
|
|
||||||
return updateOrganizationAction({
|
|
||||||
ctx,
|
|
||||||
organizationId: parsedInput.organizationId,
|
|
||||||
schema: ZOrganizationAISettingsInput,
|
|
||||||
data: parsedInput.data,
|
|
||||||
roles: ["owner", "manager"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const ZDeleteOrganizationAction = z.object({
|
const ZDeleteOrganizationAction = z.object({
|
||||||
@@ -168,10 +49,7 @@ export const deleteOrganizationAction = authenticatedActionClient
|
|||||||
.action(
|
.action(
|
||||||
withAuditLogging("deleted", "organization", async ({ ctx, parsedInput }) => {
|
withAuditLogging("deleted", "organization", async ({ ctx, parsedInput }) => {
|
||||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||||
if (!isMultiOrgEnabled) {
|
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
|
||||||
const t = await getTranslate(ctx.user.locale);
|
|
||||||
throw new OperationNotAllowedError(t("environments.settings.general.organization_deletion_disabled"));
|
|
||||||
}
|
|
||||||
|
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
|
||||||
import toast from "react-hot-toast";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
|
||||||
import { updateOrganizationAISettingsAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
|
|
||||||
import { getDisplayedOrganizationAISettingValue, getOrganizationAIEnablementState } from "@/lib/ai/utils";
|
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
|
||||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
|
||||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
|
||||||
|
|
||||||
interface AISettingsToggleProps {
|
|
||||||
organization: TOrganization;
|
|
||||||
membershipRole?: TOrganizationRole;
|
|
||||||
isInstanceAIConfigured: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AISettingsToggle = ({
|
|
||||||
organization,
|
|
||||||
membershipRole,
|
|
||||||
isInstanceAIConfigured,
|
|
||||||
}: Readonly<AISettingsToggleProps>) => {
|
|
||||||
const [loadingField, setLoadingField] = useState<string | null>(null);
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const { isOwner, isManager } = getAccessFlags(membershipRole);
|
|
||||||
const canEdit = isOwner || isManager;
|
|
||||||
const aiEnablementState = getOrganizationAIEnablementState({
|
|
||||||
isInstanceConfigured: isInstanceAIConfigured,
|
|
||||||
});
|
|
||||||
const showInstanceConfigWarning = aiEnablementState.blockReason === "instanceNotConfigured";
|
|
||||||
const isToggleDisabled = loadingField !== null || !canEdit || !aiEnablementState.canEnableFeatures;
|
|
||||||
const aiEnablementBlockedMessage = t("environments.settings.general.ai_instance_not_configured");
|
|
||||||
const displayedSmartToolsValue = getDisplayedOrganizationAISettingValue({
|
|
||||||
currentValue: organization.isAISmartToolsEnabled,
|
|
||||||
isInstanceConfigured: isInstanceAIConfigured,
|
|
||||||
});
|
|
||||||
const displayedDataAnalysisValue = getDisplayedOrganizationAISettingValue({
|
|
||||||
currentValue: organization.isAIDataAnalysisEnabled,
|
|
||||||
isInstanceConfigured: isInstanceAIConfigured,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleToggle = async (
|
|
||||||
field: "isAISmartToolsEnabled" | "isAIDataAnalysisEnabled",
|
|
||||||
checked: boolean
|
|
||||||
) => {
|
|
||||||
if (checked && !aiEnablementState.canEnableFeatures) {
|
|
||||||
toast.error(aiEnablementBlockedMessage);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoadingField(field);
|
|
||||||
try {
|
|
||||||
const data =
|
|
||||||
field === "isAISmartToolsEnabled"
|
|
||||||
? { isAISmartToolsEnabled: checked }
|
|
||||||
: { isAIDataAnalysisEnabled: checked };
|
|
||||||
const response = await updateOrganizationAISettingsAction({
|
|
||||||
organizationId: organization.id,
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response?.data) {
|
|
||||||
toast.success(t("environments.settings.general.ai_settings_updated_successfully"));
|
|
||||||
router.refresh();
|
|
||||||
} else {
|
|
||||||
toast.error(getFormattedErrorMessage(response));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error instanceof Error ? error.message : t("common.something_went_wrong_please_try_again"));
|
|
||||||
} finally {
|
|
||||||
setLoadingField(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{showInstanceConfigWarning && (
|
|
||||||
<Alert variant="warning">
|
|
||||||
<AlertDescription>{aiEnablementBlockedMessage}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AdvancedOptionToggle
|
|
||||||
isChecked={displayedSmartToolsValue}
|
|
||||||
onToggle={(checked) => handleToggle("isAISmartToolsEnabled", checked)}
|
|
||||||
htmlId="ai-smart-tools-toggle"
|
|
||||||
title={t("environments.settings.general.ai_smart_tools_enabled")}
|
|
||||||
description={t("environments.settings.general.ai_smart_tools_enabled_description")}
|
|
||||||
disabled={isToggleDisabled}
|
|
||||||
customContainerClass="px-0"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AdvancedOptionToggle
|
|
||||||
isChecked={displayedDataAnalysisValue}
|
|
||||||
onToggle={(checked) => handleToggle("isAIDataAnalysisEnabled", checked)}
|
|
||||||
htmlId="ai-data-analysis-toggle"
|
|
||||||
title={t("environments.settings.general.ai_data_analysis_enabled")}
|
|
||||||
description={t("environments.settings.general.ai_data_analysis_enabled_description")}
|
|
||||||
disabled={isToggleDisabled}
|
|
||||||
customContainerClass="px-0"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{!canEdit && (
|
|
||||||
<Alert variant="warning">
|
|
||||||
<AlertDescription>
|
|
||||||
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { LoadingCard } from "@/app/(app)/components/LoadingCard";
|
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 { getTranslate } from "@/lingodotdev/server";
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
@@ -21,7 +23,9 @@ const Loading = async () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<PageHeader pageTitle={t("environments.settings.general.organization_settings")} />
|
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
|
||||||
|
<OrganizationSettingsNavbar isFormbricksCloud={IS_FORMBRICKS_CLOUD} activeId="general" loading />
|
||||||
|
</PageHeader>
|
||||||
{cards.map((card, index) => (
|
{cards.map((card, index) => (
|
||||||
<LoadingCard key={index} {...card} />
|
<LoadingCard key={index} {...card} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,8 +1,93 @@
|
|||||||
import { redirect } from "next/navigation";
|
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 { 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 packageJson from "@/package.json";
|
||||||
|
import { SettingsCard } from "../../components/SettingsCard";
|
||||||
|
import { DeleteOrganization } from "./components/DeleteOrganization";
|
||||||
|
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
||||||
|
import { SecurityListTip } from "./components/SecurityListTip";
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
return redirect(`/environments/${params.environmentId}/settings/organization/general`);
|
const t = await getTranslate();
|
||||||
|
|
||||||
|
const { session, currentUserMembership, organization, isOwner, isManager } = await getEnvironmentAuth(
|
||||||
|
params.environmentId
|
||||||
|
);
|
||||||
|
|
||||||
|
const user = session?.user?.id ? await getUser(session.user.id) : null;
|
||||||
|
|
||||||
|
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||||
|
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
|
||||||
|
|
||||||
|
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
|
||||||
|
const currentUserRole = currentUserMembership?.role;
|
||||||
|
|
||||||
|
const isOwnerOrManager = isManager || isOwner;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContentWrapper>
|
||||||
|
<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">
|
||||||
|
<AlertDescription>{t("common.storage_not_configured")}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!IS_FORMBRICKS_CLOUD && <SecurityListTip />}
|
||||||
|
<SettingsCard
|
||||||
|
title={t("environments.settings.general.organization_name")}
|
||||||
|
description={t("environments.settings.general.organization_name_description")}>
|
||||||
|
<EditOrganizationNameForm
|
||||||
|
organization={organization}
|
||||||
|
environmentId={params.environmentId}
|
||||||
|
membershipRole={currentUserMembership?.role}
|
||||||
|
/>
|
||||||
|
</SettingsCard>
|
||||||
|
<EmailCustomizationSettings
|
||||||
|
organization={organization}
|
||||||
|
hasWhiteLabelPermission={hasWhiteLabelPermission}
|
||||||
|
environmentId={params.environmentId}
|
||||||
|
isReadOnly={!isOwnerOrManager}
|
||||||
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
|
fbLogoUrl={FB_LOGO_URL}
|
||||||
|
user={user}
|
||||||
|
isStorageConfigured={IS_STORAGE_CONFIGURED}
|
||||||
|
/>
|
||||||
|
{isMultiOrgEnabled && (
|
||||||
|
<SettingsCard
|
||||||
|
title={t("environments.settings.general.delete_organization")}
|
||||||
|
description={t("environments.settings.general.delete_organization_description")}>
|
||||||
|
<DeleteOrganization
|
||||||
|
organization={organization}
|
||||||
|
isDeleteDisabled={isDeleteDisabled}
|
||||||
|
isUserOwner={currentUserRole === "owner"}
|
||||||
|
/>
|
||||||
|
</SettingsCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
|
||||||
|
<IdBadge id={packageJson.version} label={t("common.formbricks_version")} variant="column" />
|
||||||
|
</div>
|
||||||
|
</PageContentWrapper>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Page;
|
export default Page;
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
import { ZId } from "@formbricks/types/common";
|
|
||||||
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
|
|
||||||
|
|
||||||
export const ZOrganizationAISettingsInput = ZOrganizationUpdateInput.pick({
|
|
||||||
isAISmartToolsEnabled: true,
|
|
||||||
isAIDataAnalysisEnabled: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ZUpdateOrganizationAISettingsAction = z.object({
|
|
||||||
organizationId: ZId,
|
|
||||||
data: ZOrganizationAISettingsInput,
|
|
||||||
});
|
|
||||||
@@ -1,8 +1,3 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { TeamsPage } from "@/modules/organization/settings/teams/page";
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
export default TeamsPage;
|
||||||
const params = await props.params;
|
|
||||||
return redirect(`/environments/${params.environmentId}/settings/organization/teams`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
const SettingsIndexPage = async (props: { params: Promise<{ environmentId: string }> }) => {
|
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
return redirect(`/environments/${params.environmentId}/settings/workspace/general`);
|
return redirect(`/environments/${params.environmentId}/settings/profile`);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SettingsIndexPage;
|
export default Page;
|
||||||
@@ -29,7 +29,6 @@ import { ResponseTableCell } from "@/app/(app)/environments/[environmentId]/surv
|
|||||||
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
|
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
|
||||||
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||||
import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils";
|
import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
|
||||||
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
|
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import {
|
import {
|
||||||
@@ -202,13 +201,7 @@ export const ResponseTable = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteResponse = async (responseId: string, params?: { decrementQuotas?: boolean }) => {
|
const deleteResponse = async (responseId: string, params?: { decrementQuotas?: boolean }) => {
|
||||||
const result = await deleteResponseAction({
|
await deleteResponseAction({ responseId, decrementQuotas: params?.decrementQuotas ?? false });
|
||||||
responseId,
|
|
||||||
decrementQuotas: params?.decrementQuotas ?? false,
|
|
||||||
});
|
|
||||||
if (result?.serverError) {
|
|
||||||
throw new Error(getFormattedErrorMessage(result));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle downloading selected responses
|
// Handle downloading selected responses
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user