feat: Product Model Revamp (#4353)

Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
Piyush Gupta
2024-12-03 10:04:09 +05:30
committed by GitHub
parent 5dcd32050a
commit 35b2d12e18
315 changed files with 4344 additions and 3587 deletions
@@ -0,0 +1,31 @@
"use client";
import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog";
import { EmptyContent, ModalButton } from "@/modules/ui/components/empty-content";
import { FolderIcon } from "lucide-react";
import { useTranslations } from "next-intl";
interface ProjectLimitModalProps {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
projectLimit: number;
buttons: [ModalButton, ModalButton];
}
export const ProjectLimitModal = ({ open, setOpen, projectLimit, buttons }: ProjectLimitModalProps) => {
const t = useTranslations();
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-full max-w-[564px] bg-white">
<DialogTitle>{t("common.projects_limit_reached")}</DialogTitle>
<EmptyContent
icon={<FolderIcon className="h-6 w-6 text-slate-900" />}
title={t("common.unlock_more_projects_with_a_higher_plan")}
description={t("common.you_have_reached_your_limit_of_project_limit", { projectLimit })}
buttons={buttons}
/>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,224 @@
"use client";
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { ModalButton } from "@/modules/ui/components/empty-content";
import { BlendIcon, ChevronRightIcon, GlobeIcon, GlobeLockIcon, LinkIcon, PlusIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
interface ProjectSwitcherProps {
isCollapsed: boolean;
isTextVisible: boolean;
project: TProject;
projects: TProject[];
organization: TOrganization;
organizationProjectsLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
environmentId: string;
isOwnerOrManager: boolean;
}
export const ProjectSwitcher = ({
isCollapsed,
isTextVisible,
organization,
project,
projects,
organizationProjectsLimit,
isFormbricksCloud,
isLicenseActive,
environmentId,
isOwnerOrManager,
}: ProjectSwitcherProps) => {
const [openLimitModal, setOpenLimitModal] = useState(false);
const router = useRouter();
const t = useTranslations();
const handleEnvironmentChangeByProject = (projectId: string) => {
router.push(`/projects/${projectId}/`);
};
const handleAddProject = (organizationId: string) => {
if (projects.length >= organizationProjectsLimit) {
setOpenLimitModal(true);
return;
}
router.push(`/organizations/${organizationId}/projects/new/mode`);
};
const LimitModalButtons = (): [ModalButton, ModalButton] => {
if (isFormbricksCloud && organization.billing.plan !== "enterprise") {
return [
{
text:
organization.billing.plan === "free"
? t("environments.settings.billing.start_free_trial")
: t("environments.settings.billing.upgrade"),
onClick: () => {
setOpenLimitModal(false);
router.push(`/environments/${environmentId}/settings/billing`);
},
},
{
text: t("common.learn_more"),
onClick: () => {
setOpenLimitModal(false);
router.push(`/environments/${environmentId}/settings/billing`);
},
},
];
} else {
if (isLicenseActive) {
return [
{
text: t("environments.settings.billing.get_in_touch"),
href: "https://cal.com/johannes/license",
onClick: () => setOpenLimitModal(false),
},
{
text: t("common.learn_more"),
href: "https://formbricks.com/docs/self-hosting/license",
onClick: () => setOpenLimitModal(false),
},
];
}
return [
{
text:
organization.billing.plan === "free"
? t("environments.settings.billing.start_free_trial")
: t("environments.settings.billing.get_in_touch"),
href: "https://formbricks.com/docs/self-hosting/license#30-day-trial-license-request",
onClick: () => setOpenLimitModal(false),
},
{
text: t("common.learn_more"),
href: "https://formbricks.com/docs/self-hosting/license",
onClick: () => setOpenLimitModal(false),
},
];
}
};
return (
<>
<DropdownMenu>
<DropdownMenuTrigger
asChild
id="projectDropdownTrigger"
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<div
tabIndex={0}
className={cn(
"flex cursor-pointer flex-row items-center space-x-3",
isCollapsed ? "pl-2" : "pl-4"
)}>
<div className="rounded-lg bg-slate-900 p-1.5 text-slate-50">
{project.config.channel === "website" ? (
<GlobeIcon strokeWidth={1.5} />
) : project.config.channel === "app" ? (
<GlobeLockIcon strokeWidth={1.5} />
) : project.config.channel === "link" ? (
<LinkIcon strokeWidth={1.5} />
) : (
<BlendIcon strokeWidth={1.5} />
)}
</div>
{!isCollapsed && !isTextVisible && (
<>
<div>
<p
title={project.name}
className={cn(
"ph-no-capture ph-no-capture -mb-0.5 max-w-28 truncate text-sm font-bold text-slate-700 transition-opacity duration-200",
isTextVisible ? "opacity-0" : "opacity-100"
)}>
{project.name}
</p>
<p
className={cn(
"text-sm text-slate-500 transition-opacity duration-200",
isTextVisible ? "opacity-0" : "opacity-100"
)}>
{project.config.channel === "link"
? t("common.link_and_email")
: capitalizeFirstLetter(project.config.channel)}
</p>
</div>
<ChevronRightIcon
className={cn(
"h-5 w-5 text-slate-700 transition-opacity duration-200 hover:text-slate-500",
isTextVisible ? "opacity-0" : "opacity-100"
)}
/>
</>
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
id="userDropdownInnerContentWrapper"
side="right"
sideOffset={10}
alignOffset={-1}
align="end">
<DropdownMenuRadioGroup
value={project!.id}
onValueChange={(v) => handleEnvironmentChangeByProject(v)}>
{projects.map((project) => (
<DropdownMenuRadioItem value={project.id} className="cursor-pointer break-all" key={project.id}>
<div>
{project.config.channel === "website" ? (
<GlobeIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
) : project.config.channel === "app" ? (
<GlobeLockIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
) : project.config.channel === "link" ? (
<LinkIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
) : (
<BlendIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />
)}
</div>
<div className="">{project?.name}</div>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
{isOwnerOrManager && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleAddProject(organization.id)}
icon={<PlusIcon className="mr-2 h-4 w-4" />}>
<span>{t("common.add_project")}</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{openLimitModal && (
<ProjectLimitModal
open={openLimitModal}
setOpen={setOpenLimitModal}
buttons={LimitModalButtons()}
projectLimit={organizationProjectsLimit}
/>
)}
</>
);
};
@@ -0,0 +1,47 @@
"use client";
import { LoadingCard } from "@/app/(app)/components/LoadingCard";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { useTranslations } from "next-intl";
export const AppConnectionLoading = () => {
const t = useTranslations();
const cards = [
{
title: t("environments.project.app-connection.app_connection"),
description: t("environments.project.app-connection.app_connection_description"),
skeletonLines: [{ classes: " h-44 max-w-full rounded-lg" }],
},
{
title: t("environments.project.app-connection.how_to_setup"),
description: t("environments.project.app-connection.how_to_setup_description"),
skeletonLines: [
{ classes: "h-12 w-24 rounded-lg" },
{ classes: "h-10 w-60 rounded-lg" },
{ classes: "h-10 w-60 rounded-lg" },
{ classes: "h-12 w-24 rounded-lg" },
{ classes: "h-10 w-60 rounded-lg" },
{ classes: "h-10 w-60 rounded-lg" },
],
},
{
title: t("environments.project.app-connection.environment_id"),
description: t("environments.project.app-connection.environment_id_description"),
skeletonLines: [{ classes: "h-12 w-4/6 rounded-lg" }],
},
];
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.configuration")}>
<ProjectConfigNavigation activeId="app-connection" loading />
</PageHeader>
<div className="mt-4 flex max-w-4xl animate-pulse items-center space-y-4 rounded-lg border bg-blue-50 p-6 text-sm text-blue-900 shadow-sm md:space-y-0 md:text-base"></div>
{cards.map((card, index) => (
<LoadingCard key={index} {...card} />
))}
</PageContentWrapper>
);
};
@@ -0,0 +1,68 @@
import { WidgetStatusIndicator } from "@/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import {
getMultiLanguagePermission,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { EnvironmentIdField } from "@/modules/projects/settings/(setup)/components/environment-id-field";
import { SetupInstructions } from "@/modules/projects/settings/(setup)/components/setup-instructions";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { EnvironmentNotice } from "@/modules/ui/components/environment-notice";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslations } from "next-intl/server";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
export const AppConnectionPage = async (props) => {
const params = await props.params;
const t = await getTranslations();
const [environment, organization] = await Promise.all([
getEnvironment(params.environmentId),
getOrganizationByEnvironmentId(params.environmentId),
]);
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const isMultiLanguageAllowed = await getMultiLanguagePermission(organization);
const canDoRoleManagement = await getRoleManagementPermission(organization);
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.configuration")}>
<ProjectConfigNavigation
environmentId={params.environmentId}
activeId="app-connection"
isMultiLanguageAllowed={isMultiLanguageAllowed}
canDoRoleManagement={canDoRoleManagement}
/>
</PageHeader>
<div className="space-y-4">
<EnvironmentNotice environmentId={params.environmentId} subPageUrl="/project/app-connection" />
<SettingsCard
title={t("environments.project.app-connection.app_connection")}
description={t("environments.project.app-connection.app_connection_description")}>
{environment && <WidgetStatusIndicator environment={environment} />}
</SettingsCard>
<SettingsCard
title={t("environments.project.app-connection.how_to_setup")}
description={t("environments.project.app-connection.how_to_setup_description")}
noPadding>
<SetupInstructions environmentId={params.environmentId} webAppUrl={WEBAPP_URL} />
</SettingsCard>
<SettingsCard
title={t("environments.project.app-connection.environment_id")}
description={t("environments.project.app-connection.environment_id_description")}>
<EnvironmentIdField environmentId={params.environmentId} />
</SettingsCard>
</div>
</PageContentWrapper>
);
};
@@ -0,0 +1,11 @@
"use client";
import { CodeBlock } from "@/modules/ui/components/code-block";
export const EnvironmentIdField = ({ environmentId }: { environmentId: string }) => {
return (
<div className="prose prose-slate -mt-3">
<CodeBlock language="js">{environmentId}</CodeBlock>
</div>
);
};
@@ -0,0 +1,185 @@
"use client";
import { CodeBlock } from "@/modules/ui/components/code-block";
import { Html5Icon, NpmIcon } from "@/modules/ui/components/icons";
import { TabBar } from "@/modules/ui/components/tab-bar";
import { useTranslations } from "next-intl";
import Link from "next/link";
import "prismjs/themes/prism.css";
import { useState } from "react";
const tabs = [
{
id: "npm",
label: "NPM",
icon: <NpmIcon />,
},
{ id: "html", label: "HTML", icon: <Html5Icon /> },
];
interface SetupInstructionsProps {
environmentId: string;
webAppUrl: string;
}
export const SetupInstructions = ({ environmentId, webAppUrl }: SetupInstructionsProps) => {
const t = useTranslations();
const [activeTab, setActiveTab] = useState(tabs[0].id);
return (
<div>
<TabBar tabs={tabs} activeId={activeTab} setActiveId={setActiveTab} />
<div className="px-6 py-5">
{activeTab === "npm" ? (
<div className="prose prose-slate prose-p:my-2 prose-p:text-sm prose-p:text-slate-600 prose-h4:text-slate-800 prose-h4:pt-2">
<h4>{t("environments.project.app-connection.step_1")}</h4>
<CodeBlock language="sh">pnpm install @formbricks/js</CodeBlock>
<p>or</p>
<CodeBlock language="sh">npm install @formbricks/js</CodeBlock>
<p>or</p>
<CodeBlock language="sh">yarn add @formbricks/js</CodeBlock>
<h4>{t("environments.project.app-connection.step_2")}</h4>
<p>{t("environments.project.app-connection.step_2_description")}</p>
<CodeBlock language="js">{`import formbricks from "@formbricks/js";
if (typeof window !== "undefined") {
formbricks.init({
environmentId: "${environmentId}",
apiHost: "${webAppUrl}",
});
}`}</CodeBlock>
<ul className="list-disc text-sm">
<li>
<span className="font-semibold">environmentId :</span>{" "}
{t("environments.project.app-connection.environment_id_description_with_environment_id", {
environmentId: environmentId,
})}
</li>
<li>
<span className="font-semibold">apiHost:</span>{" "}
{t("environments.project.app-connection.api_host_description")}
</li>
</ul>
<span className="text-sm text-slate-600">
{t("environments.project.app-connection.if_you_are_planning_to")}
<Link
href="https://formbricks.com//docs/app-surveys/user-identification"
target="blank"
className="underline">
{t("environments.project.app-connection.identifying_your_users")}
</Link>{" "}
{t("environments.project.app-connection.you_also_need_to_pass_a")}{" "}
<span className="font-semibold">userId</span> {t("environments.project.app-connection.to_the")}{" "}
<span className="font-semibold">init</span> {t("environments.project.app-connection.function")}.
</span>
<h4>{t("environments.project.app-connection.step_3")}</h4>
<p>
{t("environments.project.app-connection.switch_on_the_debug_mode_by_appending")}{" "}
<i>?formbricksDebug=true</i>{" "}
{t("environments.project.app-connection.to_the_url_where_you_load_the")}{" "}
{t("environments.project.app-connection.formbricks_sdk")}.{" "}
{t("environments.project.app-connection.open_the_browser_console_to_see_the_logs")}{" "}
<Link
className="decoration-brand-dark"
href="https://formbricks.com/docs/developer-docs/js-sdk#debug-mode"
target="_blank">
{t("common.read_docs")}
</Link>{" "}
</p>
<h4>{t("environments.project.app-connection.you_are_done")}</h4>
<p>{t("environments.project.app-connection.your_app_now_communicates_with_formbricks")}</p>
<ul className="list-disc text-sm text-slate-700">
<li>
<span>{t("environments.project.app-connection.need_a_more_detailed_setup_guide_for")}</span>{" "}
<Link
className="decoration-brand-dark"
href="https://formbricks.com/docs/website-surveys/quickstart"
target="_blank">
{t("environments.project.app-connection.check_out_the_docs")}
</Link>
</li>
<li>
<span>{t("environments.project.app-connection.not_working")}</span>{" "}
<Link
className="decoration-brand-dark"
target="_blank"
href="https://github.com/formbricks/formbricks/issues">
{t("environments.project.app-connection.open_an_issue_on_github")}
</Link>{" "}
</li>
<li>
<span>
{t("environments.project.app-connection.want_to_learn_how_to_add_user_attributes")}
</span>{" "}
<Link
className="decoration-brand-dark"
href="https://formbricks.com/docs/attributes/why"
target="_blank">
{t("environments.project.app-connection.dive_into_the_docs")}
</Link>
</li>
</ul>
</div>
) : activeTab === "html" ? (
<div className="prose prose-slate prose-p:my-2 prose-p:text-sm prose-p:text-slate-600 prose-h4:text-slate-800 prose-h4:pt-2">
<h4>{t("environments.project.app-connection.step_1")}</h4>
<p>
{t("environments.project.app-connection.insert_this_code_into_the")} <code>{`<head>`}</code>{" "}
{t("environments.project.app-connection.tag_of_your_app")}
</p>
<CodeBlock language="js">{`<!-- START Formbricks Surveys -->
<script type="text/javascript">
!function(){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src="${webAppUrl}/js/formbricks.umd.cjs";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: "${environmentId}", apiHost: "${window.location.protocol}//${window.location.host}"})},500)}();
</script>
<!-- END Formbricks Surveys -->`}</CodeBlock>
<h4>Step 2: Debug mode</h4>
<p>
{t("environments.project.app-connection.switch_on_the_debug_mode_by_appending")}{" "}
<i>{`?formbricksDebug=true`}</i>{" "}
{t("environments.project.app-connection.to_the_url_where_you_load_the")}{" "}
{t("environments.project.app-connection.formbricks_sdk")}.{" "}
{t("environments.project.app-connection.open_the_browser_console_to_see_the_logs")}{" "}
<Link
className="decoration-brand-dark"
href="https://formbricks.com/docs/developer-docs/js-sdk#debug-mode"
target="_blank">
{t("common.read_docs")}
</Link>{" "}
</p>
<h4>{t("environments.project.app-connection.you_are_done")}</h4>
<p>{t("environments.project.app-connection.your_app_now_communicates_with_formbricks")}</p>
<ul className="list-disc text-sm text-slate-700">
<li>
<span className="font-semibold">
{t("environments.project.app-connection.does_your_widget_work")}
</span>
<span>{t("environments.project.app-connection.scroll_to_the_top")}</span>
</li>
<li>
<span className="font-semibold">
{t("environments.project.app-connection.have_a_problem")}
</span>{" "}
<Link
className="decoration-brand-dark"
target="_blank"
href="https://github.com/formbricks/formbricks/issues">
{t("environments.project.app-connection.open_an_issue_on_github")}
</Link>{" "}
</li>
<li>
<span className="font-semibold">
{t("environments.project.app-connection.want_to_learn_how_to_add_user_attributes")}
</span>{" "}
<Link
className="decoration-brand-dark"
href="https://formbricks.com/docs/attributes/why"
target="_blank">
{t("environments.project.app-connection.dive_into_the_docs")}
</Link>
</li>
</ul>
</div>
) : null}
</div>
</div>
);
};
@@ -0,0 +1,71 @@
"use server";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import {
getRemoveInAppBrandingPermission,
getRemoveLinkBrandingPermission,
} from "@/modules/ee/license-check/lib/utils";
import { updateProject } from "@/modules/projects/settings/lib/project";
import { z } from "zod";
import { getOrganization } from "@formbricks/lib/organization/service";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { ZProjectUpdateInput } from "@formbricks/types/project";
const ZUpdateProjectAction = z.object({
projectId: ZId,
data: ZProjectUpdateInput,
});
export const updateProjectAction = authenticatedActionClient
.schema(ZUpdateProjectAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromProjectId(parsedInput.projectId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
schema: ZProjectUpdateInput,
data: parsedInput.data,
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: parsedInput.projectId,
minPermission: "manage",
},
],
});
if (
parsedInput.data.inAppSurveyBranding !== undefined ||
parsedInput.data.linkSurveyBranding !== undefined
) {
const organization = await getOrganization(organizationId);
if (!organization) {
throw new Error("Organization not found");
}
if (parsedInput.data.inAppSurveyBranding !== undefined) {
const canRemoveInAppBranding = getRemoveInAppBrandingPermission(organization);
if (!canRemoveInAppBranding) {
throw new OperationNotAllowedError("You are not allowed to remove in-app branding");
}
}
if (parsedInput.data.linkSurveyBranding !== undefined) {
const canRemoveLinkSurveyBranding = getRemoveLinkBrandingPermission(organization);
if (!canRemoveLinkSurveyBranding) {
throw new OperationNotAllowedError("You are not allowed to remove link survey branding");
}
}
}
return await updateProject(parsedInput.projectId, parsedInput.data);
});
@@ -0,0 +1,67 @@
"use server";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import {
getOrganizationIdFromApiKeyId,
getOrganizationIdFromEnvironmentId,
getProjectIdFromApiKeyId,
getProjectIdFromEnvironmentId,
} from "@/lib/utils/helper";
import { createApiKey, deleteApiKey } from "@/modules/projects/settings/lib/api-key";
import { z } from "zod";
import { ZApiKeyCreateInput } from "@formbricks/types/api-keys";
import { ZId } from "@formbricks/types/common";
const ZDeleteApiKeyAction = z.object({
id: ZId,
});
export const deleteApiKeyAction = authenticatedActionClient
.schema(ZDeleteApiKeyAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromApiKeyId(parsedInput.id),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "manage",
projectId: await getProjectIdFromApiKeyId(parsedInput.id),
},
],
});
return await deleteApiKey(parsedInput.id);
});
const ZCreateApiKeyAction = z.object({
environmentId: ZId,
apiKeyData: ZApiKeyCreateInput,
});
export const createApiKeyAction = authenticatedActionClient
.schema(ZCreateApiKeyAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "manage",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
return await createApiKey(parsedInput.environmentId, parsedInput.apiKeyData);
});
@@ -0,0 +1,74 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { AlertTriangleIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useForm } from "react-hook-form";
interface MemberModalProps {
open: boolean;
setOpen: (v: boolean) => void;
onSubmit: (data: { label: string; environment: string }) => void;
}
export const AddApiKeyModal = ({ open, setOpen, onSubmit }: MemberModalProps) => {
const t = useTranslations();
const { register, getValues, handleSubmit, reset } = useForm<{ label: string; environment: string }>();
const submitAPIKey = async () => {
const data = getValues();
onSubmit(data);
setOpen(false);
reset();
};
return (
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="text-xl font-medium text-slate-700">
{t("environments.project.api-keys.add_api_key")}
</div>
</div>
</div>
</div>
<form onSubmit={handleSubmit(submitAPIKey)}>
<div className="flex justify-between rounded-lg p-6">
<div className="w-full space-y-4">
<div>
<Label>{t("environments.project.api-keys.api_key_label")}</Label>
<Input
placeholder="e.g. GitHub, PostHog, Slack"
{...register("label", { required: true, validate: (value) => value.trim() !== "" })}
/>
</div>
<div className="flex items-center rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-700">
<AlertTriangleIcon className="mx-3 h-12 w-12 text-amber-500" />
<p>{t("environments.project.api-keys.api_key_security_warning")}</p>
</div>
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
<Button
type="button"
variant="minimal"
onClick={() => {
setOpen(false);
}}>
{t("common.cancel")}
</Button>
<Button type="submit">{t("environments.project.api-keys.add_api_key")}</Button>
</div>
</div>
</form>
</div>
</Modal>
);
};
@@ -0,0 +1,45 @@
import { getTranslations } from "next-intl/server";
import { getApiKeys } from "@formbricks/lib/apiKey/service";
import { getEnvironments } from "@formbricks/lib/environment/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { TUserLocale } from "@formbricks/types/user";
import { EditAPIKeys } from "./edit-api-keys";
interface ApiKeyListProps {
environmentId: string;
environmentType: string;
locale: TUserLocale;
isReadOnly: boolean;
}
export const ApiKeyList = async ({ environmentId, environmentType, locale, isReadOnly }: ApiKeyListProps) => {
const t = await getTranslations();
const findEnvironmentByType = (environments, targetType) => {
for (const environment of environments) {
if (environment.type === targetType) {
return environment.id;
}
}
return null;
};
const project = await getProjectByEnvironmentId(environmentId);
if (!project) {
throw new Error(t("common.project_not_found"));
}
const environments = await getEnvironments(project.id);
const environmentTypeId = findEnvironmentByType(environments, environmentType);
const apiKeys = await getApiKeys(environmentTypeId);
return (
<EditAPIKeys
environmentTypeId={environmentTypeId}
environmentType={environmentType}
apiKeys={apiKeys}
environmentId={environmentId}
locale={locale}
isReadOnly={isReadOnly}
/>
);
};
@@ -0,0 +1,168 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { FilesIcon, TrashIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useState } from "react";
import toast from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { timeSince } from "@formbricks/lib/time";
import { TApiKey } from "@formbricks/types/api-keys";
import { TUserLocale } from "@formbricks/types/user";
import { createApiKeyAction, deleteApiKeyAction } from "../actions";
import { AddApiKeyModal } from "./add-api-key-modal";
interface EditAPIKeysProps {
environmentTypeId: string;
environmentType: string;
apiKeys: TApiKey[];
environmentId: string;
locale: TUserLocale;
isReadOnly: boolean;
}
export const EditAPIKeys = ({
environmentTypeId,
environmentType,
apiKeys,
environmentId,
locale,
isReadOnly,
}: EditAPIKeysProps) => {
const t = useTranslations();
const [isAddAPIKeyModalOpen, setOpenAddAPIKeyModal] = useState(false);
const [isDeleteKeyModalOpen, setOpenDeleteKeyModal] = useState(false);
const [apiKeysLocal, setApiKeysLocal] = useState<TApiKey[]>(apiKeys);
const [activeKey, setActiveKey] = useState({} as any);
const handleOpenDeleteKeyModal = (e, apiKey) => {
e.preventDefault();
setActiveKey(apiKey);
setOpenDeleteKeyModal(true);
};
const handleDeleteKey = async () => {
try {
await deleteApiKeyAction({ id: activeKey.id });
const updatedApiKeys = apiKeysLocal?.filter((apiKey) => apiKey.id !== activeKey.id) || [];
setApiKeysLocal(updatedApiKeys);
toast.success(t("environments.project.api-keys.api_key_deleted"));
} catch (e) {
toast.error(t("environments.project.api-keys.unable_to_delete_api_key"));
} finally {
setOpenDeleteKeyModal(false);
}
};
const handleAddAPIKey = async (data) => {
const createApiKeyResponse = await createApiKeyAction({
environmentId: environmentTypeId,
apiKeyData: { label: data.label },
});
console.log("createApiKeyResponse", createApiKeyResponse);
if (createApiKeyResponse?.data) {
const updatedApiKeys = [...apiKeysLocal!, createApiKeyResponse.data];
setApiKeysLocal(updatedApiKeys);
toast.success(t("environments.project.api-keys.api_key_created"));
} else {
const errorMessage = getFormattedErrorMessage(createApiKeyResponse);
toast.error(errorMessage);
}
setOpenAddAPIKeyModal(false);
};
const ApiKeyDisplay = ({ apiKey }) => {
const copyToClipboard = () => {
navigator.clipboard.writeText(apiKey);
toast.success(t("environments.project.api-keys.api_key_copied_to_clipboard"));
};
if (!apiKey) {
return <span className="italic">{t("environments.project.api-keys.secret")}</span>;
}
return (
<div className="flex items-center">
<span>{apiKey}</span>
<div className="copyApiKeyIcon">
<FilesIcon className="mx-2 h-4 w-4 cursor-pointer" onClick={copyToClipboard} />
</div>
</div>
);
};
return (
<div className="space-y-4">
<div className="rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-10 content-center rounded-t-lg bg-slate-100 px-6 text-left text-sm font-semibold text-slate-900">
<div className="col-span-4 sm:col-span-2">{t("common.label")}</div>
<div className="col-span-4 hidden sm:col-span-5 sm:block">
{t("environments.project.api-keys.api_key")}
</div>
<div className="col-span-4 sm:col-span-2">{t("common.created_at")}</div>
<div></div>
</div>
<div className="grid-cols-9">
{apiKeysLocal && apiKeysLocal.length === 0 ? (
<div className="flex h-12 items-center justify-center whitespace-nowrap px-6 text-sm font-medium text-slate-400">
{t("environments.project.api-keys.no_api_keys_yet")}
</div>
) : (
apiKeysLocal &&
apiKeysLocal.map((apiKey) => (
<div
className="grid h-12 w-full grid-cols-10 content-center items-center rounded-lg px-6 text-left text-sm text-slate-900"
key={apiKey.hashedKey}>
<div className="col-span-4 font-semibold sm:col-span-2">{apiKey.label}</div>
<div className="col-span-4 hidden sm:col-span-5 sm:block">
<ApiKeyDisplay apiKey={apiKey.apiKey} />
</div>
<div className="col-span-4 sm:col-span-2">
{timeSince(apiKey.createdAt.toString(), locale)}
</div>
{!isReadOnly && (
<div className="col-span-1 text-center">
<Button
size="icon"
variant="minimal"
onClick={(e) => handleOpenDeleteKeyModal(e, apiKey)}
StartIcon={TrashIcon}
startIconClassName={cn("h-5 w-5 text-slate-700", isReadOnly && "opacity-50")}
/>
</div>
)}
</div>
))
)}
</div>
</div>
{!isReadOnly && (
<div>
<Button
size="sm"
disabled={environmentId !== environmentTypeId}
onClick={() => {
setOpenAddAPIKeyModal(true);
}}>
{t("environments.project.api-keys.add_env_api_key", { environmentType })}
</Button>
</div>
)}
<AddApiKeyModal
open={isAddAPIKeyModalOpen}
setOpen={setOpenAddAPIKeyModal}
onSubmit={handleAddAPIKey}
/>
<DeleteDialog
open={isDeleteKeyModalOpen}
setOpen={setOpenDeleteKeyModal}
deleteWhat={t("environments.project.api-keys.api_key")}
onDelete={handleDeleteKey}
/>
</div>
);
};
@@ -0,0 +1,52 @@
"use client";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { useTranslations } from "next-intl";
const LoadingCard = () => {
const t = useTranslations();
return (
<div className="w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 shadow-sm">
<div className="grid content-center border-b border-slate-200 px-4 pb-4 text-left text-slate-900">
<h3 className="h-6 w-full max-w-56 animate-pulse rounded-lg bg-slate-100 text-lg font-medium leading-6"></h3>
<p className="mt-3 h-4 w-full max-w-80 animate-pulse rounded-lg bg-slate-100 text-sm text-slate-500"></p>
</div>
<div className="w-full">
<div className="rounded-lg px-4 pt-4">
<div className="rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-10 content-center rounded-t-lg bg-slate-100 px-6 text-left text-sm font-semibold text-slate-900">
<div className="col-span-4 sm:col-span-2">{t("common.label")}</div>
<div className="col-span-4 hidden sm:col-span-5 sm:block">
{t("environments.project.api-keys.api_key")}
</div>
<div className="col-span-4 sm:col-span-2">{t("common.created_at")}</div>
</div>
<div className="px-6">
<div className="my-4 h-5 w-full animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
<div className="flex justify-start">
<div className="mt-4 flex h-8 w-44 animate-pulse flex-col items-center justify-center rounded-md bg-black text-sm text-white">
{t("common.loading")}
</div>
</div>
</div>
</div>
</div>
);
};
export const APIKeysLoading = () => {
const t = useTranslations();
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.configuration")}>
<ProjectConfigNavigation activeId="api-keys" loading />
</PageHeader>
<div className="mt-4 flex max-w-4xl animate-pulse items-center space-y-4 rounded-lg border bg-blue-50 p-6 text-sm text-blue-900 shadow-sm md:space-y-0 md:text-base"></div>
<LoadingCard />
</PageContentWrapper>
);
};
@@ -0,0 +1,95 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { authOptions } from "@/modules/auth/lib/authOptions";
import {
getMultiLanguagePermission,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { EnvironmentNotice } from "@/modules/ui/components/environment-notice";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { ApiKeyList } from "./components/api-key-list";
export const APIKeysPage = async (props) => {
const params = await props.params;
const t = await getTranslations();
const [session, environment, organization, project] = await Promise.all([
getServerSession(authOptions),
getEnvironment(params.environmentId),
getOrganizationByEnvironmentId(params.environmentId),
getProjectByEnvironmentId(params.environmentId),
]);
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
if (!session) {
throw new Error(t("common.session_not_found"));
}
const locale = await findMatchingLocale();
if (!project) {
throw new Error(t("common.project_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
const { hasManageAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && !hasManageAccess;
const isMultiLanguageAllowed = await getMultiLanguagePermission(organization);
const canDoRoleManagement = await getRoleManagementPermission(organization);
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.configuration")}>
<ProjectConfigNavigation
environmentId={params.environmentId}
activeId="api-keys"
isMultiLanguageAllowed={isMultiLanguageAllowed}
canDoRoleManagement={canDoRoleManagement}
/>
</PageHeader>
<EnvironmentNotice environmentId={environment.id} subPageUrl="/project/api-keys" />
{environment.type === "development" ? (
<SettingsCard
title={t("environments.project.api-keys.dev_api_keys")}
description={t("environments.project.api-keys.dev_api_keys_description")}>
<ApiKeyList
environmentId={params.environmentId}
environmentType="development"
locale={locale}
isReadOnly={isReadOnly}
/>
</SettingsCard>
) : (
<SettingsCard
title={t("environments.project.api-keys.prod_api_keys")}
description={t("environments.project.api-keys.prod_api_keys_description")}>
<ApiKeyList
environmentId={params.environmentId}
environmentType="production"
locale={locale}
isReadOnly={isReadOnly}
/>
</SettingsCard>
)}
</PageContentWrapper>
);
};
@@ -0,0 +1,80 @@
"use client";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
import { BrushIcon, KeyIcon, LanguagesIcon, ListChecksIcon, TagIcon, UsersIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { usePathname } from "next/navigation";
interface ProjectConfigNavigationProps {
activeId: string;
environmentId?: string;
isMultiLanguageAllowed?: boolean;
loading?: boolean;
canDoRoleManagement?: boolean;
}
export const ProjectConfigNavigation = ({
activeId,
environmentId,
isMultiLanguageAllowed,
loading,
canDoRoleManagement,
}: ProjectConfigNavigationProps) => {
const t = useTranslations();
const pathname = usePathname();
let navigation = [
{
id: "general",
label: t("common.general"),
icon: <UsersIcon className="h-5 w-5" />,
href: `/environments/${environmentId}/project/general`,
current: pathname?.includes("/general"),
},
{
id: "look",
label: t("common.look_and_feel"),
icon: <BrushIcon className="h-5 w-5" />,
href: `/environments/${environmentId}/project/look`,
current: pathname?.includes("/look"),
},
{
id: "languages",
label: t("common.survey_languages"),
icon: <LanguagesIcon className="h-5 w-5" />,
href: `/environments/${environmentId}/project/languages`,
hidden: !isMultiLanguageAllowed,
current: pathname?.includes("/languages"),
},
{
id: "tags",
label: t("common.tags"),
icon: <TagIcon className="h-5 w-5" />,
href: `/environments/${environmentId}/project/tags`,
current: pathname?.includes("/tags"),
},
{
id: "api-keys",
label: t("common.api_keys"),
icon: <KeyIcon className="h-5 w-5" />,
href: `/environments/${environmentId}/project/api-keys`,
current: pathname?.includes("/api-keys"),
},
{
id: "app-connection",
label: t("common.website_and_app_connection"),
icon: <ListChecksIcon className="h-5 w-5" />,
href: `/environments/${environmentId}/project/app-connection`,
current: pathname?.includes("/app-connection"),
},
{
id: "teams",
label: t("common.team_access"),
href: `/environments/${environmentId}/project/teams`,
hidden: !canDoRoleManagement,
current: pathname?.includes("/teams"),
},
];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
};
@@ -0,0 +1,39 @@
"use server";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { deleteProject } from "@/modules/projects/settings/lib/project";
import { z } from "zod";
import { getUserProjects } from "@formbricks/lib/project/service";
import { ZId } from "@formbricks/types/common";
const ZProjectDeleteAction = z.object({
projectId: ZId,
});
export const deleteProjectAction = authenticatedActionClient
.schema(ZProjectDeleteAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromProjectId(parsedInput.projectId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
const availableProjects = (await getUserProjects(ctx.user.id, organizationId)) ?? null;
if (!!availableProjects && availableProjects?.length <= 1) {
throw new Error("You can't delete the last project in the environment.");
}
// delete project
return await deleteProject(parsedInput.projectId);
});
@@ -0,0 +1,91 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { deleteProjectAction } from "@/modules/projects/settings/general/actions";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import toast from "react-hot-toast";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@formbricks/lib/localStorage";
import { truncate } from "@formbricks/lib/utils/strings";
import { TProject } from "@formbricks/types/project";
interface DeleteProjectRenderProps {
isDeleteDisabled: boolean;
isOwnerOrManager: boolean;
project: TProject;
}
export const DeleteProjectRender = ({
isDeleteDisabled,
isOwnerOrManager,
project,
}: DeleteProjectRenderProps) => {
const t = useTranslations();
const router = useRouter();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const handleDeleteProject = async () => {
setIsDeleting(true);
const deleteProjectResponse = await deleteProjectAction({ projectId: project.id });
if (deleteProjectResponse?.data) {
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
toast.success(t("environments.project.general.project_deleted_successfully"));
router.push("/");
} else {
const errorMessage = getFormattedErrorMessage(deleteProjectResponse);
toast.error(errorMessage);
setIsDeleteDialogOpen(false);
}
setIsDeleting(false);
};
return (
<div>
{!isDeleteDisabled && (
<div>
<p className="text-sm text-slate-900">
{t(
"environments.project.general.delete_project_name_includes_surveys_responses_people_and_more",
{
projectName: truncate(project.name, 30),
}
)}{" "}
<strong>{t("environments.project.general.this_action_cannot_be_undone")}</strong>
</p>
<Button
disabled={isDeleteDisabled}
variant="warn"
className={`mt-4 ${isDeleteDisabled ? "ring-grey-500 ring-1 ring-offset-1" : ""}`}
onClick={() => setIsDeleteDialogOpen(true)}>
{t("common.delete")}
</Button>
</div>
)}
{isDeleteDisabled && (
<Alert variant="warning">
<AlertDescription>
{!isOwnerOrManager
? t("environments.project.general.only_owners_or_managers_can_delete_projects")
: t("environments.project.general.cannot_delete_only_project")}
</AlertDescription>
</Alert>
)}
<DeleteDialog
deleteWhat="Project"
open={isDeleteDialogOpen}
setOpen={setIsDeleteDialogOpen}
onDelete={handleDeleteProject}
text={t("environments.project.general.delete_project_confirmation", {
projectName: truncate(project.name, 30),
})}
isDeleting={isDeleting}
/>
</div>
);
};
@@ -0,0 +1,37 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { DeleteProjectRender } from "@/modules/projects/settings/general/components/delete-project-render";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getUserProjects } from "@formbricks/lib/project/service";
import { TProject } from "@formbricks/types/project";
interface DeleteProjectProps {
environmentId: string;
project: TProject;
isOwnerOrManager: boolean;
}
export const DeleteProject = async ({ environmentId, project, isOwnerOrManager }: DeleteProjectProps) => {
const t = await getTranslations();
const session = await getServerSession(authOptions);
if (!session) {
throw new Error(t("common.session_not_found"));
}
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const availableProjects = organization ? await getUserProjects(session.user.id, organization.id) : null;
const availableProjectsLength = availableProjects ? availableProjects.length : 0;
const isDeleteDisabled = availableProjectsLength <= 1 || !isOwnerOrManager;
return (
<DeleteProjectRender
isDeleteDisabled={isDeleteDisabled}
isOwnerOrManager={isOwnerOrManager}
project={project}
/>
);
};
@@ -0,0 +1,122 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateProjectAction } from "@/modules/projects/settings/actions";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import {
FormControl,
FormError,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { z } from "zod";
import { TProject, ZProject } from "@formbricks/types/project";
interface EditProjectNameProps {
project: TProject;
isReadOnly: boolean;
}
const ZProjectNameInput = ZProject.pick({ name: true });
type TEditProjectName = z.infer<typeof ZProjectNameInput>;
export const EditProjectNameForm: React.FC<EditProjectNameProps> = ({ project, isReadOnly }) => {
const t = useTranslations();
const form = useForm<TEditProjectName>({
defaultValues: {
name: project.name,
},
resolver: zodResolver(ZProjectNameInput),
mode: "onChange",
});
const { errors, isDirty } = form.formState;
const nameError = errors.name?.message;
const isSubmitting = form.formState.isSubmitting;
const updateProject: SubmitHandler<TEditProjectName> = async (data) => {
const name = data.name.trim();
try {
if (nameError) {
toast.error(nameError);
return;
}
const updatedProjectResponse = await updateProjectAction({
projectId: project.id,
data: {
name,
},
});
if (updatedProjectResponse?.data) {
toast.success(t("environments.project.general.project_name_updated_successfully"));
form.resetField("name", { defaultValue: updatedProjectResponse.data.name });
} else {
const errorMessage = getFormattedErrorMessage(updatedProjectResponse);
toast.error(errorMessage);
}
} catch (err) {
console.error(err);
toast.error(t("environments.project.general.error_saving_project_information"));
}
};
return (
<>
<FormProvider {...form}>
<form className="w-full max-w-sm items-center space-y-2" onSubmit={form.handleSubmit(updateProject)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="name">
{t("environments.project.general.whats_your_project_called")}
</FormLabel>
<FormControl>
<Input
type="text"
id="name"
{...field}
placeholder={t("common.project_name")}
autoComplete="off"
required
isInvalid={!!nameError}
disabled={isReadOnly}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
<Button
type="submit"
size="sm"
loading={isSubmitting}
disabled={isSubmitting || !isDirty || isReadOnly}>
{t("common.update")}
</Button>
</form>
</FormProvider>
{isReadOnly && (
<Alert variant="warning" className="mt-4">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>
</Alert>
)}
</>
);
};
@@ -0,0 +1,113 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import {
FormControl,
FormError,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { z } from "zod";
import { TProject, ZProject } from "@formbricks/types/project";
import { updateProjectAction } from "../../actions";
interface EditWaitingTimeProps {
project: TProject;
isReadOnly: boolean;
}
const ZProjectRecontactDaysInput = ZProject.pick({ recontactDays: true });
type TEditWaitingTimeFormValues = z.infer<typeof ZProjectRecontactDaysInput>;
export const EditWaitingTimeForm: React.FC<EditWaitingTimeProps> = ({ project, isReadOnly }) => {
const t = useTranslations();
const form = useForm<TEditWaitingTimeFormValues>({
defaultValues: {
recontactDays: project.recontactDays,
},
resolver: zodResolver(ZProjectRecontactDaysInput),
mode: "onChange",
});
const { isDirty, isSubmitting } = form.formState;
const updateWaitingTime: SubmitHandler<TEditWaitingTimeFormValues> = async (data) => {
try {
const updatedProjectResponse = await updateProjectAction({ projectId: project.id, data });
if (updatedProjectResponse?.data) {
toast.success(t("environments.project.general.waiting_period_updated_successfully"));
form.resetField("recontactDays", { defaultValue: updatedProjectResponse.data.recontactDays });
} else {
const errorMessage = getFormattedErrorMessage(updatedProjectResponse);
toast.error(errorMessage);
}
} catch (err) {
toast.error(`Error: ${err.message}`);
}
};
return (
<>
<FormProvider {...form}>
<form
className="flex w-full max-w-sm flex-col space-y-4"
onSubmit={form.handleSubmit(updateWaitingTime)}>
<FormField
control={form.control}
name="recontactDays"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="recontactDays">
{t("environments.project.general.wait_x_days_before_showing_next_survey")}
</FormLabel>
<FormControl>
<Input
type="number"
id="recontactDays"
{...field}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
field.onChange("");
}
field.onChange(parseInt(value, 10));
}}
disabled={isReadOnly}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
<Button
type="submit"
size="sm"
className="w-fit"
loading={isSubmitting}
disabled={isSubmitting || !isDirty || isReadOnly}>
{t("common.update")}
</Button>
</form>
</FormProvider>
{isReadOnly && (
<Alert variant="warning" className="mt-4">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>
</Alert>
)}
</>
);
};
@@ -0,0 +1,39 @@
"use client";
import { LoadingCard } from "@/app/(app)/components/LoadingCard";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { useTranslations } from "next-intl";
export const GeneralSettingsLoading = () => {
const t = useTranslations();
const cards = [
{
title: t("common.project_name"),
description: t("environments.project.general.project_name_settings_description"),
skeletonLines: [{ classes: "h-4 w-28" }, { classes: "h-6 w-64" }, { classes: "h-8 w-24" }],
},
{
title: t("environments.project.general.recontact_waiting_time"),
description: t("environments.project.general.recontact_waiting_time_settings_description"),
skeletonLines: [{ classes: "h-4 w-28" }, { classes: "h-6 w-64" }, { classes: "h-8 w-24" }],
},
{
title: t("environments.project.general.delete_project"),
description: t("environments.project.general.delete_project_settings_description"),
skeletonLines: [{ classes: "h-4 w-96" }, { classes: "h-8 w-24" }],
},
];
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.configuration")}>
<ProjectConfigNavigation activeId="general" loading />
</PageHeader>
{cards.map((card, index) => (
<LoadingCard key={index} {...card} />
))}
</PageContentWrapper>
);
};
@@ -0,0 +1,94 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { authOptions } from "@/modules/auth/lib/authOptions";
import {
getMultiLanguagePermission,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsId } from "@/modules/ui/components/settings-id";
import packageJson from "@/package.json";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { DeleteProject } from "./components/delete-project";
import { EditProjectNameForm } from "./components/edit-project-name-form";
import { EditWaitingTimeForm } from "./components/edit-waiting-time-form";
export const GeneralSettingsPage = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslations();
const [project, session, organization] = await Promise.all([
getProjectByEnvironmentId(params.environmentId),
getServerSession(authOptions),
getOrganizationByEnvironmentId(params.environmentId),
]);
if (!project) {
throw new Error(t("common.project_not_found"));
}
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
const { isMember, isOwner, isManager } = getAccessFlags(currentUserMembership?.role);
const { hasManageAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && !hasManageAccess;
const isMultiLanguageAllowed = await getMultiLanguagePermission(organization);
const canDoRoleManagement = await getRoleManagementPermission(organization);
const isOwnerOrManager = isOwner || isManager;
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.configuration")}>
<ProjectConfigNavigation
environmentId={params.environmentId}
activeId="general"
isMultiLanguageAllowed={isMultiLanguageAllowed}
canDoRoleManagement={canDoRoleManagement}
/>
</PageHeader>
<SettingsCard
title={t("common.project_name")}
description={t("environments.project.general.project_name_settings_description")}>
<EditProjectNameForm project={project} isReadOnly={isReadOnly} />
</SettingsCard>
<SettingsCard
title={t("environments.project.general.recontact_waiting_time")}
description={t("environments.project.general.recontact_waiting_time_settings_description")}>
<EditWaitingTimeForm project={project} isReadOnly={isReadOnly} />
</SettingsCard>
<SettingsCard
title={t("environments.project.general.delete_project")}
description={t("environments.project.general.delete_project_settings_description")}>
<DeleteProject
environmentId={params.environmentId}
project={project}
isOwnerOrManager={isOwnerOrManager}
/>
</SettingsCard>
<div>
<SettingsId title={t("common.project_id")} id={project.id}></SettingsId>
{!IS_FORMBRICKS_CLOUD && (
<SettingsId title={t("common.formbricks_version")} id={packageJson.version}></SettingsId>
)}
</div>
</PageContentWrapper>
);
};
@@ -0,0 +1,48 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
export const metadata: Metadata = {
title: "Config",
};
export const ProjectSettingsLayout = async (props) => {
const params = await props.params;
const { children } = props;
const t = await getTranslations();
const [organization, session] = await Promise.all([
getOrganizationByEnvironmentId(params.environmentId),
getServerSession(authOptions),
]);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
if (!session) {
throw new Error(t("common.session_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
const { isBilling } = getAccessFlags(currentUserMembership?.role);
if (isBilling) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
}
const project = await getProjectByEnvironmentId(params.environmentId);
if (!project) {
throw new Error("Project not found");
}
return children;
};
@@ -0,0 +1,69 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { createHash, randomBytes } from "crypto";
import { prisma } from "@formbricks/database";
import { apiKeyCache } from "@formbricks/lib/apiKey/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { TApiKey, TApiKeyCreateInput, ZApiKeyCreateInput } from "@formbricks/types/api-keys";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
export const deleteApiKey = async (id: string): Promise<TApiKey | null> => {
validateInputs([id, ZId]);
try {
const deletedApiKeyData = await prisma.apiKey.delete({
where: {
id: id,
},
});
apiKeyCache.revalidate({
id: deletedApiKeyData.id,
hashedKey: deletedApiKeyData.hashedKey,
environmentId: deletedApiKeyData.environmentId,
});
return deletedApiKeyData;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex");
export const createApiKey = async (
environmentId: string,
apiKeyData: TApiKeyCreateInput
): Promise<TApiKey> => {
validateInputs([environmentId, ZId], [apiKeyData, ZApiKeyCreateInput]);
try {
const key = randomBytes(16).toString("hex");
const hashedKey = hashApiKey(key);
const result = await prisma.apiKey.create({
data: {
...apiKeyData,
hashedKey,
environment: { connect: { id: environmentId } },
},
});
apiKeyCache.revalidate({
id: result.id,
hashedKey: result.hashedKey,
environmentId: result.environmentId,
});
return { ...result, apiKey: key };
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
@@ -0,0 +1,219 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { isS3Configured } from "@formbricks/lib/constants";
import { environmentCache } from "@formbricks/lib/environment/cache";
import { createEnvironment } from "@formbricks/lib/environment/service";
import { projectCache } from "@formbricks/lib/project/cache";
import {
deleteLocalFilesByEnvironmentId,
deleteS3FilesByEnvironmentId,
} from "@formbricks/lib/storage/service";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId, ZString } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors";
import { TProject, TProjectUpdateInput, ZProject, ZProjectUpdateInput } from "@formbricks/types/project";
const selectProject = {
id: true,
createdAt: true,
updatedAt: true,
name: true,
organizationId: true,
languages: true,
recontactDays: true,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: true,
placement: true,
clickOutsideClose: true,
darkOverlay: true,
environments: true,
styling: true,
logo: true,
};
export const updateProject = async (
projectId: string,
inputProject: TProjectUpdateInput
): Promise<TProject> => {
validateInputs([projectId, ZId], [inputProject, ZProjectUpdateInput]);
const { environments, ...data } = inputProject;
let updatedProject;
try {
updatedProject = await prisma.project.update({
where: {
id: projectId,
},
data: {
...data,
environments: {
connect: environments?.map((environment) => ({ id: environment.id })) ?? [],
},
},
select: selectProject,
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
try {
const project = ZProject.parse(updatedProject);
projectCache.revalidate({
id: project.id,
organizationId: project.organizationId,
});
project.environments.forEach((environment) => {
// revalidate environment cache
projectCache.revalidate({
environmentId: environment.id,
});
});
return project;
} catch (error) {
if (error instanceof z.ZodError) {
console.error(JSON.stringify(error.errors, null, 2));
}
throw new ValidationError("Data validation of project failed");
}
};
export const createProject = async (
organizationId: string,
projectInput: Partial<TProjectUpdateInput>
): Promise<TProject> => {
validateInputs([organizationId, ZString], [projectInput, ZProjectUpdateInput.partial()]);
if (!projectInput.name) {
throw new ValidationError("Project Name is required");
}
const { environments, teamIds, ...data } = projectInput;
try {
let project = await prisma.project.create({
data: {
config: {
channel: null,
industry: null,
},
...data,
name: projectInput.name,
organizationId,
},
select: selectProject,
});
if (teamIds) {
await prisma.projectTeam.createMany({
data: teamIds.map((teamId) => ({
projectId: project.id,
teamId,
})),
});
}
projectCache.revalidate({
id: project.id,
organizationId: project.organizationId,
});
const devEnvironment = await createEnvironment(project.id, {
type: "development",
});
const prodEnvironment = await createEnvironment(project.id, {
type: "production",
});
const updatedProject = await updateProject(project.id, {
environments: [devEnvironment, prodEnvironment],
});
return updatedProject;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
throw new InvalidInputError("A project with this name already exists in your organization");
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2002") {
throw new InvalidInputError("A project with this name already exists in this organization");
}
throw new DatabaseError(error.message);
}
throw error;
}
};
export const deleteProject = async (projectId: string): Promise<TProject> => {
try {
const project = await prisma.project.delete({
where: {
id: projectId,
},
select: selectProject,
});
if (project) {
// delete all files from storage related to this project
if (isS3Configured()) {
const s3FilesPromises = project.environments.map(async (environment) => {
return deleteS3FilesByEnvironmentId(environment.id);
});
try {
await Promise.all(s3FilesPromises);
} catch (err) {
// fail silently because we don't want to throw an error if the files are not deleted
console.error(err);
}
} else {
const localFilesPromises = project.environments.map(async (environment) => {
return deleteLocalFilesByEnvironmentId(environment.id);
});
try {
await Promise.all(localFilesPromises);
} catch (err) {
// fail silently because we don't want to throw an error if the files are not deleted
console.error(err);
}
}
projectCache.revalidate({
id: project.id,
organizationId: project.organizationId,
});
environmentCache.revalidate({
projectId: project.id,
});
project.environments.forEach((environment) => {
// revalidate project cache
projectCache.revalidate({
environmentId: environment.id,
});
environmentCache.revalidate({
id: environment.id,
});
});
}
return project;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
@@ -0,0 +1,180 @@
import "server-only";
import { prisma } from "@formbricks/database";
import { tagCache } from "@formbricks/lib/tag/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId, ZString } from "@formbricks/types/common";
import { TTag } from "@formbricks/types/tags";
export const deleteTag = async (id: string): Promise<TTag> => {
validateInputs([id, ZId]);
try {
const tag = await prisma.tag.delete({
where: {
id,
},
});
tagCache.revalidate({
id,
environmentId: tag.environmentId,
});
return tag;
} catch (error) {
throw error;
}
};
export const updateTagName = async (id: string, name: string): Promise<TTag> => {
validateInputs([id, ZId], [name, ZString]);
try {
const tag = await prisma.tag.update({
where: {
id,
},
data: {
name,
},
});
tagCache.revalidate({
id: tag.id,
environmentId: tag.environmentId,
});
return tag;
} catch (error) {
throw error;
}
};
export const mergeTags = async (originalTagId: string, newTagId: string): Promise<TTag | undefined> => {
validateInputs([originalTagId, ZId], [newTagId, ZId]);
try {
let originalTag: TTag | null;
originalTag = await prisma.tag.findUnique({
where: {
id: originalTagId,
},
});
if (!originalTag) {
throw new Error("Tag not found");
}
let newTag: TTag | null;
newTag = await prisma.tag.findUnique({
where: {
id: newTagId,
},
});
if (!newTag) {
throw new Error("Tag not found");
}
// finds all the responses that have both the tags
let responsesWithBothTags = await prisma.response.findMany({
where: {
AND: [
{
tags: {
some: {
tagId: {
in: [originalTagId],
},
},
},
},
{
tags: {
some: {
tagId: {
in: [newTagId],
},
},
},
},
],
},
});
if (!!responsesWithBothTags?.length) {
await Promise.all(
responsesWithBothTags.map(async (response) => {
await prisma.$transaction([
prisma.tagsOnResponses.deleteMany({
where: {
responseId: response.id,
tagId: {
in: [originalTagId, newTagId],
},
},
}),
prisma.tagsOnResponses.create({
data: {
responseId: response.id,
tagId: newTagId,
},
}),
]);
})
);
await prisma.$transaction([
prisma.tagsOnResponses.updateMany({
where: {
tagId: originalTagId,
},
data: {
tagId: newTagId,
},
}),
prisma.tag.delete({
where: {
id: originalTagId,
},
}),
]);
return newTag;
}
await prisma.$transaction([
prisma.tagsOnResponses.updateMany({
where: {
tagId: originalTagId,
},
data: {
tagId: newTagId,
},
}),
prisma.tag.delete({
where: {
id: originalTagId,
},
}),
]);
tagCache.revalidate({
id: originalTagId,
environmentId: originalTag.environmentId,
});
tagCache.revalidate({
id: newTagId,
});
return newTag;
} catch (error) {
throw error;
}
};
@@ -0,0 +1,91 @@
"use client";
import { updateProjectAction } from "@/modules/projects/settings/actions";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
import { UpgradePlanNotice } from "@/modules/ui/components/upgrade-plan-notice";
import { useTranslations } from "next-intl";
import { useState } from "react";
import toast from "react-hot-toast";
import { TProject, TProjectUpdateInput } from "@formbricks/types/project";
interface EditBrandingProps {
type: "linkSurvey" | "appSurvey";
project: TProject;
canRemoveBranding: boolean;
environmentId: string;
isReadOnly?: boolean;
}
export const EditBranding = ({
type,
project,
canRemoveBranding,
environmentId,
isReadOnly,
}: EditBrandingProps) => {
const t = useTranslations();
const [isBrandingEnabled, setIsBrandingEnabled] = useState(
type === "linkSurvey" ? project.linkSurveyBranding : project.inAppSurveyBranding
);
const [updatingBranding, setUpdatingBranding] = useState(false);
const toggleBranding = async () => {
try {
setUpdatingBranding(true);
const newBrandingState = !isBrandingEnabled;
setIsBrandingEnabled(newBrandingState);
let inputProject: Partial<TProjectUpdateInput> = {
[type === "linkSurvey" ? "linkSurveyBranding" : "inAppSurveyBranding"]: newBrandingState,
};
await updateProjectAction({ projectId: project.id, data: inputProject });
toast.success(
newBrandingState
? t("environments.project.look.formbricks_branding_shown")
: t("environments.project.look.formbricks_branding_hidden")
);
} catch (error) {
toast.error(`Error: ${error.message}`);
} finally {
setUpdatingBranding(false);
}
};
return (
<div className="w-full items-center space-y-4">
<div className="flex items-center space-x-2">
<Switch
id={`branding-${type}`}
checked={isBrandingEnabled}
onCheckedChange={toggleBranding}
disabled={!canRemoveBranding || updatingBranding || isReadOnly}
/>
<Label htmlFor={`branding-${type}`}>
{t("environments.project.look.show_formbricks_branding_in", {
type: type === "linkSurvey" ? t("common.link") : t("common.app"),
})}
</Label>
</div>
{!canRemoveBranding && (
<div>
{type === "linkSurvey" && (
<div className="mb-8">
<UpgradePlanNotice
message={t("environments.project.look.formbricks_branding_upgrade_message")}
textForUrl={t("environments.project.look.formbricks_branding_upgrade_text")}
url={`/environments/${environmentId}/settings/billing`}
/>
</div>
)}
{type !== "linkSurvey" && (
<UpgradePlanNotice
message={t("environments.project.look.formbricks_branding_upgrade_message_in_app")}
textForUrl={t("environments.project.look.formbricks_branding_upgrade_text")}
url={`/environments/${environmentId}/settings/billing`}
/>
)}
</div>
)}
</div>
);
};
@@ -0,0 +1,217 @@
"use client";
import { handleFileUpload } from "@/app/lib/fileUpload";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateProjectAction } from "@/modules/projects/settings/actions";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { ColorPicker } from "@/modules/ui/components/color-picker";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { FileInput } from "@/modules/ui/components/file-input";
import { Input } from "@/modules/ui/components/input";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { ChangeEvent, useRef, useState } from "react";
import toast from "react-hot-toast";
import { TProject, TProjectUpdateInput } from "@formbricks/types/project";
interface EditLogoProps {
project: TProject;
environmentId: string;
isReadOnly: boolean;
}
export const EditLogo = ({ project, environmentId, isReadOnly }: EditLogoProps) => {
const t = useTranslations();
const [logoUrl, setLogoUrl] = useState<string | undefined>(project.logo?.url || undefined);
const [logoBgColor, setLogoBgColor] = useState<string | undefined>(project.logo?.bgColor || undefined);
const [isBgColorEnabled, setIsBgColorEnabled] = useState<boolean>(!!project.logo?.bgColor);
const [confirmRemoveLogoModalOpen, setConfirmRemoveLogoModalOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleImageUpload = async (file: File) => {
setIsLoading(true);
try {
const uploadResult = await handleFileUpload(file, environmentId);
if (uploadResult.error) {
toast.error(uploadResult.error);
return;
}
setLogoUrl(uploadResult.url);
} catch (error) {
toast.error(t("environments.project.look.logo_upload_failed"));
} finally {
setIsLoading(false);
}
};
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) await handleImageUpload(file);
setIsEditing(true);
};
const saveChanges = async () => {
if (!isEditing) {
setIsEditing(true);
return;
}
setIsLoading(true);
try {
const updatedProject: TProjectUpdateInput = {
logo: { url: logoUrl, bgColor: isBgColorEnabled ? logoBgColor : undefined },
};
const updateProjectResponse = await updateProjectAction({
projectId: project.id,
data: updatedProject,
});
if (updateProjectResponse?.data) {
toast.success(t("environments.project.look.logo_updated_successfully"));
} else {
const errorMessage = getFormattedErrorMessage(updateProjectResponse);
toast.error(errorMessage);
}
} catch (error) {
toast.error(t("environments.project.look.failed_to_update_logo"));
} finally {
setIsEditing(false);
setIsLoading(false);
}
};
const removeLogo = async () => {
setLogoUrl(undefined);
if (!isEditing) {
setIsEditing(true);
return;
}
setIsLoading(true);
try {
const updatedProject: TProjectUpdateInput = {
logo: { url: undefined, bgColor: undefined },
};
const updateProjectResponse = await updateProjectAction({
projectId: project.id,
data: updatedProject,
});
if (updateProjectResponse?.data) {
toast.success(t("environments.project.look.logo_removed_successfully"));
} else {
const errorMessage = getFormattedErrorMessage(updateProjectResponse);
toast.error(errorMessage);
}
} catch (error) {
toast.error(t("environments.project.look.failed_to_remove_logo"));
} finally {
setIsEditing(false);
setIsLoading(false);
setConfirmRemoveLogoModalOpen(false);
}
};
const toggleBackgroundColor = (enabled: boolean) => {
setIsBgColorEnabled(enabled);
if (!enabled) {
setLogoBgColor(undefined);
} else if (!logoBgColor) {
setLogoBgColor("#f8f8f8");
}
};
return (
<>
<div className="w-full space-y-8" id="edit-logo">
{logoUrl ? (
<Image
src={logoUrl}
alt="Logo"
width={256}
height={56}
style={{ backgroundColor: logoBgColor || undefined }}
className="-mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
/>
) : (
<FileInput
id="logo-input"
allowedFileExtensions={["png", "jpeg", "jpg", "webp"]}
environmentId={environmentId}
onFileUpload={(files: string[]) => {
setLogoUrl(files[0]);
setIsEditing(true);
}}
disabled={isReadOnly}
/>
)}
<Input
ref={fileInputRef}
type="file"
accept="image/jpeg, image/png, image/webp"
className="hidden"
disabled={isReadOnly}
onChange={handleFileChange}
/>
{isEditing && logoUrl && (
<>
<div className="flex gap-2">
<Button onClick={() => fileInputRef.current?.click()} variant="secondary" size="sm">
{t("environments.project.look.replace_logo")}
</Button>
<Button
variant="warn"
size="sm"
onClick={() => setConfirmRemoveLogoModalOpen(true)}
disabled={!isEditing}>
{t("environments.project.look.remove_logo")}
</Button>
</div>
<AdvancedOptionToggle
isChecked={isBgColorEnabled}
onToggle={toggleBackgroundColor}
htmlId="addBackgroundColor"
title={t("environments.project.look.add_background_color")}
description={t("environments.project.look.add_background_color_description")}
childBorder
customContainerClass="p-0"
disabled={!isEditing}>
{isBgColorEnabled && (
<div className="px-2">
<ColorPicker
color={logoBgColor || "#f8f8f8"}
onChange={setLogoBgColor}
disabled={!isEditing}
/>
</div>
)}
</AdvancedOptionToggle>
</>
)}
{logoUrl && (
<Button onClick={saveChanges} disabled={isLoading || isReadOnly} size="sm">
{isEditing ? t("common.save") : t("common.edit")}
</Button>
)}
<DeleteDialog
open={confirmRemoveLogoModalOpen}
setOpen={setConfirmRemoveLogoModalOpen}
deleteWhat={t("common.logo")}
text={t("environments.project.look.remove_logo_confirmation")}
onDelete={removeLogo}
/>
</div>
{isReadOnly && (
<Alert variant="warning" className="mt-4">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>
</Alert>
)}
</>
);
};
@@ -0,0 +1,223 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateProjectAction } from "@/modules/projects/settings/actions";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { FormControl, FormField, FormItem, FormLabel, FormProvider } from "@/modules/ui/components/form";
import { Label } from "@/modules/ui/components/label";
import { getPlacementStyle } from "@/modules/ui/components/preview-survey/lib/utils";
import { RadioGroup, RadioGroupItem } from "@/modules/ui/components/radio-group";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { z } from "zod";
import { cn } from "@formbricks/lib/cn";
import { TProject } from "@formbricks/types/project";
const placements = [
{ name: "common.bottom_right", value: "bottomRight", disabled: false },
{ name: "common.top_right", value: "topRight", disabled: false },
{ name: "common.top_left", value: "topLeft", disabled: false },
{ name: "common.bottom_left", value: "bottomLeft", disabled: false },
{ name: "common.centered_modal", value: "center", disabled: false },
];
interface EditPlacementProps {
project: TProject;
environmentId: string;
isReadOnly: boolean;
}
const ZProjectPlacementInput = z.object({
placement: z.enum(["bottomRight", "topRight", "topLeft", "bottomLeft", "center"]),
darkOverlay: z.boolean(),
clickOutsideClose: z.boolean(),
});
type EditPlacementFormValues = z.infer<typeof ZProjectPlacementInput>;
export const EditPlacementForm = ({ project, isReadOnly }: EditPlacementProps) => {
const t = useTranslations();
const form = useForm<EditPlacementFormValues>({
defaultValues: {
placement: project.placement,
darkOverlay: project.darkOverlay ?? false,
clickOutsideClose: project.clickOutsideClose ?? false,
},
resolver: zodResolver(ZProjectPlacementInput),
});
const currentPlacement = form.watch("placement");
const darkOverlay = form.watch("darkOverlay");
const clickOutsideClose = form.watch("clickOutsideClose");
const isSubmitting = form.formState.isSubmitting;
const overlayStyle = currentPlacement === "center" && darkOverlay ? "bg-slate-700/80" : "bg-slate-200";
const onSubmit: SubmitHandler<EditPlacementFormValues> = async (data) => {
const updatedProjectResponse = await updateProjectAction({
projectId: project.id,
data: {
placement: data.placement,
darkOverlay: data.darkOverlay,
clickOutsideClose: data.clickOutsideClose,
},
});
if (updatedProjectResponse?.data) {
toast.success(t("environments.project.look.placement_updated_successfully"));
} else {
const errorMessage = getFormattedErrorMessage(updatedProjectResponse);
toast.error(errorMessage);
}
};
return (
<>
<FormProvider {...form}>
<form className="w-full items-center" onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex">
<FormField
control={form.control}
name="placement"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
onValueChange={(value) => {
field.onChange(value);
}}
disabled={isReadOnly}
className="h-full">
{placements.map((placement) => (
<div key={placement.value} className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem
id={placement.value}
value={placement.value}
disabled={placement.disabled}
checked={field.value === placement.value}
/>
<Label
htmlFor={placement.value}
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
{t(placement.name)}
</Label>
</div>
))}
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
<div
className={cn(
clickOutsideClose ? "" : "cursor-not-allowed",
"relative ml-8 h-40 w-full rounded",
overlayStyle
)}>
<div
className={cn(
"absolute h-16 w-16 cursor-default rounded bg-slate-700",
getPlacementStyle(currentPlacement)
)}></div>
</div>
</div>
{currentPlacement === "center" && (
<>
<div className="mt-6 space-y-2">
<FormField
control={form.control}
name="darkOverlay"
render={({ field }) => (
<FormItem>
<FormLabel className="font-semibold">
{t("environments.project.look.centered_modal_overlay_color")}
</FormLabel>
<FormControl>
<RadioGroup
onValueChange={(value) => {
field.onChange(value === "darkOverlay");
}}
disabled={isReadOnly}
className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="lightOverlay" value="lightOverlay" checked={!field.value} />
<Label
htmlFor="lightOverlay"
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
{t("common.light_overlay")}
</Label>
</div>
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="darkOverlay" value="darkOverlay" checked={field.value} />
<Label
htmlFor="darkOverlay"
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
{t("common.dark_overlay")}
</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="mt-6 space-y-2">
<FormField
control={form.control}
name="clickOutsideClose"
render={({ field }) => (
<FormItem>
<FormLabel className="font-semibold">
{t("common.allow_users_to_exit_by_clicking_outside_the_survey")}
</FormLabel>
<FormControl>
<RadioGroup
disabled={isReadOnly}
onValueChange={(value) => {
field.onChange(value === "allow");
}}
className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="disallow" value="disallow" checked={!field.value} />
<Label
htmlFor="disallow"
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
{t("common.disallow")}
</Label>
</div>
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="allow" value="allow" checked={field.value} />
<Label
htmlFor="allow"
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
{t("common.allow")}
</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</div>
</>
)}
<Button className="mt-4 w-fit" size="sm" loading={isSubmitting} disabled={isReadOnly}>
{t("common.save")}
</Button>
</form>
</FormProvider>
{isReadOnly && (
<Alert variant="warning" className="mt-4">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>
</Alert>
)}
</>
);
};
@@ -0,0 +1,216 @@
"use client";
import { ClientLogo } from "@/modules/ui/components/client-logo";
import { MediaBackground } from "@/modules/ui/components/media-background";
import { Modal } from "@/modules/ui/components/preview-survey/components/modal";
import { ResetProgressButton } from "@/modules/ui/components/reset-progress-button";
import { SurveyInline } from "@/modules/ui/components/survey";
import { Variants, motion } from "framer-motion";
import { useTranslations } from "next-intl";
import { Fragment, useRef, useState } from "react";
import type { TProject } from "@formbricks/types/project";
import { TSurvey, TSurveyType } from "@formbricks/types/surveys/types";
interface ThemeStylingPreviewSurveyProps {
survey: TSurvey;
project: TProject;
previewType: TSurveyType;
setPreviewType: (type: TSurveyType) => void;
}
const previewParentContainerVariant: Variants = {
expanded: {
position: "fixed",
height: "100%",
width: "100%",
backgroundColor: "rgba(0, 0, 0, 0.4)",
backdropFilter: "blur(15px)",
left: 0,
top: 0,
zIndex: 1040,
transition: {
ease: "easeIn",
duration: 0.001,
},
},
shrink: {
display: "none",
position: "fixed",
backgroundColor: "rgba(0, 0, 0, 0.0)",
backdropFilter: "blur(0px)",
transition: {
duration: 0,
},
zIndex: -1,
},
};
export const ThemeStylingPreviewSurvey = ({
survey,
project,
previewType,
setPreviewType,
}: ThemeStylingPreviewSurveyProps) => {
const [isFullScreenPreview] = useState(false);
const [previewPosition] = useState("relative");
const ContentRef = useRef<HTMLDivElement | null>(null);
const [shrink] = useState(false);
const t = useTranslations();
const { projectOverwrites } = survey || {};
const previewScreenVariants: Variants = {
expanded: {
right: "5%",
bottom: "10%",
top: "12%",
width: "40%",
position: "fixed",
height: "80%",
zIndex: 1050,
boxShadow: "0px 4px 5px 4px rgba(169, 169, 169, 0.25)",
transition: {
ease: "easeInOut",
duration: shrink ? 0.3 : 0,
},
},
expanded_with_fixed_positioning: {
zIndex: 1050,
position: "fixed",
top: "5%",
right: "5%",
bottom: "10%",
width: "90%",
height: "90%",
transition: {
ease: "easeOut",
duration: 0.4,
},
},
shrink: {
display: "relative",
width: ["83.33%"],
height: ["95%"],
},
};
const { placement: surveyPlacement } = projectOverwrites || {};
const { darkOverlay: surveyDarkOverlay } = projectOverwrites || {};
const { clickOutsideClose: surveyClickOutsideClose } = projectOverwrites || {};
const placement = surveyPlacement || project.placement;
const darkOverlay = surveyDarkOverlay ?? project.darkOverlay;
const clickOutsideClose = surveyClickOutsideClose ?? project.clickOutsideClose;
const highlightBorderColor = project.styling.highlightBorderColor?.light;
const [surveyFormKey, setSurveyFormKey] = useState<number>(Date.now());
const resetQuestionProgress = () => {
setSurveyFormKey(Date.now());
};
const isAppSurvey = previewType === "app";
const scrollToEditLogoSection = () => {
const editLogoSection = document.getElementById("edit-logo");
if (editLogoSection) {
editLogoSection.scrollIntoView({ behavior: "smooth" });
}
};
return (
<div className="flex h-full w-full flex-col items-center justify-items-center overflow-hidden">
<motion.div
variants={previewParentContainerVariant}
className="fixed hidden h-[95%] w-5/6"
animate={isFullScreenPreview ? "expanded" : "shrink"}
/>
<motion.div
layout
variants={previewScreenVariants}
animate={
isFullScreenPreview
? previewPosition === "relative"
? "expanded"
: "expanded_with_fixed_positioning"
: "shrink"
}
className="relative flex h-[95%] max-h-[95%] w-5/6 items-center justify-center rounded-lg border border-slate-300 bg-slate-200">
<div className="flex h-full w-5/6 flex-1 flex-col">
<div className="flex h-8 w-full items-center rounded-t-lg bg-slate-100">
<div className="ml-6 flex space-x-2">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
</div>
<div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
<p>{isAppSurvey ? "Your web app" : "Preview"}</p>
<div className="flex items-center">
<ResetProgressButton onClick={resetQuestionProgress} />
</div>
</div>
</div>
{isAppSurvey ? (
<Modal
isOpen
placement={placement}
clickOutsideClose={clickOutsideClose}
darkOverlay={darkOverlay}
previewMode="desktop"
background={project.styling.cardBackgroundColor?.light}
borderRadius={project.styling.roundness ?? 8}>
<Fragment key={surveyFormKey}>
<SurveyInline
survey={{ ...survey, type: "app" }}
isBrandingEnabled={project.inAppSurveyBranding}
isRedirectDisabled={true}
onFileUpload={async (file) => file.name}
styling={project.styling}
isCardBorderVisible={!highlightBorderColor}
languageCode="default"
/>
</Fragment>
</Modal>
) : (
<MediaBackground survey={survey} project={project} ContentRef={ContentRef} isEditorView>
{!project.styling?.isLogoHidden && (
<div className="absolute left-5 top-5" onClick={scrollToEditLogoSection}>
<ClientLogo project={project} previewSurvey />
</div>
)}
<div
key={surveyFormKey}
className={`${project.logo?.url && !project.styling.isLogoHidden && !isFullScreenPreview ? "mt-12" : ""} z-0 w-full max-w-md rounded-lg p-4`}>
<SurveyInline
survey={{ ...survey, type: "link" }}
isBrandingEnabled={project.linkSurveyBranding}
isRedirectDisabled={true}
onFileUpload={async (file) => file.name}
responseCount={42}
styling={project.styling}
languageCode="default"
/>
</div>
</MediaBackground>
)}
</div>
</motion.div>
{/* for toggling between mobile and desktop mode */}
<div className="mt-2 flex rounded-full border-2 border-slate-300 p-1">
<div
className={`${previewType === "link" ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1 text-sm`}
onClick={() => setPreviewType("link")}>
{t("common.link_survey")}
</div>
<div
className={`${isAppSurvey ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1 text-sm`}
onClick={() => setPreviewType("app")}>
{t("common.app_survey")}
</div>
</div>
</div>
);
};
@@ -0,0 +1,284 @@
"use client";
import { BackgroundStylingCard } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/BackgroundStylingCard";
import { CardStylingSettings } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/CardStylingSettings";
import { FormStylingSettings } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/FormStylingSettings";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateProjectAction } from "@/modules/projects/settings/actions";
import { ThemeStylingPreviewSurvey } from "@/modules/projects/settings/look/components/theme-styling-preview-survey";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
import { Button } from "@/modules/ui/components/button";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { Switch } from "@/modules/ui/components/switch";
import { zodResolver } from "@hookform/resolvers/zod";
import { RotateCcwIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useCallback, useState } from "react";
import { SubmitHandler, UseFormReturn, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { COLOR_DEFAULTS, getPreviewSurvey } from "@formbricks/lib/styling/constants";
import { TProject, TProjectStyling, ZProjectStyling } from "@formbricks/types/project";
import { TSurvey, TSurveyStyling, TSurveyType } from "@formbricks/types/surveys/types";
interface ThemeStylingProps {
project: TProject;
environmentId: string;
colors: string[];
isUnsplashConfigured: boolean;
locale: string;
isReadOnly: boolean;
}
export const ThemeStyling = ({
project,
environmentId,
colors,
isUnsplashConfigured,
locale,
isReadOnly,
}: ThemeStylingProps) => {
const t = useTranslations();
const router = useRouter();
const form = useForm<TProjectStyling>({
defaultValues: {
...project.styling,
// specify the default values for the colors
allowStyleOverwrite: project.styling.allowStyleOverwrite ?? true,
brandColor: { light: project.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor },
questionColor: { light: project.styling.questionColor?.light ?? COLOR_DEFAULTS.questionColor },
inputColor: { light: project.styling.inputColor?.light ?? COLOR_DEFAULTS.inputColor },
inputBorderColor: { light: project.styling.inputBorderColor?.light ?? COLOR_DEFAULTS.inputBorderColor },
cardBackgroundColor: {
light: project.styling.cardBackgroundColor?.light ?? COLOR_DEFAULTS.cardBackgroundColor,
},
cardBorderColor: { light: project.styling.cardBorderColor?.light ?? COLOR_DEFAULTS.cardBorderColor },
cardShadowColor: { light: project.styling.cardShadowColor?.light ?? COLOR_DEFAULTS.cardShadowColor },
highlightBorderColor: project.styling.highlightBorderColor?.light
? {
light: project.styling.highlightBorderColor.light,
}
: undefined,
isDarkModeEnabled: project.styling.isDarkModeEnabled ?? false,
roundness: project.styling.roundness ?? 8,
cardArrangement: project.styling.cardArrangement ?? {
linkSurveys: "straight",
appSurveys: "straight",
},
background: project.styling.background,
hideProgressBar: project.styling.hideProgressBar ?? false,
isLogoHidden: project.styling.isLogoHidden ?? false,
},
resolver: zodResolver(ZProjectStyling),
});
const [previewSurveyType, setPreviewSurveyType] = useState<TSurveyType>("link");
const [confirmResetStylingModalOpen, setConfirmResetStylingModalOpen] = useState(false);
const [formStylingOpen, setFormStylingOpen] = useState(false);
const [cardStylingOpen, setCardStylingOpen] = useState(false);
const [backgroundStylingOpen, setBackgroundStylingOpen] = useState(false);
const onReset = useCallback(async () => {
const defaultStyling: TProjectStyling = {
allowStyleOverwrite: true,
brandColor: {
light: COLOR_DEFAULTS.brandColor,
},
questionColor: {
light: COLOR_DEFAULTS.questionColor,
},
inputColor: {
light: COLOR_DEFAULTS.inputColor,
},
inputBorderColor: {
light: COLOR_DEFAULTS.inputBorderColor,
},
cardBackgroundColor: {
light: COLOR_DEFAULTS.cardBackgroundColor,
},
cardBorderColor: {
light: COLOR_DEFAULTS.cardBorderColor,
},
isLogoHidden: false,
highlightBorderColor: undefined,
isDarkModeEnabled: false,
background: {
bg: "#fff",
bgType: "color",
},
roundness: 8,
cardArrangement: {
linkSurveys: "straight",
appSurveys: "straight",
},
};
const updatedProjectResponse = await updateProjectAction({
projectId: project.id,
data: {
styling: { ...defaultStyling },
},
});
if (updatedProjectResponse?.data) {
form.reset({ ...defaultStyling });
toast.success(t("environments.project.look.styling_updated_successfully"));
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(updatedProjectResponse);
toast.error(errorMessage);
}
}, [form, project.id, router]);
const onSubmit: SubmitHandler<TProjectStyling> = async (data) => {
const updatedProjectResponse = await updateProjectAction({
projectId: project.id,
data: {
styling: data,
},
});
if (updatedProjectResponse?.data) {
form.reset({ ...updatedProjectResponse.data.styling });
toast.success(t("environments.project.look.styling_updated_successfully"));
} else {
const errorMessage = getFormattedErrorMessage(updatedProjectResponse);
toast.error(errorMessage);
}
};
if (isReadOnly) {
return (
<Alert variant="warning">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>
</Alert>
);
}
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="flex">
{/* Styling settings */}
<div className="relative flex w-1/2 flex-col pr-6">
<div className="flex flex-1 flex-col gap-4">
<div className="flex flex-col gap-4 rounded-lg bg-slate-50 p-4">
<div className="flex items-center gap-6">
<FormField
control={form.control}
name="allowStyleOverwrite"
render={({ field }) => (
<FormItem className="flex w-full items-center gap-2 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={(value) => {
field.onChange(value);
}}
/>
</FormControl>
<div>
<FormLabel>{t("environments.project.look.enable_custom_styling")}</FormLabel>
<FormDescription>
{t("environments.project.look.enable_custom_styling_description")}
</FormDescription>
</div>
</FormItem>
)}
/>
</div>
</div>
<div className="flex flex-col gap-3 rounded-lg bg-slate-50 p-4">
<FormStylingSettings
open={formStylingOpen}
setOpen={setFormStylingOpen}
isSettingsPage
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
/>
<CardStylingSettings
open={cardStylingOpen}
setOpen={setCardStylingOpen}
isSettingsPage
project={project}
surveyType={previewSurveyType}
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
/>
<BackgroundStylingCard
open={backgroundStylingOpen}
setOpen={setBackgroundStylingOpen}
environmentId={environmentId}
colors={colors}
key={form.watch("background.bg")}
isSettingsPage
isUnsplashConfigured={isUnsplashConfigured}
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
/>
</div>
</div>
<div className="mt-4 flex items-center gap-2">
<Button size="sm" type="submit">
{t("common.save")}
</Button>
<Button
type="button"
size="sm"
variant="minimal"
className="flex items-center gap-2"
onClick={() => setConfirmResetStylingModalOpen(true)}>
{t("common.reset_to_default")}
<RotateCcwIcon className="h-4 w-4" />
</Button>
</div>
</div>
{/* Survey Preview */}
<div className="relative w-1/2 rounded-lg bg-slate-100 pt-4">
<div className="sticky top-4 mb-4 h-[600px]">
<ThemeStylingPreviewSurvey
survey={getPreviewSurvey(locale) as TSurvey}
project={{
...project,
styling: form.getValues(),
}}
previewType={previewSurveyType}
setPreviewType={setPreviewSurveyType}
/>
</div>
</div>
{/* Confirm reset styling modal */}
<AlertDialog
open={confirmResetStylingModalOpen}
setOpen={setConfirmResetStylingModalOpen}
headerText={t("environments.project.look.reset_styling")}
mainText={t("environments.project.look.reset_styling_confirmation")}
confirmBtnLabel={t("common.confirm")}
onConfirm={() => {
onReset();
setConfirmResetStylingModalOpen(false);
}}
onDecline={() => setConfirmResetStylingModalOpen(false)}
/>
</div>
</form>
</FormProvider>
);
};
@@ -0,0 +1,170 @@
"use client";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { Label } from "@/modules/ui/components/label";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { RadioGroup, RadioGroupItem } from "@/modules/ui/components/radio-group";
import { Switch } from "@/modules/ui/components/switch";
import { useTranslations } from "next-intl";
import { cn } from "@formbricks/lib/cn";
const placements = [
{ name: "common.bottom_right", value: "bottomRight", disabled: false },
{ name: "common.top_right", value: "topRight", disabled: false },
{ name: "common.top_left", value: "topLeft", disabled: false },
{ name: "common.bottom_left", value: "bottomLeft", disabled: false },
{ name: "common.centered_modal", value: "center", disabled: false },
];
export const ProjectLookSettingsLoading = () => {
const t = useTranslations();
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.configuration")}>
<ProjectConfigNavigation activeId="look" loading />
</PageHeader>
<SettingsCard
title={t("environments.project.look.theme")}
className="max-w-7xl"
description={t("environments.project.look.theme_settings_description")}>
<div className="flex animate-pulse">
<div className="w-1/2">
<div className="flex flex-col gap-4 pr-6">
<div className="flex flex-col gap-4 rounded-lg bg-slate-50 p-4">
<div className="flex items-center gap-6">
<Switch />
<div className="flex flex-col">
<h3 className="text-sm font-semibold text-slate-700">
{t("environments.project.look.enable_custom_styling")}
</h3>
<p className="text-xs text-slate-500">
{t("environments.project.look.enable_custom_styling_description")}
</p>
</div>
</div>
</div>
<div className="flex flex-col gap-3 bg-slate-50 p-4">
<div className="w-full rounded-lg border border-slate-300 bg-white">
<div className="flex flex-col p-4">
<h2 className="text-sm font-semibold text-slate-700">
{t("environments.surveys.edit.form_styling")}
</h2>
<p className="mt-1 text-xs text-slate-500">
{t("environments.surveys.edit.style_the_question_texts_descriptions_and_input_fields")}
</p>
</div>
</div>
<div className="w-full rounded-lg border border-slate-300 bg-white">
<div className="flex flex-col p-4">
<h2 className="text-sm font-semibold text-slate-700">
{t("environments.surveys.edit.card_styling")}
</h2>
<p className="mt-1 text-xs text-slate-500">
{t("environments.surveys.edit.style_the_survey_card")}
</p>
</div>
</div>
<div className="w-full rounded-lg border border-slate-300 bg-white">
<div className="flex flex-col p-4">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-slate-700">
{t("environments.surveys.edit.background_styling")}
</h2>
<Badge text={t("common.link_surveys")} type="gray" size="normal" />
</div>
<p className="mt-1 text-xs text-slate-500">
{t("environments.surveys.edit.change_the_background_to_a_color_image_or_animation")}
</p>
</div>
</div>
</div>
</div>
</div>
<div className="relative flex w-1/2 flex-row items-center justify-center rounded-lg bg-slate-100 pt-4">
<div className="relative mb-3 flex h-fit w-5/6 items-center justify-center rounded-lg border border-slate-300 bg-slate-200">
<div className="flex h-[90%] max-h-[90%] w-4/6 flex-1 flex-col">
<div className="flex h-8 w-full items-center rounded-t-lg bg-slate-100">
<div className="ml-6 flex space-x-2">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
</div>
<div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
<p>{t("common.preview")}</p>
<div className="flex items-center pr-6">{t("common.restart")}</div>
</div>
</div>
<div className="grid h-[500px] place-items-center bg-white">
<h1 className="text-xl font-semibold text-slate-700">{t("common.loading")}</h1>
</div>
</div>
</div>
</div>
</div>
</SettingsCard>
<SettingsCard title="Logo" description="Upload your company logo to brand surveys and link previews.">
<div className="w-full animate-pulse items-center">
<div className="relative flex h-52 w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:hover:border-slate-500 dark:hover:bg-slate-800">
<p className="text-xl font-semibold text-slate-700">{t("common.loading")}</p>
</div>
</div>
</SettingsCard>
<SettingsCard
title="In-app Survey Placement"
description="Change where surveys will be shown in your web app.">
<div className="w-full items-center">
<div className="flex cursor-not-allowed select-none">
<RadioGroup>
{placements.map((placement) => (
<div key={placement.value} className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem
className="cursor-not-allowed select-none"
id={placement.value}
value={placement.value}
disabled={placement.disabled}
/>
<Label
htmlFor={placement.value}
className={cn(
placement.disabled ? "cursor-not-allowed text-slate-500" : "text-slate-900"
)}>
{t(placement.name)}
</Label>
</div>
))}
</RadioGroup>
<div className="relative ml-8 h-40 w-full rounded bg-slate-200">
<div className={cn("absolute bottom-3 h-16 w-16 rounded bg-slate-700 sm:right-3")}></div>
</div>
</div>
<Button className="pointer-events-none mt-4 animate-pulse cursor-not-allowed select-none bg-slate-200">
{t("common.loading")}
</Button>
</div>
</SettingsCard>
<SettingsCard
title="Formbricks Signature"
description="We love your support but understand if you toggle it off.">
<div className="w-full items-center">
<div className="pointer-events-none flex cursor-not-allowed select-none items-center space-x-2">
<Switch id="signature" checked={false} />
<Label htmlFor="signature">{t("environments.project.look.show_powered_by_formbricks")}</Label>
</div>
</div>
</SettingsCard>
</PageContentWrapper>
);
};
@@ -0,0 +1,125 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { authOptions } from "@/modules/auth/lib/authOptions";
import {
getMultiLanguagePermission,
getRemoveInAppBrandingPermission,
getRemoveLinkBrandingPermission,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { EditLogo } from "@/modules/projects/settings/look/components/edit-logo";
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 { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { cn } from "@formbricks/lib/cn";
import { DEFAULT_LOCALE, SURVEY_BG_COLORS, UNSPLASH_ACCESS_KEY } from "@formbricks/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getUserLocale } from "@formbricks/lib/user/service";
import { EditBranding } from "./components/edit-branding";
import { EditPlacementForm } from "./components/edit-placement-form";
import { ThemeStyling } from "./components/theme-styling";
export const ProjectLookSettingsPage = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslations();
const [session, organization, project] = await Promise.all([
getServerSession(authOptions),
getOrganizationByEnvironmentId(params.environmentId),
getProjectByEnvironmentId(params.environmentId),
]);
if (!project) {
throw new Error(t("common.project_not_found"));
}
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const locale = session?.user.id ? await getUserLocale(session.user.id) : undefined;
const canRemoveInAppBranding = getRemoveInAppBrandingPermission(organization);
const canRemoveLinkBranding = getRemoveLinkBrandingPermission(organization);
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
const { hasManageAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && !hasManageAccess;
const isMultiLanguageAllowed = await getMultiLanguagePermission(organization);
const canDoRoleManagement = await getRoleManagementPermission(organization);
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.configuration")}>
<ProjectConfigNavigation
environmentId={params.environmentId}
activeId="look"
isMultiLanguageAllowed={isMultiLanguageAllowed}
canDoRoleManagement={canDoRoleManagement}
/>
</PageHeader>
<SettingsCard
title={t("environments.project.look.theme")}
className={cn(!isReadOnly && "max-w-7xl")}
description={t("environments.project.look.theme_settings_description")}>
<ThemeStyling
environmentId={params.environmentId}
project={project}
colors={SURVEY_BG_COLORS}
isUnsplashConfigured={UNSPLASH_ACCESS_KEY ? true : false}
locale={locale ?? DEFAULT_LOCALE}
isReadOnly={isReadOnly}
/>
</SettingsCard>
<SettingsCard
title={t("common.logo")}
description={t("environments.project.look.logo_settings_description")}>
<EditLogo project={project} environmentId={params.environmentId} isReadOnly={isReadOnly} />
</SettingsCard>
<SettingsCard
title={t("environments.project.look.app_survey_placement")}
description={t("environments.project.look.app_survey_placement_settings_description")}>
<EditPlacementForm project={project} environmentId={params.environmentId} isReadOnly={isReadOnly} />
</SettingsCard>
<SettingsCard
title={t("environments.project.look.formbricks_branding")}
description={t("environments.project.look.formbricks_branding_settings_description")}>
<div className="space-y-4">
<EditBranding
type="linkSurvey"
project={project}
canRemoveBranding={canRemoveLinkBranding}
environmentId={params.environmentId}
isReadOnly={isReadOnly}
/>
<EditBranding
type="appSurvey"
project={project}
canRemoveBranding={canRemoveInAppBranding}
environmentId={params.environmentId}
isReadOnly={isReadOnly}
/>
</div>
{isReadOnly && (
<Alert variant="warning" className="mt-4">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>
</Alert>
)}
</SettingsCard>
</PageContentWrapper>
);
};
@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
export const ProjectSettingsPage = async (props) => {
const params = await props.params;
return redirect(`/environments/${params.environmentId}/project/general`);
};
@@ -0,0 +1,101 @@
"use server";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import {
getEnvironmentIdFromTagId,
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromTagId,
getProjectIdFromEnvironmentId,
getProjectIdFromTagId,
} from "@/lib/utils/helper";
import { deleteTag, mergeTags, updateTagName } from "@/modules/projects/settings/lib/tag";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
const ZDeleteTagAction = z.object({
tagId: ZId,
});
export const deleteTagAction = authenticatedActionClient
.schema(ZDeleteTagAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromTagId(parsedInput.tagId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromTagId(parsedInput.tagId),
},
],
});
return await deleteTag(parsedInput.tagId);
});
const ZUpdateTagNameAction = z.object({
tagId: ZId,
name: z.string(),
});
export const updateTagNameAction = authenticatedActionClient
.schema(ZUpdateTagNameAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromTagId(parsedInput.tagId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromTagId(parsedInput.tagId),
},
],
});
return await updateTagName(parsedInput.tagId, parsedInput.name);
});
const ZMergeTagsAction = z.object({
originalTagId: ZId,
newTagId: ZId,
});
export const mergeTagsAction = authenticatedActionClient
.schema(ZMergeTagsAction)
.action(async ({ ctx, parsedInput }) => {
const originalTagEnvironmentId = await getEnvironmentIdFromTagId(parsedInput.originalTagId);
const newTagEnvironmentId = await getEnvironmentIdFromTagId(parsedInput.newTagId);
if (originalTagEnvironmentId !== newTagEnvironmentId) {
throw new Error("Tags must be in the same environment");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(newTagEnvironmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(newTagEnvironmentId),
},
],
});
return await mergeTags(parsedInput.originalTagId, parsedInput.newTagId);
});
@@ -0,0 +1,46 @@
"use client";
import { SingleTag } from "@/modules/projects/settings/tags/components/single-tag";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
import { useTranslations } from "next-intl";
import React from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TTag, TTagsCount } from "@formbricks/types/tags";
interface EditTagsWrapperProps {
environment: TEnvironment;
environmentTags: TTag[];
environmentTagsCount: TTagsCount;
isReadOnly: boolean;
}
export const EditTagsWrapper: React.FC<EditTagsWrapperProps> = (props) => {
const t = useTranslations();
const { environment, environmentTags, environmentTagsCount, isReadOnly } = props;
return (
<div className="">
<div className="grid grid-cols-4 content-center rounded-lg bg-white text-left text-sm font-semibold text-slate-900">
<div className="col-span-2">{t("environments.project.tags.tag")}</div>
<div className="col-span-1 text-center">{t("environments.project.tags.count")}</div>
{!isReadOnly && (
<div className="col-span-1 flex justify-center text-center">{t("common.actions")}</div>
)}
</div>
{!environmentTags?.length ? (
<EmptySpaceFiller environment={environment} type="tag" noWidgetRequired />
) : null}
{environmentTags?.map((tag) => (
<SingleTag
key={tag.id}
tagId={tag.id}
tagName={tag.name}
tagCount={environmentTagsCount?.find((count) => count.tagId === tag.id)?.count ?? 0}
environmentTags={environmentTags}
isReadOnly={isReadOnly}
/>
))}
</div>
);
};
@@ -0,0 +1,73 @@
import { Button } from "@/modules/ui/components/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/modules/ui/components/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import { useTranslations } from "next-intl";
import { useState } from "react";
interface MergeTagsComboboxProps {
tags: Tag[];
onSelect: (tagId: string) => void;
}
type Tag = {
label: string;
value: string;
};
export const MergeTagsCombobox = ({ tags, onSelect }: MergeTagsComboboxProps) => {
const t = useTranslations();
const [open, setOpen] = useState(false);
const [value, setValue] = useState("");
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="secondary"
size="sm"
className="font-medium text-slate-900 focus:border-transparent focus:shadow-transparent focus:outline-transparent focus:ring-0 focus:ring-transparent">
{t("environments.project.tags.merge")}
</Button>
</PopoverTrigger>
<PopoverContent className="max-h-60 w-[200px] overflow-y-auto p-0">
<Command>
<div className="p-1">
<CommandInput
placeholder={t("environments.project.tags.search_tags")}
className="border-b border-none border-transparent shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
/>
</div>
<CommandList>
<CommandEmpty>
<div className="p-2 text-sm text-slate-500">{t("environments.project.tags.no_tag_found")}</div>
</CommandEmpty>
<CommandGroup>
{tags?.length === 0 ? (
<CommandItem>{t("environments.project.tags.no_tag_found")}</CommandItem>
) : null}
{tags?.map((tag) => (
<CommandItem
key={tag.value}
onSelect={(currentValue) => {
setValue(currentValue === value ? "" : currentValue);
setOpen(false);
onSelect(tag.value);
}}>
{tag.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
@@ -0,0 +1,160 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import {
deleteTagAction,
mergeTagsAction,
updateTagNameAction,
} from "@/modules/projects/settings/tags/actions";
import { MergeTagsCombobox } from "@/modules/projects/settings/tags/components/merge-tags-combobox";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { Input } from "@/modules/ui/components/input";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import { AlertCircleIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import { toast } from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { TTag } from "@formbricks/types/tags";
interface SingleTagProps {
tagId: string;
tagName: string;
tagCount?: number;
tagCountLoading?: boolean;
updateTagsCount?: () => void;
environmentTags: TTag[];
isReadOnly?: boolean;
}
export const SingleTag: React.FC<SingleTagProps> = ({
tagId,
tagName,
tagCount = 0,
tagCountLoading = false,
updateTagsCount = () => {},
environmentTags,
isReadOnly = false,
}) => {
const t = useTranslations();
const router = useRouter();
const [updateTagError, setUpdateTagError] = useState(false);
const [isMergingTags, setIsMergingTags] = useState(false);
const [openDeleteTagDialog, setOpenDeleteTagDialog] = useState(false);
const confirmDeleteTag = async () => {
const deleteTagResponse = await deleteTagAction({ tagId });
if (deleteTagResponse?.data) {
toast.success(t("environments.project.tags.tag_deleted"));
updateTagsCount();
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(deleteTagResponse);
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"));
}
};
return (
<div className="w-full" key={tagId}>
<div className="grid h-16 grid-cols-4 content-center rounded-lg">
<div className="col-span-2 flex items-center text-sm">
<div className="w-full text-left">
<Input
disabled={isReadOnly}
className={cn(
"w-full border font-medium text-slate-900",
updateTagError
? "border-red-500 focus:border-red-500"
: "border-slate-200 focus:border-slate-500"
)}
defaultValue={tagName}
onBlur={(e) => {
updateTagNameAction({ tagId, name: e.target.value.trim() }).then((updateTagNameResponse) => {
if (updateTagNameResponse?.data) {
setUpdateTagError(false);
toast.success(t("environments.project.tags.tag_updated"));
} else {
const errorMessage = getFormattedErrorMessage(updateTagNameResponse);
if (
errorMessage.includes(
t("environments.project.tags.unique_constraint_failed_on_the_fields")
)
) {
toast.error(t("environments.project.tags.tag_already_exists"), {
duration: 2000,
icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
});
} else {
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"), {
duration: 2000,
});
}
setUpdateTagError(true);
}
});
}}
/>
</div>
</div>
<div className="col-span-1 my-auto whitespace-nowrap text-center text-sm text-slate-500">
<div className="text-slate-900">{tagCountLoading ? <LoadingSpinner /> : <p>{tagCount}</p>}</div>
</div>
{!isReadOnly && (
<div className="col-span-1 my-auto flex items-center justify-center gap-2 whitespace-nowrap text-center text-sm text-slate-500">
<div>
{isMergingTags ? (
<div className="w-24">
<LoadingSpinner />
</div>
) : (
<MergeTagsCombobox
tags={
environmentTags
?.filter((tag) => tag.id !== tagId)
?.map((tag) => ({ label: tag.name, value: tag.id })) ?? []
}
onSelect={(newTagId) => {
setIsMergingTags(true);
mergeTagsAction({ originalTagId: tagId, newTagId }).then((mergeTagsResponse) => {
if (mergeTagsResponse?.data) {
toast.success(t("environments.project.tags.tags_merged"));
updateTagsCount();
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(mergeTagsResponse);
toast.error(errorMessage ?? t("common.something_went_wrong_please_try_again"));
}
setIsMergingTags(false);
});
}}
/>
)}
</div>
<div>
<Button
variant="alert"
size="sm"
// loading={isDeletingTag}
className="font-medium text-slate-50 focus:border-transparent focus:shadow-transparent focus:outline-transparent focus:ring-0 focus:ring-transparent"
onClick={() => setOpenDeleteTagDialog(true)}>
{t("common.delete")}
</Button>
<DeleteDialog
open={openDeleteTagDialog}
setOpen={setOpenDeleteTagDialog}
deleteWhat={tagName}
text={t("environments.project.tags.delete_tag_confirmation")}
onDelete={confirmDeleteTag}
/>
</div>
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,44 @@
"use client";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { useTranslations } from "next-intl";
export const TagsLoading = () => {
const t = useTranslations();
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.configuration")}>
<ProjectConfigNavigation activeId="tags" />
</PageHeader>
<SettingsCard
title={t("environments.project.tags.manage_tags")}
description={t("environments.project.tags.manage_tags_description")}>
<div className="w-full">
<div className="grid grid-cols-4 content-center rounded-lg bg-white text-left text-sm font-semibold text-slate-900">
<div className="col-span-2">{t("environments.project.tags.tag")}</div>
<div className="col-span-1 text-center">{t("environments.project.tags.count")}</div>
<div className="col-span-1 flex justify-center text-center">{t("common.actions")}</div>
</div>
<div className="w-full">
{[...Array(3)].map((_, idx) => (
<div key={idx} className="grid h-16 w-full grid-cols-4 content-center">
<div className="col-span-2 h-10 animate-pulse rounded-md bg-slate-200" />
<div className="flex items-center justify-center">
<div className="h-5 w-5 animate-pulse rounded-md bg-slate-200" />
</div>
<div className="flex items-center gap-2">
<div className="h-8 w-1/2 animate-pulse rounded-md bg-slate-200" />
<div className="h-8 w-1/2 animate-pulse rounded-md bg-slate-200" />
</div>
</div>
))}
</div>
</div>
</SettingsCard>
</PageContentWrapper>
);
};
@@ -0,0 +1,87 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { authOptions } from "@/modules/auth/lib/authOptions";
import {
getMultiLanguagePermission,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
import { getTagsOnResponsesCount } from "@formbricks/lib/tagOnResponse/service";
import { EditTagsWrapper } from "./components/edit-tags-wrapper";
export const TagsPage = async (props) => {
const params = await props.params;
const t = await getTranslations();
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
const [tags, environmentTagsCount, organization, session, project] = await Promise.all([
getTagsByEnvironmentId(params.environmentId),
getTagsOnResponsesCount(params.environmentId),
getOrganizationByEnvironmentId(params.environmentId),
getServerSession(authOptions),
getProjectByEnvironmentId(params.environmentId),
]);
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
if (!session) {
throw new Error(t("common.session_not_found"));
}
if (!project) {
throw new Error(t("common.project_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isMember } = getAccessFlags(currentUserMembership?.role);
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
const { hasManageAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && !hasManageAccess;
const isMultiLanguageAllowed = await getMultiLanguagePermission(organization);
const canDoRoleManagement = await getRoleManagementPermission(organization);
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.configuration")}>
<ProjectConfigNavigation
environmentId={params.environmentId}
activeId="tags"
isMultiLanguageAllowed={isMultiLanguageAllowed}
canDoRoleManagement={canDoRoleManagement}
/>
</PageHeader>
<SettingsCard
title={t("environments.project.tags.manage_tags")}
description={t("environments.project.tags.manage_tags_description")}>
<EditTagsWrapper
environment={environment}
environmentTags={tags}
environmentTagsCount={environmentTagsCount}
isReadOnly={isReadOnly}
/>
</SettingsCard>
</PageContentWrapper>
);
};