From 333d1610fad10e70759545f223959503288a02c6 Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Thu, 10 Jul 2025 19:34:31 +0000 Subject: feat: Add invite user support --- apps/web/components/admin/CreateInviteDialog.tsx | 134 ++++++++++++++ apps/web/components/admin/InvitesList.tsx | 192 ++++++++++++++++++++ apps/web/components/admin/UserList.tsx | 214 ++++++++++++----------- 3 files changed, 438 insertions(+), 102 deletions(-) create mode 100644 apps/web/components/admin/CreateInviteDialog.tsx create mode 100644 apps/web/components/admin/InvitesList.tsx (limited to 'apps/web/components/admin') diff --git a/apps/web/components/admin/CreateInviteDialog.tsx b/apps/web/components/admin/CreateInviteDialog.tsx new file mode 100644 index 00000000..84f5c60f --- /dev/null +++ b/apps/web/components/admin/CreateInviteDialog.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useState } from "react"; +import { ActionButton } from "@/components/ui/action-button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { TRPCClientError } from "@trpc/client"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const createInviteSchema = z.object({ + email: z.string().email("Please enter a valid email address"), +}); + +interface CreateInviteDialogProps { + children: React.ReactNode; +} + +export default function CreateInviteDialog({ + children, +}: CreateInviteDialogProps) { + const [open, setOpen] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + const form = useForm>({ + resolver: zodResolver(createInviteSchema), + defaultValues: { + email: "", + }, + }); + + const invalidateInvitesList = api.useUtils().invites.list.invalidate; + const createInviteMutation = api.invites.create.useMutation({ + onSuccess: () => { + toast({ + description: "Invite sent successfully", + }); + invalidateInvitesList(); + setOpen(false); + form.reset(); + setErrorMessage(""); + }, + onError: (e) => { + if (e instanceof TRPCClientError) { + setErrorMessage(e.message); + } else { + setErrorMessage("Failed to send invite"); + } + }, + }); + + return ( + + {children} + + + Send User Invitation + + Send an invitation to a new user to join Karakeep. They'll + receive an email with instructions to create their account and will + be assigned the "user" role. + + + +
+ { + setErrorMessage(""); + await createInviteMutation.mutateAsync(value); + })} + className="space-y-4" + > + {errorMessage && ( +

{errorMessage}

+ )} + + ( + + Email Address + + + + + + )} + /> + +
+ setOpen(false)} + > + Cancel + + + Send Invitation + +
+ + +
+
+ ); +} diff --git a/apps/web/components/admin/InvitesList.tsx b/apps/web/components/admin/InvitesList.tsx new file mode 100644 index 00000000..56d47fa9 --- /dev/null +++ b/apps/web/components/admin/InvitesList.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { ActionButton } from "@/components/ui/action-button"; +import { ButtonWithTooltip } from "@/components/ui/button"; +import LoadingSpinner from "@/components/ui/spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { formatDistanceToNow } from "date-fns"; +import { Mail, MailX, UserPlus } from "lucide-react"; + +import ActionConfirmingDialog from "../ui/action-confirming-dialog"; +import CreateInviteDialog from "./CreateInviteDialog"; + +export default function InvitesList() { + const invalidateInvitesList = api.useUtils().invites.list.invalidate; + const { data: invites, isLoading } = api.invites.list.useQuery(); + + const { mutateAsync: revokeInvite, isPending: isRevokePending } = + api.invites.revoke.useMutation({ + onSuccess: () => { + toast({ + description: "Invite revoked successfully", + }); + invalidateInvitesList(); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: `Failed to revoke invite: ${e.message}`, + }); + }, + }); + + const { mutateAsync: resendInvite, isPending: isResendPending } = + api.invites.resend.useMutation({ + onSuccess: () => { + toast({ + description: "Invite resent successfully", + }); + invalidateInvitesList(); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: `Failed to resend invite: ${e.message}`, + }); + }, + }); + + if (isLoading) { + return ; + } + + const activeInvites = + invites?.invites?.filter( + (invite) => new Date(invite.expiresAt) > new Date(), + ) || []; + + const expiredInvites = + invites?.invites?.filter( + (invite) => new Date(invite.expiresAt) <= new Date(), + ) || []; + + const getStatusBadge = ( + invite: NonNullable["invites"][0], + ) => { + if (new Date(invite.expiresAt) <= new Date()) { + return ( + + Expired + + ); + } + return ( + + Active + + ); + }; + + const InviteTable = ({ + invites: inviteList, + title, + }: { + invites: NonNullable["invites"]; + title: string; + }) => ( +
+

+ {title} ({inviteList.length}) +

+ {inviteList.length === 0 ? ( +

+ No {title.toLowerCase()} invites +

+ ) : ( + + + Email + Invited By + Created + Expires + Status + Actions + + + {inviteList.map((invite) => ( + + {invite.email} + {invite.invitedBy.name} + + {formatDistanceToNow(new Date(invite.createdAt), { + addSuffix: true, + })} + + + {formatDistanceToNow(new Date(invite.expiresAt), { + addSuffix: true, + })} + + {getStatusBadge(invite)} + + {new Date(invite.expiresAt) > new Date() && ( + <> + resendInvite({ inviteId: invite.id })} + disabled={isResendPending} + > + + + ( + { + await revokeInvite({ inviteId: invite.id }); + setDialogOpen(false); + }} + > + Revoke + + )} + > + + + + + + )} + + + ))} + +
+ )} +
+ ); + + return ( +
+
+ User Invitations + + + + + +
+ + + +
+ ); +} diff --git a/apps/web/components/admin/UserList.tsx b/apps/web/components/admin/UserList.tsx index 2dd86277..3313fe60 100644 --- a/apps/web/components/admin/UserList.tsx +++ b/apps/web/components/admin/UserList.tsx @@ -19,6 +19,8 @@ import { useSession } from "next-auth/react"; import ActionConfirmingDialog from "../ui/action-confirming-dialog"; import AddUserDialog from "./AddUserDialog"; +import { AdminCard } from "./AdminCard"; +import InvitesList from "./InvitesList"; import ResetPasswordDialog from "./ResetPasswordDialog"; import UpdateUserDialog from "./UpdateUserDialog"; @@ -57,110 +59,118 @@ export default function UsersSection() { return (
-
- {t("admin.users_list.users_list")} - - - - - -
+ +
+
+ {t("admin.users_list.users_list")} + + + + + +
- - - {t("common.name")} - {t("common.email")} - {t("admin.users_list.num_bookmarks")} - {t("common.quota")} - Storage Quota - {t("admin.users_list.asset_sizes")} - {t("common.role")} - {t("admin.users_list.local_user")} - {t("common.actions")} - - - {users.users.map((u) => ( - - {u.name} - {u.email} - - {userStats[u.id].numBookmarks} - - - {u.bookmarkQuota ?? t("admin.users_list.unlimited")} - - - {u.storageQuota - ? toHumanReadableSize(u.storageQuota) - : t("admin.users_list.unlimited")} - - - {toHumanReadableSize(userStats[u.id].assetSizes)} - - - {u.role && t(`common.roles.${u.role}`)} - - - {u.localUser ? : } - - - ( - { - await deleteUser({ userId: u.id }); - setDialogOpen(false); - }} +
+ + {t("common.name")} + {t("common.email")} + {t("admin.users_list.num_bookmarks")} + {t("common.quota")} + Storage Quota + {t("admin.users_list.asset_sizes")} + {t("common.role")} + {t("admin.users_list.local_user")} + {t("common.actions")} + + + {users.users.map((u) => ( + + {u.name} + {u.email} + + {userStats[u.id].numBookmarks} + + + {u.bookmarkQuota ?? t("admin.users_list.unlimited")} + + + {u.storageQuota + ? toHumanReadableSize(u.storageQuota) + : t("admin.users_list.unlimited")} + + + {toHumanReadableSize(userStats[u.id].assetSizes)} + + + {u.role && t(`common.roles.${u.role}`)} + + + {u.localUser ? : } + + + ( + { + await deleteUser({ userId: u.id }); + setDialogOpen(false); + }} + > + Delete + + )} > - Delete - - )} - > - - - - - - - - - - - - - - - - - ))} - -
+ + + + + + + + + + + + + + + + + ))} + + +
+
+ + + +
); } -- cgit v1.2.3-70-g09d2