diff --git a/app/components/settings/invite-user-dialog.tsx b/app/components/settings/invite-user-dialog.tsx new file mode 100644 index 000000000..4ef96c4b9 --- /dev/null +++ b/app/components/settings/invite-user-dialog.tsx @@ -0,0 +1,244 @@ +import { cloneElement, useCallback, useEffect, useState } from "react"; +import { OrganizationRoles } from "@prisma/client"; +import { UserIcon } from "lucide-react"; +import { useZorm } from "react-zorm"; +import { z } from "zod"; +import { useCurrentOrganization } from "~/hooks/use-current-organization-id"; +import useFetcherWithReset from "~/hooks/use-fetcher-with-reset"; +import type { UserFriendlyRoles } from "~/routes/_layout+/settings.team"; +import { isFormProcessing } from "~/utils/form"; +import { validEmail } from "~/utils/misc"; +import Input from "../forms/input"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "../forms/select"; +import { Dialog, DialogPortal } from "../layout/dialog"; +import { Button } from "../shared/button"; +import { Image } from "../shared/image"; +import When from "../when/when"; + +type InviteUserDialogProps = { + className?: string; + teamMemberId?: string; + trigger?: React.ReactElement<{ onClick: () => void }>; + open?: boolean; + onClose?: () => void; +}; + +export const InviteUserFormSchema = z.object({ + email: z + .string() + .transform((email) => email.toLowerCase()) + .refine(validEmail, () => ({ + message: "Please enter a valid email", + })), + teamMemberId: z.string().optional(), + role: z.nativeEnum(OrganizationRoles, { message: "Please select a role." }), +}); + +const organizationRolesMap: Record = { + [OrganizationRoles.ADMIN]: "Administrator", + [OrganizationRoles.BASE]: "Base", + [OrganizationRoles.SELF_SERVICE]: "Self service", +}; + +export default function InviteUserDialog({ + className, + trigger, + teamMemberId, + open = false, + onClose, +}: InviteUserDialogProps) { + const [isDialogOpen, setIsDialogOpen] = useState(false); + const organization = useCurrentOrganization(); + + const fetcher = useFetcherWithReset<{ + error?: { message?: string }; + success?: boolean; + }>(); + + const disabled = isFormProcessing(fetcher.state); + + const zo = useZorm("NewQuestionWizardScreen", InviteUserFormSchema); + + function openDialog() { + setIsDialogOpen(true); + } + + const closeDialog = useCallback(() => { + zo.form?.reset(); + setIsDialogOpen(false); + onClose && onClose(); + }, [onClose, zo.form]); + + useEffect( + function handleSuccess() { + if (fetcher.data?.success === true) { + closeDialog(); + fetcher.reset(); + } + }, + [closeDialog, fetcher, fetcher.data?.success] + ); + + if (!organization) { + return null; + } + + return ( + <> + {trigger ? cloneElement(trigger, { onClick: openDialog }) : null} + + + + + + } + open={isDialogOpen || open} + onClose={closeDialog} + > +
+
+

Invite team members

+

+ Invite a user to this workspace. Make sure to give them the + proper role. +

+
+ + + {/* */} + + + + + + Workspace + + + + + Role + + + +

+ {zo.errors?.role()?.message} +

+
+ +
+ +
+ + +

+ {fetcher.data?.error?.message} +

+
+ +
+ + +
+
+
+
+
+ + ); +} diff --git a/app/components/workspace/nrm-actions-dropdown.tsx b/app/components/workspace/nrm-actions-dropdown.tsx index 2b68592ea..fa5e90251 100644 --- a/app/components/workspace/nrm-actions-dropdown.tsx +++ b/app/components/workspace/nrm-actions-dropdown.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import type { Prisma } from "@prisma/client"; import { useLoaderData } from "@remix-run/react"; import { VerticalDotsIcon } from "~/components/icons/library"; @@ -10,8 +11,8 @@ import { import { useControlledDropdownMenu } from "~/hooks/use-controlled-dropdown-menu"; import type { loader } from "~/routes/_layout+/settings.team.nrm"; -import { tw } from "~/utils/tw"; import { DeleteMember } from "./delete-member"; +import InviteUserDialog from "../settings/invite-user-dialog"; import { Button } from "../shared/button"; export function TeamMembersActionsDropdown({ @@ -27,68 +28,80 @@ export function TeamMembersActionsDropdown({ }; }>; }) { + const [isInviteOpen, setIsInviteOpen] = useState(false); const { isPersonalOrg } = useLoaderData(); const { ref, open, setOpen } = useControlledDropdownMenu(); return ( - setOpen(open)} - open={open} - > - + setOpen(open)} + open={open} > - - - - - - - - + + - - - + + + + + + + - - - + { + setIsInviteOpen(false); + }} + /> + ); } diff --git a/app/routes/_layout+/settings.team.invites.invite-user.tsx b/app/routes/_layout+/settings.team.invites.invite-user.tsx deleted file mode 100644 index 94d4249a9..000000000 --- a/app/routes/_layout+/settings.team.invites.invite-user.tsx +++ /dev/null @@ -1,299 +0,0 @@ -import { OrganizationRoles } from "@prisma/client"; -import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; -import { json, redirect } from "@remix-run/node"; -import { useActionData, useNavigation } from "@remix-run/react"; -import { useZorm } from "react-zorm"; -import z from "zod"; -import { Form } from "~/components/custom-form"; -import Input from "~/components/forms/input"; -import { - Select, - SelectGroup, - SelectContent, - SelectItem, - SelectLabel, - SelectValue, - SelectTrigger, -} from "~/components/forms/select"; -import { UserIcon } from "~/components/icons/library"; -import { Button } from "~/components/shared/button"; -import { Image } from "~/components/shared/image"; -import { db } from "~/database/db.server"; -import { useSearchParams } from "~/hooks/search-params"; -import { useCurrentOrganization } from "~/hooks/use-current-organization-id"; -import { createInvite } from "~/modules/invite/service.server"; -import styles from "~/styles/layout/custom-modal.css?url"; -import { sendNotification } from "~/utils/emitter/send-notification.server"; -import { ShelfError, makeShelfError } from "~/utils/error"; -import { isFormProcessing } from "~/utils/form"; -import { data, error, parseData } from "~/utils/http.server"; -import { validEmail } from "~/utils/misc"; -import { - PermissionAction, - PermissionEntity, -} from "~/utils/permissions/permission.data"; -import { requirePermission } from "~/utils/roles.server"; -import { assertUserCanInviteUsersToWorkspace } from "~/utils/subscription.server"; -import { tw } from "~/utils/tw"; -import type { UserFriendlyRoles } from "./settings.team"; - -const InviteUserFormSchema = z.object({ - email: z - .string() - .transform((email) => email.toLowerCase()) - .refine(validEmail, () => ({ - message: "Please enter a valid email", - })), - teamMemberId: z.string().optional(), - role: z.nativeEnum(OrganizationRoles), -}); - -export const loader = async ({ context, request }: LoaderFunctionArgs) => { - const authSession = context.getSession(); - const { userId } = authSession; - - try { - const { organizationId } = await requirePermission({ - userId, - request, - entity: PermissionEntity.teamMember, - action: PermissionAction.create, - }); - - await assertUserCanInviteUsersToWorkspace({ organizationId }); - - return json( - data({ - showModal: true, - }) - ); - } catch (cause) { - const reason = makeShelfError(cause); - throw json(error(reason), { status: reason.status }); - } -}; - -export const action = async ({ context, request }: ActionFunctionArgs) => { - const authSession = context.getSession(); - const { userId } = authSession; - - try { - const { organizationId } = await requirePermission({ - userId, - request, - entity: PermissionEntity.teamMember, - action: PermissionAction.create, - }); - - const { email, teamMemberId, role } = parseData( - await request.formData(), - InviteUserFormSchema - ); - - let teamMemberName = email.split("@")[0]; - - if (teamMemberId) { - const teamMember = await db.teamMember - .findUnique({ - where: { deletedAt: null, id: teamMemberId }, - }) - .catch((cause) => { - throw new ShelfError({ - cause, - message: "Failed to get team member", - additionalData: { teamMemberId, userId }, - label: "Team", - }); - }); - - if (teamMember) { - teamMemberName = teamMember.name; - } - } - - const existingInvites = await db.invite.findMany({ - where: { - status: "PENDING", - inviteeEmail: email, - organizationId, - }, - }); - - if (existingInvites.length) { - throw new ShelfError({ - cause: null, - message: - "User already has a pending invite. Either resend it or cancel it in order to be able to send a new one.", - additionalData: { email, organizationId }, - label: "Invite", - shouldBeCaptured: false, - }); - } - - const invite = await createInvite({ - organizationId, - inviteeEmail: email, - inviterId: userId, - roles: [role], - teamMemberName, - teamMemberId, - userId, - }); - - if (invite) { - sendNotification({ - title: "Successfully invited user", - message: - "They will receive an email in which they can complete their registration.", - icon: { name: "success", variant: "success" }, - senderId: userId, - }); - - return redirect("/settings/team/invites"); - } - - return json(data(null)); - } catch (cause) { - const reason = makeShelfError(cause, {}); - return json(error(reason), { status: reason.status }); - } -}; - -export function links() { - return [{ rel: "stylesheet", href: styles }]; -} -export const handle = { - name: "settings.team.invites.invite-user", -}; - -const organizationRolesMap: Record = { - [OrganizationRoles.ADMIN]: "Administrator", - [OrganizationRoles.BASE]: "Base", - [OrganizationRoles.SELF_SERVICE]: "Self service", -}; - -export default function InviteUser() { - const organization = useCurrentOrganization(); - const zo = useZorm("NewQuestionWizardScreen", InviteUserFormSchema); - const navigation = useNavigation(); - const disabled = isFormProcessing(navigation.state); - const [searchParams] = useSearchParams(); - const teamMemberId = searchParams.get("teamMemberId"); - - const actionData = useActionData(); - return organization ? ( - <> -
-
- -
-
-

Invite team members

-

- Invite a user to this workspace. Make sure to give them the proper - role. -

-
-
- {teamMemberId ? ( - - ) : null} - - Workspace - - - - - Role - - - -
- -
- - {actionData?.error ? ( -
- {actionData.error.message} -
- ) : null} - -
- - -
-
-
- - ) : null; -} diff --git a/app/routes/_layout+/settings.team.invites.tsx b/app/routes/_layout+/settings.team.invites.tsx index a306e1889..0d41546ec 100644 --- a/app/routes/_layout+/settings.team.invites.tsx +++ b/app/routes/_layout+/settings.team.invites.tsx @@ -11,6 +11,7 @@ import type { HeaderData } from "~/components/layout/header/types"; import { List } from "~/components/list"; import { ListContentWrapper } from "~/components/list/content-wrapper"; import { Filters } from "~/components/list/filters"; +import InviteUserDialog from "~/components/settings/invite-user-dialog"; import { Button } from "~/components/shared/button"; import { InfoTooltip } from "~/components/shared/info-tooltip"; import { Td, Th } from "~/components/table"; @@ -159,13 +160,16 @@ export default function UserInvitesSetting() { - + + Invite a user + + } + /> - + + Invite a user + + } + /> { + throw new ShelfError({ + cause, + message: "Failed to get team member", + additionalData: { teamMemberId, userId }, + label: "Team", + }); + }); + + if (teamMember) { + teamMemberName = teamMember.name; + } + } + + const existingInvites = await db.invite.findMany({ + where: { + status: "PENDING", + inviteeEmail: email, + organizationId, + }, + }); + + if (existingInvites.length) { + throw new ShelfError({ + cause: null, + message: + "User already has a pending invite. Either resend it or cancel it in order to be able to send a new one.", + additionalData: { email, organizationId }, + label: "Invite", + shouldBeCaptured: false, + }); + } + + const invite = await createInvite({ + organizationId, + inviteeEmail: email, + inviterId: userId, + roles: [role], + teamMemberName, + teamMemberId, + userId, + }); + + if (!invite) { + return json(data(null)); + } + + sendNotification({ + title: "Successfully invited user", + message: + "They will receive an email in which they can complete their registration.", + icon: { name: "success", variant: "success" }, + senderId: userId, + }); + + return json(data({ success: true })); + } catch (cause) { + const reason = makeShelfError(cause, { userId }); + return json(error(reason), { status: reason.status }); + } +}