mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 02:10:12 -06:00
chore: invite types (#4613)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { ProjectSettingsLayout } from "@/modules/projects/settings/layout";
|
||||
import { ProjectSettingsLayout, metadata } from "@/modules/projects/settings/layout";
|
||||
|
||||
export { metadata };
|
||||
export default ProjectSettingsLayout;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LoginPage } from "@/modules/auth/login/page";
|
||||
import { LoginPage, metadata } from "@/modules/auth/login/page";
|
||||
|
||||
export { metadata };
|
||||
export default LoginPage;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
78
apps/web/modules/auth/invite/lib/invite.ts
Normal file
78
apps/web/modules/auth/invite/lib/invite.ts
Normal 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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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);
|
||||
147
apps/web/modules/auth/invite/page.tsx
Normal file
147
apps/web/modules/auth/invite/page.tsx
Normal 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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
11
apps/web/modules/auth/invite/types/invites.ts
Normal file
11
apps/web/modules/auth/invite/types/invites.ts
Normal 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"> {}
|
||||
@@ -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, {
|
||||
|
||||
78
apps/web/modules/auth/signup/lib/invite.ts
Normal file
78
apps/web/modules/auth/signup/lib/invite.ts
Normal 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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
65
apps/web/modules/auth/signup/lib/team.ts
Normal file
65
apps/web/modules/auth/signup/lib/team.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
7
apps/web/modules/auth/signup/types/invites.ts
Normal file
7
apps/web/modules/auth/signup/types/invites.ts
Normal 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"> {}
|
||||
@@ -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) => {
|
||||
|
||||
31
apps/web/modules/ee/role-management/lib/invite.ts
Normal file
31
apps/web/modules/ee/role-management/lib/invite.ts
Normal 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
|
||||
}
|
||||
}
|
||||
};
|
||||
8
apps/web/modules/ee/role-management/types/invites.ts
Normal file
8
apps/web/modules/ee/role-management/types/invites.ts
Normal 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>;
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -12,7 +13,6 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@formbricks/lib/localStorage";
|
||||
import { TInvitee } from "@formbricks/types/invites";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
|
||||
@@ -86,7 +86,7 @@ export const OrganizationActions = ({
|
||||
email: email.toLowerCase(),
|
||||
name,
|
||||
role,
|
||||
teamIds: teamIds,
|
||||
teamIds,
|
||||
});
|
||||
return {
|
||||
email,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,175 +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;
|
||||
locale: 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,
|
||||
locale: 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: {
|
||||
@@ -193,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({
|
||||
@@ -200,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);
|
||||
@@ -210,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,
|
||||
@@ -218,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;
|
||||
|
||||
@@ -278,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);
|
||||
@@ -287,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)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
33
apps/web/modules/setup/(fresh-instance)/intro/page.tsx
Normal file
33
apps/web/modules/setup/(fresh-instance)/intro/page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
14
apps/web/modules/setup/(fresh-instance)/layout.tsx
Normal file
14
apps/web/modules/setup/(fresh-instance)/layout.tsx
Normal 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}</>;
|
||||
};
|
||||
55
apps/web/modules/setup/(fresh-instance)/signup/page.tsx
Normal file
55
apps/web/modules/setup/(fresh-instance)/signup/page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
20
apps/web/modules/setup/layout.tsx
Normal file
20
apps/web/modules/setup/layout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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>;
|
||||
45
apps/web/modules/setup/organization/create/page.tsx
Normal file
45
apps/web/modules/setup/organization/create/page.tsx
Normal 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();
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
15
packages/database/zod/invites.ts
Normal file
15
packages/database/zod/invites.ts
Normal 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">>;
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user