feat: whitelabel-1(moving branding to EE) (#4393)

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Piyush Gupta
2024-12-12 09:35:05 +05:30
committed by GitHub
parent c349a3b869
commit af1a5f7361
15 changed files with 318 additions and 147 deletions
+28 -14
View File
@@ -81,7 +81,15 @@ const fetchLicenseForE2ETesting = async (): Promise<{
// first call
const newResult = {
active: true,
features: { isMultiOrgEnabled: true, twoFactorAuth: true, sso: true, contacts: true, projects: 3 },
features: {
isMultiOrgEnabled: true,
twoFactorAuth: true,
sso: true,
contacts: true,
projects: 3,
whitelabel: true,
removeBranding: true,
},
lastChecked: currentTime,
};
await setPreviousResult(newResult);
@@ -140,10 +148,12 @@ export const getEnterpriseLicense = async (): Promise<{
active: false,
features: {
isMultiOrgEnabled: false,
projects: 3,
twoFactorAuth: false,
sso: false,
whitelabel: false,
removeBranding: false,
contacts: false,
projects: 3,
},
lastChecked: new Date(),
};
@@ -249,24 +259,28 @@ export const fetchLicense = reactCache(
)()
);
export const getRemoveInAppBrandingPermission = (organization: TOrganization): boolean => {
if (IS_FORMBRICKS_CLOUD) return organization.billing.plan !== PROJECT_FEATURE_KEYS.FREE;
else if (!IS_FORMBRICKS_CLOUD) return true;
return false;
};
export const getRemoveLinkBrandingPermission = (organization: TOrganization): boolean => {
if (IS_FORMBRICKS_CLOUD) return organization.billing.plan !== PROJECT_FEATURE_KEYS.FREE;
else if (!IS_FORMBRICKS_CLOUD) return true;
return false;
};
export const getSurveyFollowUpsPermission = async (organization: TOrganization): Promise<boolean> => {
if (IS_FORMBRICKS_CLOUD) return organization.billing.plan !== PROJECT_FEATURE_KEYS.FREE;
else if (!IS_FORMBRICKS_CLOUD) return (await getEnterpriseLicense()).active;
return false;
};
export const getRemoveBrandingPermission = async (organization: TOrganization): Promise<boolean> => {
if (E2E_TESTING) {
const previousResult = await fetchLicenseForE2ETesting();
return previousResult?.features?.removeBranding ?? false;
}
if (IS_FORMBRICKS_CLOUD && (await getEnterpriseLicense()).active) {
return organization.billing.plan !== PROJECT_FEATURE_KEYS.FREE;
} else {
const licenseFeatures = await getLicenseFeatures();
if (!licenseFeatures) return false;
return licenseFeatures.removeBranding;
}
};
export const getRoleManagementPermission = async (organization: TOrganization): Promise<boolean> => {
if (E2E_TESTING) {
const previousResult = await fetchLicenseForE2ETesting();
@@ -8,6 +8,8 @@ const ZEnterpriseLicenseFeatures = z.object({
isMultiOrgEnabled: z.boolean(),
contacts: z.boolean(),
projects: z.number().nullable(),
whitelabel: z.boolean(),
removeBranding: z.boolean(),
twoFactorAuth: z.boolean(),
sso: z.boolean(),
});
@@ -0,0 +1,65 @@
"use server";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { getRemoveBrandingPermission } from "@/modules/ee/license-check/lib/utils";
import { updateProjectBranding } from "@/modules/ee/whitelabel/remove-branding/lib/project";
import { ZProjectUpdateBrandingInput } from "@/modules/ee/whitelabel/remove-branding/types/project";
import { z } from "zod";
import { getOrganization } from "@formbricks/lib/organization/service";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError } from "@formbricks/types/errors";
const ZUpdateProjectAction = z.object({
projectId: ZId,
data: ZProjectUpdateBrandingInput,
});
export const updateProjectBrandingAction = authenticatedActionClient
.schema(ZUpdateProjectAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromProjectId(parsedInput.projectId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
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");
}
const canRemoveBranding = await getRemoveBrandingPermission(organization);
if (parsedInput.data.inAppSurveyBranding !== undefined) {
if (!canRemoveBranding) {
throw new OperationNotAllowedError("You are not allowed to remove in-app branding");
}
}
if (parsedInput.data.linkSurveyBranding !== undefined) {
if (!canRemoveBranding) {
throw new OperationNotAllowedError("You are not allowed to remove link survey branding");
}
}
}
return await updateProjectBranding(parsedInput.projectId, parsedInput.data);
});
@@ -0,0 +1,74 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { EditBranding } from "@/modules/ee/whitelabel/remove-branding/components/edit-branding";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { EmptyContent, ModalButton } from "@/modules/ui/components/empty-content";
import { KeyIcon } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { TProject } from "@formbricks/types/project";
interface BrandingSettingsCardProps {
canRemoveBranding: boolean;
project: TProject;
environmentId: string;
isReadOnly: boolean;
}
export const BrandingSettingsCard = async ({
canRemoveBranding,
project,
environmentId,
isReadOnly,
}: BrandingSettingsCardProps) => {
const t = await getTranslations();
const buttons: [ModalButton, ModalButton] = [
{
text: t("common.start_free_trial"),
href: IS_FORMBRICKS_CLOUD
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/docs/self-hosting/license#30-day-trial-license-request",
},
{
text: t("common.learn_more"),
href: "https://formbricks.com/docs/self-hosting/license",
},
];
return (
<SettingsCard
title={t("environments.project.look.formbricks_branding")}
description={t("environments.project.look.formbricks_branding_settings_description")}>
{canRemoveBranding ? (
<div className="space-y-4">
<EditBranding
type="linkSurvey"
isEnabled={project.linkSurveyBranding}
projectId={project.id}
isReadOnly={isReadOnly}
/>
<EditBranding
type="appSurvey"
isEnabled={project.inAppSurveyBranding}
projectId={project.id}
isReadOnly={isReadOnly}
/>
</div>
) : (
<EmptyContent
icon={<KeyIcon className="h-6 w-6 text-slate-900" />}
title={t("environments.project.look.remove_branding_with_a_higher_plan")}
description={t("environments.project.look.eliminate_branding_with_whitelabel")}
buttons={buttons}
/>
)}
{isReadOnly && (
<Alert variant="warning" className="mt-4">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>
</Alert>
)}
</SettingsCard>
);
};
@@ -0,0 +1,62 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateProjectBrandingAction } from "@/modules/ee/whitelabel/remove-branding/actions";
import { TProjectUpdateBrandingInput } from "@/modules/ee/whitelabel/remove-branding/types/project";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
import { useTranslations } from "next-intl";
import { useState } from "react";
import toast from "react-hot-toast";
interface EditBrandingProps {
type: "linkSurvey" | "appSurvey";
isEnabled: boolean;
projectId: string;
isReadOnly?: boolean;
}
export const EditBranding = ({ type, isEnabled, projectId, isReadOnly }: EditBrandingProps) => {
const t = useTranslations();
const [isBrandingEnabled, setIsBrandingEnabled] = useState(isEnabled);
const [updatingBranding, setUpdatingBranding] = useState(false);
const toggleBranding = async () => {
setUpdatingBranding(true);
const newBrandingState = !isBrandingEnabled;
setIsBrandingEnabled(newBrandingState);
let inputProject: TProjectUpdateBrandingInput = {
[type === "linkSurvey" ? "linkSurveyBranding" : "inAppSurveyBranding"]: newBrandingState,
};
const updateBrandingResponse = await updateProjectBrandingAction({ projectId, data: inputProject });
if (updateBrandingResponse?.data) {
toast.success(
newBrandingState
? t("environments.project.look.formbricks_branding_shown")
: t("environments.project.look.formbricks_branding_hidden")
);
} else {
const errorMessage = getFormattedErrorMessage(updateBrandingResponse);
toast.error(errorMessage);
}
setUpdatingBranding(false);
};
return (
<div className="flex items-center space-x-2">
<Switch
id={`branding-${type}`}
checked={isBrandingEnabled}
onCheckedChange={toggleBranding}
disabled={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>
);
};
@@ -0,0 +1,56 @@
import "server-only";
import {
TProjectUpdateBrandingInput,
ZProjectUpdateBrandingInput,
} from "@/modules/ee/whitelabel/remove-branding/types/project";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { projectCache } from "@formbricks/lib/project/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId } from "@formbricks/types/common";
import { ValidationError } from "@formbricks/types/errors";
export const updateProjectBranding = async (
projectId: string,
inputProject: TProjectUpdateBrandingInput
): Promise<boolean> => {
validateInputs([projectId, ZId], [inputProject, ZProjectUpdateBrandingInput]);
try {
const updatedProject = await prisma.project.update({
where: {
id: projectId,
},
data: {
...inputProject,
},
select: {
id: true,
organizationId: true,
environments: {
select: {
id: true,
},
},
},
});
projectCache.revalidate({
id: updatedProject.id,
organizationId: updatedProject.organizationId,
});
updatedProject.environments.forEach((environment) => {
// revalidate environment cache
projectCache.revalidate({
environmentId: environment.id,
});
});
return true;
} catch (error) {
if (error instanceof z.ZodError) {
console.error(JSON.stringify(error.errors, null, 2));
}
throw new ValidationError("Data validation of project failed");
}
};
@@ -0,0 +1,8 @@
import { z } from "zod";
export const ZProjectUpdateBrandingInput = z.object({
linkSurveyBranding: z.boolean().optional(),
inAppSurveyBranding: z.boolean().optional(),
});
export type TProjectUpdateBrandingInput = z.infer<typeof ZProjectUpdateBrandingInput>;
@@ -3,10 +3,7 @@
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 { getRemoveBrandingPermission } 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";
@@ -52,16 +49,16 @@ export const updateProjectAction = authenticatedActionClient
throw new Error("Organization not found");
}
const canRemoveBranding = await getRemoveBrandingPermission(organization);
if (parsedInput.data.inAppSurveyBranding !== undefined) {
const canRemoveInAppBranding = getRemoveInAppBrandingPermission(organization);
if (!canRemoveInAppBranding) {
if (!canRemoveBranding) {
throw new OperationNotAllowedError("You are not allowed to remove in-app branding");
}
}
if (parsedInput.data.linkSurveyBranding !== undefined) {
const canRemoveLinkSurveyBranding = getRemoveLinkBrandingPermission(organization);
if (!canRemoveLinkSurveyBranding) {
if (!canRemoveBranding) {
throw new OperationNotAllowedError("You are not allowed to remove link survey branding");
}
}
@@ -1,91 +0,0 @@
"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>
);
};
@@ -2,15 +2,14 @@ import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/
import { authOptions } from "@/modules/auth/lib/authOptions";
import {
getMultiLanguagePermission,
getRemoveInAppBrandingPermission,
getRemoveLinkBrandingPermission,
getRemoveBrandingPermission,
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 { BrandingSettingsCard } from "@/modules/ee/whitelabel/remove-branding/components/branding-settings-card";
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";
@@ -22,7 +21,6 @@ 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";
@@ -45,8 +43,7 @@ export const ProjectLookSettingsPage = async (props: { params: Promise<{ environ
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 canRemoveBranding = await getRemoveBrandingPermission(organization);
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isMember } = getAccessFlags(currentUserMembership?.role);
@@ -92,34 +89,12 @@ export const ProjectLookSettingsPage = async (props: { params: Promise<{ environ
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>
<BrandingSettingsCard
canRemoveBranding={canRemoveBranding}
project={project}
environmentId={params.environmentId}
isReadOnly={isReadOnly}
/>
</PageContentWrapper>
);
};