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/app/admin/background_jobs/page.tsx | 7 +- apps/web/app/admin/layout.tsx | 3 +- apps/web/app/admin/overview/page.tsx | 7 +- apps/web/app/invite/[token]/page.tsx | 28 ++ apps/web/components/admin/CreateInviteDialog.tsx | 134 ++++++++++ apps/web/components/admin/InvitesList.tsx | 192 ++++++++++++++ apps/web/components/admin/UserList.tsx | 214 ++++++++-------- apps/web/components/invite/InviteAcceptForm.tsx | 311 +++++++++++++++++++++++ 8 files changed, 790 insertions(+), 106 deletions(-) create mode 100644 apps/web/app/invite/[token]/page.tsx create mode 100644 apps/web/components/admin/CreateInviteDialog.tsx create mode 100644 apps/web/components/admin/InvitesList.tsx create mode 100644 apps/web/components/invite/InviteAcceptForm.tsx (limited to 'apps/web') diff --git a/apps/web/app/admin/background_jobs/page.tsx b/apps/web/app/admin/background_jobs/page.tsx index 6a13dd64..92b9e370 100644 --- a/apps/web/app/admin/background_jobs/page.tsx +++ b/apps/web/app/admin/background_jobs/page.tsx @@ -1,5 +1,10 @@ +import { AdminCard } from "@/components/admin/AdminCard"; import BackgroundJobs from "@/components/admin/BackgroundJobs"; export default function BackgroundJobsPage() { - return ; + return ( + + + + ); } diff --git a/apps/web/app/admin/layout.tsx b/apps/web/app/admin/layout.tsx index 20bd38bb..62a6932a 100644 --- a/apps/web/app/admin/layout.tsx +++ b/apps/web/app/admin/layout.tsx @@ -1,5 +1,4 @@ import { redirect } from "next/navigation"; -import { AdminCard } from "@/components/admin/AdminCard"; import { AdminNotices } from "@/components/admin/AdminNotices"; import MobileSidebar from "@/components/shared/sidebar/MobileSidebar"; import Sidebar from "@/components/shared/sidebar/Sidebar"; @@ -54,7 +53,7 @@ export default async function AdminLayout({ >
- {children} + {children}
); diff --git a/apps/web/app/admin/overview/page.tsx b/apps/web/app/admin/overview/page.tsx index 226fb9d5..fe463058 100644 --- a/apps/web/app/admin/overview/page.tsx +++ b/apps/web/app/admin/overview/page.tsx @@ -1,5 +1,10 @@ +import { AdminCard } from "@/components/admin/AdminCard"; import ServerStats from "@/components/admin/ServerStats"; export default function AdminOverviewPage() { - return ; + return ( + + + + ); } diff --git a/apps/web/app/invite/[token]/page.tsx b/apps/web/app/invite/[token]/page.tsx new file mode 100644 index 00000000..874146fc --- /dev/null +++ b/apps/web/app/invite/[token]/page.tsx @@ -0,0 +1,28 @@ +import { redirect } from "next/navigation"; +import InviteAcceptForm from "@/components/invite/InviteAcceptForm"; +import KarakeepLogo from "@/components/KarakeepIcon"; +import { getServerAuthSession } from "@/server/auth"; + +interface InvitePageProps { + params: { + token: string; + }; +} + +export default async function InvitePage({ params }: InvitePageProps) { + const session = await getServerAuthSession(); + if (session) { + redirect("/"); + } + + return ( +
+
+
+ +
+ +
+
+ ); +} 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 - - )} - > - - - - - - - - - - - - - - - - - ))} - -
+ + + + + + + + + + + + + + + + + ))} + + +
+
+ + + +
); } diff --git a/apps/web/components/invite/InviteAcceptForm.tsx b/apps/web/components/invite/InviteAcceptForm.tsx new file mode 100644 index 00000000..dcebed73 --- /dev/null +++ b/apps/web/components/invite/InviteAcceptForm.tsx @@ -0,0 +1,311 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { ActionButton } from "@/components/ui/action-button"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { api } from "@/lib/trpc"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { TRPCClientError } from "@trpc/client"; +import { AlertCircle, Clock, Loader2, Mail, UserPlus } from "lucide-react"; +import { signIn } from "next-auth/react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +const inviteAcceptSchema = z + .object({ + name: z.string().min(1, "Name is required"), + password: z.string().min(8, "Password must be at least 8 characters"), + confirmPassword: z + .string() + .min(8, "Password must be at least 8 characters"), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords don't match", + path: ["confirmPassword"], + }); + +interface InviteAcceptFormProps { + token: string; +} + +export default function InviteAcceptForm({ token }: InviteAcceptFormProps) { + const router = useRouter(); + + const form = useForm>({ + resolver: zodResolver(inviteAcceptSchema), + }); + + const [errorMessage, setErrorMessage] = useState(null); + + const { + isPending: loading, + data: inviteData, + error, + } = api.invites.get.useQuery({ token }); + + useEffect(() => { + if (error) { + setErrorMessage(error.message); + } + }, [error]); + + const acceptInviteMutation = api.invites.accept.useMutation(); + + const handleBackToSignIn = () => { + router.push("/signin"); + }; + + if (loading) { + return ( + + + + Loading Invitation + + + Please wait while we verify your invitation... + + + +
+ +
+
+
+ ); + } + + if (!inviteData) { + return ( + + + + Invalid Invitation + + + This invitation link is not valid or has been removed. + + + +
+ +
+ + {errorMessage && ( + + + {errorMessage} + + )} + + +
+
+ ); + } + + if (inviteData.expired) { + return ( + + + + Invitation Expired + + + This invitation link has expired and is no longer valid. + + + +
+ +
+ +
+

+ Please contact an administrator to request a new invitation. +

+
+ + +
+
+ ); + } + + return ( + + + + Accept Your Invitation + + + Complete your account setup to join Karakeep + + + +
+ +
+ +
+
+ +

Invited email:

+
+

{inviteData.email}

+
+ +
+ { + try { + await acceptInviteMutation.mutateAsync({ + token, + name: value.name, + password: value.password, + }); + + // Sign in the user after successful account creation + const resp = await signIn("credentials", { + redirect: false, + email: inviteData.email, + password: value.password, + }); + + if (!resp || !resp.ok || resp.error) { + setErrorMessage( + resp?.error ?? + "Account created but sign in failed. Please try signing in manually.", + ); + return; + } + + router.replace("/"); + } catch (e) { + if (e instanceof TRPCClientError) { + setErrorMessage(e.message); + } else { + setErrorMessage("An unexpected error occurred"); + } + } + })} + className="space-y-4" + > + {errorMessage && ( + + + {errorMessage} + + )} + + ( + + Full Name + + + + + + )} + /> + + ( + + Password + + + + + + )} + /> + + ( + + Confirm Password + + + + + + )} + /> + + + {form.formState.isSubmitting || acceptInviteMutation.isPending ? ( + <> + + Creating Account... + + ) : ( + "Create Account & Sign In" + )} + + + + + +
+
+ ); +} -- cgit v1.2.3-70-g09d2