From bad41f212ec963a299a362023422930ad96ad04d Mon Sep 17 00:00:00 2001
From: Rohit Kumar Saini
Date: Fri, 24 Jan 2025 10:41:15 +0100
Subject: [PATCH 01/11] feat(import-users): create client-side dialog for
importing users
---
.../settings/import-users-dialog.tsx | 144 ++++++++++++++++++
app/routes/_layout+/settings.team.users.tsx | 4 +-
2 files changed, 147 insertions(+), 1 deletion(-)
create mode 100644 app/components/settings/import-users-dialog.tsx
diff --git a/app/components/settings/import-users-dialog.tsx b/app/components/settings/import-users-dialog.tsx
new file mode 100644
index 000000000..bf1493bdf
--- /dev/null
+++ b/app/components/settings/import-users-dialog.tsx
@@ -0,0 +1,144 @@
+import { cloneElement, useState } from "react";
+import { useFetcher } from "@remix-run/react";
+import { UploadIcon } from "lucide-react";
+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";
+
+type ImportUsersDialogProps = {
+ className?: string;
+ trigger?: React.ReactElement<{ onClick: () => void }>;
+};
+
+export default function ImportUsersDialog({
+ className,
+ trigger,
+}: ImportUsersDialogProps) {
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+ const [selectedFile, setSelectedFile] = useState();
+ const [error, setError] = useState("");
+
+ const fetcher = useFetcher();
+
+ function openDialog() {
+ setIsDialogOpen(true);
+ }
+
+ function closeDialog() {
+ 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);
+ }
+
+ return (
+ <>
+ {trigger ? (
+ cloneElement(trigger, { onClick: openDialog })
+ ) : (
+
+ )}
+
+
+
+
+ >
+ );
+}
diff --git a/app/routes/_layout+/settings.team.users.tsx b/app/routes/_layout+/settings.team.users.tsx
index a629342d4..d7009075d 100644
--- a/app/routes/_layout+/settings.team.users.tsx
+++ b/app/routes/_layout+/settings.team.users.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 ImportUsersDialog from "~/components/settings/import-users-dialog";
import InviteUserDialog from "~/components/settings/invite-user-dialog";
import { Button } from "~/components/shared/button";
import { InfoTooltip } from "~/components/shared/info-tooltip";
@@ -160,10 +161,11 @@ export default function UserTeamSetting() {
+
Invite a user
From e4f40e34359e519b8274e1851ff14cb62bcb451c Mon Sep 17 00:00:00 2001
From: Rohit Kumar Saini
Date: Fri, 24 Jan 2025 12:56:32 +0100
Subject: [PATCH 02/11] feat(import-users): create backend logic for import
users
---
.../settings/import-users-dialog.tsx | 29 ++++++-
.../settings/invite-user-dialog.tsx | 1 -
app/modules/invite/service.server.ts | 53 +++++++++++-
app/routes/api+/settings.import-users.ts | 86 +++++++++++++++++++
...f.nu-example-import-users-from-content.csv | 2 +
5 files changed, 165 insertions(+), 6 deletions(-)
create mode 100644 app/routes/api+/settings.import-users.ts
create mode 100644 public/static/shelf.nu-example-import-users-from-content.csv
diff --git a/app/components/settings/import-users-dialog.tsx b/app/components/settings/import-users-dialog.tsx
index bf1493bdf..e78dffdfb 100644
--- a/app/components/settings/import-users-dialog.tsx
+++ b/app/components/settings/import-users-dialog.tsx
@@ -1,11 +1,13 @@
import { cloneElement, useState } from "react";
import { useFetcher } from "@remix-run/react";
import { UploadIcon } from "lucide-react";
+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";
type ImportUsersDialogProps = {
className?: string;
@@ -20,7 +22,11 @@ export default function ImportUsersDialog({
const [selectedFile, setSelectedFile] = useState();
const [error, setError] = useState("");
- const fetcher = useFetcher();
+ const fetcher = useFetcher<{
+ error?: { message?: string };
+ success?: boolean;
+ }>();
+ const disabled = isFormProcessing(fetcher.state);
function openDialog() {
setIsDialogOpen(true);
@@ -74,7 +80,7 @@ export default function ImportUsersDialog({
file. To get started,{" "}
-
+
-
+
+
+ {fetcher.data?.error?.message}
+
+
+
+
diff --git a/app/components/settings/invite-user-dialog.tsx b/app/components/settings/invite-user-dialog.tsx
index 4ef96c4b9..ca3bc5820 100644
--- a/app/components/settings/invite-user-dialog.tsx
+++ b/app/components/settings/invite-user-dialog.tsx
@@ -121,7 +121,6 @@ export default function InviteUserDialog({
method="post"
className="flex flex-col gap-3"
>
- {/* */}
[];
+ userId: User["id"];
+ organizationId: Organization["id"];
+}) {
+ try {
+ for (const user of users) {
+ const existingInvite = await db.invite.count({
+ where: {
+ status: "PENDING",
+ inviteeEmail: user.email,
+ organizationId,
+ },
+ });
+
+ /** If user is already invited, then we do not invite him/her again. */
+ if (existingInvite) {
+ continue;
+ }
+
+ await createInvite({
+ organizationId,
+ inviteeEmail: user.email,
+ inviterId: userId,
+ roles: [user.role],
+ teamMemberName: user.email.split("@")[0],
+ userId,
+ });
+ }
+ } catch (cause) {
+ throw new ShelfError({
+ cause,
+ message: "Something went wrong while inviting users.",
+ label,
+ additionalData: { users },
+ });
+ }
+}
diff --git a/app/routes/api+/settings.import-users.ts b/app/routes/api+/settings.import-users.ts
new file mode 100644
index 000000000..e950d6cd4
--- /dev/null
+++ b/app/routes/api+/settings.import-users.ts
@@ -0,0 +1,86 @@
+import { OrganizationRoles } from "@prisma/client";
+import { json, type ActionFunctionArgs } from "@remix-run/node";
+import { z } from "zod";
+import { bulkInviteUsers } from "~/modules/team-member/service.server";
+import { csvDataFromRequest } from "~/utils/csv.server";
+import { sendNotification } from "~/utils/emitter/send-notification.server";
+import { makeShelfError, ShelfError } from "~/utils/error";
+import { assertIsPost, data, error } from "~/utils/http.server";
+import { extractCSVDataFromContentImport } from "~/utils/import.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";
+
+export const importUsersSchema = z.object({
+ role: z.preprocess(
+ (value) => String(value).trim().toUpperCase(),
+ z.enum([
+ OrganizationRoles.ADMIN,
+ OrganizationRoles.BASE,
+ OrganizationRoles.SELF_SERVICE,
+ ])
+ ),
+ email: z
+ .string()
+ .transform((email) => email.toLowerCase())
+ .refine(validEmail, () => ({
+ message: "Please enter a valid email",
+ })),
+});
+
+export async function action({ context, request }: ActionFunctionArgs) {
+ const authSession = context.getSession();
+ const { userId } = authSession;
+
+ try {
+ assertIsPost(request);
+
+ const { organizationId } = await requirePermission({
+ userId,
+ request,
+ entity: PermissionEntity.teamMember,
+ action: PermissionAction.create,
+ });
+
+ await assertUserCanInviteUsersToWorkspace({ organizationId });
+
+ const csvData = await csvDataFromRequest({ request });
+ if (csvData.length < 2) {
+ throw new ShelfError({
+ cause: null,
+ message: "CSV file is empty",
+ additionalData: { userId },
+ label: "Team Member",
+ shouldBeCaptured: false,
+ });
+ }
+
+ const users = extractCSVDataFromContentImport(
+ csvData,
+ importUsersSchema.array()
+ );
+
+ await bulkInviteUsers({
+ organizationId,
+ userId,
+ users,
+ });
+
+ sendNotification({
+ title: "Successfully invited users",
+ 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 });
+ }
+}
diff --git a/public/static/shelf.nu-example-import-users-from-content.csv b/public/static/shelf.nu-example-import-users-from-content.csv
new file mode 100644
index 000000000..dbe779696
--- /dev/null
+++ b/public/static/shelf.nu-example-import-users-from-content.csv
@@ -0,0 +1,2 @@
+role,email
+ADMIN,rohitsaini.codes+1@gmail.com
\ No newline at end of file
From b3628f946573b971375d278c1076ea0c3d369dde Mon Sep 17 00:00:00 2001
From: Rohit Kumar Saini
Date: Fri, 24 Jan 2025 13:23:19 +0100
Subject: [PATCH 03/11] feat(import-users): fix schema for invite user
---
.../settings/invite-user-dialog.tsx | 12 +++++++++-
app/modules/invite/service.server.ts | 4 ++--
app/routes/api+/settings.import-users.ts | 24 +++----------------
3 files changed, 16 insertions(+), 24 deletions(-)
diff --git a/app/components/settings/invite-user-dialog.tsx b/app/components/settings/invite-user-dialog.tsx
index ca3bc5820..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 = {
diff --git a/app/modules/invite/service.server.ts b/app/modules/invite/service.server.ts
index 6efa51b43..993fae09e 100644
--- a/app/modules/invite/service.server.ts
+++ b/app/modules/invite/service.server.ts
@@ -9,11 +9,11 @@ import { InviteStatuses } from "@prisma/client";
import type { AppLoadContext, LoaderFunctionArgs } from "@remix-run/node";
import jwt from "jsonwebtoken";
import type { z } from "zod";
+import type { InviteUserFormSchema } from "~/components/settings/invite-user-dialog";
import { db } from "~/database/db.server";
import { invitationTemplateString } from "~/emails/invite-template";
import { sendEmail } from "~/emails/mail.server";
import { organizationRolesMap } from "~/routes/_layout+/settings.team";
-import type { importUsersSchema } from "~/routes/api+/settings.import-users";
import { INVITE_EXPIRY_TTL_DAYS } from "~/utils/constants";
import { updateCookieWithPerPage } from "~/utils/cookies.server";
import { sendNotification } from "~/utils/emitter/send-notification.server";
@@ -546,7 +546,7 @@ export async function bulkInviteUsers({
userId,
organizationId,
}: {
- users: z.infer[];
+ users: z.infer[];
userId: User["id"];
organizationId: Organization["id"];
}) {
diff --git a/app/routes/api+/settings.import-users.ts b/app/routes/api+/settings.import-users.ts
index e950d6cd4..196027782 100644
--- a/app/routes/api+/settings.import-users.ts
+++ b/app/routes/api+/settings.import-users.ts
@@ -1,13 +1,12 @@
-import { OrganizationRoles } from "@prisma/client";
import { json, type ActionFunctionArgs } from "@remix-run/node";
import { z } from "zod";
-import { bulkInviteUsers } from "~/modules/team-member/service.server";
+import { InviteUserFormSchema } from "~/components/settings/invite-user-dialog";
+import { bulkInviteUsers } from "~/modules/invite/service.server";
import { csvDataFromRequest } from "~/utils/csv.server";
import { sendNotification } from "~/utils/emitter/send-notification.server";
import { makeShelfError, ShelfError } from "~/utils/error";
import { assertIsPost, data, error } from "~/utils/http.server";
import { extractCSVDataFromContentImport } from "~/utils/import.server";
-import { validEmail } from "~/utils/misc";
import {
PermissionAction,
PermissionEntity,
@@ -15,23 +14,6 @@ import {
import { requirePermission } from "~/utils/roles.server";
import { assertUserCanInviteUsersToWorkspace } from "~/utils/subscription.server";
-export const importUsersSchema = z.object({
- role: z.preprocess(
- (value) => String(value).trim().toUpperCase(),
- z.enum([
- OrganizationRoles.ADMIN,
- OrganizationRoles.BASE,
- OrganizationRoles.SELF_SERVICE,
- ])
- ),
- email: z
- .string()
- .transform((email) => email.toLowerCase())
- .refine(validEmail, () => ({
- message: "Please enter a valid email",
- })),
-});
-
export async function action({ context, request }: ActionFunctionArgs) {
const authSession = context.getSession();
const { userId } = authSession;
@@ -61,7 +43,7 @@ export async function action({ context, request }: ActionFunctionArgs) {
const users = extractCSVDataFromContentImport(
csvData,
- importUsersSchema.array()
+ InviteUserFormSchema.array()
);
await bulkInviteUsers({
From d69a2784363d62450dd1b89fd0375a5a408d07ac Mon Sep 17 00:00:00 2001
From: Rohit Kumar Saini
Date: Fri, 24 Jan 2025 15:38:10 +0100
Subject: [PATCH 04/11] feat(import-users): close dialog on success
---
.../settings/import-users-dialog.tsx | 22 ++++++++++++++-----
app/routes/api+/settings.import-users.ts | 1 -
2 files changed, 16 insertions(+), 7 deletions(-)
diff --git a/app/components/settings/import-users-dialog.tsx b/app/components/settings/import-users-dialog.tsx
index e78dffdfb..8299a8b67 100644
--- a/app/components/settings/import-users-dialog.tsx
+++ b/app/components/settings/import-users-dialog.tsx
@@ -1,6 +1,6 @@
-import { cloneElement, useState } from "react";
-import { useFetcher } from "@remix-run/react";
+import { cloneElement, useCallback, useEffect, useState } from "react";
import { UploadIcon } from "lucide-react";
+import useFetcherWithReset from "~/hooks/use-fetcher-with-reset";
import { isFormProcessing } from "~/utils/form";
import { tw } from "~/utils/tw";
import Input from "../forms/input";
@@ -22,7 +22,7 @@ export default function ImportUsersDialog({
const [selectedFile, setSelectedFile] = useState();
const [error, setError] = useState("");
- const fetcher = useFetcher<{
+ const fetcher = useFetcherWithReset<{
error?: { message?: string };
success?: boolean;
}>();
@@ -32,9 +32,9 @@ export default function ImportUsersDialog({
setIsDialogOpen(true);
}
- function closeDialog() {
+ const closeDialog = useCallback(() => {
setIsDialogOpen(false);
- }
+ }, []);
function handleSelectFile(event: React.ChangeEvent) {
setError("");
@@ -48,13 +48,23 @@ export default function ImportUsersDialog({
setSelectedFile(file);
}
+ useEffect(
+ function handleSuccess() {
+ if (fetcher.data?.success === true) {
+ closeDialog();
+ fetcher.reset();
+ }
+ },
+ [closeDialog, fetcher, fetcher.data?.success]
+ );
+
return (
<>
{trigger ? (
cloneElement(trigger, { onClick: openDialog })
) : (
)}
+
+ )}
>
From d9e7229c421a00f80dfb4b80a8afe4067c01b65e Mon Sep 17 00:00:00 2001
From: Rohit Kumar Saini
Date: Wed, 29 Jan 2025 21:25:56 +0100
Subject: [PATCH 10/11] feat(import-users): showing skipped users table, add
extraMessage, update colors in email
---
.../import-users-dialog.tsx | 61 ++++++++---------
.../import-users-success-content.tsx | 68 +++++++++++++++++++
.../import-users-table.tsx | 50 ++++++++++++++
app/emails/invite-template.tsx | 8 +--
app/modules/invite/service.server.ts | 47 ++++++++++++-
app/routes/_layout+/settings.team.users.tsx | 2 +-
app/routes/api+/settings.import-users.ts | 8 ++-
7 files changed, 199 insertions(+), 45 deletions(-)
rename app/components/settings/{ => import-users-dialog}/import-users-dialog.tsx (79%)
create mode 100644 app/components/settings/import-users-dialog/import-users-success-content.tsx
create mode 100644 app/components/settings/import-users-dialog/import-users-table.tsx
diff --git a/app/components/settings/import-users-dialog.tsx b/app/components/settings/import-users-dialog/import-users-dialog.tsx
similarity index 79%
rename from app/components/settings/import-users-dialog.tsx
rename to app/components/settings/import-users-dialog/import-users-dialog.tsx
index fea31c924..6db6cf27e 100644
--- a/app/components/settings/import-users-dialog.tsx
+++ b/app/components/settings/import-users-dialog/import-users-dialog.tsx
@@ -1,22 +1,33 @@
import { cloneElement, useState } from "react";
import { useNavigate } from "@remix-run/react";
import { UploadIcon } from "lucide-react";
-import { ClientOnly } from "remix-utils/client-only";
+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 SuccessAnimation from "../zxing-scanner/success-animation";
+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,
@@ -27,10 +38,7 @@ export default function ImportUsersDialog({
const navigate = useNavigate();
- const fetcher = useFetcherWithReset<{
- error?: { message?: string };
- success?: boolean;
- }>();
+ const fetcher = useFetcherWithReset();
const disabled = isFormProcessing(fetcher.state);
function openDialog() {
@@ -76,9 +84,8 @@ export default function ImportUsersDialog({