chore: invite types (#4613)

Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
Piyush Gupta
2025-01-31 12:26:55 +05:30
committed by GitHub
parent 9b3d409695
commit 06e00f3066
47 changed files with 938 additions and 594 deletions
@@ -0,0 +1,57 @@
"use server";
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 { ZId } from "@formbricks/types/common";
import { AuthenticationError } from "@formbricks/types/errors";
import { ZUserEmail, ZUserName } from "@formbricks/types/user";
const ZInviteOrganizationMemberAction = z.object({
email: ZUserEmail,
organizationId: ZId,
name: ZUserName,
});
export const inviteOrganizationMemberAction = authenticatedActionClient
.schema(ZInviteOrganizationMemberAction)
.action(async ({ ctx, parsedInput }) => {
if (INVITE_DISABLED) {
throw new AuthenticationError("Invite disabled");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
],
});
const invitedUserId = await inviteUser({
organizationId: parsedInput.organizationId,
invitee: {
email: parsedInput.email,
name: parsedInput.name,
},
currentUserId: ctx.user.id,
});
await sendInviteMemberEmail(
invitedUserId,
parsedInput.email,
ctx.user.name,
"",
false, // is onboarding invite
undefined,
ctx.user.locale
);
return invitedUserId;
});
@@ -0,0 +1,157 @@
"use client";
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";
import { Input } from "@/modules/ui/components/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
interface InviteMembersProps {
IS_SMTP_CONFIGURED: boolean;
organizationId: string;
}
export const InviteMembers = ({ IS_SMTP_CONFIGURED, organizationId }: InviteMembersProps) => {
const t = useTranslations();
const [membersCount, setMembersCount] = useState(1);
const router = useRouter();
const form = useForm<TInviteMembersFormSchema>({
resolver: zodResolver(ZInviteMembersFormSchema),
});
const { isSubmitting } = form.formState;
const inviteTeamMembers = async (data: TInviteMembersFormSchema) => {
for (const member of Object.values(data)) {
try {
if (!member.email) continue;
const inviteResponse = await inviteOrganizationMemberAction({
email: member.email.toLowerCase(),
name: member.name,
organizationId,
});
if (inviteResponse?.data) {
toast.success(`${t("setup.invite.invitation_sent_to")} ${member.email}!`);
} else {
const errorMessage = getFormattedErrorMessage(inviteResponse);
toast.error(errorMessage);
}
} catch (error) {
toast.error(`${t("setup.invite.failed_to_invite")} ${member.email}.`);
}
}
router.push("/");
};
const handleSkip = () => {
router.push("/");
};
return (
<FormProvider {...form}>
{!IS_SMTP_CONFIGURED && (
<Alert variant="warning">
<AlertTitle>{t("setup.invite.smtp_not_configured")}</AlertTitle>
<AlertDescription>{t("setup.invite.smtp_not_configured_description")}</AlertDescription>
</Alert>
)}
<form
onSubmit={(e) => {
e.preventDefault();
void form.handleSubmit(inviteTeamMembers)(e);
}}
className="space-y-4">
<div className="flex flex-col items-center space-y-4">
<h2 className="text-2xl font-medium">{t("setup.invite.invite_your_organization_members")}</h2>
<p>{t("setup.invite.life_s_no_fun_alone")}</p>
{Array.from({ length: membersCount }).map((_, index) => (
<div key={`member-${index.toString()}`} className="space-y-2">
<FormField
control={form.control}
name={`member-${index.toString()}.email`}
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormControl>
<div>
<div className="relative">
<Input
{...field}
placeholder={`user@example.com`}
className="w-80"
isInvalid={Boolean(error?.message)}
/>
</div>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name={`member-${index.toString()}.name`}
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormControl>
<div>
<div className="relative">
<Input
{...field}
placeholder={`Full Name (optional)`}
className="w-80"
isInvalid={Boolean(error?.message)}
/>
</div>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>
</FormControl>
</FormItem>
)}
/>
</div>
))}
<Button
variant="ghost"
onClick={() => {
setMembersCount((count) => count + 1);
}}
type="button">
<PlusIcon />
{t("setup.invite.add_another_member")}
</Button>
<hr className="my-6 w-full border-slate-200" />
<div className="space-y-2">
<Button
className="flex w-80 justify-center"
type="submit"
loading={isSubmitting}
disabled={isSubmitting}>
{t("setup.invite.continue")}
</Button>
<Button type="button" variant="ghost" className="flex w-80 justify-center" onClick={handleSkip}>
{t("setup.invite.skip")}
</Button>
</div>
</div>
</form>
</FormProvider>
);
};
@@ -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>;
@@ -0,0 +1,87 @@
"use client";
import { createOrganizationAction } from "@/app/setup/organization/create/actions";
import { Button } from "@/modules/ui/components/button";
import { FormControl, FormError, FormField, FormItem, FormProvider } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { z } from "zod";
import { ZOrganization } from "@formbricks/types/organizations";
const ZCreateOrganizationFormSchema = ZOrganization.pick({ name: true });
type TCreateOrganizationForm = z.infer<typeof ZCreateOrganizationFormSchema>;
export const CreateOrganization = () => {
const t = useTranslations();
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const form = useForm<TCreateOrganizationForm>({
defaultValues: {
name: "",
},
mode: "onChange",
resolver: zodResolver(ZCreateOrganizationFormSchema),
});
const organizationName = form.watch("name");
const onSubmit: SubmitHandler<TCreateOrganizationForm> = async () => {
try {
setIsSubmitting(true);
const createOrganizationResponse = await createOrganizationAction({ organizationName });
if (createOrganizationResponse?.data) {
router.push(`/setup/organization/${createOrganizationResponse.data.id}/invite`);
}
} catch (error) {
toast.error("Some error occurred while creating organization");
setIsSubmitting(false);
}
};
return (
<FormProvider {...form}>
<form
onSubmit={(e) => {
e.preventDefault();
void form.handleSubmit(onSubmit)(e);
}}>
<div className="flex flex-col items-center space-y-4">
<h2 className="text-2xl font-medium">{t("setup.organization.create.title")}</h2>
<p>{t("setup.organization.create.description")}</p>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
isInvalid={Boolean(form.formState.errors.name)}
placeholder="e.g., Acme Inc"
className="w-80"
required
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
<Button
type="submit"
className="flex w-80 justify-center"
loading={isSubmitting}
disabled={isSubmitting || organizationName.trim() === ""}>
{t("setup.organization.create.continue")}
</Button>
</div>
</form>
</FormProvider>
);
};
@@ -0,0 +1,43 @@
"use client";
import { formbricksLogout } from "@/app/lib/formbricks";
import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountModal";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { useTranslations } from "next-intl";
import React, { useState } from "react";
import { TUser } from "@formbricks/types/user";
interface RemovedFromOrganizationProps {
isFormbricksCloud: boolean;
user: TUser;
}
export const RemovedFromOrganization = ({ user, isFormbricksCloud }: RemovedFromOrganizationProps) => {
const t = useTranslations();
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<div className="space-y-4">
<Alert variant="warning">
<AlertTitle>{t("setup.organization.create.no_membership_found")}</AlertTitle>
<AlertDescription>{t("setup.organization.create.no_membership_found_description")}</AlertDescription>
</Alert>
<hr className="my-4 border-slate-200" />
<p className="text-sm">{t("setup.organization.create.delete_account_description")}</p>
<DeleteAccountModal
open={isModalOpen}
setOpen={setIsModalOpen}
user={user}
isFormbricksCloud={isFormbricksCloud}
formbricksLogout={formbricksLogout}
organizationsWithSingleOwner={[]}
/>
<Button
onClick={() => {
setIsModalOpen(true);
}}>
{t("setup.organization.create.delete_account")}
</Button>
</div>
);
};
@@ -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();
};