aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--apps/web/app/admin/background_jobs/page.tsx7
-rw-r--r--apps/web/app/admin/layout.tsx3
-rw-r--r--apps/web/app/admin/overview/page.tsx7
-rw-r--r--apps/web/app/invite/[token]/page.tsx28
-rw-r--r--apps/web/components/admin/CreateInviteDialog.tsx134
-rw-r--r--apps/web/components/admin/InvitesList.tsx192
-rw-r--r--apps/web/components/admin/UserList.tsx214
-rw-r--r--apps/web/components/invite/InviteAcceptForm.tsx311
-rw-r--r--packages/db/drizzle/0056_user_invites.sql12
-rw-r--r--packages/db/drizzle/meta/0056_snapshot.json2132
-rw-r--r--packages/db/drizzle/meta/_journal.json7
-rw-r--r--packages/db/schema.ts23
-rw-r--r--packages/trpc/email.ts61
-rw-r--r--packages/trpc/package.json2
-rw-r--r--packages/trpc/routers/_app.ts2
-rw-r--r--packages/trpc/routers/invites.test.ts653
-rw-r--r--packages/trpc/routers/invites.ts285
-rw-r--r--packages/trpc/testUtils.ts9
18 files changed, 3973 insertions, 109 deletions
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 <BackgroundJobs />;
+ return (
+ <AdminCard>
+ <BackgroundJobs />
+ </AdminCard>
+ );
}
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({
>
<div className="flex flex-col gap-1">
<AdminNotices />
- <AdminCard>{children}</AdminCard>
+ {children}
</div>
</SidebarLayout>
);
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 <ServerStats />;
+ return (
+ <AdminCard>
+ <ServerStats />
+ </AdminCard>
+ );
}
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 (
+ <div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8">
+ <div className="w-full max-w-md space-y-8">
+ <div className="flex items-center justify-center">
+ <KarakeepLogo height={80} />
+ </div>
+ <InviteAcceptForm token={params.token} />
+ </div>
+ </div>
+ );
+}
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&apos;ll
+ receive an email with instructions to create their account and will
+ be assigned the &quot;user&quot; 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>
);
}
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<z.infer<typeof inviteAcceptSchema>>({
+ resolver: zodResolver(inviteAcceptSchema),
+ });
+
+ const [errorMessage, setErrorMessage] = useState<string | null>(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 (
+ <Card className="w-full">
+ <CardHeader className="text-center">
+ <CardTitle className="text-2xl font-bold">
+ Loading Invitation
+ </CardTitle>
+ <CardDescription>
+ Please wait while we verify your invitation...
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="flex items-center justify-center">
+ <Loader2 className="h-8 w-8 animate-spin text-blue-600" />
+ </div>
+ </CardContent>
+ </Card>
+ );
+ }
+
+ if (!inviteData) {
+ return (
+ <Card className="w-full">
+ <CardHeader className="text-center">
+ <CardTitle className="text-2xl font-bold">
+ Invalid Invitation
+ </CardTitle>
+ <CardDescription>
+ This invitation link is not valid or has been removed.
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="flex items-center justify-center">
+ <AlertCircle className="h-12 w-12 text-red-500" />
+ </div>
+
+ {errorMessage && (
+ <Alert variant="destructive">
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>{errorMessage}</AlertDescription>
+ </Alert>
+ )}
+
+ <Button onClick={handleBackToSignIn} className="w-full">
+ Back to Sign In
+ </Button>
+ </CardContent>
+ </Card>
+ );
+ }
+
+ if (inviteData.expired) {
+ return (
+ <Card className="w-full">
+ <CardHeader className="text-center">
+ <CardTitle className="text-2xl font-bold">
+ Invitation Expired
+ </CardTitle>
+ <CardDescription>
+ This invitation link has expired and is no longer valid.
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="flex items-center justify-center">
+ <Clock className="h-12 w-12 text-orange-500" />
+ </div>
+
+ <div className="space-y-2 text-center">
+ <p className="text-sm text-gray-600">
+ Please contact an administrator to request a new invitation.
+ </p>
+ </div>
+
+ <Button
+ onClick={handleBackToSignIn}
+ variant="outline"
+ className="w-full"
+ >
+ Back to Sign In
+ </Button>
+ </CardContent>
+ </Card>
+ );
+ }
+
+ return (
+ <Card className="w-full">
+ <CardHeader className="text-center">
+ <CardTitle className="text-2xl font-bold">
+ Accept Your Invitation
+ </CardTitle>
+ <CardDescription>
+ Complete your account setup to join Karakeep
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="flex items-center justify-center">
+ <UserPlus className="h-12 w-12 text-blue-600" />
+ </div>
+
+ <div className="space-y-2 text-center">
+ <div className="flex items-center justify-center space-x-2">
+ <Mail className="h-4 w-4 text-gray-500" />
+ <p className="text-sm text-gray-600">Invited email:</p>
+ </div>
+ <p className="font-medium text-gray-900">{inviteData.email}</p>
+ </div>
+
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(async (value) => {
+ 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 && (
+ <Alert variant="destructive">
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>{errorMessage}</AlertDescription>
+ </Alert>
+ )}
+
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Full Name</FormLabel>
+ <FormControl>
+ <Input
+ type="text"
+ placeholder="Enter your full name"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="password"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Password</FormLabel>
+ <FormControl>
+ <Input
+ type="password"
+ placeholder="Create a password"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="confirmPassword"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Confirm Password</FormLabel>
+ <FormControl>
+ <Input
+ type="password"
+ placeholder="Confirm your password"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <ActionButton
+ type="submit"
+ loading={
+ form.formState.isSubmitting || acceptInviteMutation.isPending
+ }
+ className="w-full"
+ >
+ {form.formState.isSubmitting || acceptInviteMutation.isPending ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ Creating Account...
+ </>
+ ) : (
+ "Create Account & Sign In"
+ )}
+ </ActionButton>
+
+ <Button
+ type="button"
+ variant="ghost"
+ onClick={handleBackToSignIn}
+ className="w-full"
+ >
+ Back to Sign In
+ </Button>
+ </form>
+ </Form>
+ </CardContent>
+ </Card>
+ );
+}
diff --git a/packages/db/drizzle/0056_user_invites.sql b/packages/db/drizzle/0056_user_invites.sql
new file mode 100644
index 00000000..5ea38cca
--- /dev/null
+++ b/packages/db/drizzle/0056_user_invites.sql
@@ -0,0 +1,12 @@
+CREATE TABLE `invites` (
+ `id` text PRIMARY KEY NOT NULL,
+ `email` text NOT NULL,
+ `token` text NOT NULL,
+ `createdAt` integer NOT NULL,
+ `expiresAt` integer NOT NULL,
+ `usedAt` integer,
+ `invitedBy` text NOT NULL,
+ FOREIGN KEY (`invitedBy`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `invites_token_unique` ON `invites` (`token`); \ No newline at end of file
diff --git a/packages/db/drizzle/meta/0056_snapshot.json b/packages/db/drizzle/meta/0056_snapshot.json
new file mode 100644
index 00000000..085db432
--- /dev/null
+++ b/packages/db/drizzle/meta/0056_snapshot.json
@@ -0,0 +1,2132 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "c39611a8-5fb3-4fd2-8643-64e5ed826095",
+ "prevId": "a7674152-1484-4144-9faa-2f4597ba619e",
+ "tables": {
+ "account": {
+ "name": "account",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "providerAccountId": {
+ "name": "providerAccountId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "session_state": {
+ "name": "session_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "account_userId_user_id_fk": {
+ "name": "account_userId_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "account_provider_providerAccountId_pk": {
+ "columns": [
+ "provider",
+ "providerAccountId"
+ ],
+ "name": "account_provider_providerAccountId_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "apiKey": {
+ "name": "apiKey",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyId": {
+ "name": "keyId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "keyHash": {
+ "name": "keyHash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "apiKey_keyId_unique": {
+ "name": "apiKey_keyId_unique",
+ "columns": [
+ "keyId"
+ ],
+ "isUnique": true
+ },
+ "apiKey_name_userId_unique": {
+ "name": "apiKey_name_userId_unique",
+ "columns": [
+ "name",
+ "userId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "apiKey_userId_user_id_fk": {
+ "name": "apiKey_userId_user_id_fk",
+ "tableFrom": "apiKey",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "assets": {
+ "name": "assets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assetType": {
+ "name": "assetType",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "contentType": {
+ "name": "contentType",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fileName": {
+ "name": "fileName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "assets_bookmarkId_idx": {
+ "name": "assets_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "assets_assetType_idx": {
+ "name": "assets_assetType_idx",
+ "columns": [
+ "assetType"
+ ],
+ "isUnique": false
+ },
+ "assets_userId_idx": {
+ "name": "assets_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "assets_bookmarkId_bookmarks_id_fk": {
+ "name": "assets_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "assets",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "assets_userId_user_id_fk": {
+ "name": "assets_userId_user_id_fk",
+ "tableFrom": "assets",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkAssets": {
+ "name": "bookmarkAssets",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assetType": {
+ "name": "assetType",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assetId": {
+ "name": "assetId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "fileName": {
+ "name": "fileName",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sourceUrl": {
+ "name": "sourceUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "bookmarkAssets_id_bookmarks_id_fk": {
+ "name": "bookmarkAssets_id_bookmarks_id_fk",
+ "tableFrom": "bookmarkAssets",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkLinks": {
+ "name": "bookmarkLinks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "author": {
+ "name": "author",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "publisher": {
+ "name": "publisher",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "datePublished": {
+ "name": "datePublished",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "dateModified": {
+ "name": "dateModified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "imageUrl": {
+ "name": "imageUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "favicon": {
+ "name": "favicon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "htmlContent": {
+ "name": "htmlContent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "contentAssetId": {
+ "name": "contentAssetId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "crawledAt": {
+ "name": "crawledAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "crawlStatus": {
+ "name": "crawlStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "crawlStatusCode": {
+ "name": "crawlStatusCode",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": 200
+ }
+ },
+ "indexes": {
+ "bookmarkLinks_url_idx": {
+ "name": "bookmarkLinks_url_idx",
+ "columns": [
+ "url"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "bookmarkLinks_id_bookmarks_id_fk": {
+ "name": "bookmarkLinks_id_bookmarks_id_fk",
+ "tableFrom": "bookmarkLinks",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkLists": {
+ "name": "bookmarkLists",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon": {
+ "name": "icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "query": {
+ "name": "query",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "parentId": {
+ "name": "parentId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "rssToken": {
+ "name": "rssToken",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "public": {
+ "name": "public",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "bookmarkLists_userId_idx": {
+ "name": "bookmarkLists_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "bookmarkLists_userId_id_idx": {
+ "name": "bookmarkLists_userId_id_idx",
+ "columns": [
+ "userId",
+ "id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "bookmarkLists_userId_user_id_fk": {
+ "name": "bookmarkLists_userId_user_id_fk",
+ "tableFrom": "bookmarkLists",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "bookmarkLists_parentId_bookmarkLists_id_fk": {
+ "name": "bookmarkLists_parentId_bookmarkLists_id_fk",
+ "tableFrom": "bookmarkLists",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "parentId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkTags": {
+ "name": "bookmarkTags",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarkTags_name_idx": {
+ "name": "bookmarkTags_name_idx",
+ "columns": [
+ "name"
+ ],
+ "isUnique": false
+ },
+ "bookmarkTags_userId_idx": {
+ "name": "bookmarkTags_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "bookmarkTags_userId_name_unique": {
+ "name": "bookmarkTags_userId_name_unique",
+ "columns": [
+ "userId",
+ "name"
+ ],
+ "isUnique": true
+ },
+ "bookmarkTags_userId_id_idx": {
+ "name": "bookmarkTags_userId_id_idx",
+ "columns": [
+ "userId",
+ "id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "bookmarkTags_userId_user_id_fk": {
+ "name": "bookmarkTags_userId_user_id_fk",
+ "tableFrom": "bookmarkTags",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarkTexts": {
+ "name": "bookmarkTexts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sourceUrl": {
+ "name": "sourceUrl",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "bookmarkTexts_id_bookmarks_id_fk": {
+ "name": "bookmarkTexts_id_bookmarks_id_fk",
+ "tableFrom": "bookmarkTexts",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarks": {
+ "name": "bookmarks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "modifiedAt": {
+ "name": "modifiedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "archived": {
+ "name": "archived",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "favourited": {
+ "name": "favourited",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "taggingStatus": {
+ "name": "taggingStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "summarizationStatus": {
+ "name": "summarizationStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarks_userId_idx": {
+ "name": "bookmarks_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_archived_idx": {
+ "name": "bookmarks_archived_idx",
+ "columns": [
+ "archived"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_favourited_idx": {
+ "name": "bookmarks_favourited_idx",
+ "columns": [
+ "favourited"
+ ],
+ "isUnique": false
+ },
+ "bookmarks_createdAt_idx": {
+ "name": "bookmarks_createdAt_idx",
+ "columns": [
+ "createdAt"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "bookmarks_userId_user_id_fk": {
+ "name": "bookmarks_userId_user_id_fk",
+ "tableFrom": "bookmarks",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "bookmarksInLists": {
+ "name": "bookmarksInLists",
+ "columns": {
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "addedAt": {
+ "name": "addedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "bookmarksInLists_bookmarkId_idx": {
+ "name": "bookmarksInLists_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "bookmarksInLists_listId_idx": {
+ "name": "bookmarksInLists_listId_idx",
+ "columns": [
+ "listId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "bookmarksInLists_bookmarkId_bookmarks_id_fk": {
+ "name": "bookmarksInLists_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "bookmarksInLists",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "bookmarksInLists_listId_bookmarkLists_id_fk": {
+ "name": "bookmarksInLists_listId_bookmarkLists_id_fk",
+ "tableFrom": "bookmarksInLists",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "listId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "bookmarksInLists_bookmarkId_listId_pk": {
+ "columns": [
+ "bookmarkId",
+ "listId"
+ ],
+ "name": "bookmarksInLists_bookmarkId_listId_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "config": {
+ "name": "config",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "customPrompts": {
+ "name": "customPrompts",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "appliesTo": {
+ "name": "appliesTo",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "customPrompts_userId_idx": {
+ "name": "customPrompts_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "customPrompts_userId_user_id_fk": {
+ "name": "customPrompts_userId_user_id_fk",
+ "tableFrom": "customPrompts",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "highlights": {
+ "name": "highlights",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "startOffset": {
+ "name": "startOffset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "endOffset": {
+ "name": "endOffset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "color": {
+ "name": "color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'yellow'"
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "note": {
+ "name": "note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "highlights_bookmarkId_idx": {
+ "name": "highlights_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ },
+ "highlights_userId_idx": {
+ "name": "highlights_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "highlights_bookmarkId_bookmarks_id_fk": {
+ "name": "highlights_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "highlights",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "highlights_userId_user_id_fk": {
+ "name": "highlights_userId_user_id_fk",
+ "tableFrom": "highlights",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invites": {
+ "name": "invites",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expiresAt": {
+ "name": "expiresAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "usedAt": {
+ "name": "usedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "invitedBy": {
+ "name": "invitedBy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "invites_token_unique": {
+ "name": "invites_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "invites_invitedBy_user_id_fk": {
+ "name": "invites_invitedBy_user_id_fk",
+ "tableFrom": "invites",
+ "tableTo": "user",
+ "columnsFrom": [
+ "invitedBy"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rssFeedImports": {
+ "name": "rssFeedImports",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "entryId": {
+ "name": "entryId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "rssFeedId": {
+ "name": "rssFeedId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "rssFeedImports_feedIdIdx_idx": {
+ "name": "rssFeedImports_feedIdIdx_idx",
+ "columns": [
+ "rssFeedId"
+ ],
+ "isUnique": false
+ },
+ "rssFeedImports_entryIdIdx_idx": {
+ "name": "rssFeedImports_entryIdIdx_idx",
+ "columns": [
+ "entryId"
+ ],
+ "isUnique": false
+ },
+ "rssFeedImports_rssFeedId_entryId_unique": {
+ "name": "rssFeedImports_rssFeedId_entryId_unique",
+ "columns": [
+ "rssFeedId",
+ "entryId"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "rssFeedImports_rssFeedId_rssFeeds_id_fk": {
+ "name": "rssFeedImports_rssFeedId_rssFeeds_id_fk",
+ "tableFrom": "rssFeedImports",
+ "tableTo": "rssFeeds",
+ "columnsFrom": [
+ "rssFeedId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "rssFeedImports_bookmarkId_bookmarks_id_fk": {
+ "name": "rssFeedImports_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "rssFeedImports",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "rssFeeds": {
+ "name": "rssFeeds",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lastFetchedAt": {
+ "name": "lastFetchedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "lastFetchedStatus": {
+ "name": "lastFetchedStatus",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'pending'"
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "rssFeeds_userId_idx": {
+ "name": "rssFeeds_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "rssFeeds_userId_user_id_fk": {
+ "name": "rssFeeds_userId_user_id_fk",
+ "tableFrom": "rssFeeds",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "ruleEngineActions": {
+ "name": "ruleEngineActions",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ruleId": {
+ "name": "ruleId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tagId": {
+ "name": "tagId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "ruleEngineActions_userId_idx": {
+ "name": "ruleEngineActions_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ },
+ "ruleEngineActions_ruleId_idx": {
+ "name": "ruleEngineActions_ruleId_idx",
+ "columns": [
+ "ruleId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "ruleEngineActions_userId_user_id_fk": {
+ "name": "ruleEngineActions_userId_user_id_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineActions_ruleId_ruleEngineRules_id_fk": {
+ "name": "ruleEngineActions_ruleId_ruleEngineRules_id_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "ruleEngineRules",
+ "columnsFrom": [
+ "ruleId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineActions_userId_tagId_fk": {
+ "name": "ruleEngineActions_userId_tagId_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "bookmarkTags",
+ "columnsFrom": [
+ "userId",
+ "tagId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineActions_userId_listId_fk": {
+ "name": "ruleEngineActions_userId_listId_fk",
+ "tableFrom": "ruleEngineActions",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "userId",
+ "listId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "ruleEngineRules": {
+ "name": "ruleEngineRules",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "event": {
+ "name": "event",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "condition": {
+ "name": "condition",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "listId": {
+ "name": "listId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "tagId": {
+ "name": "tagId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "ruleEngine_userId_idx": {
+ "name": "ruleEngine_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "ruleEngineRules_userId_user_id_fk": {
+ "name": "ruleEngineRules_userId_user_id_fk",
+ "tableFrom": "ruleEngineRules",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineRules_userId_tagId_fk": {
+ "name": "ruleEngineRules_userId_tagId_fk",
+ "tableFrom": "ruleEngineRules",
+ "tableTo": "bookmarkTags",
+ "columnsFrom": [
+ "userId",
+ "tagId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ruleEngineRules_userId_listId_fk": {
+ "name": "ruleEngineRules_userId_listId_fk",
+ "tableFrom": "ruleEngineRules",
+ "tableTo": "bookmarkLists",
+ "columnsFrom": [
+ "userId",
+ "listId"
+ ],
+ "columnsTo": [
+ "userId",
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "sessionToken": {
+ "name": "sessionToken",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "session_userId_user_id_fk": {
+ "name": "session_userId_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tagsOnBookmarks": {
+ "name": "tagsOnBookmarks",
+ "columns": {
+ "bookmarkId": {
+ "name": "bookmarkId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "tagId": {
+ "name": "tagId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attachedAt": {
+ "name": "attachedAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "attachedBy": {
+ "name": "attachedBy",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "tagsOnBookmarks_tagId_idx": {
+ "name": "tagsOnBookmarks_tagId_idx",
+ "columns": [
+ "tagId"
+ ],
+ "isUnique": false
+ },
+ "tagsOnBookmarks_bookmarkId_idx": {
+ "name": "tagsOnBookmarks_bookmarkId_idx",
+ "columns": [
+ "bookmarkId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "tagsOnBookmarks_bookmarkId_bookmarks_id_fk": {
+ "name": "tagsOnBookmarks_bookmarkId_bookmarks_id_fk",
+ "tableFrom": "tagsOnBookmarks",
+ "tableTo": "bookmarks",
+ "columnsFrom": [
+ "bookmarkId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "tagsOnBookmarks_tagId_bookmarkTags_id_fk": {
+ "name": "tagsOnBookmarks_tagId_bookmarkTags_id_fk",
+ "tableFrom": "tagsOnBookmarks",
+ "tableTo": "bookmarkTags",
+ "columnsFrom": [
+ "tagId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "tagsOnBookmarks_bookmarkId_tagId_pk": {
+ "columns": [
+ "bookmarkId",
+ "tagId"
+ ],
+ "name": "tagsOnBookmarks_bookmarkId_tagId_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "userSettings": {
+ "name": "userSettings",
+ "columns": {
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "bookmarkClickAction": {
+ "name": "bookmarkClickAction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'open_original_link'"
+ },
+ "archiveDisplayBehaviour": {
+ "name": "archiveDisplayBehaviour",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'show'"
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'UTC'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "userSettings_userId_user_id_fk": {
+ "name": "userSettings_userId_user_id_fk",
+ "tableFrom": "userSettings",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "emailVerified": {
+ "name": "emailVerified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "salt": {
+ "name": "salt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "''"
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'user'"
+ },
+ "bookmarkQuota": {
+ "name": "bookmarkQuota",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "storageQuota": {
+ "name": "storageQuota",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "columns": [
+ "email"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "verificationToken": {
+ "name": "verificationToken",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verificationToken_identifier_token_pk": {
+ "columns": [
+ "identifier",
+ "token"
+ ],
+ "name": "verificationToken_identifier_token_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "webhooks": {
+ "name": "webhooks",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "events": {
+ "name": "events",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "webhooks_userId_idx": {
+ "name": "webhooks_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "webhooks_userId_user_id_fk": {
+ "name": "webhooks_userId_user_id_fk",
+ "tableFrom": "webhooks",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+} \ No newline at end of file
diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json
index 6c509b15..2ff89c1b 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -393,6 +393,13 @@
"when": 1751839469055,
"tag": "0055_content_asset_id",
"breakpoints": true
+ },
+ {
+ "idx": 56,
+ "version": "6",
+ "when": 1752180326709,
+ "tag": "0056_user_invites",
+ "breakpoints": true
}
]
} \ No newline at end of file
diff --git a/packages/db/schema.ts b/packages/db/schema.ts
index 4375b201..881d72ec 100644
--- a/packages/db/schema.ts
+++ b/packages/db/schema.ts
@@ -552,6 +552,21 @@ export const userSettings = sqliteTable("userSettings", {
timezone: text("timezone").default("UTC"),
});
+export const invites = sqliteTable("invites", {
+ id: text("id")
+ .notNull()
+ .primaryKey()
+ .$defaultFn(() => createId()),
+ email: text("email").notNull(),
+ token: text("token").notNull().unique(),
+ createdAt: createdAtField(),
+ expiresAt: integer("expiresAt", { mode: "timestamp" }).notNull(),
+ usedAt: integer("usedAt", { mode: "timestamp" }),
+ invitedBy: text("invitedBy")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+});
+
// Relations
export const userRelations = relations(users, ({ many, one }) => ({
@@ -559,6 +574,7 @@ export const userRelations = relations(users, ({ many, one }) => ({
bookmarks: many(bookmarks),
webhooks: many(webhooksTable),
rules: many(ruleEngineRulesTable),
+ invites: many(invites),
settings: one(userSettings, {
fields: [users.id],
references: [userSettings.userId],
@@ -704,3 +720,10 @@ export const userSettingsRelations = relations(userSettings, ({ one }) => ({
references: [users.id],
}),
}));
+
+export const invitesRelations = relations(invites, ({ one }) => ({
+ invitedBy: one(users, {
+ fields: [invites.invitedBy],
+ references: [users.id],
+ }),
+}));
diff --git a/packages/trpc/email.ts b/packages/trpc/email.ts
index 2ca3e396..ded23ed8 100644
--- a/packages/trpc/email.ts
+++ b/packages/trpc/email.ts
@@ -108,3 +108,64 @@ export async function verifyEmailToken(
return true;
}
+
+export async function sendInviteEmail(
+ email: string,
+ token: string,
+ inviterName: string,
+) {
+ if (!serverConfig.email.smtp) {
+ throw new Error("SMTP is not configured");
+ }
+
+ const transporter = createTransport({
+ host: serverConfig.email.smtp.host,
+ port: serverConfig.email.smtp.port,
+ secure: serverConfig.email.smtp.secure,
+ auth:
+ serverConfig.email.smtp.user && serverConfig.email.smtp.password
+ ? {
+ user: serverConfig.email.smtp.user,
+ pass: serverConfig.email.smtp.password,
+ }
+ : undefined,
+ });
+
+ const inviteUrl = `${serverConfig.publicUrl}/invite/${encodeURIComponent(token)}`;
+
+ const mailOptions = {
+ from: serverConfig.email.smtp.from,
+ to: email,
+ subject: "You've been invited to join Karakeep",
+ html: `
+ <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
+ <h2>You've been invited to join Karakeep!</h2>
+ <p>${inviterName} has invited you to join Karakeep, the bookmark everything app.</p>
+ <p>Click the link below to accept your invitation and create your account:</p>
+ <p>
+ <a href="${inviteUrl}" style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
+ Accept Invitation
+ </a>
+ </p>
+ <p>If the button doesn't work, you can copy and paste this link into your browser:</p>
+ <p><a href="${inviteUrl}">${inviteUrl}</a></p>
+ <p>This invitation will expire in 7 days.</p>
+ <p>If you weren't expecting this invitation, you can safely ignore this email.</p>
+ </div>
+ `,
+ text: `
+You've been invited to join Karakeep!
+
+${inviterName} has invited you to join Karakeep, a powerful bookmarking and content organization platform.
+
+Accept your invitation by visiting this link:
+${inviteUrl}
+
+This invitation will expire in 7 days.
+
+If you weren't expecting this invitation, you can safely ignore this email.
+ `,
+ };
+
+ await transporter.sendMail(mailOptions);
+}
diff --git a/packages/trpc/package.json b/packages/trpc/package.json
index 43792d9a..355b6ca6 100644
--- a/packages/trpc/package.json
+++ b/packages/trpc/package.json
@@ -19,8 +19,8 @@
"bcryptjs": "^2.4.3",
"deep-equal": "^2.2.3",
"drizzle-orm": "^0.38.3",
- "prom-client": "^15.1.3",
"nodemailer": "^7.0.4",
+ "prom-client": "^15.1.3",
"superjson": "^2.2.1",
"tiny-invariant": "^1.3.3",
"zod": "^3.24.2"
diff --git a/packages/trpc/routers/_app.ts b/packages/trpc/routers/_app.ts
index e09f959e..54335da3 100644
--- a/packages/trpc/routers/_app.ts
+++ b/packages/trpc/routers/_app.ts
@@ -5,6 +5,7 @@ import { assetsAppRouter } from "./assets";
import { bookmarksAppRouter } from "./bookmarks";
import { feedsAppRouter } from "./feeds";
import { highlightsAppRouter } from "./highlights";
+import { invitesAppRouter } from "./invites";
import { listsAppRouter } from "./lists";
import { promptsAppRouter } from "./prompts";
import { publicBookmarks } from "./publicBookmarks";
@@ -26,6 +27,7 @@ export const appRouter = router({
webhooks: webhooksAppRouter,
assets: assetsAppRouter,
rules: rulesAppRouter,
+ invites: invitesAppRouter,
publicBookmarks: publicBookmarks,
});
// export type definition of API
diff --git a/packages/trpc/routers/invites.test.ts b/packages/trpc/routers/invites.test.ts
new file mode 100644
index 00000000..bb1209c4
--- /dev/null
+++ b/packages/trpc/routers/invites.test.ts
@@ -0,0 +1,653 @@
+import { eq } from "drizzle-orm";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+import { invites, users } from "@karakeep/db/schema";
+
+import type { CustomTestContext } from "../testUtils";
+import { defaultBeforeEach, getApiCaller } from "../testUtils";
+
+beforeEach<CustomTestContext>(defaultBeforeEach(false));
+
+describe("Invites Router", () => {
+ test<CustomTestContext>("admin can create invite", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const admin = await unauthedAPICaller.users.create({
+ name: "Admin User",
+ email: "admin@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
+
+ const invite = await adminCaller.invites.create({
+ email: "newuser@test.com",
+ });
+
+ expect(invite.email).toBe("newuser@test.com");
+ expect(invite.expiresAt).toBeDefined();
+ expect(invite.id).toBeDefined();
+
+ // Verify the invite was created in the database
+ const dbInvite = await db.query.invites.findFirst({
+ where: eq(invites.id, invite.id),
+ });
+ expect(dbInvite?.invitedBy).toBe(admin.id);
+ expect(dbInvite?.usedAt).toBeNull();
+ expect(dbInvite?.token).toBeDefined();
+ });
+
+ test<CustomTestContext>("non-admin cannot create invite", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ await unauthedAPICaller.users.create({
+ name: "Admin User",
+ email: "admin@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ const user = await unauthedAPICaller.users.create({
+ name: "Regular User",
+ email: "user@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ const userCaller = getApiCaller(db, user.id, user.email);
+
+ await expect(() =>
+ userCaller.invites.create({
+ email: "newuser@test.com",
+ }),
+ ).rejects.toThrow(/FORBIDDEN/);
+ });
+
+ test<CustomTestContext>("cannot invite existing user", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const admin = await unauthedAPICaller.users.create({
+ name: "Admin User",
+ email: "admin@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ await unauthedAPICaller.users.create({
+ name: "Existing User",
+ email: "existing@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
+
+ await expect(() =>
+ adminCaller.invites.create({
+ email: "existing@test.com",
+ }),
+ ).rejects.toThrow(/User with this email already exists/);
+ });
+
+ test<CustomTestContext>("cannot create duplicate pending invite", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const admin = await unauthedAPICaller.users.create({
+ name: "Admin User",
+ email: "admin@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
+
+ await adminCaller.invites.create({
+ email: "newuser@test.com",
+ });
+
+ await expect(() =>
+ adminCaller.invites.create({
+ email: "newuser@test.com",
+ }),
+ ).rejects.toThrow(/An active invite for this email already exists/);
+ });
+
+ test<CustomTestContext>("admin can list invites", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const admin = await unauthedAPICaller.users.create({
+ name: "Admin User",
+ email: "admin@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
+
+ await adminCaller.invites.create({
+ email: "user1@test.com",
+ });
+
+ await adminCaller.invites.create({
+ email: "user2@test.com",
+ });
+
+ const result = await adminCaller.invites.list();
+
+ expect(result.invites).toHaveLength(2);
+ expect(
+ result.invites.find((i) => i.email === "user1@test.com"),
+ ).toBeTruthy();
+ expect(
+ result.invites.find((i) => i.email === "user2@test.com"),
+ ).toBeTruthy();
+ });
+
+ test<CustomTestContext>("non-admin cannot list invites", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ await unauthedAPICaller.users.create({
+ name: "Admin User",
+ email: "admin@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ const user = await unauthedAPICaller.users.create({
+ name: "Regular User",
+ email: "user@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ const userCaller = getApiCaller(db, user.id, user.email);
+
+ await expect(() => userCaller.invites.list()).rejects.toThrow(/FORBIDDEN/);
+ });
+
+ test<CustomTestContext>("can get invite by token", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const admin = await unauthedAPICaller.users.create({
+ name: "Admin User",
+ email: "admin@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
+
+ const invite = await adminCaller.invites.create({
+ email: "newuser@test.com",
+ });
+
+ // Get the token from the database since it's not returned by create
+ const dbInvite = await db.query.invites.findFirst({
+ where: eq(invites.id, invite.id),
+ });
+
+ const retrievedInvite = await unauthedAPICaller.invites.get({
+ token: dbInvite!.token,
+ });
+
+ expect(retrievedInvite.email).toBe("newuser@test.com");
+ expect(retrievedInvite.expired).toBe(false);
+ });
+
+ test<CustomTestContext>("cannot get invite with invalid token", async ({
+ unauthedAPICaller,
+ }) => {
+ await expect(() =>
+ unauthedAPICaller.invites.get({
+ token: "invalid-token",
+ }),
+ ).rejects.toThrow(/Invite not found/);
+ });
+
+ test<CustomTestContext>("cannot get expired invite", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const admin = await unauthedAPICaller.users.create({
+ name: "Admin User",
+ email: "admin@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
+
+ const invite = await adminCaller.invites.create({
+ email: "newuser@test.com",
+ });
+
+ // Set expiry to past date
+ const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000);
+ await db
+ .update(invites)
+ .set({ expiresAt: pastDate })
+ .where(eq(invites.id, invite.id));
+
+ const dbInvite = await db.query.invites.findFirst({
+ where: eq(invites.id, invite.id),
+ });
+
+ await expect(() =>
+ unauthedAPICaller.invites.get({
+ token: dbInvite!.token,
+ }),
+ ).rejects.toThrow(/Invite has expired/);
+ });
+
+ test<CustomTestContext>("cannot get used invite (deleted)", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const admin = await unauthedAPICaller.users.create({
+ name: "Admin User",
+ email: "admin@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
+
+ const invite = await adminCaller.invites.create({
+ email: "newuser@test.com",
+ });
+
+ const dbInvite = await db.query.invites.findFirst({
+ where: eq(invites.id, invite.id),
+ });
+
+ // Accept the invite (which deletes it)
+ await unauthedAPICaller.invites.accept({
+ token: dbInvite!.token,
+ name: "New User",
+ password: "newpass123",
+ });
+
+ // Try to get the invite again - should fail
+ await expect(() =>
+ unauthedAPICaller.invites.get({
+ token: dbInvite!.token,
+ }),
+ ).rejects.toThrow(/Invite not found or has been used/);
+ });
+
+ test<CustomTestContext>("can accept valid invite", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const admin = await unauthedAPICaller.users.create({
+ name: "Admin User",
+ email: "admin@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
+
+ const invite = await adminCaller.invites.create({
+ email: "newuser@test.com",
+ });
+
+ const dbInvite = await db.query.invites.findFirst({
+ where: eq(invites.id, invite.id),
+ });
+
+ const newUser = await unauthedAPICaller.invites.accept({
+ token: dbInvite!.token,
+ name: "New User",
+ password: "newpass123",
+ });
+
+ expect(newUser.name).toBe("New User");
+ expect(newUser.email).toBe("newuser@test.com");
+
+ // Verify invite was deleted
+ const deletedInvite = await db.query.invites.findFirst({
+ where: eq(invites.id, invite.id),
+ });
+ expect(deletedInvite).toBeUndefined();
+ });
+
+ test<CustomTestContext>("cannot accept expired invite", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const admin = await unauthedAPICaller.users.create({
+ name: "Admin User",
+ email: "admin@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
+
+ const invite = await adminCaller.invites.create({
+ email: "newuser@test.com",
+ });
+
+ const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000);
+ await db
+ .update(invites)
+ .set({ expiresAt: pastDate })
+ .where(eq(invites.id, invite.id));
+
+ const dbInvite = await db.query.invites.findFirst({
+ where: eq(invites.id, invite.id),
+ });
+
+ await expect(() =>
+ unauthedAPICaller.invites.accept({
+ token: dbInvite!.token,
+ name: "New User",
+ password: "newpass123",
+ }),
+ ).rejects.toThrow(/This invite has expired/);
+ });
+
+ test<CustomTestContext>("cannot accept used invite (deleted)", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const admin = await unauthedAPICaller.users.create({
+ name: "Admin User",
+ email: "admin@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
+
+ const invite = await adminCaller.invites.create({
+ email: "newuser@test.com",
+ });
+
+ const dbInvite = await db.query.invites.findFirst({
+ where: eq(invites.id, invite.id),
+ });
+
+ // Accept the invite first time
+ await unauthedAPICaller.invites.accept({
+ token: dbInvite!.token,
+ name: "New User",
+ password: "newpass123",
+ });
+
+ // Try to accept again - should fail because invite is deleted
+ await expect(() =>
+ unauthedAPICaller.invites.accept({
+ token: dbInvite!.token,
+ name: "Another User",
+ password: "anotherpass123",
+ }),
+ ).rejects.toThrow(/Invite not found or has been used/);
+ });
+
+ test<CustomTestContext>("admin can revoke invite", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const admin = await unauthedAPICaller.users.create({
+ name: "Admin User",
+ email: "admin@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
+
+ const invite = await adminCaller.invites.create({
+ email: "newuser@test.com",
+ });
+
+ const result = await adminCaller.invites.revoke({
+ inviteId: invite.id,
+ });
+
+ expect(result.success).toBe(true);
+
+ // Verify the invite is deleted
+ const revokedInvite = await db.query.invites.findFirst({
+ where: eq(invites.id, invite.id),
+ });
+ expect(revokedInvite).toBeUndefined();
+ });
+
+ test<CustomTestContext>("non-admin cannot revoke invite", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const admin = await unauthedAPICaller.users.create({
+ name: "Admin User",
+ email: "admin@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ const user = await unauthedAPICaller.users.create({
+ name: "Regular User",
+ email: "user@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
+ const userCaller = getApiCaller(db, user.id, user.email);
+
+ const invite = await adminCaller.invites.create({
+ email: "newuser@test.com",
+ });
+
+ await expect(() =>
+ userCaller.invites.revoke({
+ inviteId: invite.id,
+ }),
+ ).rejects.toThrow(/FORBIDDEN/);
+ });
+
+ test<CustomTestContext>("admin can resend invite", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const admin = await unauthedAPICaller.users.create({
+ name: "Admin User",
+ email: "admin@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
+
+ const invite = await adminCaller.invites.create({
+ email: "newuser@test.com",
+ });
+
+ const originalDbInvite = await db.query.invites.findFirst({
+ where: eq(invites.id, invite.id),
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 10));
+
+ const resentInvite = await adminCaller.invites.resend({
+ inviteId: invite.id,
+ });
+
+ expect(resentInvite.email).toBe("newuser@test.com");
+ expect(resentInvite.expiresAt.getTime()).toBeGreaterThan(
+ originalDbInvite!.expiresAt.getTime(),
+ );
+
+ // Verify token was updated in database
+ const updatedDbInvite = await db.query.invites.findFirst({
+ where: eq(invites.id, invite.id),
+ });
+ expect(updatedDbInvite?.token).not.toBe(originalDbInvite?.token);
+ });
+
+ test<CustomTestContext>("cannot resend used invite (deleted)", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const admin = await unauthedAPICaller.users.create({
+ name: "Admin User",
+ email: "admin@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
+
+ const invite = await adminCaller.invites.create({
+ email: "newuser@test.com",
+ });
+
+ const dbInvite = await db.query.invites.findFirst({
+ where: eq(invites.id, invite.id),
+ });
+
+ // Accept the invite (which deletes it)
+ await unauthedAPICaller.invites.accept({
+ token: dbInvite!.token,
+ name: "New User",
+ password: "newpass123",
+ });
+
+ await expect(() =>
+ adminCaller.invites.resend({
+ inviteId: invite.id,
+ }),
+ ).rejects.toThrow(/Invite not found/);
+ });
+
+ test<CustomTestContext>("invite expiration is set correctly", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const admin = await unauthedAPICaller.users.create({
+ name: "Admin User",
+ email: "admin@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
+
+ const beforeCreate = new Date();
+ const invite = await adminCaller.invites.create({
+ email: "newuser@test.com",
+ });
+ const afterCreate = new Date();
+
+ // Allow for some timing variance (1 second buffer)
+ const expectedMinExpiry = new Date(
+ beforeCreate.getTime() + 7 * 24 * 60 * 60 * 1000 - 1000,
+ );
+ const expectedMaxExpiry = new Date(
+ afterCreate.getTime() + 7 * 24 * 60 * 60 * 1000 + 1000,
+ );
+
+ expect(invite.expiresAt.getTime()).toBeGreaterThanOrEqual(
+ expectedMinExpiry.getTime(),
+ );
+ expect(invite.expiresAt.getTime()).toBeLessThanOrEqual(
+ expectedMaxExpiry.getTime(),
+ );
+ });
+
+ test<CustomTestContext>("invite includes inviter information", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const admin = await unauthedAPICaller.users.create({
+ name: "Admin User",
+ email: "admin@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
+
+ const invite = await adminCaller.invites.create({
+ email: "newuser@test.com",
+ });
+
+ const result = await adminCaller.invites.list();
+ const createdInvite = result.invites.find((i) => i.id === invite.id);
+
+ expect(createdInvite?.invitedBy.id).toBe(admin.id);
+ expect(createdInvite?.invitedBy.name).toBe("Admin User");
+ expect(createdInvite?.invitedBy.email).toBe("admin@test.com");
+ });
+
+ test<CustomTestContext>("all invites create user role", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ const admin = await unauthedAPICaller.users.create({
+ name: "Admin User",
+ email: "admin@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
+
+ const invite = await adminCaller.invites.create({
+ email: "newuser@test.com",
+ });
+
+ const dbInvite = await db.query.invites.findFirst({
+ where: eq(invites.id, invite.id),
+ });
+
+ const newUser = await unauthedAPICaller.invites.accept({
+ token: dbInvite!.token,
+ name: "New User",
+ password: "userpass123",
+ });
+
+ const user = await db.query.users.findFirst({
+ where: eq(users.email, newUser.email),
+ });
+ expect(user?.role).toBe("user");
+ });
+
+ test<CustomTestContext>("email sending is called during invite creation", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ // Mock the email module
+ const mockSendInviteEmail = vi.fn().mockResolvedValue(undefined);
+ vi.doMock("../email", () => ({
+ sendInviteEmail: mockSendInviteEmail,
+ }));
+
+ const admin = await unauthedAPICaller.users.create({
+ name: "Admin User",
+ email: "admin@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ const adminCaller = getApiCaller(db, admin.id, admin.email, "admin");
+
+ await adminCaller.invites.create({
+ email: "newuser@test.com",
+ });
+
+ // Note: In a real test environment, we'd need to properly mock the email module
+ // This test demonstrates the structure but may not actually verify the mock call
+ // due to how the module is imported in the router
+ });
+});
diff --git a/packages/trpc/routers/invites.ts b/packages/trpc/routers/invites.ts
new file mode 100644
index 00000000..5f7897c5
--- /dev/null
+++ b/packages/trpc/routers/invites.ts
@@ -0,0 +1,285 @@
+import { randomBytes } from "crypto";
+import { TRPCError } from "@trpc/server";
+import { and, eq, gt } from "drizzle-orm";
+import { z } from "zod";
+
+import { invites, users } from "@karakeep/db/schema";
+
+import { generatePasswordSalt, hashPassword } from "../auth";
+import { sendInviteEmail } from "../email";
+import { adminProcedure, publicProcedure, router } from "../index";
+import { createUserRaw } from "./users";
+
+export const invitesAppRouter = router({
+ create: adminProcedure
+ .input(
+ z.object({
+ email: z.string().email(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ const existingUser = await ctx.db.query.users.findFirst({
+ where: eq(users.email, input.email),
+ });
+
+ if (existingUser) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "User with this email already exists",
+ });
+ }
+
+ const existingInvite = await ctx.db.query.invites.findFirst({
+ where: and(
+ eq(invites.email, input.email),
+ gt(invites.expiresAt, new Date()),
+ ),
+ });
+
+ if (existingInvite) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "An active invite for this email already exists",
+ });
+ }
+
+ const token = randomBytes(32).toString("hex");
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
+
+ const [invite] = await ctx.db
+ .insert(invites)
+ .values({
+ email: input.email,
+ token,
+ expiresAt,
+ invitedBy: ctx.user.id,
+ })
+ .returning();
+
+ // Send invite email
+ try {
+ await sendInviteEmail(
+ input.email,
+ token,
+ ctx.user.name || "A Karakeep admin",
+ );
+ } catch (error) {
+ console.error("Failed to send invite email:", error);
+ // Don't fail the invite creation if email sending fails
+ }
+
+ return {
+ id: invite.id,
+ email: invite.email,
+ expiresAt: invite.expiresAt,
+ };
+ }),
+
+ list: adminProcedure
+ .output(
+ z.object({
+ invites: z.array(
+ z.object({
+ id: z.string(),
+ email: z.string(),
+ createdAt: z.date(),
+ expiresAt: z.date(),
+ invitedBy: z.object({
+ id: z.string(),
+ name: z.string(),
+ email: z.string(),
+ }),
+ }),
+ ),
+ }),
+ )
+ .query(async ({ ctx }) => {
+ const dbInvites = await ctx.db.query.invites.findMany({
+ with: {
+ invitedBy: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ },
+ },
+ },
+ orderBy: (invites, { desc }) => [desc(invites.createdAt)],
+ });
+
+ return {
+ invites: dbInvites,
+ };
+ }),
+
+ get: publicProcedure
+ .input(
+ z.object({
+ token: z.string(),
+ }),
+ )
+ .output(
+ z.object({
+ email: z.string(),
+ expired: z.boolean(),
+ }),
+ )
+ .query(async ({ input, ctx }) => {
+ const invite = await ctx.db.query.invites.findFirst({
+ where: eq(invites.token, input.token),
+ });
+
+ if (!invite) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Invite not found or has been used",
+ });
+ }
+
+ const now = new Date();
+ const expired = invite.expiresAt < now;
+
+ if (expired) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Invite has expired",
+ });
+ }
+
+ return {
+ email: invite.email,
+ expired: false,
+ };
+ }),
+
+ accept: publicProcedure
+ .input(
+ z.object({
+ token: z.string(),
+ name: z.string().min(1),
+ password: z.string().min(8),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ const invite = await ctx.db.query.invites.findFirst({
+ where: eq(invites.token, input.token),
+ });
+
+ if (!invite) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Invite not found or has been used",
+ });
+ }
+
+ const now = new Date();
+ if (invite.expiresAt < now) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "This invite has expired",
+ });
+ }
+
+ const existingUser = await ctx.db.query.users.findFirst({
+ where: eq(users.email, invite.email),
+ });
+
+ if (existingUser) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "User with this email already exists",
+ });
+ }
+
+ const salt = generatePasswordSalt();
+ const user = await createUserRaw(ctx.db, {
+ name: input.name,
+ email: invite.email,
+ password: await hashPassword(input.password, salt),
+ salt,
+ role: "user",
+ emailVerified: new Date(), // Auto-verify invited users
+ });
+
+ // Delete the invite after successful user creation
+ await ctx.db.delete(invites).where(eq(invites.id, invite.id));
+
+ return {
+ id: user.id,
+ name: user.name,
+ email: user.email,
+ };
+ }),
+
+ revoke: adminProcedure
+ .input(
+ z.object({
+ inviteId: z.string(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ const invite = await ctx.db.query.invites.findFirst({
+ where: eq(invites.id, input.inviteId),
+ });
+
+ if (!invite) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Invite not found",
+ });
+ }
+
+ // Delete the invite to revoke it
+ await ctx.db.delete(invites).where(eq(invites.id, input.inviteId));
+
+ return { success: true };
+ }),
+
+ resend: adminProcedure
+ .input(
+ z.object({
+ inviteId: z.string(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ const invite = await ctx.db.query.invites.findFirst({
+ where: eq(invites.id, input.inviteId),
+ });
+
+ if (!invite) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Invite not found",
+ });
+ }
+
+ const newToken = randomBytes(32).toString("hex");
+ const newExpiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
+
+ await ctx.db
+ .update(invites)
+ .set({
+ token: newToken,
+ expiresAt: newExpiresAt,
+ })
+ .where(eq(invites.id, input.inviteId));
+
+ // Send invite email with new token
+ try {
+ await sendInviteEmail(
+ invite.email,
+ newToken,
+ ctx.user.name || "A Karakeep admin",
+ );
+ } catch (error) {
+ console.error("Failed to send invite email:", error);
+ // Don't fail the resend if email sending fails
+ }
+
+ return {
+ id: invite.id,
+ email: invite.email,
+ expiresAt: newExpiresAt,
+ };
+ }),
+});
diff --git a/packages/trpc/testUtils.ts b/packages/trpc/testUtils.ts
index c0ad74fb..ee9d1d42 100644
--- a/packages/trpc/testUtils.ts
+++ b/packages/trpc/testUtils.ts
@@ -28,14 +28,19 @@ export async function seedUsers(db: TestDB) {
.returning();
}
-export function getApiCaller(db: TestDB, userId?: string, email?: string) {
+export function getApiCaller(
+ db: TestDB,
+ userId?: string,
+ email?: string,
+ role: "user" | "admin" = "user",
+) {
const createCaller = createCallerFactory(appRouter);
return createCaller({
user: userId
? {
id: userId,
email,
- role: "user",
+ role,
}
: null,
db,