feat: whitelabel 2 | Email customization (#4546)

Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
Piyush Gupta
2025-01-02 17:58:56 +05:30
committed by GitHub
parent f4f2836bdb
commit 117ec317de
41 changed files with 945 additions and 121 deletions

View File

@@ -101,6 +101,7 @@ PASSWORD_RESET_DISABLED=1
PRIVACY_URL=
TERMS_URL=
IMPRINT_URL=
IMPRINT_ADDRESS=
# Configure Github Login
GITHUB_ID=

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,49 @@
import { MdxImage } from "@/components/MdxImage";
import EmailCustomizationSettings from "./email-customization-card.webp";
import EmailSample from "./email-sample.webp";
import UpdatedLogo from "./updated-logo.webp";
export const metadata = {
title: "Email Customization",
description: "Customize the email that is sent to your users!",
};
# Email Customization
Email customization is a white-label feature that allows you to customize the email that is sent to your users. You can upload a logo of your company and use it in the email.
<Note>
This feature is a white-label feature. It is only available for users on paid plans or have an enterprise
license.
</Note>
## How to Upload a Logo
1. Go to the Organization Settings page.
2. You will see a card called **Email Customization** under the **General** section.
<MdxImage
src={EmailCustomizationSettings}
alt="Email Customization Settings"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
3. Upload a logo of your company.
4. Click on the **Save** button.
<MdxImage src={UpdatedLogo} alt="Updated Logo" quality="100" className="max-w-full rounded-lg sm:max-w-3xl" />
## Viewing the Logo in the Email
You can click on the **Send test email** button to get a test email with the logo.
<MdxImage src={EmailSample} alt="Email Sample" quality="100" className="max-w-full rounded-lg sm:max-w-3xl" />
<Note>Only the owner and managers of the organization can modify the logo.</Note>
## Use Cases
- **White-labeling**: You can use this feature to white-label your emails to your users.
- **Branding**: You can use this feature to add your logo to your emails to increase brand recognition.

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -43,6 +43,7 @@ These variables are present inside your machines docker-compose file. Restart
| PRIVACY_URL | URL for privacy policy. | optional | |
| TERMS_URL | URL for terms of service. | optional | |
| IMPRINT_URL | URL for imprint. | optional | |
|IMPRINT_ADDRESS | Address for imprint. | optional | |
| EMAIL_AUTH_DISABLED | Disables the ability for users to signup or login via email and password if set to 1. | optional | |
| PASSWORD_RESET_DISABLED | Disables password reset functionality if set to 1. | optional | |
| EMAIL_VERIFICATION_DISABLED | Disables email verification if set to 1. | optional | |

View File

@@ -129,6 +129,7 @@ export const navigation: Array<NavGroup> = [
},
{ title: "User Management", href: "/core-features/global/access-roles" },
{ title: "Styling Theme", href: "/core-features/global/styling-theme" },
{ title: "Email Customization", href: "/core-features/global/email-customization" },
],
},
{

View File

@@ -1,7 +1,12 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { AIToggle } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AIToggle";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getIsMultiOrgEnabled, getIsOrganizationAIReady } from "@/modules/ee/license-check/lib/utils";
import {
getIsMultiOrgEnabled,
getIsOrganizationAIReady,
getWhiteLabelPermission,
} from "@/modules/ee/license-check/lib/utils";
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
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";
@@ -11,6 +16,7 @@ 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 { getUser } from "@formbricks/lib/user/service";
import { SettingsCard } from "../../components/SettingsCard";
import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
@@ -22,6 +28,8 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
if (!session) {
throw new Error(t("common.session_not_found"));
}
const user = session?.user?.id ? await getUser(session.user.id) : null;
const organization = await getOrganizationByEnvironmentId(params.environmentId);
if (!organization) {
@@ -31,6 +39,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isOwner, isManager } = getAccessFlags(currentUserMembership?.role);
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization);
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
const currentUserRole = currentUserMembership?.role;
@@ -69,6 +78,14 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
/>
</SettingsCard>
)}
<EmailCustomizationSettings
organization={organization}
hasWhiteLabelPermission={hasWhiteLabelPermission}
environmentId={params.environmentId}
isReadOnly={!isOwnerOrManager}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
user={user}
/>
{isMultiOrgEnabled && (
<SettingsCard
title={t("environments.settings.general.delete_organization")}

View File

@@ -4,6 +4,7 @@ import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/s
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
import { customAlphabet } from "nanoid";
import { z } from "zod";
@@ -18,9 +19,12 @@ const ZSendEmbedSurveyPreviewEmailAction = z.object({
export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
.schema(ZSendEmbedSurveyPreviewEmailAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const organizationLogoUrl = await getOrganizationLogoUrl(organizationId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
organizationId,
access: [
{
type: "organization",
@@ -50,7 +54,8 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
"Formbricks Email Survey Preview",
emailHtml,
survey.environmentId,
ctx.user.locale
ctx.user.locale,
organizationLogoUrl || ""
);
});

View File

@@ -1,6 +1,7 @@
import { sendFollowUpEmail } from "@/modules/email";
import { z } from "zod";
import { TSurveyFollowUpAction } from "@formbricks/database/types/survey-follow-up";
import { TOrganization } from "@formbricks/types/organizations";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -13,11 +14,13 @@ type FollowUpResult = {
const evaluateFollowUp = async (
followUpId: string,
followUpAction: TSurveyFollowUpAction,
response: TResponse
response: TResponse,
organization: TOrganization
): Promise<void> => {
const { properties } = followUpAction;
const { to, subject, body, replyTo } = properties;
const toValueFromResponse = response.data[to];
const logoUrl = organization.whitelabel?.logoUrl || "";
if (!toValueFromResponse) {
throw new Error(`"To" value not found in response data for followup: ${followUpId}`);
}
@@ -27,7 +30,7 @@ const evaluateFollowUp = async (
const parsedResult = z.string().email().safeParse(toValueFromResponse);
if (parsedResult.data) {
// send email to this email address
await sendFollowUpEmail(body, subject, parsedResult.data, replyTo);
await sendFollowUpEmail(body, subject, parsedResult.data, replyTo, logoUrl);
} else {
throw new Error(`Email address is not valid for followup: ${followUpId}`);
}
@@ -38,14 +41,18 @@ const evaluateFollowUp = async (
}
const parsedResult = z.string().email().safeParse(emailAddress);
if (parsedResult.data) {
await sendFollowUpEmail(body, subject, parsedResult.data, replyTo);
await sendFollowUpEmail(body, subject, parsedResult.data, replyTo, logoUrl);
} else {
throw new Error(`Email address is not valid for followup: ${followUpId}`);
}
}
};
export const sendSurveyFollowUps = async (survey: TSurvey, response: TResponse) => {
export const sendSurveyFollowUps = async (
survey: TSurvey,
response: TResponse,
organization: TOrganization
) => {
const followUpPromises = survey.followUps.map(async (followUp): Promise<FollowUpResult> => {
const { trigger } = followUp;
@@ -62,7 +69,7 @@ export const sendSurveyFollowUps = async (survey: TSurvey, response: TResponse)
}
}
return evaluateFollowUp(followUp.id, followUp.action, response)
return evaluateFollowUp(followUp.id, followUp.action, response, organization)
.then(() => ({
followUpId: followUp.id,
status: "success" as const,

View File

@@ -169,7 +169,7 @@ export const POST = async (request: Request) => {
const surveyFollowUpsPermission = await getSurveyFollowUpsPermission(organization);
if (surveyFollowUpsPermission) {
await sendSurveyFollowUps(survey, response);
await sendSurveyFollowUps(survey, response, organization);
}
const emailPromises = usersWithNotifications.map((user) =>

View File

@@ -1,6 +1,8 @@
"use server";
import { actionClient } from "@/lib/utils/action-client";
import { getOrganizationIdFromSurveyId } from "@/lib/utils/helper";
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
import { sendLinkSurveyToVerifiedEmail } from "@/modules/email";
import { z } from "zod";
import { verifyTokenForLinkSurvey } from "@formbricks/lib/jwt";
@@ -13,7 +15,10 @@ import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/erro
export const sendLinkSurveyEmailAction = actionClient
.schema(ZLinkSurveyEmailData)
.action(async ({ parsedInput }) => {
await sendLinkSurveyToVerifiedEmail(parsedInput);
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const organizationLogoUrl = await getOrganizationLogoUrl(organizationId);
await sendLinkSurveyToVerifiedEmail({ ...parsedInput, logoUrl: organizationLogoUrl || "" });
return { success: true };
});

View File

@@ -76,6 +76,7 @@ export const VerifyEmail = ({
return;
}
}
const data = {
surveyId: localSurvey.id,
email: email,

View File

@@ -277,6 +277,22 @@ export const getRemoveBrandingPermission = async (organization: TOrganization):
}
};
export const getWhiteLabelPermission = async (organization: TOrganization): Promise<boolean> => {
if (E2E_TESTING) {
const previousResult = await fetchLicenseForE2ETesting();
return previousResult?.features?.whitelabel ?? 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.whitelabel;
}
};
export const getRoleManagementPermission = async (organization: TOrganization): Promise<boolean> => {
if (E2E_TESTING) {
const previousResult = await fetchLicenseForE2ETesting();

View File

@@ -0,0 +1,100 @@
"use server";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
import {
removeOrganizationEmailLogoUrl,
updateOrganizationEmailLogoUrl,
} from "@/modules/ee/whitelabel/email-customization/lib/organization";
import { sendEmailCustomizationPreviewEmail } from "@/modules/email";
import { z } from "zod";
import { getOrganization } from "@formbricks/lib/organization/service";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
export const checkWhiteLabelPermission = async (organizationId: string) => {
const organization = await getOrganization(organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization", organizationId);
}
const isWhiteLabelAllowed = await getWhiteLabelPermission(organization);
if (!isWhiteLabelAllowed) {
throw new OperationNotAllowedError("White label is not allowed for this organization");
}
};
const ZUpdateOrganizationEmailLogoUrlAction = z.object({
organizationId: ZId,
logoUrl: z.string(),
});
export const updateOrganizationEmailLogoUrlAction = authenticatedActionClient
.schema(ZUpdateOrganizationEmailLogoUrlAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
await checkWhiteLabelPermission(parsedInput.organizationId);
return await updateOrganizationEmailLogoUrl(parsedInput.organizationId, parsedInput.logoUrl);
});
const ZRemoveOrganizationEmailLogoUrlAction = z.object({
organizationId: ZId,
});
export const removeOrganizationEmailLogoUrlAction = authenticatedActionClient
.schema(ZRemoveOrganizationEmailLogoUrlAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [{ type: "organization", roles: ["owner", "manager"] }],
});
await checkWhiteLabelPermission(parsedInput.organizationId);
return await removeOrganizationEmailLogoUrl(parsedInput.organizationId);
});
const ZSendTestEmailAction = z.object({
organizationId: ZId,
});
export const sendTestEmailAction = authenticatedActionClient
.schema(ZSendTestEmailAction)
.action(async ({ ctx, parsedInput }) => {
const organization = await getOrganization(parsedInput.organizationId);
if (!organization) {
throw new ResourceNotFoundError("Organization", parsedInput.organizationId);
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: organization.id,
access: [{ type: "organization", roles: ["owner", "manager"] }],
});
await checkWhiteLabelPermission(organization.id);
await sendEmailCustomizationPreviewEmail(
ctx.user.email,
"Formbricks Email Customization Preview",
ctx.user.name,
ctx.user.locale,
organization?.whitelabel?.logoUrl || ""
);
return { success: true };
});

View File

@@ -0,0 +1,280 @@
"use client";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import {
removeOrganizationEmailLogoUrlAction,
sendTestEmailAction,
updateOrganizationEmailLogoUrlAction,
} from "@/modules/ee/whitelabel/email-customization/actions";
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { Uploader } from "@/modules/ui/components/file-input/components/uploader";
import { uploadFile } from "@/modules/ui/components/file-input/lib/utils";
import { Muted, P, Small } from "@/modules/ui/components/typography";
import { ModalButton, UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { RepeatIcon, Trash2Icon } from "lucide-react";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { TAllowedFileExtension } from "@formbricks/types/common";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
const allowedFileExtensions: TAllowedFileExtension[] = ["jpeg", "png", "jpg", "webp"];
const DEFAULT_LOGO_URL =
"https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Formbricks-Light-transparent.png";
interface EmailCustomizationSettingsProps {
organization: TOrganization;
hasWhiteLabelPermission: boolean;
environmentId: string;
isReadOnly: boolean;
isFormbricksCloud: boolean;
user: TUser | null;
}
export const EmailCustomizationSettings = ({
organization,
hasWhiteLabelPermission,
environmentId,
isReadOnly,
isFormbricksCloud,
user,
}: EmailCustomizationSettingsProps) => {
const t = useTranslations();
const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoUrl, setLogoUrl] = useState<string>(organization.whitelabel?.logoUrl || DEFAULT_LOGO_URL);
const [isSaving, setIsSaving] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const isDefaultLogo = logoUrl === DEFAULT_LOGO_URL;
const router = useRouter();
const onFileInputChange = (files: File[]) => {
const file = files[0];
if (!file) return;
// Revoke any previous object URL so we dont leak memory
if (logoUrl) {
URL.revokeObjectURL(logoUrl);
}
setLogoFile(file);
setLogoUrl(URL.createObjectURL(file));
};
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
};
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
const files = Array.from(e.dataTransfer.files);
const file = files[0];
if (!file) return;
const extension = file.name.split(".").pop()! as TAllowedFileExtension;
if (!allowedFileExtensions.includes(extension)) {
toast.error(t("common.invalid_file_type"));
return;
}
onFileInputChange(files);
};
const removeLogo = async () => {
if (logoUrl) {
URL.revokeObjectURL(logoUrl);
}
setLogoFile(null);
setLogoUrl("");
if (inputRef.current) {
inputRef.current.value = "";
}
if (isDefaultLogo || !organization.whitelabel?.logoUrl) return;
const removeLogoResponse = await removeOrganizationEmailLogoUrlAction({
organizationId: organization.id,
});
if (removeLogoResponse?.data) {
toast.success(t("environments.settings.general.logo_removed_successfully"));
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(removeLogoResponse);
toast.error(errorMessage);
}
};
const handleSave = async () => {
if (!logoFile) return;
setIsSaving(true);
const { url } = await uploadFile(logoFile, allowedFileExtensions, environmentId);
const updateLogoResponse = await updateOrganizationEmailLogoUrlAction({
organizationId: organization.id,
logoUrl: url,
});
if (updateLogoResponse?.data) {
toast.success(t("environments.settings.general.logo_saved_successfully"));
setLogoUrl(url);
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(updateLogoResponse);
toast.error(errorMessage);
}
setIsSaving(false);
};
const sendTestEmail = async () => {
if (!logoUrl) {
toast.error(t("environments.settings.general.please_add_a_logo"));
return;
}
if (logoUrl !== organization.whitelabel?.logoUrl && !isDefaultLogo) {
toast.error(t("environments.settings.general.please_save_logo_before_sending_test_email"));
return;
}
const sendTestEmailResponse = await sendTestEmailAction({
organizationId: organization.id,
});
if (sendTestEmailResponse?.data) {
toast.success(t("environments.settings.general.test_email_sent_successfully"));
} else {
const errorMessage = getFormattedErrorMessage(sendTestEmailResponse);
toast.error(errorMessage);
}
};
const buttons: [ModalButton, ModalButton] = [
{
text: t("common.start_free_trial"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/upgrade-self-hosting-license",
},
{
text: t("common.learn_more"),
href: isFormbricksCloud
? `/environments/${environmentId}/settings/billing`
: "https://formbricks.com/learn-more-self-hosting-license",
},
];
return (
<SettingsCard
className="overflow-hidden pb-0"
title={t("environments.project.look.email_customization")}
description={t("environments.project.look.email_customization_description")}
noPadding>
<div className="px-6 pt-6">
{hasWhiteLabelPermission ? (
<div className="flex items-end justify-between gap-4">
<div className="mb-10">
<Small>{t("environments.settings.general.logo_in_email_header")}</Small>
<div className="mb-6 mt-2 flex items-center gap-4">
{logoUrl && (
<div className="flex flex-col gap-2">
<div className="flex w-max items-center justify-center rounded-lg border border-slate-200 px-4 py-2">
<Image
src={logoUrl}
alt="Logo"
className="max-h-24 max-w-full object-contain"
width={192}
height={192}
/>
</div>
<div className="flex items-center gap-2">
<Button
variant="secondary"
onClick={() => inputRef.current?.click()}
disabled={isReadOnly}>
<RepeatIcon className="h-4 w-4" />
{t("environments.settings.general.replace_logo")}
</Button>
<Button onClick={removeLogo} variant="outline" disabled={isReadOnly}>
<Trash2Icon className="h-4 w-4" />
{t("environments.settings.general.remove_logo")}
</Button>
</div>
</div>
)}
<Uploader
ref={inputRef}
allowedFileExtensions={allowedFileExtensions}
id="email-customization"
name="email-customization"
handleDragOver={handleDragOver}
uploaderClassName={cn(
"h-20 w-96 border border-slate-200 bg-white",
logoUrl ? "hidden" : "block"
)}
handleDrop={handleDrop}
multiple={false}
handleUpload={onFileInputChange}
disabled={isReadOnly}
/>
</div>
<div className="flex gap-4">
<Button variant="secondary" disabled={isReadOnly} onClick={sendTestEmail}>
{t("common.send_test_email")}
</Button>
<Button onClick={handleSave} disabled={!logoFile || isReadOnly} loading={isSaving}>
{t("common.save")}
</Button>
</div>
</div>
<div className="shadow-card-xl min-h-52 w-[446px] rounded-t-lg border border-slate-100 px-10 pb-4 pt-10">
<Image
src={logoUrl || DEFAULT_LOGO_URL}
alt="Logo"
className="mx-auto max-h-[100px] max-w-full object-contain"
width={192}
height={192}
/>
<P className="font-bold">
{t("environments.settings.general.email_customization_preview_email_heading", {
userName: user?.name,
})}
</P>
<Muted className="text-slate-500">
{t("environments.settings.general.email_customization_preview_email_text")}
</Muted>
</div>
</div>
) : (
<UpgradePrompt
title={t("environments.settings.general.customize_email_with_a_higher_plan")}
description={t("environments.settings.general.eliminate_branding_with_whitelabel")}
buttons={buttons}
/>
)}
{hasWhiteLabelPermission && isReadOnly && (
<Alert variant="warning" className="mb-6 mt-4">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>
</Alert>
)}
</div>
</SettingsCard>
);
};

View File

@@ -0,0 +1,161 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { organizationCache } from "@formbricks/lib/organization/cache";
import { projectCache } from "@formbricks/lib/project/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId, ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
export const updateOrganizationEmailLogoUrl = async (
organizationId: string,
logoUrl: string
): Promise<boolean> => {
validateInputs([organizationId, ZId], [logoUrl, ZString]);
try {
const organization = await prisma.organization.findUnique({
where: { id: organizationId },
});
if (!organization) {
throw new ResourceNotFoundError("Organization", organizationId);
}
const updatedOrganization = await prisma.organization.update({
where: { id: organizationId },
data: {
whitelabel: {
...organization.whitelabel,
logoUrl,
},
},
select: {
projects: {
select: {
id: true,
environments: {
select: {
id: true,
},
},
},
},
},
});
organizationCache.revalidate({
id: organizationId,
});
for (const project of updatedOrganization.projects) {
for (const environment of project.environments) {
organizationCache.revalidate({
environmentId: environment.id,
});
}
}
projectCache.revalidate({
organizationId: organizationId,
});
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") {
throw new ResourceNotFoundError("Organization", organizationId);
}
throw error;
}
};
export const removeOrganizationEmailLogoUrl = async (organizationId: string): Promise<boolean> => {
validateInputs([organizationId, ZId]);
try {
const organization = await prisma.organization.findUnique({
where: { id: organizationId },
select: {
whitelabel: true,
projects: {
select: {
id: true,
environments: {
select: {
id: true,
},
},
},
},
},
});
if (!organization) {
throw new ResourceNotFoundError("Organization", organizationId);
}
await prisma.organization.update({
where: { id: organizationId },
data: {
whitelabel: {
...organization.whitelabel,
logoUrl: null,
},
},
});
organizationCache.revalidate({
id: organizationId,
});
for (const project of organization.projects) {
for (const environment of project.environments) {
organizationCache.revalidate({
environmentId: environment.id,
});
}
}
projectCache.revalidate({
organizationId: organizationId,
});
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") {
throw new ResourceNotFoundError("Organization", organizationId);
}
throw error;
}
};
export const getOrganizationLogoUrl = reactCache(
async (organizationId: string): Promise<string | null> =>
cache(
async () => {
validateInputs([organizationId, ZId]);
try {
const organization = await prisma.organization.findUnique({
where: { id: organizationId },
select: {
whitelabel: true,
},
});
return organization?.whitelabel?.logoUrl ?? null;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getOrganizationLogoUrl-${organizationId}`],
{
tags: [organizationCache.tag.byId(organizationId)],
}
)()
);

View File

@@ -1,7 +1,18 @@
import { Body, Column, Container, Html, Img, Link, Section, Tailwind } from "@react-email/components";
import { IMPRINT_URL, PRIVACY_URL } from "@formbricks/lib/constants";
import { Body, Container, Html, Img, Link, Section, Tailwind, Text } from "@react-email/components";
import { IMPRINT_ADDRESS, IMPRINT_URL, PRIVACY_URL } from "@formbricks/lib/constants";
const fbLogoUrl =
"https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Formbricks-Light-transparent.png";
const logoLink = "https://formbricks.com?utm_source=email_header&utm_medium=email";
interface EmailTemplateProps {
children: React.ReactNode;
logoUrl?: string;
}
export function EmailTemplate({ children, logoUrl }: EmailTemplateProps): React.JSX.Element {
const isDefaultLogo = !logoUrl || logoUrl === fbLogoUrl;
export function EmailTemplate({ children }): React.JSX.Element {
return (
<Html>
<Tailwind>
@@ -11,53 +22,37 @@ export function EmailTemplate({ children }): React.JSX.Element {
fontFamily: "'Jost', 'Helvetica Neue', 'Segoe UI', 'Helvetica', 'sans-serif'",
}}>
<Section>
<Link href="https://formbricks.com?utm_source=email_header&utm_medium=email" target="_blank">
<Img
alt="Formbricks Logo"
className="mx-auto w-80"
src="https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Formbricks-Light-transparent.png"
/>
</Link>
{isDefaultLogo ? (
<Link href={logoLink} target="_blank">
<Img alt="Logo" className="mx-auto w-80" src={fbLogoUrl} />
</Link>
) : (
<Img alt="Logo" className="mx-auto max-h-[100px] w-80 object-contain" src={logoUrl} />
)}
</Section>
<Container className="mx-auto my-8 max-w-xl bg-white p-4 text-left">{children}</Container>
<Container className="mx-auto my-8 max-w-xl rounded-md bg-white p-4 text-left">
{children}
</Container>
<Section className="flex justify-center">
<Column align="center" className="px-2" key="twitter">
<Link href="https://twitter.com/formbricks" target="_blank" className="w-fit">
<Img
alt="Tw"
src="https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Twitter-transp.png"
title="Twitter"
width="32"
/>
</Link>
</Column>
<Column align="center" className="px-2" key="github">
<Link href="https://formbricks.com/github" target="_blank" className="w-fit">
<Img
alt="GitHub"
src="https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Github-transp.png"
title="GitHub"
width="32"
/>
</Link>
</Column>
</Section>
<Section className="mt-4 text-center text-sm">
Formbricks {new Date().getFullYear()}. All rights reserved.
<br />
{IMPRINT_URL && (
<Link href={IMPRINT_URL} target="_blank" rel="noopener noreferrer">
Imprint{" "}
</Link>
)}
{IMPRINT_URL && PRIVACY_URL && "|"}
{PRIVACY_URL && (
<Link href={PRIVACY_URL} target="_blank" rel="noopener noreferrer">
{" "}
Privacy Policy
</Link>
<Text className="m-0 font-normal text-slate-500">This email was sent via Formbricks.</Text>
{IMPRINT_ADDRESS && (
<Text className="m-0 font-normal text-slate-500 opacity-50">{IMPRINT_ADDRESS}</Text>
)}
<Text className="m-0 font-normal text-slate-500 opacity-50">
{IMPRINT_URL && (
<Link href={IMPRINT_URL} target="_blank" rel="noopener noreferrer" className="text-slate-500">
Imprint{" "}
</Link>
)}
{IMPRINT_URL && PRIVACY_URL && "•"}
{PRIVACY_URL && (
<Link href={PRIVACY_URL} target="_blank" rel="noopener noreferrer" className="text-slate-500">
{" "}
Privacy Policy
</Link>
)}
</Text>
</Section>
</Body>
</Tailwind>

View File

@@ -0,0 +1,31 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { EmailTemplate } from "../../components/email-template";
import { translateEmailText } from "../../lib/utils";
interface EmailCustomizationPreviewEmailProps {
userName: string;
locale: string;
logoUrl?: string;
}
export function EmailCustomizationPreviewEmail({
userName,
locale,
logoUrl,
}: EmailCustomizationPreviewEmailProps): React.JSX.Element {
return (
<EmailTemplate logoUrl={logoUrl}>
<Container>
<Heading>
{translateEmailText("email_customization_preview_email_heading", locale, {
userName,
})}
</Heading>
<Text>{translateEmailText("email_customization_preview_email_text", locale)}</Text>
</Container>
</EmailTemplate>
);
}
export default EmailCustomizationPreviewEmail;

View File

@@ -7,15 +7,17 @@ interface EmbedSurveyPreviewEmailProps {
html: string;
environmentId: string;
locale: string;
logoUrl?: string;
}
export function EmbedSurveyPreviewEmail({
html,
environmentId,
locale,
logoUrl,
}: EmbedSurveyPreviewEmailProps): React.JSX.Element {
return (
<EmailTemplate>
<EmailTemplate logoUrl={logoUrl}>
<Container>
<Heading>{translateEmailText("embed_survey_preview_email_heading", locale)}</Heading>
<Text>{translateEmailText("embed_survey_preview_email_text", locale)}</Text>

View File

@@ -1,12 +1,13 @@
import { Body, Container, Html, Link, Section, Tailwind } from "@react-email/components";
import { Body, Container, Html, Img, Link, Section, Tailwind, Text } from "@react-email/components";
import dompurify from "isomorphic-dompurify";
import { IMPRINT_URL, PRIVACY_URL } from "@formbricks/lib/constants";
import { IMPRINT_ADDRESS, IMPRINT_URL, PRIVACY_URL } from "@formbricks/lib/constants";
interface FollowUpEmailProps {
html: string;
logoUrl?: string;
}
export function FollowUpEmail({ html }: FollowUpEmailProps): React.JSX.Element {
export function FollowUpEmail({ html, logoUrl }: FollowUpEmailProps): React.JSX.Element {
return (
<Html>
<Tailwind>
@@ -15,7 +16,12 @@ export function FollowUpEmail({ html }: FollowUpEmailProps): React.JSX.Element {
style={{
fontFamily: "'Jost', 'Helvetica Neue', 'Segoe UI', 'Helvetica', 'sans-serif'",
}}>
<Container className="mx-auto my-8 max-w-xl bg-white p-4 text-left">
{logoUrl && (
<Section>
<Img alt="Logo" className="mx-auto max-h-[100px] w-80 object-contain" src={logoUrl} />
</Section>
)}
<Container className="mx-auto my-8 max-w-xl rounded-md bg-white p-4 text-left">
<div
dangerouslySetInnerHTML={{
__html: dompurify.sanitize(html, {
@@ -29,15 +35,25 @@ export function FollowUpEmail({ html }: FollowUpEmailProps): React.JSX.Element {
</Container>
<Section className="mt-4 text-center text-sm">
powered by Formbricks
<br />
<Link href={IMPRINT_URL} target="_blank" rel="noopener noreferrer">
Imprint
</Link>{" "}
|{" "}
<Link href={PRIVACY_URL} target="_blank" rel="noopener noreferrer">
Privacy Policy
</Link>
<Text className="m-0 font-normal text-slate-500">powered by Formbricks</Text>
{IMPRINT_ADDRESS && (
<Text className="m-0 font-normal text-slate-500 opacity-50">{IMPRINT_ADDRESS}</Text>
)}
<Text className="m-0 font-normal text-slate-500 opacity-50">
{IMPRINT_URL && (
<Link href={IMPRINT_URL} target="_blank" rel="noopener noreferrer" className="text-slate-500">
Imprint{" "}
</Link>
)}
{IMPRINT_URL && PRIVACY_URL && "•"}
{PRIVACY_URL && (
<Link href={PRIVACY_URL} target="_blank" rel="noopener noreferrer" className="text-slate-500">
{" "}
Privacy Policy
</Link>
)}
</Text>
</Section>
</Body>
</Tailwind>

View File

@@ -9,11 +9,17 @@ interface LinkSurveyEmailProps {
surveyName: string;
surveyLink: string;
locale: string;
logoUrl: string;
}
export function LinkSurveyEmail({ surveyName, surveyLink, locale }: LinkSurveyEmailProps): React.JSX.Element {
export function LinkSurveyEmail({
surveyName,
surveyLink,
locale,
logoUrl,
}: LinkSurveyEmailProps): React.JSX.Element {
return (
<EmailTemplate>
<EmailTemplate logoUrl={logoUrl}>
<Container>
<Heading>{translateEmailText("verification_email_hey", locale)}</Heading>
<Text>{translateEmailText("verification_email_thanks", locale)}</Text>

View File

@@ -1,3 +1,4 @@
import { EmailCustomizationPreviewEmail } from "@/modules/email/emails/general/email-customization-preview-email";
import { render } from "@react-email/render";
import { createTransport } from "nodemailer";
import type SMTPTransport from "nodemailer/lib/smtp-transport";
@@ -211,9 +212,10 @@ export const sendEmbedSurveyPreviewEmail = async (
subject: string,
innerHtml: string,
environmentId: string,
locale: string
locale: string,
logoUrl?: string
): Promise<void> => {
const html = await render(EmbedSurveyPreviewEmail({ html: innerHtml, environmentId, locale }));
const html = await render(EmbedSurveyPreviewEmail({ html: innerHtml, environmentId, locale, logoUrl }));
await sendEmail({
to,
subject,
@@ -221,12 +223,29 @@ export const sendEmbedSurveyPreviewEmail = async (
});
};
export const sendEmailCustomizationPreviewEmail = async (
to: string,
subject: string,
userName: string,
locale: string,
logoUrl?: string
): Promise<void> => {
const emailHtmlBody = await render(EmailCustomizationPreviewEmail({ userName, locale, logoUrl }));
await sendEmail({
to,
subject,
html: emailHtmlBody,
});
};
export const sendLinkSurveyToVerifiedEmail = async (data: TLinkSurveyEmailData): Promise<void> => {
const surveyId = data.surveyId;
const email = data.email;
const surveyName = data.surveyName;
const singleUseId = data.suId;
const locale = data.locale;
const logoUrl = data.logoUrl || "";
const token = createTokenForLinkSurvey(surveyId, email);
const getSurveyLink = (): string => {
if (singleUseId) {
@@ -236,7 +255,7 @@ export const sendLinkSurveyToVerifiedEmail = async (data: TLinkSurveyEmailData):
};
const surveyLink = getSurveyLink();
const html = await render(LinkSurveyEmail({ surveyName, surveyLink, locale }));
const html = await render(LinkSurveyEmail({ surveyName, surveyLink, locale, logoUrl }));
await sendEmail({
to: data.email,
subject: "Your survey is ready to be filled out.",
@@ -312,11 +331,13 @@ export const sendFollowUpEmail = async (
html: string,
subject: string,
to: string,
replyTo: string[]
replyTo: string[],
logoUrl?: string
): Promise<void> => {
const emailHtmlBody = await render(
FollowUpEmail({
html,
logoUrl,
})
);

View File

@@ -2,8 +2,8 @@ import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/
import { authOptions } from "@/modules/auth/lib/authOptions";
import {
getMultiLanguagePermission,
getRemoveBrandingPermission,
getRoleManagementPermission,
getWhiteLabelPermission,
} from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
@@ -43,7 +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 canRemoveBranding = await getRemoveBrandingPermission(organization);
const canRemoveBranding = await getWhiteLabelPermission(organization);
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isMember } = getAccessFlags(currentUserMembership?.role);
@@ -89,6 +89,7 @@ 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>
<BrandingSettingsCard
canRemoveBranding={canRemoveBranding}
project={project}

View File

@@ -5,6 +5,7 @@ import { TAllowedFileExtension } from "@formbricks/types/common";
interface UploaderProps {
id: string;
name: string;
ref?: React.RefObject<HTMLInputElement>;
handleDragOver: (e: React.DragEvent<HTMLLabelElement>) => void;
uploaderClassName: string;
handleDrop: (e: React.DragEvent<HTMLLabelElement>) => void;
@@ -18,6 +19,7 @@ interface UploaderProps {
export const Uploader = ({
id,
name,
ref,
handleDragOver,
uploaderClassName,
handleDrop,
@@ -52,6 +54,7 @@ export const Uploader = ({
className="hidden"
multiple={multiple}
disabled={disabled}
ref={ref}
onChange={async (e) => {
let selectedFiles = Array.from(e.target?.files || []);
handleUpload(selectedFiles);

View File

@@ -40,45 +40,50 @@ export const SecondaryNavigation = ({ navigation, activeId, loading, ...props }:
/>
</div>
))
: navigation.map((navElem) => (
<div className="group flex h-full flex-col" key={navElem.id}>
{navElem.href ? (
<Link
href={navElem.href}
{...(navElem.onClick ? { onClick: navElem.onClick } : {})}
className={cn(
navElem.id === activeId
? "font-semibold text-slate-900"
: "text-slate-500 hover:text-slate-700",
"flex h-full items-center px-3 text-sm font-medium",
navElem.hidden && "hidden"
: navigation.map(
(navElem) =>
!navElem.hidden && (
<div className="group flex h-full flex-col" key={navElem.id}>
{navElem.href ? (
<Link
href={navElem.href}
{...(navElem.onClick ? { onClick: navElem.onClick } : {})}
className={cn(
navElem.id === activeId
? "font-semibold text-slate-900"
: "text-slate-500 hover:text-slate-700",
"flex h-full items-center px-3 text-sm font-medium",
navElem.hidden && "hidden"
)}
aria-current={navElem.id === activeId ? "page" : undefined}>
{navElem.label}
</Link>
) : (
<button
{...(navElem.onClick ? { onClick: navElem.onClick } : {})}
className={cn(
navElem.id === activeId
? "font-semibold text-slate-900"
: "text-slate-500 hover:text-slate-700",
"grow items-center px-3 text-sm font-medium transition-all duration-150 ease-in-out",
navElem.hidden && "hidden"
)}
aria-current={navElem.id === activeId ? "page" : undefined}>
{navElem.label}
</button>
)}
aria-current={navElem.id === activeId ? "page" : undefined}>
{navElem.label}
</Link>
) : (
<button
{...(navElem.onClick ? { onClick: navElem.onClick } : {})}
className={cn(
navElem.id === activeId
? "font-semibold text-slate-900"
: "text-slate-500 hover:text-slate-700",
"grow items-center px-3 text-sm font-medium transition-all duration-150 ease-in-out",
navElem.hidden && "hidden"
)}
aria-current={navElem.id === activeId ? "page" : undefined}>
{navElem.label}
</button>
)}
<div
className={cn(
"bottom-0 mt-auto h-[2px] w-full rounded-t-lg transition-all duration-150 ease-in-out",
navElem.id === activeId ? "bg-brand-dark" : "bg-transparent group-hover:bg-slate-300",
navElem.hidden && "hidden"
)}
/>
</div>
))}
<div
className={cn(
"bottom-0 mt-auto h-[2px] w-full rounded-t-lg transition-all duration-150 ease-in-out",
navElem.id === activeId
? "bg-brand-dark"
: "bg-transparent group-hover:bg-slate-300",
navElem.hidden && "hidden"
)}
/>
</div>
)
)}
</nav>
<div className="justify-self-end"></div>
</div>

View File

@@ -30,6 +30,7 @@ module.exports = {
"card-sm": "0px 0.5px 12px -5px rgba(30,41,59,0.20)",
"card-md": "0px 1px 25px -10px rgba(30,41,59,0.30)",
"card-lg": "0px 2px 51px -19px rgba(30,41,59,0.40)",
"card-xl": "0px 20px 25px -5px rgba(0, 0, 0, 0.10), 0px 10px 10px -5px rgba(0, 0, 0, 0.04)",
},
colors: {
brand: {

View File

@@ -138,6 +138,9 @@ x-environment: &environment
# Set the below to have your own Imprint Page URL on auth & link survey page
# IMPRINT_URL:
# Set the below to have your own Address on email footer
# IMPRINT_ADDRESS:
########################################## OPTIONAL (SERVER CONFIGURATION) ###########################################
# Set the below to 1 to disable Rate Limiting across Formbricks

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Organization" ADD COLUMN "whitelabel" JSONB NOT NULL DEFAULT '{}';

View File

@@ -493,6 +493,9 @@ model Organization {
/// @zod.custom(imports.ZOrganizationBilling)
/// [OrganizationBilling]
billing Json
/// @zod.custom(imports.ZOrganizationWhitelabel)
/// [OrganizationWhitelabel]
whitelabel Json @default("{}")
invites Invite[]
isAIEnabled Boolean @default(false)
teams Team[]

View File

@@ -23,5 +23,5 @@ export {
export { ZSurveyFollowUpAction, ZSurveyFollowUpTrigger } from "./types/survey-follow-up";
export { ZSegmentFilters } from "../types/segment";
export { ZOrganizationBilling } from "../types/organizations";
export { ZOrganizationBilling, ZOrganizationWhitelabel } from "../types/organizations";
export { ZUserNotificationSettings } from "../types/user";

View File

@@ -21,6 +21,7 @@ export const DEFAULT_BRAND_COLOR = "#64748b";
export const PRIVACY_URL = env.PRIVACY_URL;
export const TERMS_URL = env.TERMS_URL;
export const IMPRINT_URL = env.IMPRINT_URL;
export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";

View File

@@ -44,6 +44,7 @@ export const env = createEnv({
.url()
.optional()
.or(z.string().refine((str) => str === "")),
IMPRINT_ADDRESS: z.string().optional(),
INVITE_DISABLED: z.enum(["1", "0"]).optional(),
INTERCOM_SECRET_KEY: z.string().optional(),
IS_FORMBRICKS_CLOUD: z.enum(["1", "0"]).optional(),
@@ -160,6 +161,7 @@ export const env = createEnv({
HTTP_PROXY: process.env.HTTP_PROXY,
HTTPS_PROXY: process.env.HTTPS_PROXY,
IMPRINT_URL: process.env.IMPRINT_URL,
IMPRINT_ADDRESS: process.env.IMPRINT_ADDRESS,
INVITE_DISABLED: process.env.INVITE_DISABLED,
INTERCOM_SECRET_KEY: process.env.INTERCOM_SECRET_KEY,
IS_FORMBRICKS_CLOUD: process.env.IS_FORMBRICKS_CLOUD,

View File

@@ -325,6 +325,7 @@
"questions": "Fragen",
"read_docs": "Dokumentation lesen",
"remove": "Entfernen",
"removed": "Entfernt",
"reorder_and_hide_columns": "Spalten neu anordnen und ausblenden",
"report_survey": "Umfrage melden",
"request_an_enterprise_license": "eine Enterprise Lizenz anfordern.",
@@ -339,6 +340,7 @@
"sales": "Vertrieb",
"save": "Speichern",
"save_changes": "Änderungen speichern",
"saved": "Gespeichert",
"scheduled": "Geplant",
"search": "Suchen",
"security": "Sicherheit",
@@ -352,6 +354,8 @@
"selection": "Auswahl",
"selections": "Auswahlen",
"send": "Senden",
"send_test_email": "Test-E-Mail senden",
"sent": "Gesendet",
"session_not_found": "Sitzung nicht gefunden",
"settings": "Einstellungen",
"share_feedback": "Feedback geben",
@@ -437,6 +441,8 @@
"you_will_be_downgraded_to_the_community_edition_on_date": "Du wirst am {Datum} auf die Community Edition herabgestuft."
},
"emails": {
"email_customization_preview_email_heading": "Hey {userName}",
"email_customization_preview_email_text": "Dies ist eine E-Mail-Vorschau, um dir zu zeigen, welches Logo in den E-Mails gerendert wird.",
"embed_survey_preview_email_didnt_request": "Kein Interesse?",
"embed_survey_preview_email_environment_id": "Umgebungs-ID",
"embed_survey_preview_email_fight_spam": "Hilf uns, Spam zu bekämpfen, und leite diese Mail an hola@formbricks.com weiter.",
@@ -897,7 +903,8 @@
"app_survey_placement": "Platzierung der Umfragen",
"app_survey_placement_settings_description": "Ändere, wo Umfragen in deiner App oder Website angezeigt werden.",
"centered_modal_overlay_color": "Hintergrundfarbe bei Modal-Ansicht",
"eliminate_branding_with_whitelabel": "Entferne Formbricks Branding und aktiviere zusätzliche White-Label-Anpassungsoptionen.",
"email_customization": "E-Mail-Anpassung",
"email_customization_description": "Ändere das Aussehen und das Gefühl der E-Mails, die Formbricks in deinem Namen sendet.",
"enable_custom_styling": "Eigenes Styling aktivieren",
"enable_custom_styling_description": "Erlaube Nutzern, dieses Styling im Umfrageditor zu überschreiben.",
"failed_to_remove_logo": "Logo konnte nicht entfernt werden",
@@ -1117,12 +1124,16 @@
"copy_invite_link_to_clipboard": "Einladungslink in die Zwischenablage kopieren",
"create_new_organization": "Neue Organisation erstellen",
"create_new_organization_description": "Erstelle eine neue Organisation, um weitere Projekte zu verwalten.",
"customize_email_with_a_higher_plan": "E-Mail-Anpassung mit einem höheren Plan",
"delete_organization": "Organisation löschen",
"delete_organization_description": "Organisation mit allen Projekten einschließlich aller Umfragen, Antworten, Personen, Aktionen und Attribute löschen",
"delete_organization_warning": "Bevor Du mit dem Löschen dieser Organisation fortfährst, sei dir bitte der folgenden Konsequenzen bewusst:",
"delete_organization_warning_1": "Dauerhafte Entfernung aller Projekte, die mit dieser Organisation verbunden sind.",
"delete_organization_warning_2": "Diese Aktion kann nicht rückgängig gemacht werden. Wenn es weg ist, ist es weg.",
"delete_organization_warning_3": "Bitte gib {organizationName} in das folgende Feld ein, um die endgültige Löschung dieser Organisation zu bestätigen:",
"eliminate_branding_with_whitelabel": "Entferne Formbricks Branding und aktiviere zusätzliche White-Label-Anpassungsoptionen.",
"email_customization_preview_email_heading": "Hey {userName}",
"email_customization_preview_email_text": "Dies ist eine E-Mail-Vorschau, um dir zu zeigen, welches Logo in den E-Mails gerendert wird.",
"error_deleting_organization_please_try_again": "Fehler beim Löschen der Organisation. Bitte versuche es erneut.",
"formbricks_ai": "Formbricks KI",
"formbricks_ai_description": "Erhalte personalisierte Einblicke aus deinen Umfrageantworten mit Formbricks KI",
@@ -1139,6 +1150,9 @@
"leave_organization_description": "Du wirst diese Organisation verlassen und den Zugriff auf alle Umfragen und Antworten verlieren. Du kannst nur wieder beitreten, wenn Du erneut eingeladen wirst.",
"leave_organization_ok_btn_text": "Ja, Organisation verlassen",
"leave_organization_title": "Bist Du sicher?",
"logo_in_email_header": "Logo in der E-Mail-Kopfzeile",
"logo_removed_successfully": "Logo erfolgreich entfernt",
"logo_saved_successfully": "Logo erfolgreich gespeichert",
"manage_members": "Mitglieder verwalten",
"manage_members_description": "Mitglieder in deiner Organisation hinzufügen oder entfernen.",
"member_deleted_successfully": "Mitglied erfolgreich gelöscht",
@@ -1155,11 +1169,16 @@
"organization_name_updated_successfully": "Organisationsname erfolgreich aktualisiert",
"organization_settings": "Organisationseinstellungen",
"ownership_transferred_successfully": "Eigentumsrechte erfolgreich übertragen",
"please_add_a_logo": "Bitte füge ein Logo hinzu",
"please_check_csv_file": "Bitte überprüfe die CSV-Datei und stelle sicher, dass sie unserem Format entspricht",
"please_save_logo_before_sending_test_email": "Bitte speichere das Logo, bevor Du einen Test-E-Mail sendest.",
"remove_logo": "Logo entfernen",
"replace_logo": "Logo ersetzen",
"resend_invitation_email": "Einladungsemail erneut senden",
"send_invitation": "Einladung senden",
"share_invite_link": "Einladungslink teilen",
"share_this_link_to_let_your_organization_member_join_your_organization": "Teile diesen Link, damit dein Organisationsmitglied deiner Organisation beitreten kann:",
"test_email_sent_successfully": "Test-E-Mail erfolgreich gesendet",
"there_can_only_be_one_owner_of_each_organization": "Es kann nur einen Besitzer jeder Organisation geben. Wenn Du deine Eigentümerschaft überträgst an",
"to_confirm": "bestätigen:",
"type_in": "Tippe ein",

View File

@@ -325,6 +325,7 @@
"questions": "Questions",
"read_docs": "Read Docs",
"remove": "Remove",
"removed": "Removed",
"reorder_and_hide_columns": "Reorder and hide columns",
"report_survey": "Report Survey",
"request_an_enterprise_license": "request an Enterprise license.",
@@ -339,6 +340,7 @@
"sales": "Sales",
"save": "Save",
"save_changes": "Save changes",
"saved": "Saved",
"scheduled": "Scheduled",
"search": "Search",
"security": "Security",
@@ -352,6 +354,8 @@
"selection": "Selection",
"selections": "Selections",
"send": "Send",
"send_test_email": "Send test email",
"sent": "Sent",
"session_not_found": "Session not found",
"settings": "Settings",
"share_feedback": "Share feedback",
@@ -437,6 +441,8 @@
"you_will_be_downgraded_to_the_community_edition_on_date": "You will be downgraded to the Community Edition on {date}."
},
"emails": {
"email_customization_preview_email_heading": "Hey {userName}",
"email_customization_preview_email_text": "This is an email preview to show you which logo will be rendered in the emails.",
"embed_survey_preview_email_didnt_request": "Didn't request this?",
"embed_survey_preview_email_environment_id": "Environment ID",
"embed_survey_preview_email_fight_spam": "Help us fight spam and forward this mail to hola@formbricks.com",
@@ -897,7 +903,8 @@
"app_survey_placement": "App Survey Placement",
"app_survey_placement_settings_description": "Change where surveys will be shown in your web app or website.",
"centered_modal_overlay_color": "Centered modal overlay color",
"eliminate_branding_with_whitelabel": "Eliminate Formbricks branding and enable additional white-label customization options.",
"email_customization": "Email Customization",
"email_customization_description": "Change the look and feel of emails Formbricks sends out on your behalf.",
"enable_custom_styling": "Enable custom styling",
"enable_custom_styling_description": "Allow users to override this theme in the survey editor.",
"failed_to_remove_logo": "Failed to remove the logo",
@@ -1117,12 +1124,16 @@
"copy_invite_link_to_clipboard": "Copy invite link to clipboard",
"create_new_organization": "Create new organization",
"create_new_organization_description": "Create a new organization to handle a different set of projects.",
"customize_email_with_a_higher_plan": "Customize email with a higher plan",
"delete_organization": "Delete Organization",
"delete_organization_description": "Delete organization with all its projects including all surveys, responses, people, actions and attributes",
"delete_organization_warning": "Before you proceed with deleting this organization, please be aware of the following consequences:",
"delete_organization_warning_1": "Permanent removal of all projects linked to this organization.",
"delete_organization_warning_2": "This action cannot be undone. If it's gone, it's gone.",
"delete_organization_warning_3": "Please enter {organizationName} in the following field to confirm the definitive deletion of this organization:",
"eliminate_branding_with_whitelabel": "Eliminate Formbricks branding and enable additional white-label customization options.",
"email_customization_preview_email_heading": "Hey {userName}",
"email_customization_preview_email_text": "This is an email preview to show you which logo will be rendered in the emails.",
"error_deleting_organization_please_try_again": "Error deleting organization. Please try again.",
"formbricks_ai": "Formbricks AI",
"formbricks_ai_description": "Get personalised insights from your survey responses with Formbricks AI",
@@ -1139,6 +1150,9 @@
"leave_organization_description": "You wil leave this organization and loose access to all surveys and responses. You can only rejoin if you are invited again.",
"leave_organization_ok_btn_text": "Yes, leave organization",
"leave_organization_title": "Are you sure?",
"logo_in_email_header": "Logo in email header",
"logo_removed_successfully": "Logo removed successfully",
"logo_saved_successfully": "Logo saved successfully",
"manage_members": "Manage members",
"manage_members_description": "Add or remove members in your organization.",
"member_deleted_successfully": "Member deleted successfully",
@@ -1155,11 +1169,16 @@
"organization_name_updated_successfully": "Organization name updated successfully",
"organization_settings": "Organization Settings",
"ownership_transferred_successfully": "Ownership transferred successfully",
"please_add_a_logo": "Please add a logo",
"please_check_csv_file": "Please check the CSV file and make sure it is according to our format",
"please_save_logo_before_sending_test_email": "Please save the logo before sending a test email.",
"remove_logo": "Remove logo",
"replace_logo": "Replace logo",
"resend_invitation_email": "Resend Invitation Email",
"send_invitation": "Send Invitation",
"share_invite_link": "Share Invite Link",
"share_this_link_to_let_your_organization_member_join_your_organization": "Share this link to let your organization member join your organization:",
"test_email_sent_successfully": "Test email sent successfully",
"there_can_only_be_one_owner_of_each_organization": "There can only be one owner of each organization. If you transfer your ownership to",
"to_confirm": "to confirm:",
"type_in": "Type in",

View File

@@ -325,6 +325,7 @@
"questions": "Questions",
"read_docs": "Lire les documents",
"remove": "Retirer",
"removed": "Retiré",
"reorder_and_hide_columns": "Réorganiser et masquer des colonnes",
"report_survey": "Rapport d'enquête",
"request_an_enterprise_license": "demander une licence Entreprise.",
@@ -339,6 +340,7 @@
"sales": "Ventes",
"save": "Enregistrer",
"save_changes": "Enregistrer les modifications",
"saved": "Enregistré",
"scheduled": "Programmé",
"search": "Recherche",
"security": "Sécurité",
@@ -352,6 +354,8 @@
"selection": "Sélection",
"selections": "Sélections",
"send": "Envoyer",
"send_test_email": "Envoyer un e-mail de test",
"sent": "Envoyé",
"session_not_found": "Session non trouvée",
"settings": "Paramètres",
"share_feedback": "Partager des retours",
@@ -437,6 +441,8 @@
"you_will_be_downgraded_to_the_community_edition_on_date": "Vous serez rétrogradé à l'édition communautaire le {date}."
},
"emails": {
"email_customization_preview_email_heading": "Salut {userName}",
"email_customization_preview_email_text": "C'est une prévisualisation d'e-mail pour vous montrer quel logo sera rendu dans les e-mails.",
"embed_survey_preview_email_didnt_request": "Vous n'avez pas demandé cela ?",
"embed_survey_preview_email_environment_id": "ID d'environnement",
"embed_survey_preview_email_fight_spam": "Aidez-nous à lutter contre le spam et transférez ce mail à hola@formbricks.com.",
@@ -897,7 +903,9 @@
"app_survey_placement": "Placement de l'enquête dans l'application",
"app_survey_placement_settings_description": "Changez l'emplacement où les enquêtes seront affichées dans votre application web ou votre site web.",
"centered_modal_overlay_color": "Couleur de superposition modale centrée",
"eliminate_branding_with_whitelabel": "Éliminez la marque Formbricks et activez des options de personnalisation supplémentaires.",
"email_customization": "Personnalisation des e-mails",
"email_customization_description": "Modifiez l'apparence des e-mails envoyés par Formbricks en votre nom.",
"enable_custom_styling": "Activer le style personnalisé",
"enable_custom_styling_description": "Permettre aux utilisateurs de remplacer ce thème dans l'éditeur d'enquête.",
"failed_to_remove_logo": "Échec de la suppression du logo",
@@ -1117,12 +1125,16 @@
"copy_invite_link_to_clipboard": "Copier le lien d'invitation dans le presse-papiers",
"create_new_organization": "Créer une nouvelle organisation",
"create_new_organization_description": "Créer une nouvelle organisation pour gérer un ensemble différent de projets.",
"customize_email_with_a_higher_plan": "Personnalisez l'e-mail avec un plan supérieur",
"delete_organization": "Supprimer l'organisation",
"delete_organization_description": "Supprimer l'organisation avec tous ses projets, y compris toutes les enquêtes, réponses, personnes, actions et attributs.",
"delete_organization_warning": "Avant de procéder à la suppression de cette organisation, veuillez prendre connaissance des conséquences suivantes :",
"delete_organization_warning_1": "Suppression permanente de tous les projets liés à cette organisation.",
"delete_organization_warning_2": "Cette action ne peut pas être annulée. Si c'est parti, c'est parti.",
"delete_organization_warning_3": "Veuillez entrer {organizationName} dans le champ suivant pour confirmer la suppression définitive de cette organisation :",
"eliminate_branding_with_whitelabel": "Éliminez la marque Formbricks et activez des options de personnalisation supplémentaires.",
"email_customization_preview_email_heading": "Hey {userName}",
"email_customization_preview_email_text": "Cette est une prévisualisation d'e-mail pour vous montrer quel logo sera rendu dans les e-mails.",
"error_deleting_organization_please_try_again": "Erreur lors de la suppression de l'organisation. Veuillez réessayer.",
"formbricks_ai": "Formbricks IA",
"formbricks_ai_description": "Obtenez des insights personnalisés à partir de vos réponses au sondage avec Formbricks AI.",
@@ -1139,6 +1151,9 @@
"leave_organization_description": "Vous quitterez cette organisation et perdrez l'accès à toutes les enquêtes et réponses. Vous ne pourrez revenir que si vous êtes de nouveau invité.",
"leave_organization_ok_btn_text": "Oui, quitter l'organisation",
"leave_organization_title": "Es-tu sûr ?",
"logo_in_email_header": "Logo dans l'en-tête de l'e-mail",
"logo_removed_successfully": "Logo supprimé avec succès",
"logo_saved_successfully": "Logo enregistré avec succès",
"manage_members": "Gérer les membres",
"manage_members_description": "Ajouter ou supprimer des membres dans votre organisation.",
"member_deleted_successfully": "Membre supprimé avec succès",
@@ -1155,11 +1170,16 @@
"organization_name_updated_successfully": "Nom de l'organisation mis à jour avec succès",
"organization_settings": "Paramètres de l'organisation",
"ownership_transferred_successfully": "Propriété transférée avec succès",
"please_add_a_logo": "Veuillez ajouter un logo",
"please_check_csv_file": "Veuillez vérifier le fichier CSV et vous assurer qu'il est conforme à notre format.",
"please_save_logo_before_sending_test_email": "Veuillez enregistrer le logo avant d'envoyer un e-mail de test.",
"remove_logo": "Supprimer le logo",
"replace_logo": "Remplacer le logo",
"resend_invitation_email": "Renvoyer l'e-mail d'invitation",
"send_invitation": "Envoyer l'invitation",
"share_invite_link": "Partager le lien d'invitation",
"share_this_link_to_let_your_organization_member_join_your_organization": "Partagez ce lien pour permettre à un membre de votre organisation de rejoindre votre organisation :",
"test_email_sent_successfully": "E-mail de test envoyé avec succès",
"there_can_only_be_one_owner_of_each_organization": "Il ne peut y avoir qu'un seul propriétaire pour chaque organisation. Si vous transférez votre propriété à",
"to_confirm": "pour confirmer :",
"type_in": "Tapez",

View File

@@ -325,6 +325,7 @@
"questions": "Perguntas",
"read_docs": "Ler Documentos",
"remove": "remover",
"removed": "Removido",
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
"report_survey": "Relatório de Pesquisa",
"request_an_enterprise_license": "pedir uma licença Enterprise",
@@ -339,6 +340,7 @@
"sales": "vendas",
"save": "Salvar",
"save_changes": "Salvar alterações",
"saved": "Salvo",
"scheduled": "agendado",
"search": "Buscar",
"security": "Segurança",
@@ -352,6 +354,8 @@
"selection": "seleção",
"selections": "seleções",
"send": "Enviar",
"send_test_email": "Enviar e-mail de teste",
"sent": "Enviado",
"session_not_found": "Sessão não encontrada",
"settings": "Configurações",
"share_feedback": "Compartilhar feedback",
@@ -437,6 +441,8 @@
"you_will_be_downgraded_to_the_community_edition_on_date": "Você será rebaixado para a Edição Comunitária em {data}."
},
"emails": {
"email_customization_preview_email_heading": "Oi {userName}",
"email_customization_preview_email_text": "Esta é uma pré-visualização de e-mail para mostrar qual logo será renderizado nos e-mails.",
"embed_survey_preview_email_didnt_request": "Não pediu isso?",
"embed_survey_preview_email_environment_id": "ID do Ambiente",
"embed_survey_preview_email_fight_spam": "Ajude a gente a combater spam e encaminhe este e-mail para hola@formbricks.com",
@@ -897,7 +903,8 @@
"app_survey_placement": "Posicionamento da Pesquisa no App",
"app_survey_placement_settings_description": "Mude onde as pesquisas serão exibidas no seu app ou site.",
"centered_modal_overlay_color": "Cor de sobreposição modal centralizada",
"eliminate_branding_with_whitelabel": "Elimine a marca Formbricks e ative opções adicionais de personalização de marca branca.",
"email_customization": "Personalização de Email",
"email_customization_description": "Mude a aparência e o estilo dos e-mails que o Formbricks envia em seu nome.",
"enable_custom_styling": "Ativar estilo personalizado",
"enable_custom_styling_description": "Permitir que os usuários alterem esse tema no editor de pesquisa.",
"failed_to_remove_logo": "Falha ao remover o logo",
@@ -1117,12 +1124,16 @@
"copy_invite_link_to_clipboard": "Copiar link do convite para a área de transferência",
"create_new_organization": "Criar nova organização",
"create_new_organization_description": "Criar uma nova organização para lidar com um conjunto diferente de projetos.",
"customize_email_with_a_higher_plan": "Personalize o email com um plano superior",
"delete_organization": "Excluir Organização",
"delete_organization_description": "Excluir organização com todos os seus projetos, incluindo todas as pesquisas, respostas, pessoas, ações e atributos",
"delete_organization_warning": "Antes de continuar com a exclusão desta organização, esteja ciente das seguintes consequências:",
"delete_organization_warning_1": "Remoção permanente de todos os projetos ligados a essa organização.",
"delete_organization_warning_2": "Essa ação não pode ser desfeita. Se foi, foi.",
"delete_organization_warning_3": "Por favor, digite {organizationName} no campo abaixo para confirmar a exclusão definitiva desta organização:",
"eliminate_branding_with_whitelabel": "Elimine a marca Formbricks e ative opções adicionais de personalização de marca branca.",
"email_customization_preview_email_heading": "Olá {userName}",
"email_customization_preview_email_text": "Esta é uma pré-visualização de e-mail para mostrar qual logo será renderizado nos e-mails.",
"error_deleting_organization_please_try_again": "Erro ao deletar a organização. Por favor, tente novamente.",
"formbricks_ai": "Formbricks IA",
"formbricks_ai_description": "Obtenha insights personalizados das suas respostas de pesquisa com o Formbricks AI",
@@ -1139,6 +1150,9 @@
"leave_organization_description": "Você vai sair dessa organização e perder acesso a todas as pesquisas e respostas. Você só pode voltar se for convidado de novo.",
"leave_organization_ok_btn_text": "Sim, sair da organização",
"leave_organization_title": "Você tem certeza?",
"logo_in_email_header": "Logo na cabeçalho do e-mail",
"logo_removed_successfully": "Logo removido com sucesso",
"logo_saved_successfully": "Logo salvo com sucesso",
"manage_members": "Gerenciar membros",
"manage_members_description": "Adicionar ou remover membros na sua organização.",
"member_deleted_successfully": "Membro deletado com sucesso",
@@ -1155,11 +1169,16 @@
"organization_name_updated_successfully": "Nome da organização atualizado com sucesso",
"organization_settings": "Configurações da Organização",
"ownership_transferred_successfully": "Propriedade transferida com sucesso",
"please_add_a_logo": "Por favor, adicione um logo",
"please_check_csv_file": "Por favor, verifique o arquivo CSV e certifique-se de que está de acordo com o nosso formato",
"please_save_logo_before_sending_test_email": "Por favor, salve o logo antes de enviar um e-mail de teste.",
"remove_logo": "Remover logo",
"replace_logo": "Substituir logo",
"resend_invitation_email": "Reenviar E-mail de Convite",
"send_invitation": "Enviar Convite",
"share_invite_link": "Compartilhar Link de Convite",
"share_this_link_to_let_your_organization_member_join_your_organization": "Compartilhe esse link para que o membro da sua organização possa entrar na sua organização:",
"test_email_sent_successfully": "E-mail de teste enviado com sucesso",
"there_can_only_be_one_owner_of_each_organization": "Só pode ter um dono de cada organização. Se você transferir sua propriedade para",
"to_confirm": "pra confirmar:",
"type_in": "Digita aí",

View File

@@ -27,6 +27,7 @@ export const select: Prisma.OrganizationSelect = {
name: true,
billing: true,
isAIEnabled: true,
whitelabel: true,
};
export const getOrganizationsTag = (organizationId: string) => `organizations-${organizationId}`;

View File

@@ -6,6 +6,7 @@ export const ZLinkSurveyEmailData = z.object({
suId: z.string().optional(),
surveyName: z.string(),
locale: z.string(),
logoUrl: z.string().optional(),
});
export type TLinkSurveyEmailData = z.infer<typeof ZLinkSurveyEmailData>;

View File

@@ -33,6 +33,12 @@ export const ZOrganizationBilling = z.object({
export type TOrganizationBilling = z.infer<typeof ZOrganizationBilling>;
export const ZOrganizationWhitelabel = z.object({
logoUrl: z.string().nullable(),
});
export type TOrganizationWhitelabel = z.infer<typeof ZOrganizationWhitelabel>;
export const ZOrganization = z.object({
id: z.string().cuid2(),
createdAt: z.date(),
@@ -40,6 +46,7 @@ export const ZOrganization = z.object({
name: z.string({ message: "Organization name is required" }).trim().min(1, {
message: "Organization name must be at least 1 character long",
}),
whitelabel: ZOrganizationWhitelabel.optional(),
billing: ZOrganizationBilling,
isAIEnabled: z.boolean().default(false),
});
@@ -53,6 +60,7 @@ export type TOrganizationCreateInput = z.infer<typeof ZOrganizationCreateInput>;
export const ZOrganizationUpdateInput = z.object({
name: z.string(),
whitelabel: ZOrganizationWhitelabel.optional(),
billing: ZOrganizationBilling.optional(),
isAIEnabled: z.boolean().optional(),
});

View File

@@ -108,6 +108,7 @@
"HTTP_PROXY",
"HTTPS_PROXY",
"IMPRINT_URL",
"IMPRINT_ADDRESS",
"INVITE_DISABLED",
"IS_FORMBRICKS_CLOUD",
"INTERCOM_SECRET_KEY",