Compare commits

...

8 Commits

Author SHA1 Message Date
GitHub Actions
7012f27391 chore: release v3.1.4 2025-02-03 18:59:15 +00:00
dependabot[bot]
510fe3902e chore(deps): bump the npm_and_yarn group across 2 directories with 2 updates (#4686)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-03 10:05:27 +00:00
Dhruwang Jariwala
2bc23594ad fix: open file in new tab (#4697) 2025-01-31 07:08:02 +00:00
Piyush Gupta
06e00f3066 chore: invite types (#4613)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-01-31 06:56:55 +00:00
Dhruwang Jariwala
9b3d409695 fix: org leaving issue and minor tweaks (#4691) 2025-01-31 03:59:26 +00:00
Dhruwang Jariwala
f7f5737abf fix: email inconsistencies (#4678) 2025-01-30 09:54:48 +00:00
Matti Nannt
458f135ee1 chore(cloud): move from customer-io to brevo (#4681)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
2025-01-29 09:18:16 +00:00
Anshuman Pandey
8e116bf62d fix: recall in follow up (#4680) 2025-01-29 06:04:22 +00:00
90 changed files with 1715 additions and 1015 deletions

View File

@@ -167,9 +167,9 @@ ENTERPRISE_LICENSE_KEY=
# DEFAULT_ORGANIZATION_ID=
# DEFAULT_ORGANIZATION_ROLE=owner
# Send new users to customer.io
# CUSTOMER_IO_API_KEY=
# CUSTOMER_IO_SITE_ID=
# Send new users to Brevo
# BREVO_API_KEY=
# BREVO_LIST_ID=
# Ignore Rate Limiting across the Formbricks app
# RATE_LIMITING_DISABLED=1

View File

@@ -127,7 +127,7 @@ export const LandingSidebar = ({
await signOut({ callbackUrl: "/auth/login" });
await formbricksLogout();
}}
icon={<LogOutIcon className="h-4 w-4" strokeWidth={1.5} />}>
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}
</DropdownMenuItem>

View File

@@ -392,7 +392,7 @@ export const MainNavigation = ({
router.push(route.url);
await formbricksLogout();
}}
icon={<LogOutIcon className="h-4 w-4" strokeWidth={1.5} />}>
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}
</DropdownMenuItem>

View File

@@ -1,3 +1,4 @@
import { ProjectSettingsLayout } from "@/modules/projects/settings/layout";
import { ProjectSettingsLayout, metadata } from "@/modules/projects/settings/layout";
export { metadata };
export default ProjectSettingsLayout;

View File

@@ -43,7 +43,7 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
const rawEmailHtml = await getEmailTemplateHtml(parsedInput.surveyId);
const rawEmailHtml = await getEmailTemplateHtml(parsedInput.surveyId, ctx.user.locale);
const emailHtml = rawEmailHtml
.replaceAll("?preview=true&amp;", "?")
.replaceAll("?preview=true&;", "?")
@@ -51,7 +51,6 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
return await sendEmbedSurveyPreviewEmail(
ctx.user.email,
"Formbricks Email Survey Preview",
emailHtml,
survey.environmentId,
ctx.user.locale,
@@ -182,5 +181,5 @@ export const getEmailHtmlAction = authenticatedActionClient
],
});
return await getEmailTemplateHtml(parsedInput.surveyId);
return await getEmailTemplateHtml(parsedInput.surveyId, ctx.user.locale);
});

View File

@@ -77,12 +77,7 @@ export const FileUploadSummary = ({
return (
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
<a
href={fileUrl as string}
key={index}
download={fileName}
target="_blank"
rel="noopener noreferrer">
<a href={fileUrl} key={index} target="_blank" rel="noopener noreferrer">
<div className="absolute right-0 top-0 m-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
<DownloadIcon className="h-6 text-slate-500" />

View File

@@ -4,7 +4,7 @@ import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { getStyling } from "@formbricks/lib/utils/styling";
export const getEmailTemplateHtml = async (surveyId: string) => {
export const getEmailTemplateHtml = async (surveyId: string, locale: string) => {
const survey = await getSurvey(surveyId);
if (!survey) {
throw new Error("Survey not found");
@@ -16,7 +16,7 @@ export const getEmailTemplateHtml = async (surveyId: string) => {
const styling = getStyling(project, survey);
const surveyUrl = WEBAPP_URL + "/s/" + survey.id;
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling);
const html = await getPreviewEmailTemplateHtml(survey, surveyUrl, styling, locale);
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
const htmlCleaned = html.toString().replace(doctype, "");

View File

@@ -1,3 +1,4 @@
import { LoginPage } from "@/modules/auth/login/page";
import { LoginPage, metadata } from "@/modules/auth/login/page";
export { metadata };
export default LoginPage;

View File

@@ -1,133 +1,3 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { sendInviteAcceptedEmail } from "@/modules/email";
import { createTeamMembership } from "@/modules/invite/lib/team";
import { Button } from "@/modules/ui/components/button";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { after } from "next/server";
import { DEFAULT_LOCALE, WEBAPP_URL } from "@formbricks/lib/constants";
import { deleteInvite, getInvite } from "@formbricks/lib/invite/service";
import { verifyInviteToken } from "@formbricks/lib/jwt";
import { createMembership } from "@formbricks/lib/membership/service";
import { getUser, updateUser } from "@formbricks/lib/user/service";
import { ContentLayout } from "./components/ContentLayout";
import { InvitePage } from "@/modules/auth/invite/page";
const Page = async (props) => {
const searchParams = await props.searchParams;
const t = await getTranslations();
const session = await getServerSession(authOptions);
const user = session?.user.id ? await getUser(session.user.id) : null;
try {
const { inviteId, email } = verifyInviteToken(searchParams.token);
const invite = await getInvite(inviteId);
if (!invite) {
return (
<ContentLayout
headline={t("auth.invite.invite_not_found")}
description={t("auth.invite.invite_not_found_description")}
/>
);
}
const isInviteExpired = new Date(invite.expiresAt) < new Date();
const createMembershipAction = async () => {
"use server";
if (!session || !user) return;
await createMembership(invite.organizationId, session.user.id, {
accepted: true,
role: invite.role,
});
if (invite.teamIds) {
await createTeamMembership(invite, user.id);
}
await deleteInvite(inviteId);
await sendInviteAcceptedEmail(
invite.creator.name ?? "",
user?.name ?? "",
invite.creator.email,
user?.locale ?? DEFAULT_LOCALE
);
await updateUser(session.user.id, {
notificationSettings: {
...user.notificationSettings,
alert: user.notificationSettings.alert ?? {},
weeklySummary: user.notificationSettings.weeklySummary ?? {},
unsubscribedOrganizationIds: Array.from(
new Set([
...(user.notificationSettings?.unsubscribedOrganizationIds || []),
invite.organizationId,
])
),
},
});
};
if (isInviteExpired) {
return (
<ContentLayout
headline={t("auth.invite.invite_expired")}
description={t("auth.invite.invite_expired_description")}
/>
);
} else if (!session) {
const redirectUrl = WEBAPP_URL + "/invite?token=" + searchParams.token;
const encodedEmail = encodeURIComponent(email);
return (
<ContentLayout
headline={t("auth.invite.happy_to_have_you")}
description={t("auth.invite.happy_to_have_you_description")}>
<Button variant="secondary" asChild>
<Link href={`/auth/signup?inviteToken=${searchParams.token}&email=${encodedEmail}`}>
{t("auth.invite.create_account")}
</Link>
</Button>
<Button asChild>
<Link href={`/auth/login?callbackUrl=${redirectUrl}&email=${encodedEmail}`}>
{t("auth.invite.login")}
</Link>
</Button>
</ContentLayout>
);
} else if (user?.email?.toLowerCase() !== email?.toLowerCase()) {
return (
<ContentLayout
headline={t("auth.invite.email_does_not_match")}
description={t("auth.invite.email_does_not_match_description")}>
<Button asChild>
<Link href="/">{t("auth.invite.go_to_app")}</Link>
</Button>
</ContentLayout>
);
} else {
after(async () => {
await createMembershipAction();
});
return (
<ContentLayout
headline={t("auth.invite.welcome_to_organization")}
description={t("auth.invite.welcome_to_organization_description")}>
<Button asChild>
<Link href="/">{t("auth.invite.go_to_app")}</Link>
</Button>
</ContentLayout>
);
}
} catch (e) {
console.error(e);
return (
<ContentLayout
headline={t("auth.invite.invite_not_found")}
description={t("auth.invite.invite_not_found_description")}
/>
);
}
};
export default Page;
export default InvitePage;

View File

@@ -1,35 +1,4 @@
import { Button } from "@/modules/ui/components/button";
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { IntroPage, metadata } from "@/modules/setup/(fresh-instance)/intro/page";
export const metadata: Metadata = {
title: "Intro",
description: "Open-source Experience Management. Free & open source.",
};
const renderRichText = async (text: string) => {
const t = await getTranslations();
return <p>{t.rich(text, { b: (chunks) => <b>{chunks}</b> })}</p>;
};
const Page = async () => {
const t = await getTranslations();
return (
<div className="flex flex-col items-center">
<h2 className="mb-6 text-xl font-medium">{t("setup.intro.welcome_to_formbricks")}</h2>
<div className="mx-auto max-w-sm space-y-4 text-sm leading-6 text-slate-600">
{renderRichText("setup.intro.paragraph_1")}
{renderRichText("setup.intro.paragraph_2")}
{renderRichText("setup.intro.paragraph_3")}
</div>
<Button className="mt-6" asChild>
<Link href="/setup/signup">{t("setup.intro.get_started")}</Link>
</Button>
<p className="pt-6 text-xs text-slate-400">{t("setup.intro.made_with_love_in_kiel")}</p>
</div>
);
};
export default Page;
export { metadata };
export default IntroPage;

View File

@@ -1,16 +1,3 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { notFound } from "next/navigation";
import { getIsFreshInstance } from "@formbricks/lib/instance/service";
const FreshInstanceLayout = async ({ children }: { children: React.ReactNode }) => {
const session = await getServerSession(authOptions);
const isFreshInstance = await getIsFreshInstance();
if (session ?? !isFreshInstance) {
return notFound();
}
return <>{children}</>;
};
import { FreshInstanceLayout } from "@/modules/setup/(fresh-instance)/layout";
export default FreshInstanceLayout;

View File

@@ -1,57 +1,4 @@
import { SignupForm } from "@/modules/auth/signup/components/signup-form";
import { getIsSSOEnabled } from "@/modules/ee/license-check/lib/utils";
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import {
AZURE_OAUTH_ENABLED,
DEFAULT_ORGANIZATION_ID,
DEFAULT_ORGANIZATION_ROLE,
EMAIL_AUTH_ENABLED,
EMAIL_VERIFICATION_DISABLED,
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
IS_TURNSTILE_CONFIGURED,
OIDC_DISPLAY_NAME,
OIDC_OAUTH_ENABLED,
PRIVACY_URL,
TERMS_URL,
WEBAPP_URL,
} from "@formbricks/lib/constants";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
import { SignupPage, metadata } from "@/modules/setup/(fresh-instance)/signup/page";
export const metadata: Metadata = {
title: "Sign up",
description: "Open-source Experience Management. Free & open source.",
};
const Page = async () => {
const locale = await findMatchingLocale();
const isSSOEnabled = await getIsSSOEnabled();
const t = await getTranslations();
return (
<div className="flex flex-col items-center">
<h2 className="mb-6 text-xl font-medium">{t("setup.signup.create_administrator")}</h2>
<p className="text-sm text-slate-800">{t("setup.signup.this_user_has_all_the_power")}</p>
<hr className="my-6 w-full border-slate-200" />
<SignupForm
webAppUrl={WEBAPP_URL}
termsUrl={TERMS_URL}
privacyUrl={PRIVACY_URL}
emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED}
emailAuthEnabled={EMAIL_AUTH_ENABLED}
googleOAuthEnabled={GOOGLE_OAUTH_ENABLED}
githubOAuthEnabled={GITHUB_OAUTH_ENABLED}
azureOAuthEnabled={AZURE_OAUTH_ENABLED}
oidcOAuthEnabled={OIDC_OAUTH_ENABLED}
oidcDisplayName={OIDC_DISPLAY_NAME}
userLocale={locale}
defaultOrganizationId={DEFAULT_ORGANIZATION_ID}
defaultOrganizationRole={DEFAULT_ORGANIZATION_ROLE}
isSSOEnabled={isSSOEnabled}
isTurnstileConfigured={IS_TURNSTILE_CONFIGURED}
/>
</div>
);
};
export default Page;
export { metadata };
export default SignupPage;

View File

@@ -1,22 +1,3 @@
import { FormbricksLogo } from "@/modules/ui/components/formbricks-logo";
import { Toaster } from "react-hot-toast";
const SetupLayout = ({ children }: { children: React.ReactNode }) => {
return (
<>
<Toaster />
<div className="flex h-full w-full items-center justify-center bg-slate-50">
<div
style={{ scrollbarGutter: "stable both-edges" }}
className="flex max-h-[90vh] w-[40rem] flex-col items-center space-y-4 overflow-auto rounded-lg border bg-white p-12 text-center shadow-md">
<div className="h-20 w-20 rounded-lg bg-slate-900 p-2">
<FormbricksLogo className="h-full w-full" />
</div>
{children}
</div>
</div>
</>
);
};
import { SetupLayout } from "@/modules/setup/layout";
export default SetupLayout;

View File

@@ -1,40 +1,4 @@
import { InviteMembers } from "@/app/setup/organization/[organizationId]/invite/components/invite-members";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation";
import { SMTP_HOST, SMTP_PASSWORD, SMTP_PORT, SMTP_USER } from "@formbricks/lib/constants";
import { verifyUserRoleAccess } from "@formbricks/lib/organization/auth";
import { AuthenticationError } from "@formbricks/types/errors";
import { InvitePage, metadata } from "@/modules/setup/organization/[organizationId]/invite/page";
type Params = Promise<{
organizationId: string;
}>;
export const metadata: Metadata = {
title: "Invite",
description: "Open-source Experience Management. Free & open source.",
};
interface InvitePageProps {
params: Params;
}
const Page = async (props: InvitePageProps) => {
const params = await props.params;
const t = await getTranslations();
const IS_SMTP_CONFIGURED = Boolean(SMTP_HOST && SMTP_PORT && SMTP_USER && SMTP_PASSWORD);
const session = await getServerSession(authOptions);
if (!session) throw new AuthenticationError(t("common.session_not_found"));
const { hasCreateOrUpdateMembersAccess } = await verifyUserRoleAccess(
params.organizationId,
session.user.id
);
if (!hasCreateOrUpdateMembersAccess) return notFound();
return <InviteMembers IS_SMTP_CONFIGURED={IS_SMTP_CONFIGURED} organizationId={params.organizationId} />;
};
export default Page;
export { metadata };
export default InvitePage;

View File

@@ -1,47 +1,4 @@
import { RemovedFromOrganization } from "@/app/setup/organization/create/components/removed-from-organization";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { ClientLogout } from "@/modules/ui/components/client-logout";
import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { gethasNoOrganizations } from "@formbricks/lib/instance/service";
import { getOrganizationsByUserId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
import { AuthenticationError } from "@formbricks/types/errors";
import { CreateOrganization } from "./components/create-organization";
import { CreateOrganizationPage, metadata } from "@/modules/setup/organization/create/page";
export const metadata: Metadata = {
title: "Create Organization",
description: "Open-source Experience Management. Free & open source.",
};
const Page = async () => {
const t = await getTranslations();
const session = await getServerSession(authOptions);
if (!session) throw new AuthenticationError(t("common.session_not_found"));
const user = await getUser(session.user.id);
if (!user) {
return <ClientLogout />;
}
const hasNoOrganizations = await gethasNoOrganizations();
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const userOrganizations = await getOrganizationsByUserId(session.user.id);
if (hasNoOrganizations || isMultiOrgEnabled) {
return <CreateOrganization />;
}
if (userOrganizations.length === 0) {
return <RemovedFromOrganization user={user} isFormbricksCloud={IS_FORMBRICKS_CLOUD} />;
}
return notFound();
};
export default Page;
export { metadata };
export default CreateOrganizationPage;

View File

@@ -2,6 +2,7 @@
import { apiKeyCache } from "@/lib/cache/api-key";
import { contactCache } from "@/lib/cache/contact";
import { inviteCache } from "@/lib/cache/invite";
import { teamCache } from "@/lib/cache/team";
import { webhookCache } from "@/lib/cache/webhook";
import { Prisma } from "@prisma/client";
@@ -12,7 +13,6 @@ import { cache } from "@formbricks/lib/cache";
import { segmentCache } from "@formbricks/lib/cache/segment";
import { environmentCache } from "@formbricks/lib/environment/cache";
import { integrationCache } from "@formbricks/lib/integration/cache";
import { inviteCache } from "@formbricks/lib/invite/cache";
import { projectCache } from "@formbricks/lib/project/cache";
import { responseCache } from "@formbricks/lib/response/cache";
import { responseNoteCache } from "@formbricks/lib/responseNote/cache";

View File

@@ -0,0 +1,78 @@
import { inviteCache } from "@/lib/cache/invite";
import { type InviteWithCreator } from "@/modules/auth/invite/types/invites";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
export const deleteInvite = async (inviteId: string): Promise<boolean> => {
try {
const invite = await prisma.invite.delete({
where: {
id: inviteId,
},
select: {
id: true,
organizationId: true,
},
});
if (!invite) {
throw new ResourceNotFoundError("Invite", inviteId);
}
inviteCache.revalidate({
id: invite.id,
organizationId: invite.organizationId,
});
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getInvite = reactCache(
async (inviteId: string): Promise<InviteWithCreator | null> =>
cache(
async () => {
try {
const invite = await prisma.invite.findUnique({
where: {
id: inviteId,
},
select: {
id: true,
expiresAt: true,
organizationId: true,
role: true,
teamIds: true,
creator: {
select: {
name: true,
email: true,
},
},
},
});
return invite;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`invite-getInvite-${inviteId}`],
{
tags: [inviteCache.tag.byId(inviteId)],
}
)()
);

View File

@@ -1,17 +1,13 @@
import "server-only";
import { teamCache } from "@/lib/cache/team";
import { CreateMembershipInvite } from "@/modules/auth/invite/types/invites";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { projectCache } from "@formbricks/lib/project/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZString } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TInvite, ZInvite } from "@formbricks/types/invites";
export const createTeamMembership = async (invite: TInvite, userId: string): Promise<void> => {
validateInputs([invite, ZInvite], [userId, ZString]);
export const createTeamMembership = async (invite: CreateMembershipInvite, userId: string): Promise<void> => {
const teamIds = invite.teamIds || [];
const userMembershipRole = invite.role;
const { isOwner, isManager } = getAccessFlags(userMembershipRole);

View File

@@ -0,0 +1,147 @@
import { deleteInvite, getInvite } from "@/modules/auth/invite/lib/invite";
import { createTeamMembership } from "@/modules/auth/invite/lib/team";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { sendInviteAcceptedEmail } from "@/modules/email";
import { Button } from "@/modules/ui/components/button";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
import { after } from "next/server";
import { DEFAULT_LOCALE, WEBAPP_URL } from "@formbricks/lib/constants";
import { verifyInviteToken } from "@formbricks/lib/jwt";
import { createMembership } from "@formbricks/lib/membership/service";
import { getUser, updateUser } from "@formbricks/lib/user/service";
import { ContentLayout } from "./components/content-layout";
interface InvitePageProps {
searchParams: Promise<{ token: string }>;
}
export const InvitePage = async (props: InvitePageProps) => {
const searchParams = await props.searchParams;
const t = await getTranslations();
const session = await getServerSession(authOptions);
const user = session?.user.id ? await getUser(session.user.id) : null;
try {
const { inviteId, email } = verifyInviteToken(searchParams.token);
const invite = await getInvite(inviteId);
if (!invite) {
return (
<ContentLayout
headline={t("auth.invite.invite_not_found")}
description={t("auth.invite.invite_not_found_description")}
/>
);
}
const isInviteExpired = new Date(invite.expiresAt) < new Date();
if (isInviteExpired) {
return (
<ContentLayout
headline={t("auth.invite.invite_expired")}
description={t("auth.invite.invite_expired_description")}
/>
);
}
if (!session) {
const redirectUrl = WEBAPP_URL + "/invite?token=" + searchParams.token;
const encodedEmail = encodeURIComponent(email);
return (
<ContentLayout
headline={t("auth.invite.happy_to_have_you")}
description={t("auth.invite.happy_to_have_you_description")}>
<Button variant="secondary" asChild>
<Link href={`/auth/signup?inviteToken=${searchParams.token}&email=${encodedEmail}`}>
{t("auth.invite.create_account")}
</Link>
</Button>
<Button asChild>
<Link href={`/auth/login?callbackUrl=${redirectUrl}&email=${encodedEmail}`}>
{t("auth.invite.login")}
</Link>
</Button>
</ContentLayout>
);
}
if (user?.email?.toLowerCase() !== email?.toLowerCase()) {
return (
<ContentLayout
headline={t("auth.invite.email_does_not_match")}
description={t("auth.invite.email_does_not_match_description")}>
<Button asChild>
<Link href="/">{t("auth.invite.go_to_app")}</Link>
</Button>
</ContentLayout>
);
}
const createMembershipAction = async () => {
"use server";
if (!session || !user) return;
await createMembership(invite.organizationId, session.user.id, {
accepted: true,
role: invite.role,
});
if (invite.teamIds) {
await createTeamMembership(
{
organizationId: invite.organizationId,
role: invite.role,
teamIds: invite.teamIds,
},
user.id
);
}
await deleteInvite(inviteId);
await sendInviteAcceptedEmail(
invite.creator.name ?? "",
user?.name ?? "",
invite.creator.email,
user?.locale ?? DEFAULT_LOCALE
);
await updateUser(session.user.id, {
notificationSettings: {
...user.notificationSettings,
alert: user.notificationSettings.alert ?? {},
weeklySummary: user.notificationSettings.weeklySummary ?? {},
unsubscribedOrganizationIds: Array.from(
new Set([
...(user.notificationSettings?.unsubscribedOrganizationIds || []),
invite.organizationId,
])
),
},
});
};
after(async () => {
await createMembershipAction();
});
return (
<ContentLayout
headline={t("auth.invite.welcome_to_organization")}
description={t("auth.invite.welcome_to_organization_description")}>
<Button asChild>
<Link href="/">{t("auth.invite.go_to_app")}</Link>
</Button>
</ContentLayout>
);
} catch (e) {
console.error(e);
return (
<ContentLayout
headline={t("auth.invite.invite_not_found")}
description={t("auth.invite.invite_not_found_description")}
/>
);
}
};

View File

@@ -0,0 +1,11 @@
import { Invite } from "@prisma/client";
export interface InviteWithCreator
extends Pick<Invite, "id" | "expiresAt" | "organizationId" | "role" | "teamIds"> {
creator: {
name: string | null;
email: string;
};
}
export interface CreateMembershipInvite extends Pick<Invite, "organizationId" | "role" | "teamIds"> {}

View File

@@ -13,6 +13,7 @@ import {
import { symmetricDecrypt, symmetricEncrypt } from "@formbricks/lib/crypto";
import { verifyToken } from "@formbricks/lib/jwt";
import { TUser } from "@formbricks/types/user";
import { createBrevoCustomer } from "./brevo";
export const authOptions: NextAuthOptions = {
providers: [
@@ -162,6 +163,9 @@ export const authOptions: NextAuthOptions = {
user = await updateUser(user.id, { emailVerified: new Date() });
// send new user to brevo after email verification
createBrevoCustomer({ id: user.id, email: user.email });
return user;
},
}),

View File

@@ -0,0 +1,58 @@
import { Response } from "node-fetch";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { createBrevoCustomer } from "./brevo";
vi.mock("@formbricks/lib/constants", () => ({
BREVO_API_KEY: "mock_api_key",
BREVO_LIST_ID: "123",
}));
vi.mock("@formbricks/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
global.fetch = vi.fn();
describe("createBrevoCustomer", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should return early if BREVO_API_KEY is not defined", async () => {
vi.doMock("@formbricks/lib/constants", () => ({
BREVO_API_KEY: undefined,
BREVO_LIST_ID: "123",
}));
const { createBrevoCustomer } = await import("./brevo");
const result = await createBrevoCustomer({ id: "123", email: "test@example.com" });
expect(result).toBeUndefined();
expect(global.fetch).not.toHaveBeenCalled();
expect(validateInputs).not.toHaveBeenCalled();
});
it("should log an error if fetch fails", async () => {
const consoleSpy = vi.spyOn(console, "error");
vi.mocked(global.fetch).mockRejectedValueOnce(new Error("Fetch failed"));
await createBrevoCustomer({ id: "123", email: "test@example.com" });
expect(consoleSpy).toHaveBeenCalledWith("Error sending user to Brevo:", expect.any(Error));
});
it("should log the error response if fetch status is not 200", async () => {
const consoleSpy = vi.spyOn(console, "error");
vi.mocked(global.fetch).mockResolvedValueOnce(
new Response("Bad Request", { status: 400, statusText: "Bad Request" })
);
await createBrevoCustomer({ id: "123", email: "test@example.com" });
expect(consoleSpy).toHaveBeenCalledWith("Error sending user to Brevo:", "Bad Request");
});
});

View File

@@ -0,0 +1,42 @@
import { BREVO_API_KEY, BREVO_LIST_ID } from "@formbricks/lib/constants";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId } from "@formbricks/types/common";
import { TUserEmail, ZUserEmail } from "@formbricks/types/user";
export const createBrevoCustomer = async ({ id, email }: { id: string; email: TUserEmail }) => {
if (!BREVO_API_KEY) {
return;
}
validateInputs([id, ZId], [email, ZUserEmail]);
try {
const requestBody: any = {
email,
ext_id: id,
updateEnabled: false,
};
// Add `listIds` only if `BREVO_LIST_ID` is defined
const listId = BREVO_LIST_ID ? parseInt(BREVO_LIST_ID, 10) : null;
if (listId && !Number.isNaN(listId)) {
requestBody.listIds = [listId];
}
const res = await fetch("https://api.brevo.com/v3/contacts", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"api-key": BREVO_API_KEY,
},
body: JSON.stringify(requestBody),
});
if (res.status !== 200) {
console.error("Error sending user to Brevo:", await res.text());
}
} catch (error) {
console.error("Error sending user to Brevo:", error);
}
};

View File

@@ -1,7 +1,6 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { createCustomerIoCustomer } from "@formbricks/lib/customerio";
import { userCache } from "@formbricks/lib/user/cache";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { mockUser } from "./mock-data";
@@ -27,10 +26,6 @@ vi.mock("@formbricks/database", () => ({
},
}));
vi.mock("@formbricks/lib/customerio", () => ({
createCustomerIoCustomer: vi.fn(),
}));
vi.mock("@formbricks/lib/user/cache", () => ({
userCache: {
revalidate: vi.fn(),
@@ -57,10 +52,6 @@ describe("User Management", () => {
});
expect(result).toEqual(mockPrismaUser);
expect(createCustomerIoCustomer).toHaveBeenCalledWith({
id: mockPrismaUser.id,
email: mockPrismaUser.email,
});
expect(userCache.revalidate).toHaveBeenCalled();
});

View File

@@ -2,7 +2,6 @@ import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { createCustomerIoCustomer } from "@formbricks/lib/customerio";
import { userCache } from "@formbricks/lib/user/cache";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId } from "@formbricks/types/common";
@@ -128,8 +127,6 @@ export const createUser = async (data: TUserCreateInput) => {
count: true,
});
// send new user customer.io to customer.io
createCustomerIoCustomer({ id: user.id, email: user.email });
return user;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {

View File

@@ -1,17 +1,15 @@
"use server";
import { actionClient } from "@/lib/utils/action-client";
import { createUser } from "@/modules/auth/lib/user";
import { updateUser } from "@/modules/auth/lib/user";
import { createUser, updateUser } from "@/modules/auth/lib/user";
import { deleteInvite, getInvite } from "@/modules/auth/signup/lib/invite";
import { createTeamMembership } from "@/modules/auth/signup/lib/team";
import { captureFailedSignup, verifyTurnstileToken } from "@/modules/auth/signup/lib/utils";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { sendInviteAcceptedEmail, sendVerificationEmail } from "@/modules/email";
import { createTeamMembership } from "@/modules/invite/lib/team";
import { z } from "zod";
import { hashPassword } from "@formbricks/lib/auth";
import { IS_TURNSTILE_CONFIGURED, TURNSTILE_SECRET_KEY } from "@formbricks/lib/constants";
import { getInvite } from "@formbricks/lib/invite/service";
import { deleteInvite } from "@formbricks/lib/invite/service";
import { verifyInviteToken } from "@formbricks/lib/jwt";
import { createMembership } from "@formbricks/lib/membership/service";
import { createOrganization, getOrganization } from "@formbricks/lib/organization/service";
@@ -74,7 +72,14 @@ export const createUserAction = actionClient.schema(ZCreateUserAction).action(as
});
if (invite.teamIds) {
await createTeamMembership(invite, user.id);
await createTeamMembership(
{
organizationId: invite.organizationId,
role: invite.role,
teamIds: invite.teamIds,
},
user.id
);
}
await updateUser(user.id, {
@@ -85,7 +90,12 @@ export const createUserAction = actionClient.schema(ZCreateUserAction).action(as
},
});
await sendInviteAcceptedEmail(invite.creator.name ?? "", user.name, invite.creator.email, user.locale);
await sendInviteAcceptedEmail(
invite.creator.name ?? "",
user.name,
invite.creator.email,
invite.creator.locale
);
await deleteInvite(invite.id);
}
// Handle organization assignment

View File

@@ -0,0 +1,78 @@
import { inviteCache } from "@/lib/cache/invite";
import { InviteWithCreator } from "@/modules/auth/signup/types/invites";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
export const deleteInvite = async (inviteId: string): Promise<boolean> => {
try {
const invite = await prisma.invite.delete({
where: {
id: inviteId,
},
select: {
id: true,
organizationId: true,
},
});
if (!invite) {
throw new ResourceNotFoundError("Invite", inviteId);
}
inviteCache.revalidate({
id: invite.id,
organizationId: invite.organizationId,
});
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getInvite = reactCache(
async (inviteId: string): Promise<InviteWithCreator | null> =>
cache(
async () => {
try {
const invite = await prisma.invite.findUnique({
where: {
id: inviteId,
},
select: {
id: true,
organizationId: true,
role: true,
teamIds: true,
creator: {
select: {
name: true,
email: true,
locale: true,
},
},
},
});
return invite;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`signup-getInvite-${inviteId}`],
{
tags: [inviteCache.tag.byId(inviteId)],
}
)()
);

View File

@@ -0,0 +1,65 @@
import "server-only";
import { teamCache } from "@/lib/cache/team";
import { CreateMembershipInvite } from "@/modules/auth/signup/types/invites";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { projectCache } from "@formbricks/lib/project/cache";
import { DatabaseError } from "@formbricks/types/errors";
export const createTeamMembership = async (invite: CreateMembershipInvite, userId: string): Promise<void> => {
const teamIds = invite.teamIds || [];
const userMembershipRole = invite.role;
const { isOwner, isManager } = getAccessFlags(userMembershipRole);
const validTeamIds: string[] = [];
const validProjectIds: string[] = [];
const isOwnerOrManager = isOwner || isManager;
try {
for (const teamId of teamIds) {
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
select: {
projectTeams: {
select: {
projectId: true,
},
},
},
});
if (team) {
await prisma.teamUser.create({
data: {
teamId,
userId,
role: isOwnerOrManager ? "admin" : "contributor",
},
});
validTeamIds.push(teamId);
validProjectIds.push(...team.projectTeams.map((pt) => pt.projectId));
}
}
for (const projectId of validProjectIds) {
teamCache.revalidate({ id: projectId });
}
for (const teamId of validTeamIds) {
teamCache.revalidate({ id: teamId });
}
teamCache.revalidate({ userId, organizationId: invite.organizationId });
projectCache.revalidate({ userId, organizationId: invite.organizationId });
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -0,0 +1,7 @@
import { Invite, User } from "@prisma/client";
export interface InviteWithCreator extends Pick<Invite, "id" | "organizationId" | "role" | "teamIds"> {
creator: Pick<User, "name" | "email" | "locale">;
}
export interface CreateMembershipInvite extends Pick<Invite, "organizationId" | "role" | "teamIds"> {}

View File

@@ -3,14 +3,14 @@
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { updateInvite } from "@/modules/ee/role-management/lib/invite";
import { updateMembership } from "@/modules/ee/role-management/lib/membership";
import { ZInviteUpdateInput } from "@/modules/ee/role-management/types/invites";
import { z } from "zod";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { updateInvite } from "@formbricks/lib/invite/service";
import { getOrganization } from "@formbricks/lib/organization/service";
import { ZId, ZUuid } from "@formbricks/types/common";
import { OperationNotAllowedError, ValidationError } from "@formbricks/types/errors";
import { ZInviteUpdateInput } from "@formbricks/types/invites";
import { ZMembershipUpdateInput } from "@formbricks/types/memberships";
export const checkRoleManagementPermission = async (organizationId: string) => {

View File

@@ -0,0 +1,31 @@
import { inviteCache } from "@/lib/cache/invite";
import { type TInviteUpdateInput } from "@/modules/ee/role-management/types/invites";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { ResourceNotFoundError } from "@formbricks/types/errors";
export const updateInvite = async (inviteId: string, data: TInviteUpdateInput): Promise<boolean> => {
try {
const invite = await prisma.invite.update({
where: { id: inviteId },
data,
});
if (invite === null) {
throw new ResourceNotFoundError("Invite", inviteId);
}
inviteCache.revalidate({
id: invite.id,
organizationId: invite.organizationId,
});
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") {
throw new ResourceNotFoundError("Invite", inviteId);
} else {
throw error; // Re-throw any other errors
}
}
};

View File

@@ -0,0 +1,8 @@
import { z } from "zod";
import { ZInvite } from "@formbricks/database/zod/invites";
export const ZInviteUpdateInput = ZInvite.pick({
role: true,
});
export type TInviteUpdateInput = z.infer<typeof ZInviteUpdateInput>;

View File

@@ -1,3 +1,4 @@
import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
import { getUserByEmail, updateUser } from "@/modules/auth/lib/user";
import { createUser } from "@/modules/auth/lib/user";
import type { IdentityProvider } from "@prisma/client";
@@ -78,6 +79,9 @@ export const handleSSOCallback = async ({ user, account }: { user: TUser; accoun
locale: await findMatchingLocale(),
});
// send new user to brevo
createBrevoCustomer({ id: user.id, email: user.email });
// Default organization assignment if env variable is set
if (DEFAULT_ORGANIZATION_ID && DEFAULT_ORGANIZATION_ID.length > 0) {
// check if organization exists

View File

@@ -90,7 +90,6 @@ export const sendTestEmailAction = authenticatedActionClient
await sendEmailCustomizationPreviewEmail(
ctx.user.email,
"Formbricks Email Customization Preview",
ctx.user.name,
ctx.user.locale,
organization?.whitelabel?.logoUrl || ""

View File

@@ -1,10 +1,15 @@
import { translateEmailText } from "@/modules/email/lib/utils";
import { Text } from "@react-email/components";
export function EmailFooter(): React.JSX.Element {
interface EmailFooterProps {
locale: string;
}
export function EmailFooter({ locale }: EmailFooterProps): React.JSX.Element {
return (
<Text>
Have a great day!
<br /> The Formbricks Team
{translateEmailText("email_footer_text_1", locale)}
<br /> {translateEmailText("email_footer_text_2", locale)}
</Text>
);
}

View File

@@ -1,3 +1,4 @@
import { translateEmailText } from "@/modules/email/lib/utils";
import { Body, Container, Html, Img, Link, Section, Tailwind, Text } from "@react-email/components";
import { IMPRINT_ADDRESS, IMPRINT_URL, PRIVACY_URL } from "@formbricks/lib/constants";
@@ -8,9 +9,10 @@ const logoLink = "https://formbricks.com?utm_source=email_header&utm_medium=emai
interface EmailTemplateProps {
children: React.ReactNode;
logoUrl?: string;
locale: string;
}
export function EmailTemplate({ children, logoUrl }: EmailTemplateProps): React.JSX.Element {
export function EmailTemplate({ children, logoUrl, locale }: EmailTemplateProps): React.JSX.Element {
const isDefaultLogo = !logoUrl || logoUrl === fbLogoUrl;
return (
@@ -35,21 +37,22 @@ export function EmailTemplate({ children, logoUrl }: EmailTemplateProps): React.
</Container>
<Section className="mt-4 text-center text-sm">
<Text className="m-0 font-normal text-slate-500">This email was sent via Formbricks.</Text>
<Text className="m-0 font-normal text-slate-500">
{translateEmailText("email_template_text_1", locale)}
</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{" "}
{translateEmailText("imprint", locale)}
</Link>
)}
{IMPRINT_URL && PRIVACY_URL && "•"}
{PRIVACY_URL && (
<Link href={PRIVACY_URL} target="_blank" rel="noopener noreferrer" className="text-slate-500">
{" "}
Privacy Policy
{translateEmailText("privacy_policy", locale)}
</Link>
)}
</Text>

View File

@@ -18,28 +18,34 @@ import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { isLight, mixColor } from "@formbricks/lib/utils/colors";
import { type TSurvey, TSurveyQuestionTypeEnum, type TSurveyStyling } from "@formbricks/types/surveys/types";
import { getNPSOptionColor, getRatingNumberOptionColor } from "../lib/utils";
import { getNPSOptionColor, getRatingNumberOptionColor, translateEmailText } from "../lib/utils";
interface PreviewEmailTemplateProps {
survey: TSurvey;
surveyUrl: string;
styling: TSurveyStyling;
locale: string;
}
export const getPreviewEmailTemplateHtml = async (
survey: TSurvey,
surveyUrl: string,
styling: TSurveyStyling
styling: TSurveyStyling,
locale: string
): Promise<string> => {
return render(<PreviewEmailTemplate styling={styling} survey={survey} surveyUrl={surveyUrl} />, {
pretty: true,
});
return render(
<PreviewEmailTemplate styling={styling} survey={survey} surveyUrl={surveyUrl} locale={locale} />,
{
pretty: true,
}
);
};
export function PreviewEmailTemplate({
survey,
surveyUrl,
styling,
locale,
}: PreviewEmailTemplateProps): React.JSX.Element {
const url = `${surveyUrl}?preview=true`;
const urlWithPrefilling = `${surveyUrl}?preview=true&skipPrefilled=true&`;
@@ -87,7 +93,7 @@ export function PreviewEmailTemplate({
<EmailButton
className="rounded-custom inline-flex cursor-pointer appearance-none px-6 py-3 text-sm font-medium text-black"
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}>
Reject
{translateEmailText("reject", locale)}
</EmailButton>
)}
<EmailButton
@@ -96,7 +102,7 @@ export function PreviewEmailTemplate({
isLight(brandColor) ? "text-black" : "text-white"
)}
href={`${urlWithPrefilling}${firstQuestion.id}=accepted`}>
Accept
{translateEmailText("accept", locale)}
</EmailButton>
</Container>
<EmailFooter />
@@ -365,17 +371,17 @@ export function PreviewEmailTemplate({
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
<Container>
<Text className="text-question-color m-0 mb-2 block p-0 text-sm font-normal leading-6">
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
{getLocalizedValue(firstQuestion.headline, defaultLanguageCode)}
</Text>
<Text className="text-question-color m-0 mb-2 block p-0 text-sm font-normal leading-6">
You have been invited to schedule a meet via cal.com.
{getLocalizedValue(firstQuestion.subheader, defaultLanguageCode)}
</Text>
<EmailButton
className={cn(
"bg-brand-color rounded-custom mx-auto block w-max cursor-pointer appearance-none px-6 py-3 text-sm font-medium",
isLight(brandColor) ? "text-black" : "text-white"
)}>
Schedule your meeting
{translateEmailText("schedule_your_meeting", defaultLanguageCode)}
</EmailButton>
</Container>
<EmailFooter />
@@ -392,7 +398,9 @@ export function PreviewEmailTemplate({
</Text>
<Section className="border-input-border-color bg-input-color rounded-custom mt-4 flex h-12 w-full items-center justify-center border border-solid">
<CalendarDaysIcon className="text-question-color inline h-4 w-4" />
<Text className="text-question-color inline text-sm font-medium">Select a date</Text>
<Text className="text-question-color inline text-sm font-medium">
{translateEmailText("select_a_date", defaultLanguageCode)}
</Text>
</Section>
<EmailFooter />
</EmailTemplateWrapper>
@@ -478,7 +486,9 @@ export function PreviewEmailTemplate({
<Section className="border-input-border-color rounded-custom mt-4 flex h-24 w-full items-center justify-center border border-dashed bg-slate-50">
<Container className="mx-auto flex items-center text-center">
<UploadIcon className="mt-6 inline h-5 w-5 text-slate-400" />
<Text className="text-slate-400">Click or drag to upload files.</Text>
<Text className="text-slate-400">
{translateEmailText("click_or_drag_to_upload_files", defaultLanguageCode)}
</Text>
</Container>
</Section>
<EmailFooter />

View File

@@ -12,7 +12,7 @@ interface ForgotPasswordEmailProps {
export function ForgotPasswordEmail({ verifyLink, locale }: ForgotPasswordEmailProps): React.JSX.Element {
return (
<EmailTemplate>
<EmailTemplate locale={locale}>
<Container>
<Heading>{translateEmailText("forgot_password_email_heading", locale)}</Heading>
<Text>{translateEmailText("forgot_password_email_text", locale)}</Text>
@@ -24,7 +24,7 @@ export function ForgotPasswordEmail({ verifyLink, locale }: ForgotPasswordEmailP
{translateEmailText("forgot_password_email_link_valid_for_24_hours", locale)}
</Text>
<Text className="mb-0">{translateEmailText("forgot_password_email_did_not_request", locale)}</Text>
<EmailFooter />
<EmailFooter locale={locale} />
</Container>
</EmailTemplate>
);

View File

@@ -10,11 +10,11 @@ interface PasswordResetNotifyEmailProps {
export function PasswordResetNotifyEmail({ locale }: PasswordResetNotifyEmailProps): React.JSX.Element {
return (
<EmailTemplate>
<EmailTemplate locale={locale}>
<Container>
<Heading>{translateEmailText("password_changed_email_heading", locale)}</Heading>
<Text>{translateEmailText("password_changed_email_text", locale)}</Text>
<EmailFooter />
<EmailFooter locale={locale} />
</Container>
</EmailTemplate>
);

View File

@@ -17,7 +17,7 @@ export function VerificationEmail({
locale,
}: VerificationEmailProps): React.JSX.Element {
return (
<EmailTemplate>
<EmailTemplate locale={locale}>
<Container>
<Heading>{translateEmailText("verification_email_heading", locale)}</Heading>
<Text>{translateEmailText("verification_email_text", locale)}</Text>
@@ -38,7 +38,7 @@ export function VerificationEmail({
{translateEmailText("verification_email_request_new_verification", locale)}
</Link>
</Text>
<EmailFooter />
<EmailFooter locale={locale} />
</Container>
</EmailTemplate>
);

View File

@@ -15,7 +15,7 @@ export function EmailCustomizationPreviewEmail({
logoUrl,
}: EmailCustomizationPreviewEmailProps): React.JSX.Element {
return (
<EmailTemplate logoUrl={logoUrl}>
<EmailTemplate logoUrl={logoUrl} locale={locale}>
<Container>
<Heading>
{translateEmailText("email_customization_preview_email_heading", locale, {

View File

@@ -16,7 +16,7 @@ export function InviteAcceptedEmail({
locale,
}: InviteAcceptedEmailProps): React.JSX.Element {
return (
<EmailTemplate>
<EmailTemplate locale={locale}>
<Container>
<Text>
{translateEmailText("invite_accepted_email_heading", locale)} {inviterName},
@@ -25,7 +25,7 @@ export function InviteAcceptedEmail({
{translateEmailText("invite_accepted_email_text_par1", locale)} {inviteeName}{" "}
{translateEmailText("invite_accepted_email_text_par2", locale)}
</Text>
<EmailFooter />
<EmailFooter locale={locale} />
</Container>
</EmailTemplate>
);

View File

@@ -19,7 +19,7 @@ export function InviteEmail({
locale,
}: InviteEmailProps): React.JSX.Element {
return (
<EmailTemplate>
<EmailTemplate locale={locale}>
<Container>
<Text>
{translateEmailText("invite_email_heading", locale)} {inviteeName},
@@ -28,8 +28,8 @@ export function InviteEmail({
{translateEmailText("invite_email_text_par1", locale)} {inviterName}{" "}
{translateEmailText("invite_email_text_par2", locale)}
</Text>
<EmailButton href={verifyLink} label="Join organization" />
<EmailFooter />
<EmailButton href={verifyLink} label={translateEmailText("invite_email_button_label", locale)} />
<EmailFooter locale={locale} />
</Container>
</EmailTemplate>
);

View File

@@ -20,7 +20,7 @@ export function OnboardingInviteEmail({
inviteeName,
}: OnboardingInviteEmailProps): React.JSX.Element {
return (
<EmailTemplate>
<EmailTemplate locale={locale}>
<Container>
<Heading>
{translateEmailText("onboarding_invite_email_heading", locale)} {inviteeName} 👋
@@ -34,8 +34,11 @@ export function OnboardingInviteEmail({
<li>{translateEmailText("onboarding_invite_email_connect_formbricks", locale)}</li>
<li>{translateEmailText("onboarding_invite_email_done", locale)} </li>
</ol>
<EmailButton href={verifyLink} label={`Join ${inviterName}'s organization`} />
<EmailFooter />
<EmailButton
href={verifyLink}
label={translateEmailText("onboarding_invite_email_button_label", locale, { inviterName })}
/>
<EmailFooter locale={locale} />
</Container>
</EmailTemplate>
);

View File

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

View File

@@ -19,7 +19,7 @@ export function LinkSurveyEmail({
logoUrl,
}: LinkSurveyEmailProps): React.JSX.Element {
return (
<EmailTemplate logoUrl={logoUrl}>
<EmailTemplate logoUrl={logoUrl} locale={locale}>
<Container>
<Heading>{translateEmailText("verification_email_hey", locale)}</Heading>
<Text>{translateEmailText("verification_email_thanks", locale)}</Text>
@@ -28,7 +28,7 @@ export function LinkSurveyEmail({
<Text className="text-xs text-slate-400">
{translateEmailText("verification_email_survey_name", locale)}: {surveyName}
</Text>
<EmailFooter />
<EmailFooter locale={locale} />
</Container>
</EmailTemplate>
);

View File

@@ -93,7 +93,7 @@ export function ResponseFinishedEmail({
const questions = getQuestionResponseMapping(survey, response);
return (
<EmailTemplate>
<EmailTemplate locale={locale}>
<Container>
<Row>
<Column>

View File

@@ -24,7 +24,7 @@ export function WeeklySummaryNotificationEmail({
locale,
}: WeeklySummaryNotificationEmailProps): React.JSX.Element {
return (
<EmailTemplate>
<EmailTemplate locale={locale}>
<NotificationHeader
endDate={endDate}
endYear={endYear}

View File

@@ -46,8 +46,10 @@ interface SendEmailDataProps {
html: string;
}
const getEmailSubject = (projectName: string): string => {
return `${projectName} User Insights - Last Week by Formbricks`;
const getEmailSubject = (projectName: string, locale: string): string => {
return translateEmailText("weekly_summary_email_subject", locale, {
projectName,
});
};
export const sendEmail = async (emailData: SendEmailDataProps): Promise<boolean> => {
@@ -117,7 +119,7 @@ export const sendForgotPasswordEmail = async (user: {
const html = await render(ForgotPasswordEmail({ verifyLink, locale: user.locale }));
return await sendEmail({
to: user.email,
subject: "Reset your Formbricks password",
subject: translateEmailText("forgot_password_email_subject", user.locale),
html,
});
};
@@ -129,7 +131,7 @@ export const sendPasswordResetNotifyEmail = async (user: {
const html = await render(PasswordResetNotifyEmail({ locale: user.locale }));
return await sendEmail({
to: user.email,
subject: "Your Formbricks password has been changed",
subject: translateEmailText("password_reset_notify_email_subject", user.locale),
html,
});
};
@@ -155,14 +157,16 @@ export const sendInviteMemberEmail = async (
);
return await sendEmail({
to: email,
subject: `${inviterName} needs a hand setting up Formbricks. Can you help out?`,
subject: translateEmailText("onboarding_invite_email_subject", locale, {
inviterName,
}),
html,
});
} else {
const html = await render(InviteEmail({ inviteeName, inviterName, verifyLink, locale }));
return await sendEmail({
to: email,
subject: `You're invited to collaborate on Formbricks!`,
subject: translateEmailText("invite_member_email_subject", locale),
html,
});
}
@@ -177,7 +181,7 @@ export const sendInviteAcceptedEmail = async (
const html = await render(InviteAcceptedEmail({ inviteeName, inviterName, locale }));
await sendEmail({
to: email,
subject: `You've got a new organization member!`,
subject: translateEmailText("invite_accepted_email_subject", locale),
html,
});
};
@@ -212,8 +216,13 @@ export const sendResponseFinishedEmail = async (
await sendEmail({
to: email,
subject: personEmail
? `${personEmail} just completed your ${survey.name} survey ✅`
: `A response for ${survey.name} was completed ✅`,
? translateEmailText("response_finished_email_subject_with_email", locale, {
personEmail,
surveyName: survey.name,
})
: translateEmailText("response_finished_email_subject", locale, {
surveyName: survey.name,
}),
replyTo: personEmail?.toString() ?? MAIL_FROM,
html,
});
@@ -221,7 +230,6 @@ export const sendResponseFinishedEmail = async (
export const sendEmbedSurveyPreviewEmail = async (
to: string,
subject: string,
innerHtml: string,
environmentId: string,
locale: string,
@@ -230,14 +238,13 @@ export const sendEmbedSurveyPreviewEmail = async (
const html = await render(EmbedSurveyPreviewEmail({ html: innerHtml, environmentId, locale, logoUrl }));
return await sendEmail({
to,
subject,
subject: translateEmailText("embed_survey_preview_email_subject", locale),
html,
});
};
export const sendEmailCustomizationPreviewEmail = async (
to: string,
subject: string,
userName: string,
locale: string,
logoUrl?: string
@@ -246,7 +253,7 @@ export const sendEmailCustomizationPreviewEmail = async (
return await sendEmail({
to,
subject,
subject: translateEmailText("email_customization_preview_email_subject", locale),
html: emailHtmlBody,
});
};
@@ -270,7 +277,7 @@ export const sendLinkSurveyToVerifiedEmail = async (data: TLinkSurveyEmailData):
const html = await render(LinkSurveyEmail({ surveyName, surveyLink, locale, logoUrl }));
return await sendEmail({
to: data.email,
subject: "Your survey is ready to be filled out.",
subject: translateEmailText("verified_link_survey_email_subject", locale),
html,
});
};
@@ -302,7 +309,7 @@ export const sendWeeklySummaryNotificationEmail = async (
);
await sendEmail({
to: email,
subject: getEmailSubject(notificationData.projectName),
subject: getEmailSubject(notificationData.projectName, locale),
html,
});
};
@@ -334,7 +341,7 @@ export const sendNoLiveSurveyNotificationEmail = async (
);
await sendEmail({
to: email,
subject: getEmailSubject(notificationData.projectName),
subject: getEmailSubject(notificationData.projectName, locale),
html,
});
};

View File

@@ -14,13 +14,13 @@ import {
import { OrganizationRole } from "@prisma/client";
import { z } from "zod";
import { INVITE_DISABLED, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { deleteInvite, getInvite, inviteUser, resendInvite } from "@formbricks/lib/invite/service";
import { createInviteToken } from "@formbricks/lib/jwt";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { ZId, ZUuid } from "@formbricks/types/common";
import { AuthenticationError, OperationNotAllowedError, ValidationError } from "@formbricks/types/errors";
import { ZOrganizationRole } from "@formbricks/types/memberships";
import { deleteInvite, getInvite, inviteUser, resendInvite } from "./lib/invite";
const ZDeleteInviteAction = z.object({
inviteId: ZUuid,
@@ -206,7 +206,7 @@ export const inviteUserAction = authenticatedActionClient
await checkRoleManagementPermission(parsedInput.organizationId);
}
const invite = await inviteUser({
const inviteId = await inviteUser({
organizationId: parsedInput.organizationId,
invitee: {
email: parsedInput.email,
@@ -217,9 +217,9 @@ export const inviteUserAction = authenticatedActionClient
currentUserId: ctx.user.id,
});
if (invite) {
if (inviteId) {
await sendInviteMemberEmail(
invite.id,
inviteId,
parsedInput.email,
ctx.user.name ?? "",
parsedInput.name ?? "",
@@ -229,7 +229,7 @@ export const inviteUserAction = authenticatedActionClient
);
}
return invite;
return inviteId;
});
const ZLeaveOrganizationAction = z.object({

View File

@@ -1,8 +1,8 @@
import { MembersInfo } from "@/modules/organization/settings/teams/components/edit-memberships/members-info";
import { getInvitesByOrganizationId } from "@/modules/organization/settings/teams/lib/invite";
import { getMembershipByOrganizationId } from "@/modules/organization/settings/teams/lib/membership";
import { getTranslations } from "next-intl/server";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getInvitesByOrganizationId } from "@formbricks/lib/invite/service";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";

View File

@@ -8,6 +8,7 @@ import {
resendInviteAction,
} from "@/modules/organization/settings/teams/actions";
import { ShareInviteModal } from "@/modules/organization/settings/teams/components/invite-member/share-invite-modal";
import { TInvite } from "@/modules/organization/settings/teams/types/invites";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
@@ -16,7 +17,6 @@ import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import React, { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { TInvite } from "@formbricks/types/invites";
import { TMember } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";

View File

@@ -1,12 +1,12 @@
import { isInviteExpired } from "@/app/lib/utils";
import { EditMembershipRole } from "@/modules/ee/role-management/components/edit-membership-role";
import { MemberActions } from "@/modules/organization/settings/teams/components/edit-memberships/member-actions";
import { isInviteExpired } from "@/modules/organization/settings/teams/lib/utilts";
import { TInvite } from "@/modules/organization/settings/teams/types/invites";
import { Badge } from "@/modules/ui/components/badge";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useTranslations } from "next-intl";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getFormattedDateTimeString } from "@formbricks/lib/utils/datetime";
import { TInvite } from "@formbricks/types/invites";
import { TMember, TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";

View File

@@ -4,6 +4,7 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
import { inviteUserAction, leaveOrganizationAction } from "@/modules/organization/settings/teams/actions";
import { InviteMemberModal } from "@/modules/organization/settings/teams/components/invite-member/invite-member-modal";
import { TInvitee } from "@/modules/organization/settings/teams/types/invites";
import { Button } from "@/modules/ui/components/button";
import { CustomDialog } from "@/modules/ui/components/custom-dialog";
import { XIcon } from "lucide-react";
@@ -11,7 +12,7 @@ import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { TInvitee } from "@formbricks/types/invites";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@formbricks/lib/localStorage";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
@@ -53,6 +54,7 @@ export const OrganizationActions = ({
toast.success(t("environments.settings.general.member_deleted_successfully"));
router.refresh();
setLoading(false);
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
router.push("/");
} catch (err) {
toast.error(`Error: ${err.message}`);
@@ -84,7 +86,7 @@ export const OrganizationActions = ({
email: email.toLowerCase(),
name,
role,
teamIds: teamIds,
teamIds,
});
return {
email,

View File

@@ -1,5 +1,6 @@
"use client";
import { ZInvitees } from "@/modules/organization/settings/teams/types/invites";
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";
@@ -9,7 +10,6 @@ import Link from "next/link";
import Papa, { type ParseResult } from "papaparse";
import { useState } from "react";
import toast from "react-hot-toast";
import { ZInvitees } from "@formbricks/types/invites";
import { TOrganizationRole } from "@formbricks/types/memberships";
interface BulkInviteTabProps {

View File

@@ -1,173 +1,21 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { inviteCache } from "@/lib/cache/invite";
import { Invite, Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
import { cache } from "@formbricks/lib/cache";
import { ITEMS_PER_PAGE } from "@formbricks/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { validateInputs } from "@formbricks/lib/utils/validate";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
ValidationError,
} from "@formbricks/types/errors";
import {
TInvite,
TInviteUpdateInput,
TInvitee,
ZInviteUpdateInput,
ZInvitee,
} from "@formbricks/types/invites";
import { cache } from "../cache";
import { ITEMS_PER_PAGE } from "../constants";
import { getMembershipByUserIdOrganizationId } from "../membership/service";
import { validateInputs } from "../utils/validate";
import { inviteCache } from "./cache";
const inviteSelect: Prisma.InviteSelect = {
id: true,
email: true,
name: true,
organizationId: true,
creatorId: true,
acceptorId: true,
createdAt: true,
expiresAt: true,
role: true,
teamIds: true,
};
interface InviteWithCreator extends TInvite {
creator: {
name: string | null;
email: string;
};
}
export const getInvitesByOrganizationId = reactCache(
async (organizationId: string, page?: number): Promise<TInvite[]> =>
cache(
async () => {
validateInputs([organizationId, ZString], [page, ZOptionalNumber]);
try {
const invites = await prisma.invite.findMany({
where: { organizationId },
select: inviteSelect,
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
return invites;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getInvitesByOrganizationId-${organizationId}-${page}`],
{
tags: [inviteCache.tag.byOrganizationId(organizationId)],
}
)()
);
export const updateInvite = async (inviteId: string, data: TInviteUpdateInput): Promise<TInvite | null> => {
validateInputs([inviteId, ZString], [data, ZInviteUpdateInput]);
try {
const invite = await prisma.invite.update({
where: { id: inviteId },
data,
select: inviteSelect,
});
if (invite === null) {
throw new ResourceNotFoundError("Invite", inviteId);
}
inviteCache.revalidate({
id: invite.id,
organizationId: invite.organizationId,
});
return invite;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") {
throw new ResourceNotFoundError("Invite", inviteId);
} else {
throw error; // Re-throw any other errors
}
}
};
export const deleteInvite = async (inviteId: string): Promise<TInvite> => {
validateInputs([inviteId, ZString]);
try {
const invite = await prisma.invite.delete({
where: {
id: inviteId,
},
});
if (invite === null) {
throw new ResourceNotFoundError("Invite", inviteId);
}
inviteCache.revalidate({
id: invite.id,
organizationId: invite.organizationId,
});
return invite;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getInvite = reactCache(
async (inviteId: string): Promise<InviteWithCreator | null> =>
cache(
async () => {
validateInputs([inviteId, ZString]);
try {
const invite = await prisma.invite.findUnique({
where: {
id: inviteId,
},
include: {
creator: {
select: {
name: true,
email: true,
},
},
},
});
return invite;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getInvite-${inviteId}`],
{
tags: [inviteCache.tag.byId(inviteId)],
}
)()
);
export const resendInvite = async (inviteId: string): Promise<TInvite> => {
validateInputs([inviteId, ZString]);
import { type InviteWithCreator, type TInvite, type TInvitee } from "../types/invites";
export const resendInvite = async (inviteId: string): Promise<Pick<Invite, "email" | "name">> => {
try {
const invite = await prisma.invite.findUnique({
where: {
@@ -191,6 +39,12 @@ export const resendInvite = async (inviteId: string): Promise<TInvite> => {
data: {
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
},
select: {
id: true,
email: true,
name: true,
organizationId: true,
},
});
inviteCache.revalidate({
@@ -198,7 +52,10 @@ export const resendInvite = async (inviteId: string): Promise<TInvite> => {
organizationId: updatedInvite.organizationId,
});
return updatedInvite;
return {
email: updatedInvite.email,
name: updatedInvite.name,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -208,6 +65,43 @@ export const resendInvite = async (inviteId: string): Promise<TInvite> => {
}
};
export const getInvitesByOrganizationId = reactCache(
async (organizationId: string, page?: number): Promise<TInvite[]> =>
cache(
async () => {
validateInputs([organizationId, z.string()], [page, z.number().optional()]);
try {
const invites = await prisma.invite.findMany({
where: { organizationId },
select: {
expiresAt: true,
role: true,
email: true,
name: true,
id: true,
createdAt: true,
},
take: page ? ITEMS_PER_PAGE : undefined,
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
});
return invites;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getInvitesByOrganizationId-${organizationId}-${page}`],
{
tags: [inviteCache.tag.byOrganizationId(organizationId)],
}
)()
);
export const inviteUser = async ({
invitee,
organizationId,
@@ -216,9 +110,7 @@ export const inviteUser = async ({
organizationId: string;
invitee: TInvitee;
currentUserId: string;
}): Promise<TInvite> => {
validateInputs([organizationId, ZString], [invitee, ZInvitee]);
}): Promise<string> => {
try {
const { name, email, role, teamIds } = invitee;
@@ -276,7 +168,7 @@ export const inviteUser = async ({
organizationId: invite.organizationId,
});
return invite;
return invite.id;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
@@ -285,3 +177,69 @@ export const inviteUser = async ({
throw error;
}
};
export const deleteInvite = async (inviteId: string): Promise<boolean> => {
try {
const invite = await prisma.invite.delete({
where: {
id: inviteId,
},
select: {
id: true,
organizationId: true,
},
});
if (!invite) {
throw new ResourceNotFoundError("Invite", inviteId);
}
inviteCache.revalidate({
id: invite.id,
organizationId: invite.organizationId,
});
return true;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getInvite = reactCache(
async (inviteId: string): Promise<InviteWithCreator | null> =>
cache(
async () => {
try {
const invite = await prisma.invite.findUnique({
where: {
id: inviteId,
},
select: {
email: true,
creator: {
select: {
name: true,
},
},
},
});
return invite;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`teams-getInvite-${inviteId}`],
{
tags: [inviteCache.tag.byId(inviteId)],
}
)()
);

View File

@@ -1,4 +1,4 @@
import { TInvite } from "@formbricks/types/invites";
import { TInvite } from "@/modules/organization/settings/teams/types/invites";
export const isInviteExpired = (invite: TInvite) => {
const now = new Date();

View File

@@ -0,0 +1,25 @@
import { Invite } from "@prisma/client";
import { z } from "zod";
import { ZInvite } from "@formbricks/database/zod/invites";
import { ZUserName } from "@formbricks/types/user";
export interface TInvite
extends Omit<Invite, "deprecatedRole" | "organizationId" | "creatorId" | "acceptorId" | "teamIds"> {}
export interface InviteWithCreator extends Pick<Invite, "email"> {
creator: {
name: string;
};
}
export const ZInvitee = ZInvite.pick({
email: true,
role: true,
teamIds: true,
}).extend({
name: ZUserName,
});
export type TInvitee = z.infer<typeof ZInvitee>;
export const ZInvitees = z.array(ZInvitee);

View File

@@ -16,13 +16,15 @@ import { TProject } from "@formbricks/types/project";
interface DeleteProjectRenderProps {
isDeleteDisabled: boolean;
isOwnerOrManager: boolean;
project: TProject;
currentProject: TProject;
organizationProjects: TProject[];
}
export const DeleteProjectRender = ({
isDeleteDisabled,
isOwnerOrManager,
project,
currentProject,
organizationProjects,
}: DeleteProjectRenderProps) => {
const t = useTranslations();
const router = useRouter();
@@ -30,9 +32,20 @@ export const DeleteProjectRender = ({
const [isDeleting, setIsDeleting] = useState(false);
const handleDeleteProject = async () => {
setIsDeleting(true);
const deleteProjectResponse = await deleteProjectAction({ projectId: project.id });
const deleteProjectResponse = await deleteProjectAction({ projectId: currentProject.id });
if (deleteProjectResponse?.data) {
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
if (organizationProjects.length === 1) {
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
} else if (organizationProjects.length > 1) {
// prevents changing of organization when deleting project
const remainingProjects = organizationProjects.filter((project) => project.id !== currentProject.id);
const productionEnvironment = remainingProjects[0].environments.find(
(environment) => environment.type === "production"
);
if (productionEnvironment) {
localStorage.setItem(FORMBRICKS_ENVIRONMENT_ID_LS, productionEnvironment.id);
}
}
toast.success(t("environments.project.general.project_deleted_successfully"));
router.push("/");
} else {
@@ -51,7 +64,7 @@ export const DeleteProjectRender = ({
{t(
"environments.project.general.delete_project_name_includes_surveys_responses_people_and_more",
{
projectName: truncate(project.name, 30),
projectName: truncate(currentProject.name, 30),
}
)}{" "}
<strong>{t("environments.project.general.this_action_cannot_be_undone")}</strong>
@@ -81,7 +94,7 @@ export const DeleteProjectRender = ({
setOpen={setIsDeleteDialogOpen}
onDelete={handleDeleteProject}
text={t("environments.project.general.delete_project_confirmation", {
projectName: truncate(project.name, 30),
projectName: truncate(currentProject.name, 30),
})}
isDeleting={isDeleting}
/>

View File

@@ -8,11 +8,17 @@ import { TProject } from "@formbricks/types/project";
interface DeleteProjectProps {
environmentId: string;
project: TProject;
currentProject: TProject;
organizationProjects: TProject[];
isOwnerOrManager: boolean;
}
export const DeleteProject = async ({ environmentId, project, isOwnerOrManager }: DeleteProjectProps) => {
export const DeleteProject = async ({
environmentId,
currentProject,
organizationProjects,
isOwnerOrManager,
}: DeleteProjectProps) => {
const t = await getTranslations();
const session = await getServerSession(authOptions);
if (!session) {
@@ -31,7 +37,8 @@ export const DeleteProject = async ({ environmentId, project, isOwnerOrManager }
<DeleteProjectRender
isDeleteDisabled={isDeleteDisabled}
isOwnerOrManager={isOwnerOrManager}
project={project}
currentProject={currentProject}
organizationProjects={organizationProjects}
/>
);
};

View File

@@ -17,7 +17,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 { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getProjectByEnvironmentId, getProjects } 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";
@@ -41,6 +41,8 @@ export const GeneralSettingsPage = async (props: { params: Promise<{ environment
throw new Error(t("common.organization_not_found"));
}
const organizationProjects = await getProjects(organization.id);
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const projectPermission = await getProjectPermissionByUserId(session.user.id, project.id);
@@ -79,7 +81,8 @@ export const GeneralSettingsPage = async (props: { params: Promise<{ environment
description={t("environments.project.general.delete_project_settings_description")}>
<DeleteProject
environmentId={params.environmentId}
project={project}
currentProject={project}
organizationProjects={organizationProjects}
isOwnerOrManager={isOwnerOrManager}
/>
</SettingsCard>

View File

@@ -0,0 +1,33 @@
import { Button } from "@/modules/ui/components/button";
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import Link from "next/link";
export const metadata: Metadata = {
title: "Intro",
description: "Open-source Experience Management. Free & open source.",
};
const renderRichText = async (text: string) => {
const t = await getTranslations();
return <p>{t.rich(text, { b: (chunks) => <b>{chunks}</b> })}</p>;
};
export const IntroPage = async () => {
const t = await getTranslations();
return (
<div className="flex flex-col items-center">
<h2 className="mb-6 text-xl font-medium">{t("setup.intro.welcome_to_formbricks")}</h2>
<div className="mx-auto max-w-sm space-y-4 text-sm leading-6 text-slate-600">
{renderRichText("setup.intro.paragraph_1")}
{renderRichText("setup.intro.paragraph_2")}
{renderRichText("setup.intro.paragraph_3")}
</div>
<Button className="mt-6" asChild>
<Link href="/setup/signup">{t("setup.intro.get_started")}</Link>
</Button>
<p className="pt-6 text-xs text-slate-400">{t("setup.intro.made_with_love_in_kiel")}</p>
</div>
);
};

View File

@@ -0,0 +1,14 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getServerSession } from "next-auth";
import { notFound } from "next/navigation";
import { getIsFreshInstance } from "@formbricks/lib/instance/service";
export const FreshInstanceLayout = async ({ children }: { children: React.ReactNode }) => {
const session = await getServerSession(authOptions);
const isFreshInstance = await getIsFreshInstance();
if (session || !isFreshInstance) {
return notFound();
}
return <>{children}</>;
};

View File

@@ -0,0 +1,55 @@
import { SignupForm } from "@/modules/auth/signup/components/signup-form";
import { getIsSSOEnabled } from "@/modules/ee/license-check/lib/utils";
import { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import {
AZURE_OAUTH_ENABLED,
DEFAULT_ORGANIZATION_ID,
DEFAULT_ORGANIZATION_ROLE,
EMAIL_AUTH_ENABLED,
EMAIL_VERIFICATION_DISABLED,
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
IS_TURNSTILE_CONFIGURED,
OIDC_DISPLAY_NAME,
OIDC_OAUTH_ENABLED,
PRIVACY_URL,
TERMS_URL,
WEBAPP_URL,
} from "@formbricks/lib/constants";
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
export const metadata: Metadata = {
title: "Sign up",
description: "Open-source Experience Management. Free & open source.",
};
export const SignupPage = async () => {
const locale = await findMatchingLocale();
const isSSOEnabled = await getIsSSOEnabled();
const t = await getTranslations();
return (
<div className="flex flex-col items-center">
<h2 className="mb-6 text-xl font-medium">{t("setup.signup.create_administrator")}</h2>
<p className="text-sm text-slate-800">{t("setup.signup.this_user_has_all_the_power")}</p>
<hr className="my-6 w-full border-slate-200" />
<SignupForm
webAppUrl={WEBAPP_URL}
termsUrl={TERMS_URL}
privacyUrl={PRIVACY_URL}
emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED}
emailAuthEnabled={EMAIL_AUTH_ENABLED}
googleOAuthEnabled={GOOGLE_OAUTH_ENABLED}
githubOAuthEnabled={GITHUB_OAUTH_ENABLED}
azureOAuthEnabled={AZURE_OAUTH_ENABLED}
oidcOAuthEnabled={OIDC_OAUTH_ENABLED}
oidcDisplayName={OIDC_DISPLAY_NAME}
userLocale={locale}
defaultOrganizationId={DEFAULT_ORGANIZATION_ID}
defaultOrganizationRole={DEFAULT_ORGANIZATION_ROLE}
isSSOEnabled={isSSOEnabled}
isTurnstileConfigured={IS_TURNSTILE_CONFIGURED}
/>
</div>
);
};

View File

@@ -0,0 +1,20 @@
import { FormbricksLogo } from "@/modules/ui/components/formbricks-logo";
import { Toaster } from "react-hot-toast";
export const SetupLayout = ({ children }: { children: React.ReactNode }) => {
return (
<>
<Toaster />
<div className="flex h-full w-full items-center justify-center bg-slate-50">
<div
style={{ scrollbarGutter: "stable both-edges" }}
className="flex max-h-[90vh] w-[40rem] flex-col items-center space-y-4 overflow-auto rounded-lg border bg-white p-12 text-center shadow-md">
<div className="h-20 w-20 rounded-lg bg-slate-900 p-2">
<FormbricksLogo className="h-full w-full" />
</div>
{children}
</div>
</div>
</>
);
};

View File

@@ -3,9 +3,9 @@
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import { sendInviteMemberEmail } from "@/modules/email";
import { inviteUser } from "@/modules/setup/organization/[organizationId]/invite/lib/invite";
import { z } from "zod";
import { INVITE_DISABLED } from "@formbricks/lib/constants";
import { inviteUser } from "@formbricks/lib/invite/service";
import { ZId } from "@formbricks/types/common";
import { AuthenticationError } from "@formbricks/types/errors";
import { ZUserEmail, ZUserName } from "@formbricks/types/user";
@@ -34,19 +34,17 @@ export const inviteOrganizationMemberAction = authenticatedActionClient
],
});
const invite = await inviteUser({
const invitedUserId = await inviteUser({
organizationId: parsedInput.organizationId,
invitee: {
email: parsedInput.email,
name: parsedInput.name,
role: "owner",
teamIds: [],
},
currentUserId: ctx.user.id,
});
await sendInviteMemberEmail(
invite.id,
invitedUserId,
parsedInput.email,
ctx.user.name,
"",
@@ -55,5 +53,5 @@ export const inviteOrganizationMemberAction = authenticatedActionClient
ctx.user.locale
);
return invite;
return invitedUserId;
});

View File

@@ -1,7 +1,11 @@
"use client";
import { inviteOrganizationMemberAction } from "@/app/setup/organization/[organizationId]/invite/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { inviteOrganizationMemberAction } from "@/modules/setup/organization/[organizationId]/invite/actions";
import {
type TInviteMembersFormSchema,
ZInviteMembersFormSchema,
} from "@/modules/setup/organization/[organizationId]/invite/types/invites";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { FormControl, FormError, FormField, FormItem, FormProvider } from "@/modules/ui/components/form";
@@ -13,7 +17,6 @@ import { useRouter } from "next/navigation";
import React, { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { TInviteMembersFormSchema, ZInviteMembersFormSchema } from "@formbricks/types/invites";
interface InviteMembersProps {
IS_SMTP_CONFIGURED: boolean;

View File

@@ -0,0 +1,64 @@
import { inviteCache } from "@/lib/cache/invite";
import { TInvitee } from "@/modules/setup/organization/[organizationId]/invite/types/invites";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
export const inviteUser = async ({
invitee,
organizationId,
currentUserId,
}: {
organizationId: string;
invitee: TInvitee;
currentUserId: string;
}): Promise<string> => {
try {
const { name, email } = invitee;
const existingInvite = await prisma.invite.findFirst({ where: { email, organizationId } });
if (existingInvite) {
throw new InvalidInputError("Invite already exists");
}
const user = await prisma.user.findUnique({ where: { email } });
if (user) {
const member = await getMembershipByUserIdOrganizationId(user.id, organizationId);
if (member) {
throw new InvalidInputError("User is already a member of this organization");
}
}
const expiresIn = 7 * 24 * 60 * 60 * 1000; // 7 days
const expiresAt = new Date(Date.now() + expiresIn);
const invite = await prisma.invite.create({
data: {
email,
name,
organization: { connect: { id: organizationId } },
creator: { connect: { id: currentUserId } },
acceptor: user ? { connect: { id: user.id } } : undefined,
role: "owner",
expiresAt,
},
});
inviteCache.revalidate({
id: invite.id,
organizationId: invite.organizationId,
});
return invite.id;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -0,0 +1,35 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { InviteMembers } from "@/modules/setup/organization/[organizationId]/invite/components/invite-members";
import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation";
import { SMTP_HOST, SMTP_PASSWORD, SMTP_PORT, SMTP_USER } from "@formbricks/lib/constants";
import { verifyUserRoleAccess } from "@formbricks/lib/organization/auth";
import { AuthenticationError } from "@formbricks/types/errors";
export const metadata: Metadata = {
title: "Invite",
description: "Open-source Experience Management. Free & open source.",
};
interface InvitePageProps {
params: Promise<{ organizationId: string }>;
}
export const InvitePage = async (props: InvitePageProps) => {
const params = await props.params;
const t = await getTranslations();
const IS_SMTP_CONFIGURED = Boolean(SMTP_HOST && SMTP_PORT && SMTP_USER && SMTP_PASSWORD);
const session = await getServerSession(authOptions);
if (!session) throw new AuthenticationError(t("common.session_not_found"));
const { hasCreateOrUpdateMembersAccess } = await verifyUserRoleAccess(
params.organizationId,
session.user.id
);
if (!hasCreateOrUpdateMembersAccess) return notFound();
return <InviteMembers IS_SMTP_CONFIGURED={IS_SMTP_CONFIGURED} organizationId={params.organizationId} />;
};

View File

@@ -0,0 +1,24 @@
import { z } from "zod";
import { ZInvite } from "@formbricks/database/zod/invites";
import { ZUserName } from "@formbricks/types/user";
export const ZInvitee = ZInvite.pick({
name: true,
email: true,
}).extend({
name: ZUserName,
});
export type TInvitee = z.infer<typeof ZInvitee>;
export const ZInviteMembersFormSchema = z.record(
ZInvite.pick({
email: true,
name: true,
}).extend({
email: z.string().email("Invalid email address"),
name: ZUserName,
})
);
export type TInviteMembersFormSchema = z.infer<typeof ZInviteMembersFormSchema>;

View File

@@ -0,0 +1,45 @@
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { RemovedFromOrganization } from "@/modules/setup/organization/create/components/removed-from-organization";
import { ClientLogout } from "@/modules/ui/components/client-logout";
import { Metadata } from "next";
import { getServerSession } from "next-auth";
import { getTranslations } from "next-intl/server";
import { notFound } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { gethasNoOrganizations } from "@formbricks/lib/instance/service";
import { getOrganizationsByUserId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
import { AuthenticationError } from "@formbricks/types/errors";
import { CreateOrganization } from "./components/create-organization";
export const metadata: Metadata = {
title: "Create Organization",
description: "Open-source Experience Management. Free & open source.",
};
export const CreateOrganizationPage = async () => {
const t = await getTranslations();
const session = await getServerSession(authOptions);
if (!session) throw new AuthenticationError(t("common.session_not_found"));
const user = await getUser(session.user.id);
if (!user) {
return <ClientLogout />;
}
const hasNoOrganizations = await gethasNoOrganizations();
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const userOrganizations = await getOrganizationsByUserId(session.user.id);
if (hasNoOrganizations || isMultiOrgEnabled) {
return <CreateOrganization />;
}
if (userOrganizations.length === 0) {
return <RemovedFromOrganization user={user} isFormbricksCloud={IS_FORMBRICKS_CLOUD} />;
}
return notFound();
};

View File

@@ -33,6 +33,7 @@ import toast from "react-hot-toast";
import { TSurveyFollowUpAction, TSurveyFollowUpTrigger } from "@formbricks/database/types/survey-follow-up";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { QUESTIONS_ICON_MAP } from "@formbricks/lib/utils/questions";
import { recallToHeadline } from "@formbricks/lib/utils/recall";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import {
@@ -101,7 +102,9 @@ export const FollowUpModal = ({
return [
...openTextAndContactQuestions.map((question) => ({
label: getLocalizedValue(question.headline, selectedLanguageCode),
label: recallToHeadline(question.headline, localSurvey, false, selectedLanguageCode)[
selectedLanguageCode
],
id: question.id,
type:
question.type === TSurveyQuestionTypeEnum.OpenText

View File

@@ -7,23 +7,21 @@ import { getOriginalFileNameFromUrl } from "@formbricks/lib/storage/utils";
interface FileUploadResponseProps {
selected: string[];
}
export const FileUploadResponse = ({ selected }: FileUploadResponseProps) => {
const t = useTranslations();
if (selected.length === 0) {
return <div className="font-semibold text-slate-500">{t("common.skipped")}</div>;
}
return (
<div className="">
{selected.map((fileUrl, index) => {
const fileName = getOriginalFileNameFromUrl(fileUrl);
return (
<a
href={fileUrl}
key={index}
download={fileName}
target="_blank"
rel="noopener noreferrer"
className="group flex max-w-60 items-center justify-center rounded-lg bg-slate-200 px-2 py-1 hover:bg-slate-300">
<p className="w-full overflow-hidden overflow-ellipsis whitespace-nowrap text-center text-slate-700 group-hover:text-slate-800">
{fileName ? fileName : "Download"}

View File

@@ -1,6 +1,6 @@
{
"name": "@formbricks/web",
"version": "3.1.3",
"version": "3.1.4",
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next",
@@ -58,7 +58,7 @@
"@radix-ui/react-toggle-group": "1.1.1",
"@radix-ui/react-tooltip": "1.1.5",
"@react-email/components": "0.0.31",
"@sentry/nextjs": "8.45.1",
"@sentry/nextjs": "8.52.0",
"@tailwindcss/forms": "0.5.9",
"@tailwindcss/typography": "0.5.15",
"@tanstack/react-table": "8.20.6",
@@ -92,6 +92,7 @@
"next-auth": "4.24.11",
"next-intl": "3.26.1",
"next-safe-action": "7.10.2",
"node-fetch": "3.3.2",
"nodemailer": "6.9.16",
"optional": "0.1.4",
"otplib": "12.0.1",

View File

@@ -138,6 +138,7 @@ test.describe("Create, update and delete team", async () => {
await page.getByRole("link", { name: "Organization" }).click();
await page.waitForURL(/\/environments\/[^/]+\/settings\/general/);
await page.waitForTimeout(2000);
await page.waitForLoadState("networkidle");
await expect(page.getByText("Teams")).toBeVisible();
await page.getByText("Teams").click();

View File

@@ -0,0 +1,15 @@
import { type Invite } from "@prisma/client";
import { z } from "zod";
export const ZInvite = z.object({
id: z.string(),
email: z.string().email(),
name: z.string().nullable(),
organizationId: z.string(),
creatorId: z.string(),
acceptorId: z.string().nullable(),
createdAt: z.date(),
expiresAt: z.date(),
role: z.enum(["owner", "manager", "member", "billing"]),
teamIds: z.array(z.string()),
}) satisfies z.ZodType<Omit<Invite, "deprecatedRole">>;

View File

@@ -188,8 +188,9 @@ export const REDIS_URL = env.REDIS_URL;
export const REDIS_HTTP_URL = env.REDIS_HTTP_URL;
export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1";
export const CUSTOMER_IO_SITE_ID = env.CUSTOMER_IO_SITE_ID;
export const CUSTOMER_IO_API_KEY = env.CUSTOMER_IO_API_KEY;
export const BREVO_API_KEY = env.BREVO_API_KEY;
export const BREVO_LIST_ID = env.BREVO_LIST_ID;
export const UNSPLASH_ACCESS_KEY = env.UNSPLASH_ACCESS_KEY;
export const UNSPLASH_ALLOWED_DOMAINS = ["api.unsplash.com"];

View File

@@ -1,31 +0,0 @@
import { ZId } from "@formbricks/types/common";
import { TUserEmail, ZUserEmail } from "@formbricks/types/user";
import { CUSTOMER_IO_API_KEY, CUSTOMER_IO_SITE_ID } from "./constants";
import { validateInputs } from "./utils/validate";
export const createCustomerIoCustomer = async ({ id, email }: { id: string; email: TUserEmail }) => {
if (!CUSTOMER_IO_SITE_ID || !CUSTOMER_IO_API_KEY) {
return;
}
validateInputs([id, ZId], [email, ZUserEmail]);
try {
const auth = Buffer.from(`${CUSTOMER_IO_SITE_ID}:${CUSTOMER_IO_API_KEY}`).toString("base64");
const res = await fetch(`https://track-eu.customer.io/api/v1/customers/${id}`, {
method: "PUT",
headers: {
Authorization: `Basic ${auth}`,
},
body: JSON.stringify({
id: id,
email: email,
}),
});
if (res.status !== 200) {
console.log("Error sending user to CustomerIO:", await res.text());
}
} catch (error) {
console.log("error sending user to CustomerIO:", error);
}
};

View File

@@ -18,8 +18,8 @@ export const env = createEnv({
AZUREAD_CLIENT_SECRET: z.string().optional(),
AZUREAD_TENANT_ID: z.string().optional(),
CRON_SECRET: z.string().min(10),
CUSTOMER_IO_API_KEY: z.string().optional(),
CUSTOMER_IO_SITE_ID: z.string().optional(),
BREVO_API_KEY: z.string().optional(),
BREVO_LIST_ID: z.string().optional(),
DATABASE_URL: z.string().url(),
DEBUG: z.enum(["1", "0"]).optional(),
DEFAULT_ORGANIZATION_ID: z.string().optional(),
@@ -141,9 +141,9 @@ export const env = createEnv({
AZUREAD_CLIENT_ID: process.env.AZUREAD_CLIENT_ID,
AZUREAD_CLIENT_SECRET: process.env.AZUREAD_CLIENT_SECRET,
AZUREAD_TENANT_ID: process.env.AZUREAD_TENANT_ID,
BREVO_API_KEY: process.env.BREVO_API_KEY,
BREVO_LIST_ID: process.env.BREVO_LIST_ID,
CRON_SECRET: process.env.CRON_SECRET,
CUSTOMER_IO_API_KEY: process.env.CUSTOMER_IO_API_KEY,
CUSTOMER_IO_SITE_ID: process.env.CUSTOMER_IO_SITE_ID,
DATABASE_URL: process.env.DATABASE_URL,
DEBUG: process.env.DEBUG,
DEFAULT_ORGANIZATION_ID: process.env.DEFAULT_ORGANIZATION_ID,

View File

@@ -443,24 +443,36 @@
"you_will_be_downgraded_to_the_community_edition_on_date": "Du wirst am {Datum} auf die Community Edition herabgestuft."
},
"emails": {
"accept": "Annehmen",
"click_or_drag_to_upload_files": "Klicke oder ziehe, um Dateien hochzuladen.",
"email_customization_preview_email_heading": "Hey {userName}",
"email_customization_preview_email_subject": "Formbricks E-Mail-Umfrage Vorschau",
"email_customization_preview_email_text": "Dies ist eine E-Mail-Vorschau, um dir zu zeigen, welches Logo in den E-Mails gerendert wird.",
"email_footer_text_1": "Einen schönen Tag noch!",
"email_footer_text_2": "Dein Formbricks Team",
"email_template_text_1": "Diese E-Mail wurde via Formbricks gesendet.",
"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.",
"embed_survey_preview_email_heading": "Vorschau Einbettung in E-Mail",
"embed_survey_preview_email_subject": "Formbricks E-Mail-Umfrage Vorschau",
"embed_survey_preview_email_text": "So sieht die Umfrage eingebettet in eine E-Mail aus:",
"forgot_password_email_change_password": "Passwort ändern",
"forgot_password_email_did_not_request": "Wenn Du sie nicht angefordert hast, ignoriere bitte diese E-Mail.",
"forgot_password_email_heading": "Passwort ändern",
"forgot_password_email_link_valid_for_24_hours": "Der Link ist 24 Stunden gültig.",
"forgot_password_email_subject": "Setz dein Formbricks-Passwort zurück",
"forgot_password_email_text": "Du hast einen Link angefordert, um dein Passwort zu ändern. Du kannst dies tun, indem Du auf den untenstehenden Link klickst:",
"imprint": "Impressum",
"invite_accepted_email_heading": "Hey",
"invite_accepted_email_subject": "Du hast einen neuen Organisation-Mitglied!",
"invite_accepted_email_text_par1": "Wollte dir nur Bescheid geben, dass",
"invite_accepted_email_text_par2": "deine Einladung angenommen hat. Viel Spaß bei der Zusammenarbeit!",
"invite_email_button_label": "Organisation beitreten",
"invite_email_heading": "Hey",
"invite_email_text_par1": "Dein Kollege",
"invite_email_text_par2": "hat Dich eingeladen, Formbricks zu nutzen. Um die Einladung anzunehmen, klicke bitte auf den untenstehenden Link:",
"invite_member_email_subject": "Du wurdest eingeladen, Formbricks zu nutzen!",
"live_survey_notification_completed": "Abgeschlossen",
"live_survey_notification_draft": "Entwurf",
"live_survey_notification_in_progress": "In Bearbeitung",
@@ -483,13 +495,22 @@
"notification_insight_displays": "Displays",
"notification_insight_responses": "Antworten",
"notification_insight_surveys": "Umfragen",
"onboarding_invite_email_button_label": "Tritt {inviterName}s Organisation bei",
"onboarding_invite_email_connect_formbricks": "Verbinde Formbricks in nur wenigen Minuten über ein HTML-Snippet oder via NPM mit deiner App oder Website.",
"onboarding_invite_email_create_account": "Erstelle ein Konto, um {inviterName}s Organisation beizutreten.",
"onboarding_invite_email_done": "Erledigt ✅",
"onboarding_invite_email_get_started_in_minutes": "Dauert nur wenige Minuten",
"onboarding_invite_email_heading": "Hey ",
"onboarding_invite_email_subject": "{inviterName} braucht Hilfe bei Formbricks. Kannst Du ihm helfen?",
"password_changed_email_heading": "Passwort geändert",
"password_changed_email_text": "Dein Passwort wurde erfolgreich geändert.",
"password_reset_notify_email_subject": "Dein Formbricks-Passwort wurde geändert",
"privacy_policy": "Datenschutzerklärung",
"reject": "Ablehnen",
"response_finished_email_subject": "Eine Antwort für {surveyName} wurde abgeschlossen ✅",
"response_finished_email_subject_with_email": "{personEmail} hat deine Umfrage {surveyName} abgeschlossen ✅",
"schedule_your_meeting": "Termin planen",
"select_a_date": "Datum auswählen",
"survey_response_finished_email_congrats": "Glückwunsch, Du hast eine neue Antwort auf deine Umfrage {surveyName} erhalten!",
"survey_response_finished_email_dont_want_notifications": "Möchtest Du diese Benachrichtigungen nicht erhalten?",
"survey_response_finished_email_hey": "Hey 👋",
@@ -512,12 +533,14 @@
"verification_email_thanks": "Danke, dass Du deine E-Mail bestätigt hast!",
"verification_email_to_fill_survey": "Um die Umfrage auszufüllen, klicke bitte auf den untenstehenden Button:",
"verification_email_verify_email": "E-Mail bestätigen",
"verified_link_survey_email_subject": "Deine Umfrage ist bereit zum Ausfüllen.",
"weekly_summary_create_reminder_notification_body_cal_slot": "Wähle einen 15-minütigen Termin im Kalender unseres Gründers aus.",
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Lass keine Woche vergehen, ohne etwas über deine Nutzer zu lernen:",
"weekly_summary_create_reminder_notification_body_need_help": "Brauchst Du Hilfe, die richtige Umfrage für dein Produkt zu finden?",
"weekly_summary_create_reminder_notification_body_reply_email": "oder antworte auf diese E-Mail :)",
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Neue Umfrage einrichten",
"weekly_summary_create_reminder_notification_body_text": "Wir würden dir gerne eine wöchentliche Zusammenfassung schicken, aber momentan laufen keine Umfragen für {projectName}."
"weekly_summary_create_reminder_notification_body_text": "Wir würden dir gerne eine wöchentliche Zusammenfassung schicken, aber momentan laufen keine Umfragen für {projectName}.",
"weekly_summary_email_subject": "{projectName} Nutzer-Insights Letzte Woche von Formbricks"
},
"environments": {
"actions": {

View File

@@ -443,24 +443,36 @@
"you_will_be_downgraded_to_the_community_edition_on_date": "You will be downgraded to the Community Edition on {date}."
},
"emails": {
"accept": "Accept",
"click_or_drag_to_upload_files": "Click or drag to upload files.",
"email_customization_preview_email_heading": "Hey {userName}",
"email_customization_preview_email_subject": "Formbricks Email Customization Preview",
"email_customization_preview_email_text": "This is an email preview to show you which logo will be rendered in the emails.",
"email_footer_text_1": "Have a great day!",
"email_footer_text_2": "The Formbricks Team",
"email_template_text_1": "This email was sent via Formbricks.",
"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",
"embed_survey_preview_email_heading": "Preview Email Embed",
"embed_survey_preview_email_subject": "Formbricks Email Survey Preview",
"embed_survey_preview_email_text": "This is how the code snippet looks embedded into an email:",
"forgot_password_email_change_password": "Change password",
"forgot_password_email_did_not_request": "If you didn't request this, please ignore this email.",
"forgot_password_email_heading": "Change password",
"forgot_password_email_link_valid_for_24_hours": "The link is valid for 24 hours.",
"forgot_password_email_subject": "Reset your Formbricks password",
"forgot_password_email_text": "You have requested a link to change your password. You can do this by clicking the link below:",
"imprint": "Imprint",
"invite_accepted_email_heading": "Hey",
"invite_accepted_email_subject": "You've got a new organization member!",
"invite_accepted_email_text_par1": "Just letting you know that",
"invite_accepted_email_text_par2": "accepted your invitation. Have fun collaborating!",
"invite_email_button_label": "Join organization",
"invite_email_heading": "Hey",
"invite_email_text_par1": "Your colleague",
"invite_email_text_par2": "invited you to join them at Formbricks. To accept the invitation, please click the link below:",
"invite_member_email_subject": "You're invited to collaborate on Formbricks!",
"live_survey_notification_completed": "Completed",
"live_survey_notification_draft": "Draft",
"live_survey_notification_in_progress": "In Progress",
@@ -483,13 +495,22 @@
"notification_insight_displays": "Displays",
"notification_insight_responses": "Responses",
"notification_insight_surveys": "Surveys",
"onboarding_invite_email_button_label": "Join {inviterName}'s organization",
"onboarding_invite_email_connect_formbricks": "Connect Formbricks to your app or website via HTML Snippet or NPM in just a few minutes.",
"onboarding_invite_email_create_account": "Create an account to join {inviterName}'s organization.",
"onboarding_invite_email_done": "Done ✅",
"onboarding_invite_email_get_started_in_minutes": "Get Started in Minutes",
"onboarding_invite_email_heading": "Hey ",
"onboarding_invite_email_subject": "{inviterName} needs a hand setting up Formbricks. Can you help out?",
"password_changed_email_heading": "Password changed",
"password_changed_email_text": "Your password has been changed successfully.",
"password_reset_notify_email_subject": "Your Formbricks password has been changed",
"privacy_policy": "Privacy Policy",
"reject": "Reject",
"response_finished_email_subject": "A response for {surveyName} was completed ✅",
"response_finished_email_subject_with_email": "{personEmail} just completed your {surveyName} survey ✅",
"schedule_your_meeting": "Schedule your meeting",
"select_a_date": "Select a date",
"survey_response_finished_email_congrats": "Congrats, you received a new response to your survey! Someone just completed your survey: {surveyName}",
"survey_response_finished_email_dont_want_notifications": "Don't want to get these notifications?",
"survey_response_finished_email_hey": "Hey 👋",
@@ -512,12 +533,14 @@
"verification_email_thanks": "Thanks for validating your email!",
"verification_email_to_fill_survey": "To fill out the survey please click on the button below:",
"verification_email_verify_email": "Verify email",
"verified_link_survey_email_subject": "Your survey is ready to be filled out.",
"weekly_summary_create_reminder_notification_body_cal_slot": "Pick a 15-minute slot in our CEOs calendar",
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Don't let a week pass without learning about your users:",
"weekly_summary_create_reminder_notification_body_need_help": "Need help finding the right survey for your product?",
"weekly_summary_create_reminder_notification_body_reply_email": "or reply to this email :)",
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Setup a new survey",
"weekly_summary_create_reminder_notification_body_text": "We'd love to send you a Weekly Summary, but currently there are no surveys running for {projectName}."
"weekly_summary_create_reminder_notification_body_text": "We'd love to send you a Weekly Summary, but currently there are no surveys running for {projectName}.",
"weekly_summary_email_subject": "{projectName} User Insights - Last Week by Formbricks"
},
"environments": {
"actions": {

View File

@@ -443,24 +443,36 @@
"you_will_be_downgraded_to_the_community_edition_on_date": "Vous serez rétrogradé à l'édition communautaire le {date}."
},
"emails": {
"accept": "Accepter",
"click_or_drag_to_upload_files": "Cliquez ou faites glisser pour télécharger des fichiers.",
"email_customization_preview_email_heading": "Salut {userName}",
"email_customization_preview_email_subject": "Aperçu de la personnalisation des e-mails Formbricks",
"email_customization_preview_email_text": "C'est une prévisualisation d'e-mail pour vous montrer quel logo sera rendu dans les e-mails.",
"email_footer_text_1": "Passe une belle journée !",
"email_footer_text_2": "L'équipe Formbricks",
"email_template_text_1": "Cet e-mail a été envoyé via Formbricks.",
"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.",
"embed_survey_preview_email_heading": "Aperçu de l'email intégré",
"embed_survey_preview_email_subject": "Aperçu du sondage par e-mail Formbricks",
"embed_survey_preview_email_text": "C'est ainsi que le code s'affiche intégré dans un e-mail :",
"forgot_password_email_change_password": "Changer le mot de passe",
"forgot_password_email_did_not_request": "Si vous n'avez pas demandé cela, veuillez ignorer cet e-mail.",
"forgot_password_email_heading": "Changer le mot de passe",
"forgot_password_email_link_valid_for_24_hours": "Le lien est valable pendant 24 heures.",
"forgot_password_email_subject": "Réinitialise ton mot de passe Formbricks",
"forgot_password_email_text": "Vous avez demandé un lien pour changer votre mot de passe. Vous pouvez le faire en cliquant sur le lien ci-dessous :",
"imprint": "Impressum",
"invite_accepted_email_heading": "Salut",
"invite_accepted_email_subject": "Vous avez un nouveau membre dans votre organisation !",
"invite_accepted_email_text_par1": "Je te fais savoir que",
"invite_accepted_email_text_par2": "accepté votre invitation. Amusez-vous bien à collaborer !",
"invite_email_button_label": "Rejoindre l'organisation",
"invite_email_heading": "Salut",
"invite_email_text_par1": "Votre collègue",
"invite_email_text_par2": "vous a invité à les rejoindre sur Formbricks. Pour accepter l'invitation, veuillez cliquer sur le lien ci-dessous :",
"invite_member_email_subject": "Vous avez été invité à collaborer sur Formbricks !",
"live_survey_notification_completed": "Terminé",
"live_survey_notification_draft": "Brouillon",
"live_survey_notification_in_progress": "En cours",
@@ -483,13 +495,22 @@
"notification_insight_displays": "Affichages",
"notification_insight_responses": "Réponses",
"notification_insight_surveys": "Enquêtes",
"onboarding_invite_email_button_label": "Rejoins l'organisation de {inviterName}",
"onboarding_invite_email_connect_formbricks": "Connectez Formbricks à votre application ou site web via un extrait HTML ou NPM en quelques minutes seulement.",
"onboarding_invite_email_create_account": "Créez un compte pour rejoindre l'organisation de {inviterName}.",
"onboarding_invite_email_done": "Fait ✅",
"onboarding_invite_email_get_started_in_minutes": "Commencez en quelques minutes",
"onboarding_invite_email_heading": "Salut ",
"onboarding_invite_email_subject": "{inviterName} a besoin d'aide pour configurer Formbricks. Peux-tu l'aider ?",
"password_changed_email_heading": "Mot de passe changé",
"password_changed_email_text": "Votre mot de passe a été changé avec succès.",
"password_reset_notify_email_subject": "Ton mot de passe Formbricks a été changé",
"privacy_policy": "Politique de confidentialité",
"reject": "Rejeter",
"response_finished_email_subject": "Une réponse pour {surveyName} a été complétée ✅",
"response_finished_email_subject_with_email": "{personEmail} vient de compléter votre enquête {surveyName} ✅",
"schedule_your_meeting": "Planifier votre rendez-vous",
"select_a_date": "Sélectionner une date",
"survey_response_finished_email_congrats": "Félicitations, vous avez reçu une nouvelle réponse à votre enquête ! Quelqu'un vient de compléter votre enquête : {surveyName}",
"survey_response_finished_email_dont_want_notifications": "Vous ne voulez pas recevoir ces notifications ?",
"survey_response_finished_email_hey": "Salut 👋",
@@ -512,12 +533,14 @@
"verification_email_thanks": "Merci de valider votre email !",
"verification_email_to_fill_survey": "Pour remplir le questionnaire, veuillez cliquer sur le bouton ci-dessous :",
"verification_email_verify_email": "Vérifier l'email",
"verified_link_survey_email_subject": "Votre enquête est prête à être remplie.",
"weekly_summary_create_reminder_notification_body_cal_slot": "Choisissez un créneau de 15 minutes dans le calendrier de notre PDG.",
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Ne laissez pas une semaine passer sans en apprendre davantage sur vos utilisateurs :",
"weekly_summary_create_reminder_notification_body_need_help": "Besoin d'aide pour trouver le bon sondage pour votre produit ?",
"weekly_summary_create_reminder_notification_body_reply_email": "ou répondez à cet e-mail :)",
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Configurer une nouvelle enquête",
"weekly_summary_create_reminder_notification_body_text": "Nous aimerions vous envoyer un résumé hebdomadaire, mais actuellement, il n'y a pas d'enquêtes en cours pour {projectName}."
"weekly_summary_create_reminder_notification_body_text": "Nous aimerions vous envoyer un résumé hebdomadaire, mais actuellement, il n'y a pas d'enquêtes en cours pour {projectName}.",
"weekly_summary_email_subject": "Aperçu des utilisateurs de {projectName} La semaine dernière par Formbricks"
},
"environments": {
"actions": {

View File

@@ -443,24 +443,36 @@
"you_will_be_downgraded_to_the_community_edition_on_date": "Você será rebaixado para a Edição Comunitária em {data}."
},
"emails": {
"accept": "Aceitar",
"click_or_drag_to_upload_files": "Clique ou arraste para fazer o upload de arquivos.",
"email_customization_preview_email_heading": "Oi {userName}",
"email_customization_preview_email_subject": "Prévia da personalização de e-mails do Formbricks",
"email_customization_preview_email_text": "Esta é uma pré-visualização de e-mail para mostrar qual logo será renderizado nos e-mails.",
"email_footer_text_1": "Tenha um ótimo dia!",
"email_footer_text_2": "O time Formbricks",
"email_template_text_1": "Este e-mail foi enviado através do Formbricks.",
"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",
"embed_survey_preview_email_heading": "Pré-visualizar Incorporação de Email",
"embed_survey_preview_email_subject": "Prévia da pesquisa por e-mail do Formbricks",
"embed_survey_preview_email_text": "É assim que o trecho de código fica embutido em um e-mail:",
"forgot_password_email_change_password": "Mudar senha",
"forgot_password_email_did_not_request": "Se você não solicitou isso, por favor ignore este e-mail.",
"forgot_password_email_heading": "Mudar senha",
"forgot_password_email_link_valid_for_24_hours": "O link é válido por 24 horas.",
"forgot_password_email_subject": "Redefinir sua senha Formbricks",
"forgot_password_email_text": "Você pediu um link pra trocar sua senha. Você pode fazer isso clicando no link abaixo:",
"imprint": "Impressum",
"invite_accepted_email_heading": "E aí",
"invite_accepted_email_subject": "Você tem um novo membro na sua organização!",
"invite_accepted_email_text_par1": "Só pra te avisar que",
"invite_accepted_email_text_par2": "aceitou seu convite. Divirta-se colaborando!",
"invite_email_button_label": "Entrar na organização",
"invite_email_heading": "E aí",
"invite_email_text_par1": "Seu colega",
"invite_email_text_par2": "te convidou para se juntar a eles na Formbricks. Para aceitar o convite, por favor clique no link abaixo:",
"invite_member_email_subject": "Você foi convidado a colaborar no Formbricks!",
"live_survey_notification_completed": "Concluído",
"live_survey_notification_draft": "Rascunho",
"live_survey_notification_in_progress": "Em andamento",
@@ -483,13 +495,22 @@
"notification_insight_displays": "telas",
"notification_insight_responses": "Respostas",
"notification_insight_surveys": "pesquisas",
"onboarding_invite_email_button_label": "Entre na organização de {inviterName}",
"onboarding_invite_email_connect_formbricks": "Conecte o Formbricks ao seu app ou site via HTML Snippet ou NPM em apenas alguns minutos.",
"onboarding_invite_email_create_account": "Crie uma conta para entrar na organização de {inviterName}.",
"onboarding_invite_email_done": "Feito ✅",
"onboarding_invite_email_get_started_in_minutes": "Comece em Minutos",
"onboarding_invite_email_heading": "Oi ",
"onboarding_invite_email_subject": "{inviterName} precisa de ajuda para configurar o Formbricks. Você pode ajudar?",
"password_changed_email_heading": "Senha alterada",
"password_changed_email_text": "Sua senha foi alterada com sucesso.",
"password_reset_notify_email_subject": "Sua senha Formbricks foi alterada",
"privacy_policy": "Política de Privacidade",
"reject": "Rejeitar",
"response_finished_email_subject": "Uma resposta para {surveyName} foi concluída ✅",
"response_finished_email_subject_with_email": "{personEmail} acabou de completar sua pesquisa {surveyName} ✅",
"schedule_your_meeting": "Agendar sua reunião",
"select_a_date": "Selecione uma data",
"survey_response_finished_email_congrats": "Parabéns, você recebeu uma nova resposta na sua pesquisa! Alguém acabou de completar sua pesquisa: {surveyName}",
"survey_response_finished_email_dont_want_notifications": "Não quer receber essas notificações?",
"survey_response_finished_email_hey": "E aí 👋",
@@ -512,12 +533,14 @@
"verification_email_thanks": "Valeu por validar seu e-mail!",
"verification_email_to_fill_survey": "Para preencher a pesquisa, por favor clique no botão abaixo:",
"verification_email_verify_email": "Verificar e-mail",
"verified_link_survey_email_subject": "Sua pesquisa está pronta para ser preenchida.",
"weekly_summary_create_reminder_notification_body_cal_slot": "Escolha um horário de 15 minutos na agenda do nosso CEO",
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Não deixe uma semana passar sem aprender sobre seus usuários:",
"weekly_summary_create_reminder_notification_body_need_help": "Precisa de ajuda pra encontrar a pesquisa certa pro seu produto?",
"weekly_summary_create_reminder_notification_body_reply_email": "ou responde a esse e-mail :)",
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Configurar uma nova pesquisa",
"weekly_summary_create_reminder_notification_body_text": "Adoraríamos te enviar um Resumo Semanal, mas no momento não há pesquisas em andamento para {projectName}."
"weekly_summary_create_reminder_notification_body_text": "Adoraríamos te enviar um Resumo Semanal, mas no momento não há pesquisas em andamento para {projectName}.",
"weekly_summary_email_subject": "Insights de usuários do {projectName} Semana passada por Formbricks"
},
"environments": {
"actions": {

View File

@@ -1,47 +0,0 @@
import { z } from "zod";
import { ZOrganizationRole } from "./memberships";
import { ZUserName } from "./user";
export const ZInvite = z.object({
id: z.string(),
email: z.string().email(),
name: z.string().nullish(),
organizationId: z.string(),
creatorId: z.string(),
acceptorId: z.string().nullish(),
createdAt: z.date(),
expiresAt: z.date(),
role: ZOrganizationRole,
teamIds: z.array(z.string()),
});
export type TInvite = z.infer<typeof ZInvite>;
export const ZInvitee = z.object({
email: z.string().email(),
name: ZUserName,
role: ZOrganizationRole,
teamIds: z.array(z.string()),
});
export type TInvitee = z.infer<typeof ZInvitee>;
export const ZInvitees = z.array(ZInvitee);
export const ZCurrentUser = z.object({
id: z.string(),
name: z.string().nullable(),
});
export type TCurrentUser = z.infer<typeof ZCurrentUser>;
export const ZInviteUpdateInput = z.object({
role: ZOrganizationRole,
});
export type TInviteUpdateInput = z.infer<typeof ZInviteUpdateInput>;
export const ZInviteMembersFormSchema = z.record(
z.object({
email: z.string().email("Invalid email address"),
name: ZUserName,
})
);
export type TInviteMembersFormSchema = z.infer<typeof ZInviteMembersFormSchema>;

695
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -84,12 +84,12 @@
"AZUREAD_CLIENT_ID",
"AZUREAD_CLIENT_SECRET",
"AZUREAD_TENANT_ID",
"BREVO_API_KEY",
"BREVO_LIST_ID",
"DEFAULT_ORGANIZATION_ID",
"DEFAULT_ORGANIZATION_ROLE",
"CRON_SECRET",
"CUSTOM_CACHE_DISABLED",
"CUSTOMER_IO_API_KEY",
"CUSTOMER_IO_SITE_ID",
"DATABASE_URL",
"DEBUG",
"E2E_TESTING",