Compare commits

..

2 Commits

Author SHA1 Message Date
Matti Nannt
122de0121e fix: address vendored minimatch review comments (#7608) 2026-03-30 13:52:02 +02:00
Matti Nannt
eb416d7ff7 fix: resolve dependabot vulnerability alerts 2026-03-27 09:18:16 +01:00
454 changed files with 20599 additions and 25779 deletions

View File

@@ -1,9 +0,0 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "formbricks"
[setup]
script = '''
pnpm install
pnpm dev:setup
'''

View File

@@ -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
View File

@@ -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/

View File

@@ -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.
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template/PPDzCd)
##### RepoCloud
Or you can also deploy Formbricks on [RepoCloud](https://repocloud.io) using the button below.
[![Deploy on RepoCloud](https://d16t0pc4846x52.cloudfront.net/deploy.png)](https://repocloud.io/details/?app_id=254)
##### Zeabur
Or you can also deploy Formbricks on [Zeabur](https://zeabur.com) using the button below.
[![Deploy to Zeabur](https://zeabur.com/button.svg)](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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,3 +0,0 @@
import { ProjectLookSettingsPage } from "@/modules/projects/settings/look/page";
export default ProjectLookSettingsPage;

View File

@@ -1,3 +0,0 @@
import { AppConnectionPage } from "@/modules/projects/settings/(setup)/app-connection/page";
export default AppConnectionPage;

View File

@@ -1,3 +0,0 @@
import { GeneralSettingsPage } from "@/modules/projects/settings/general/page";
export default GeneralSettingsPage;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -1,3 +0,0 @@
import { WebhooksPage } from "@/modules/integrations/webhooks/page";
export default WebhooksPage;

View File

@@ -1,3 +0,0 @@
import { LanguagesPage } from "@/modules/projects/settings/languages/page";
export default LanguagesPage;

View File

@@ -1,3 +0,0 @@
import { TagsPage } from "@/modules/projects/settings/tags/page";
export default TagsPage;

View File

@@ -1,3 +0,0 @@
import { ProjectTeams } from "@/modules/ee/teams/project-teams/page";
export default ProjectTeams;

View File

@@ -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;

View File

@@ -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

View File

@@ -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}
/>
)}
</> </>
); );
}; };

View File

@@ -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>
)} )}
</> </>

View File

@@ -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>

View File

@@ -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 && (

View File

@@ -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 && (

View File

@@ -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}

View File

@@ -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>
</> </>
); );

View File

@@ -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`);

View File

@@ -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} />;
};

View File

@@ -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} />
))} ))}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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} />
))} ))}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;

View File

@@ -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} />;
};

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;

View File

@@ -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,
});
});
});

View File

@@ -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,

View File

@@ -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>
);
};

View File

@@ -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} />
))} ))}

View File

@@ -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;

View File

@@ -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,
});

View File

@@ -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;

View File

@@ -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;

View File

@@ -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