diff --git a/app/components/settings/import-users-dialog/import-users-dialog.tsx b/app/components/settings/import-users-dialog/import-users-dialog.tsx new file mode 100644 index 000000000..6db6cf27e --- /dev/null +++ b/app/components/settings/import-users-dialog/import-users-dialog.tsx @@ -0,0 +1,200 @@ +import { cloneElement, useState } from "react"; +import { useNavigate } from "@remix-run/react"; +import { UploadIcon } from "lucide-react"; +import type { z } from "zod"; +import useFetcherWithReset from "~/hooks/use-fetcher-with-reset"; +import { isFormProcessing } from "~/utils/form"; +import { tw } from "~/utils/tw"; +import Input from "../../forms/input"; +import { Dialog, DialogPortal } from "../../layout/dialog"; +import { Button } from "../../shared/button"; +import { WarningBox } from "../../shared/warning-box"; +import When from "../../when/when"; +import type { InviteUserFormSchema } from "../invite-user-dialog"; +import ImportUsersSuccessContent from "./import-users-success-content"; + +type ImportUsersDialogProps = { + className?: string; + trigger?: React.ReactElement<{ onClick: () => void }>; +}; + +type ImportUser = z.infer; + +export type FetcherData = { + error?: { message?: string }; + success?: boolean; + inviteSentUsers?: ImportUser[]; + skippedUsers?: ImportUser[]; + extraMessage?: string; +}; + +export default function ImportUsersDialog({ + className, + trigger, +}: ImportUsersDialogProps) { + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [selectedFile, setSelectedFile] = useState(); + const [error, setError] = useState(""); + + const navigate = useNavigate(); + + const fetcher = useFetcherWithReset(); + const disabled = isFormProcessing(fetcher.state); + + function openDialog() { + setIsDialogOpen(true); + } + + function closeDialog() { + fetcher.reset(); + setIsDialogOpen(false); + } + + function handleSelectFile(event: React.ChangeEvent) { + setError(""); + + const file = event.target.files?.[0]; + if (file?.type !== "text/csv") { + setError("Invalid file type. Please select a CSV file."); + return; + } + + setSelectedFile(file); + } + + function goToInvites() { + navigate("/settings/team/invites"); + closeDialog(); + } + + return ( + <> + {trigger ? ( + cloneElement(trigger, { onClick: openDialog }) + ) : ( + + )} + + + + + + } + > + {fetcher.data?.success ? ( + + ) : ( +
+

Invite Users via CSV Upload

+

+ Invite multiple users to your organization by uploading a CSV + file. To get started,{" "} + +

+ + <> + IMPORTANT: Use the provided template to + ensure proper formatting. Uploading incorrectly formatted + files may cause errors. + + +

Base Rules and Limitations

+
    +
  • + You must use , (comma) as a delimiter in your CSV file. +
  • +
  • + Only valid roles are ADMIN, BASE and{" "} + SELF_SERVICE. Role column is case-sensitive. +
  • +
  • + Each row represents a new user to be invited. Ensure the email + column is valid. +
  • +
  • + Invited users will receive an email with a link to join the + organization. +
  • +
+ +

Extra Considerations

+
    +
  • + The first row of the sheet will be ignored. Use it for column + headers as in the provided template. +
  • +
+ +

+ Once you've uploaded your file, a summary of the processed + invitations will be displayed, along with any errors + encountered. +

+ + + + + + + +

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

+
+ + +
+
+ )} +
+
+ + ); +} diff --git a/app/components/settings/import-users-dialog/import-users-success-content.tsx b/app/components/settings/import-users-dialog/import-users-success-content.tsx new file mode 100644 index 000000000..4b42a300e --- /dev/null +++ b/app/components/settings/import-users-dialog/import-users-success-content.tsx @@ -0,0 +1,69 @@ +import { ClientOnly } from "remix-utils/client-only"; +import { Button } from "~/components/shared/button"; +import { WarningBox } from "~/components/shared/warning-box"; +import When from "~/components/when/when"; +import SuccessAnimation from "~/components/zxing-scanner/success-animation"; +import { tw } from "~/utils/tw"; +import type { FetcherData } from "./import-users-dialog"; +import ImportUsersTable from "./import-users-table"; + +type ImportUsersSuccessContentProps = { + className?: string; + data: FetcherData; + onClose: () => void; + onViewInvites: () => void; +}; + +export default function ImportUsersSuccessContent({ + className, + data, + onClose, + onViewInvites, +}: ImportUsersSuccessContentProps) { + return ( +
+ {() => } + +

Import completed

+

+ Users from the csv file has been invited. Below you can find a summary + of the invited users. +

+ + + + {data.extraMessage ?? ""} + + + + + + + + + + +
+ + +
+
+ ); +} diff --git a/app/components/settings/import-users-dialog/import-users-table.tsx b/app/components/settings/import-users-dialog/import-users-table.tsx new file mode 100644 index 000000000..f9981de13 --- /dev/null +++ b/app/components/settings/import-users-dialog/import-users-table.tsx @@ -0,0 +1,51 @@ +import type { z } from "zod"; +import { organizationRolesMap } from "~/routes/_layout+/settings.team"; +import { tw } from "~/utils/tw"; +import type { InviteUserFormSchema } from "../invite-user-dialog"; + +type ImportUsersTableProps = { + className?: string; + style?: React.CSSProperties; + title: string; + users: z.infer[]; +}; + +export default function ImportUsersTable({ + className, + style, + title, + users, +}: ImportUsersTableProps) { + return ( +
+

{title}

+ + + + + + + + + + {users.map((user) => ( + + + + + ))} + +
+ Email + + Role +
{user.email}{organizationRolesMap[user.role]}
+
+ ); +} diff --git a/app/components/settings/invite-user-dialog.tsx b/app/components/settings/invite-user-dialog.tsx index 4ef96c4b9..1cb33f76e 100644 --- a/app/components/settings/invite-user-dialog.tsx +++ b/app/components/settings/invite-user-dialog.tsx @@ -39,7 +39,17 @@ export const InviteUserFormSchema = z.object({ message: "Please enter a valid email", })), teamMemberId: z.string().optional(), - role: z.nativeEnum(OrganizationRoles, { message: "Please select a role." }), + role: z.preprocess( + (value) => String(value).trim().toUpperCase(), + z.enum( + [ + OrganizationRoles.ADMIN, + OrganizationRoles.BASE, + OrganizationRoles.SELF_SERVICE, + ], + { message: "Please select a role" } + ) + ), }); const organizationRolesMap: Record = { @@ -121,7 +131,6 @@ export default function InviteUserDialog({ method="post" className="flex flex-col gap-3" > - {/* */} {invite.inviter.firstName} {invite.inviter.lastName} invites you to join Shelf as a member of {invite.organization.name} - ’s workspace. Click the link to accept the invite: + 's workspace. Click the link to accept the invite: + + {extraMessage ? ( +
+ alert-icon + + + Message from sender, please read carefully. + + + + {extraMessage} + +
+ ) : null} +