diff options
Diffstat (limited to 'apps/web/components/admin')
| -rw-r--r-- | apps/web/components/admin/CreateInviteDialog.tsx | 134 | ||||
| -rw-r--r-- | apps/web/components/admin/InvitesList.tsx | 192 | ||||
| -rw-r--r-- | apps/web/components/admin/UserList.tsx | 214 |
3 files changed, 438 insertions, 102 deletions
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<z.infer<typeof createInviteSchema>>({ + 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 ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild>{children}</DialogTrigger> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle>Send User Invitation</DialogTitle> + <DialogDescription> + 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. + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form + onSubmit={form.handleSubmit(async (value) => { + setErrorMessage(""); + await createInviteMutation.mutateAsync(value); + })} + className="space-y-4" + > + {errorMessage && ( + <p className="text-sm text-destructive">{errorMessage}</p> + )} + + <FormField + control={form.control} + name="email" + render={({ field }) => ( + <FormItem> + <FormLabel>Email Address</FormLabel> + <FormControl> + <Input + type="email" + placeholder="user@example.com" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <div className="flex justify-end space-x-2"> + <ActionButton + type="button" + variant="outline" + loading={false} + onClick={() => setOpen(false)} + > + Cancel + </ActionButton> + <ActionButton + type="submit" + loading={createInviteMutation.isPending} + > + Send Invitation + </ActionButton> + </div> + </form> + </Form> + </DialogContent> + </Dialog> + ); +} 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 <LoadingSpinner />; + } + + 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<typeof invites>["invites"][0], + ) => { + if (new Date(invite.expiresAt) <= new Date()) { + return ( + <span className="rounded-full bg-red-100 px-2 py-1 text-xs text-red-800"> + Expired + </span> + ); + } + return ( + <span className="rounded-full bg-blue-100 px-2 py-1 text-xs text-blue-800"> + Active + </span> + ); + }; + + const InviteTable = ({ + invites: inviteList, + title, + }: { + invites: NonNullable<typeof invites>["invites"]; + title: string; + }) => ( + <div className="mb-6"> + <h3 className="mb-3 text-lg font-medium"> + {title} ({inviteList.length}) + </h3> + {inviteList.length === 0 ? ( + <p className="text-sm text-gray-500"> + No {title.toLowerCase()} invites + </p> + ) : ( + <Table> + <TableHeader className="bg-gray-200"> + <TableHead>Email</TableHead> + <TableHead>Invited By</TableHead> + <TableHead>Created</TableHead> + <TableHead>Expires</TableHead> + <TableHead>Status</TableHead> + <TableHead>Actions</TableHead> + </TableHeader> + <TableBody> + {inviteList.map((invite) => ( + <TableRow key={invite.id}> + <TableCell className="py-2">{invite.email}</TableCell> + <TableCell className="py-2">{invite.invitedBy.name}</TableCell> + <TableCell className="py-2"> + {formatDistanceToNow(new Date(invite.createdAt), { + addSuffix: true, + })} + </TableCell> + <TableCell className="py-2"> + {formatDistanceToNow(new Date(invite.expiresAt), { + addSuffix: true, + })} + </TableCell> + <TableCell className="py-2">{getStatusBadge(invite)}</TableCell> + <TableCell className="flex gap-1 py-2"> + {new Date(invite.expiresAt) > new Date() && ( + <> + <ButtonWithTooltip + tooltip="Resend Invite" + variant="outline" + size="sm" + onClick={() => resendInvite({ inviteId: invite.id })} + disabled={isResendPending} + > + <Mail size={14} /> + </ButtonWithTooltip> + <ActionConfirmingDialog + title="Revoke Invite" + description={`Are you sure you want to revoke the invite for ${invite.email}? This action cannot be undone.`} + actionButton={(setDialogOpen) => ( + <ActionButton + variant="destructive" + loading={isRevokePending} + onClick={async () => { + await revokeInvite({ inviteId: invite.id }); + setDialogOpen(false); + }} + > + Revoke + </ActionButton> + )} + > + <ButtonWithTooltip + tooltip="Revoke Invite" + variant="outline" + size="sm" + > + <MailX size={14} color="red" /> + </ButtonWithTooltip> + </ActionConfirmingDialog> + </> + )} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + )} + </div> + ); + + return ( + <div className="flex flex-col gap-4"> + <div className="mb-2 flex items-center justify-between text-xl font-medium"> + <span>User Invitations</span> + <CreateInviteDialog> + <ButtonWithTooltip tooltip="Send Invite" variant="outline"> + <UserPlus size={16} /> + </ButtonWithTooltip> + </CreateInviteDialog> + </div> + + <InviteTable invites={activeInvites} title="Active Invites" /> + <InviteTable invites={expiredInvites} title="Expired Invites" /> + </div> + ); +} 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 ( <div className="flex flex-col gap-4"> - <div className="mb-2 flex items-center justify-between text-xl font-medium"> - <span>{t("admin.users_list.users_list")}</span> - <AddUserDialog> - <ButtonWithTooltip tooltip="Create User" variant="outline"> - <UserPlus size={16} /> - </ButtonWithTooltip> - </AddUserDialog> - </div> + <AdminCard> + <div className="flex flex-col gap-4"> + <div className="mb-2 flex items-center justify-between text-xl font-medium"> + <span>{t("admin.users_list.users_list")}</span> + <AddUserDialog> + <ButtonWithTooltip tooltip="Create User" variant="outline"> + <UserPlus size={16} /> + </ButtonWithTooltip> + </AddUserDialog> + </div> - <Table> - <TableHeader className="bg-gray-200"> - <TableHead>{t("common.name")}</TableHead> - <TableHead>{t("common.email")}</TableHead> - <TableHead>{t("admin.users_list.num_bookmarks")}</TableHead> - <TableHead>{t("common.quota")}</TableHead> - <TableHead>Storage Quota</TableHead> - <TableHead>{t("admin.users_list.asset_sizes")}</TableHead> - <TableHead>{t("common.role")}</TableHead> - <TableHead>{t("admin.users_list.local_user")}</TableHead> - <TableHead>{t("common.actions")}</TableHead> - </TableHeader> - <TableBody> - {users.users.map((u) => ( - <TableRow key={u.id}> - <TableCell className="py-1">{u.name}</TableCell> - <TableCell className="py-1">{u.email}</TableCell> - <TableCell className="py-1"> - {userStats[u.id].numBookmarks} - </TableCell> - <TableCell className="py-1"> - {u.bookmarkQuota ?? t("admin.users_list.unlimited")} - </TableCell> - <TableCell className="py-1"> - {u.storageQuota - ? toHumanReadableSize(u.storageQuota) - : t("admin.users_list.unlimited")} - </TableCell> - <TableCell className="py-1"> - {toHumanReadableSize(userStats[u.id].assetSizes)} - </TableCell> - <TableCell className="py-1"> - {u.role && t(`common.roles.${u.role}`)} - </TableCell> - <TableCell className="py-1"> - {u.localUser ? <Check /> : <X />} - </TableCell> - <TableCell className="flex gap-1 py-1"> - <ActionConfirmingDialog - title={t("admin.users_list.delete_user")} - description={t( - "admin.users_list.delete_user_confirm_description", - { - name: u.name ?? "this user", - }, - )} - actionButton={(setDialogOpen) => ( - <ActionButton - variant="destructive" - loading={isDeletionPending} - onClick={async () => { - await deleteUser({ userId: u.id }); - setDialogOpen(false); - }} + <Table> + <TableHeader className="bg-gray-200"> + <TableHead>{t("common.name")}</TableHead> + <TableHead>{t("common.email")}</TableHead> + <TableHead>{t("admin.users_list.num_bookmarks")}</TableHead> + <TableHead>{t("common.quota")}</TableHead> + <TableHead>Storage Quota</TableHead> + <TableHead>{t("admin.users_list.asset_sizes")}</TableHead> + <TableHead>{t("common.role")}</TableHead> + <TableHead>{t("admin.users_list.local_user")}</TableHead> + <TableHead>{t("common.actions")}</TableHead> + </TableHeader> + <TableBody> + {users.users.map((u) => ( + <TableRow key={u.id}> + <TableCell className="py-1">{u.name}</TableCell> + <TableCell className="py-1">{u.email}</TableCell> + <TableCell className="py-1"> + {userStats[u.id].numBookmarks} + </TableCell> + <TableCell className="py-1"> + {u.bookmarkQuota ?? t("admin.users_list.unlimited")} + </TableCell> + <TableCell className="py-1"> + {u.storageQuota + ? toHumanReadableSize(u.storageQuota) + : t("admin.users_list.unlimited")} + </TableCell> + <TableCell className="py-1"> + {toHumanReadableSize(userStats[u.id].assetSizes)} + </TableCell> + <TableCell className="py-1"> + {u.role && t(`common.roles.${u.role}`)} + </TableCell> + <TableCell className="py-1"> + {u.localUser ? <Check /> : <X />} + </TableCell> + <TableCell className="flex gap-1 py-1"> + <ActionConfirmingDialog + title={t("admin.users_list.delete_user")} + description={t( + "admin.users_list.delete_user_confirm_description", + { + name: u.name ?? "this user", + }, + )} + actionButton={(setDialogOpen) => ( + <ActionButton + variant="destructive" + loading={isDeletionPending} + onClick={async () => { + await deleteUser({ userId: u.id }); + setDialogOpen(false); + }} + > + Delete + </ActionButton> + )} > - Delete - </ActionButton> - )} - > - <ButtonWithTooltip - tooltip={t("admin.users_list.delete_user")} - variant="outline" - disabled={session!.user.id == u.id} - > - <Trash size={16} color="red" /> - </ButtonWithTooltip> - </ActionConfirmingDialog> - <ResetPasswordDialog userId={u.id}> - <ButtonWithTooltip - tooltip={t("admin.users_list.reset_password")} - variant="outline" - disabled={session!.user.id == u.id || !u.localUser} - > - <KeyRound size={16} color="red" /> - </ButtonWithTooltip> - </ResetPasswordDialog> - <UpdateUserDialog - userId={u.id} - currentRole={u.role!} - currentQuota={u.bookmarkQuota} - currentStorageQuota={u.storageQuota} - > - <ButtonWithTooltip - tooltip="Edit User" - variant="outline" - disabled={session!.user.id == u.id} - > - <Pencil size={16} color="red" /> - </ButtonWithTooltip> - </UpdateUserDialog> - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> + <ButtonWithTooltip + tooltip={t("admin.users_list.delete_user")} + variant="outline" + disabled={session!.user.id == u.id} + > + <Trash size={16} color="red" /> + </ButtonWithTooltip> + </ActionConfirmingDialog> + <ResetPasswordDialog userId={u.id}> + <ButtonWithTooltip + tooltip={t("admin.users_list.reset_password")} + variant="outline" + disabled={session!.user.id == u.id || !u.localUser} + > + <KeyRound size={16} color="red" /> + </ButtonWithTooltip> + </ResetPasswordDialog> + <UpdateUserDialog + userId={u.id} + currentRole={u.role!} + currentQuota={u.bookmarkQuota} + currentStorageQuota={u.storageQuota} + > + <ButtonWithTooltip + tooltip="Edit User" + variant="outline" + disabled={session!.user.id == u.id} + > + <Pencil size={16} color="red" /> + </ButtonWithTooltip> + </UpdateUserDialog> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + </AdminCard> + + <AdminCard> + <InvitesList /> + </AdminCard> </div> ); } |
