aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-07-10 20:50:19 +0000
committerMohamed Bassem <me@mbassem.com>2025-07-12 12:20:41 +0000
commit140311d7419fa2192e5149df8f589c3c3733a399 (patch)
treeddf532bbf09e4f7c947854b5515c0e8674030645
parent385f9f0b055678420e820b8ed30e595871630e58 (diff)
downloadkarakeep-140311d7419fa2192e5149df8f589c3c3733a399.tar.zst
feat: Support forget and reset password
-rw-r--r--apps/web/app/forgot-password/page.tsx22
-rw-r--r--apps/web/app/reset-password/page.tsx36
-rw-r--r--apps/web/components/signin/CredentialsForm.tsx9
-rw-r--r--apps/web/components/signin/ForgotPasswordForm.tsx149
-rw-r--r--apps/web/components/signin/ResetPasswordForm.tsx181
-rw-r--r--packages/db/drizzle/0057_salty_carmella_unuscione.sql11
-rw-r--r--packages/db/drizzle/meta/0057_snapshot.json2206
-rw-r--r--packages/db/drizzle/meta/_journal.json7
-rw-r--r--packages/db/schema.ts27
-rw-r--r--packages/shared/types/users.ts8
-rw-r--r--packages/trpc/email.ts112
-rw-r--r--packages/trpc/routers/users.test.ts291
-rw-r--r--packages/trpc/routers/users.ts158
13 files changed, 3170 insertions, 47 deletions
diff --git a/apps/web/app/forgot-password/page.tsx b/apps/web/app/forgot-password/page.tsx
new file mode 100644
index 00000000..1faa8967
--- /dev/null
+++ b/apps/web/app/forgot-password/page.tsx
@@ -0,0 +1,22 @@
+import { redirect } from "next/navigation";
+import KarakeepLogo from "@/components/KarakeepIcon";
+import ForgotPasswordForm from "@/components/signin/ForgotPasswordForm";
+import { getServerAuthSession } from "@/server/auth";
+
+export default async function ForgotPasswordPage() {
+ 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>
+ <ForgotPasswordForm />
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/app/reset-password/page.tsx b/apps/web/app/reset-password/page.tsx
new file mode 100644
index 00000000..1d05606e
--- /dev/null
+++ b/apps/web/app/reset-password/page.tsx
@@ -0,0 +1,36 @@
+import { redirect } from "next/navigation";
+import KarakeepLogo from "@/components/KarakeepIcon";
+import ResetPasswordForm from "@/components/signin/ResetPasswordForm";
+import { getServerAuthSession } from "@/server/auth";
+
+interface ResetPasswordPageProps {
+ searchParams: {
+ token?: string;
+ };
+}
+
+export default async function ResetPasswordPage({
+ searchParams,
+}: ResetPasswordPageProps) {
+ const session = await getServerAuthSession();
+ if (session) {
+ redirect("/");
+ }
+
+ const { token } = searchParams;
+
+ if (!token) {
+ redirect("/signin");
+ }
+
+ 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>
+ <ResetPasswordForm token={token} />
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/components/signin/CredentialsForm.tsx b/apps/web/components/signin/CredentialsForm.tsx
index 1ad240a7..a3b854b7 100644
--- a/apps/web/components/signin/CredentialsForm.tsx
+++ b/apps/web/components/signin/CredentialsForm.tsx
@@ -142,6 +142,15 @@ export default function CredentialsForm() {
>
Sign In
</ActionButton>
+
+ <div className="text-center">
+ <Link
+ href="/forgot-password"
+ className="text-sm text-muted-foreground underline hover:text-primary"
+ >
+ Forgot your password?
+ </Link>
+ </div>
</form>
</Form>
diff --git a/apps/web/components/signin/ForgotPasswordForm.tsx b/apps/web/components/signin/ForgotPasswordForm.tsx
new file mode 100644
index 00000000..29d55f2b
--- /dev/null
+++ b/apps/web/components/signin/ForgotPasswordForm.tsx
@@ -0,0 +1,149 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { ActionButton } from "@/components/ui/action-button";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+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, CheckCircle } from "lucide-react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+
+const forgotPasswordSchema = z.object({
+ email: z.string().email("Please enter a valid email address"),
+});
+
+export default function ForgotPasswordForm() {
+ const [isSubmitted, setIsSubmitted] = useState(false);
+ const [errorMessage, setErrorMessage] = useState("");
+ const router = useRouter();
+
+ const form = useForm<z.infer<typeof forgotPasswordSchema>>({
+ resolver: zodResolver(forgotPasswordSchema),
+ });
+
+ const forgotPasswordMutation = api.users.forgotPassword.useMutation();
+
+ const onSubmit = async (values: z.infer<typeof forgotPasswordSchema>) => {
+ try {
+ setErrorMessage("");
+ await forgotPasswordMutation.mutateAsync(values);
+ setIsSubmitted(true);
+ } catch (error) {
+ if (error instanceof TRPCClientError) {
+ setErrorMessage(error.message);
+ } else {
+ setErrorMessage("An unexpected error occurred. Please try again.");
+ }
+ }
+ };
+
+ return (
+ <Card className="w-full">
+ <CardHeader className="text-center">
+ <CardTitle className="text-2xl font-bold">
+ {isSubmitted ? "Check your email" : "Forgot your password?"}
+ </CardTitle>
+ <CardDescription>
+ {isSubmitted
+ ? "If an account with that email exists, we've sent you a password reset link."
+ : "Enter your email address and we'll send you a link to reset your password."}
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ {isSubmitted ? (
+ <>
+ <div className="flex items-center justify-center">
+ <CheckCircle className="h-12 w-12 text-green-600" />
+ </div>
+ <Alert>
+ <AlertDescription className="text-center">
+ If an account with that email exists, we&apos;ve sent you a
+ password reset link.
+ </AlertDescription>
+ </Alert>
+ <ActionButton
+ variant="outline"
+ loading={false}
+ onClick={() => router.push("/signin")}
+ className="w-full"
+ >
+ Back to Sign In
+ </ActionButton>
+ </>
+ ) : (
+ <>
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="space-y-4"
+ >
+ {errorMessage && (
+ <Alert variant="destructive">
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>{errorMessage}</AlertDescription>
+ </Alert>
+ )}
+
+ <FormField
+ control={form.control}
+ name="email"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Email</FormLabel>
+ <FormControl>
+ <Input
+ type="email"
+ placeholder="Enter your email address"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <ActionButton
+ type="submit"
+ loading={form.formState.isSubmitting}
+ className="w-full"
+ >
+ Send Reset Link
+ </ActionButton>
+ </form>
+ </Form>
+
+ <div className="text-center">
+ <ActionButton
+ variant="ghost"
+ loading={false}
+ onClick={() => router.push("/signin")}
+ className="w-full"
+ >
+ Back to Sign In
+ </ActionButton>
+ </div>
+ </>
+ )}
+ </CardContent>
+ </Card>
+ );
+}
diff --git a/apps/web/components/signin/ResetPasswordForm.tsx b/apps/web/components/signin/ResetPasswordForm.tsx
new file mode 100644
index 00000000..d4d8a285
--- /dev/null
+++ b/apps/web/components/signin/ResetPasswordForm.tsx
@@ -0,0 +1,181 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { ActionButton } from "@/components/ui/action-button";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+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, CheckCircle } from "lucide-react";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+
+import { zResetPasswordSchema } from "@karakeep/shared/types/users";
+
+const resetPasswordSchema = z
+ .object({
+ confirmPassword: z.string(),
+ })
+ .merge(zResetPasswordSchema.pick({ newPassword: true }))
+ .refine((data) => data.newPassword === data.confirmPassword, {
+ message: "Passwords don't match",
+ path: ["confirmPassword"],
+ });
+
+interface ResetPasswordFormProps {
+ token: string;
+}
+
+export default function ResetPasswordForm({ token }: ResetPasswordFormProps) {
+ const [isSuccess, setIsSuccess] = useState(false);
+ const [errorMessage, setErrorMessage] = useState("");
+ const router = useRouter();
+
+ const form = useForm<z.infer<typeof resetPasswordSchema>>({
+ resolver: zodResolver(resetPasswordSchema),
+ });
+
+ const resetPasswordMutation = api.users.resetPassword.useMutation();
+
+ const onSubmit = async (values: z.infer<typeof resetPasswordSchema>) => {
+ try {
+ setErrorMessage("");
+ await resetPasswordMutation.mutateAsync({
+ token,
+ newPassword: values.newPassword,
+ });
+ setIsSuccess(true);
+ } catch (error) {
+ if (error instanceof TRPCClientError) {
+ setErrorMessage(error.message);
+ } else {
+ setErrorMessage("An unexpected error occurred. Please try again.");
+ }
+ }
+ };
+
+ return (
+ <Card className="w-full">
+ <CardHeader className="text-center">
+ <CardTitle className="text-2xl font-bold">
+ {isSuccess ? "Password reset successful" : "Reset your password"}
+ </CardTitle>
+ <CardDescription>
+ {isSuccess
+ ? "Your password has been successfully reset. You can now sign in with your new password."
+ : "Enter your new password below."}
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ {isSuccess ? (
+ <>
+ <div className="flex items-center justify-center">
+ <CheckCircle className="h-12 w-12 text-green-600" />
+ </div>
+ <Alert>
+ <AlertDescription className="text-center">
+ Your password has been successfully reset. You can now sign in
+ with your new password.
+ </AlertDescription>
+ </Alert>
+ <ActionButton
+ loading={false}
+ onClick={() => router.push("/signin")}
+ className="w-full"
+ >
+ Go to Sign In
+ </ActionButton>
+ </>
+ ) : (
+ <>
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="space-y-4"
+ >
+ {errorMessage && (
+ <Alert variant="destructive">
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>{errorMessage}</AlertDescription>
+ </Alert>
+ )}
+
+ <FormField
+ control={form.control}
+ name="newPassword"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>New Password</FormLabel>
+ <FormControl>
+ <Input
+ type="password"
+ placeholder="Enter your new password"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="confirmPassword"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Confirm New Password</FormLabel>
+ <FormControl>
+ <Input
+ type="password"
+ placeholder="Confirm your new password"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <ActionButton
+ type="submit"
+ loading={form.formState.isSubmitting}
+ className="w-full"
+ >
+ Reset Password
+ </ActionButton>
+ </form>
+ </Form>
+
+ <div className="text-center">
+ <ActionButton
+ variant="ghost"
+ loading={false}
+ onClick={() => router.push("/signin")}
+ className="w-full"
+ >
+ Back to Sign In
+ </ActionButton>
+ </div>
+ </>
+ )}
+ </CardContent>
+ </Card>
+ );
+}
diff --git a/packages/db/drizzle/0057_salty_carmella_unuscione.sql b/packages/db/drizzle/0057_salty_carmella_unuscione.sql
new file mode 100644
index 00000000..c557425b
--- /dev/null
+++ b/packages/db/drizzle/0057_salty_carmella_unuscione.sql
@@ -0,0 +1,11 @@
+CREATE TABLE `passwordResetToken` (
+ `id` text PRIMARY KEY NOT NULL,
+ `userId` text NOT NULL,
+ `token` text NOT NULL,
+ `expires` integer NOT NULL,
+ `createdAt` integer NOT NULL,
+ FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
+);
+--> statement-breakpoint
+CREATE UNIQUE INDEX `passwordResetToken_token_unique` ON `passwordResetToken` (`token`);--> statement-breakpoint
+CREATE INDEX `passwordResetTokens_userId_idx` ON `passwordResetToken` (`userId`); \ No newline at end of file
diff --git a/packages/db/drizzle/meta/0057_snapshot.json b/packages/db/drizzle/meta/0057_snapshot.json
new file mode 100644
index 00000000..b2e7f07b
--- /dev/null
+++ b/packages/db/drizzle/meta/0057_snapshot.json
@@ -0,0 +1,2206 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "b5e79604-adc2-4ad2-b2e2-96a871ec8f01",
+ "prevId": "c39611a8-5fb3-4fd2-8643-64e5ed826095",
+ "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": {}
+ },
+ "passwordResetToken": {
+ "name": "passwordResetToken",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "userId": {
+ "name": "userId",
+ "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
+ },
+ "createdAt": {
+ "name": "createdAt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "passwordResetToken_token_unique": {
+ "name": "passwordResetToken_token_unique",
+ "columns": [
+ "token"
+ ],
+ "isUnique": true
+ },
+ "passwordResetTokens_userId_idx": {
+ "name": "passwordResetTokens_userId_idx",
+ "columns": [
+ "userId"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "passwordResetToken_userId_user_id_fk": {
+ "name": "passwordResetToken_userId_user_id_fk",
+ "tableFrom": "passwordResetToken",
+ "tableTo": "user",
+ "columnsFrom": [
+ "userId"
+ ],
+ "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 2ff89c1b..d1fb1769 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -400,6 +400,13 @@
"when": 1752180326709,
"tag": "0056_user_invites",
"breakpoints": true
+ },
+ {
+ "idx": 57,
+ "version": "6",
+ "when": 1752314617600,
+ "tag": "0057_salty_carmella_unuscione",
+ "breakpoints": true
}
]
} \ No newline at end of file
diff --git a/packages/db/schema.ts b/packages/db/schema.ts
index 881d72ec..79cf2def 100644
--- a/packages/db/schema.ts
+++ b/packages/db/schema.ts
@@ -87,6 +87,23 @@ export const verificationTokens = sqliteTable(
(vt) => [primaryKey({ columns: [vt.identifier, vt.token] })],
);
+export const passwordResetTokens = sqliteTable(
+ "passwordResetToken",
+ {
+ id: text("id")
+ .notNull()
+ .primaryKey()
+ .$defaultFn(() => createId()),
+ userId: text("userId")
+ .notNull()
+ .references(() => users.id, { onDelete: "cascade" }),
+ token: text("token").notNull().unique(),
+ expires: integer("expires", { mode: "timestamp_ms" }).notNull(),
+ createdAt: createdAtField(),
+ },
+ (prt) => [index("passwordResetTokens_userId_idx").on(prt.userId)],
+);
+
export const apiKeys = sqliteTable(
"apiKey",
{
@@ -727,3 +744,13 @@ export const invitesRelations = relations(invites, ({ one }) => ({
references: [users.id],
}),
}));
+
+export const passwordResetTokensRelations = relations(
+ passwordResetTokens,
+ ({ one }) => ({
+ user: one(users, {
+ fields: [passwordResetTokens.userId],
+ references: [users.id],
+ }),
+ }),
+);
diff --git a/packages/shared/types/users.ts b/packages/shared/types/users.ts
index b06b598d..e4b4315b 100644
--- a/packages/shared/types/users.ts
+++ b/packages/shared/types/users.ts
@@ -1,12 +1,13 @@
import { z } from "zod";
+export const PASSWORD_MIN_LENGTH = 8;
export const PASSWORD_MAX_LENGTH = 100;
export const zSignUpSchema = z
.object({
name: z.string().min(1, { message: "Name can't be empty" }),
email: z.string().email(),
- password: z.string().min(8).max(PASSWORD_MAX_LENGTH),
+ password: z.string().min(PASSWORD_MIN_LENGTH).max(PASSWORD_MAX_LENGTH),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
@@ -14,6 +15,11 @@ export const zSignUpSchema = z
path: ["confirmPassword"],
});
+export const zResetPasswordSchema = z.object({
+ token: z.string(),
+ newPassword: z.string().min(PASSWORD_MIN_LENGTH).max(PASSWORD_MAX_LENGTH),
+});
+
export const zChangePasswordSchema = z
.object({
currentPassword: z.string(),
diff --git a/packages/trpc/email.ts b/packages/trpc/email.ts
index ded23ed8..1c0b8800 100644
--- a/packages/trpc/email.ts
+++ b/packages/trpc/email.ts
@@ -1,9 +1,8 @@
import { randomBytes } from "crypto";
-import { and, eq } from "drizzle-orm";
import { createTransport } from "nodemailer";
import { db } from "@karakeep/db";
-import { verificationTokens } from "@karakeep/db/schema";
+import { passwordResetTokens, verificationTokens } from "@karakeep/db/schema";
import serverConfig from "@karakeep/shared/config";
export async function sendVerificationEmail(email: string, name: string) {
@@ -70,45 +69,6 @@ If you didn't create an account with us, please ignore this email.
await transporter.sendMail(mailOptions);
}
-export async function verifyEmailToken(
- email: string,
- token: string,
-): Promise<boolean> {
- const verificationToken = await db.query.verificationTokens.findFirst({
- where: (vt, { and, eq }) =>
- and(eq(vt.identifier, email), eq(vt.token, token)),
- });
-
- if (!verificationToken) {
- return false;
- }
-
- if (verificationToken.expires < new Date()) {
- // Clean up expired token
- await db
- .delete(verificationTokens)
- .where(
- and(
- eq(verificationTokens.identifier, email),
- eq(verificationTokens.token, token),
- ),
- );
- return false;
- }
-
- // Clean up used token
- await db
- .delete(verificationTokens)
- .where(
- and(
- eq(verificationTokens.identifier, email),
- eq(verificationTokens.token, token),
- ),
- );
-
- return true;
-}
-
export async function sendInviteEmail(
email: string,
token: string,
@@ -169,3 +129,73 @@ If you weren't expecting this invitation, you can safely ignore this email.
await transporter.sendMail(mailOptions);
}
+
+export async function sendPasswordResetEmail(
+ email: string,
+ name: string,
+ userId: string,
+) {
+ if (!serverConfig.email.smtp) {
+ throw new Error("SMTP is not configured");
+ }
+
+ const token = randomBytes(32).toString("hex");
+ const expires = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
+
+ // Store password reset token
+ await db.insert(passwordResetTokens).values({
+ userId,
+ token,
+ expires,
+ });
+
+ 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 resetUrl = `${serverConfig.publicUrl}/reset-password?token=${encodeURIComponent(token)}`;
+
+ const mailOptions = {
+ from: serverConfig.email.smtp.from,
+ to: email,
+ subject: "Reset your password",
+ html: `
+ <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
+ <h2>Password Reset Request</h2>
+ <p>Hi ${name},</p>
+ <p>You requested to reset your password for your Karakeep account. Click the link below to reset your password:</p>
+ <p>
+ <a href="${resetUrl}" style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
+ Reset Password
+ </a>
+ </p>
+ <p>If the button doesn't work, you can copy and paste this link into your browser:</p>
+ <p><a href="${resetUrl}">${resetUrl}</a></p>
+ <p>This link will expire in 1 hour.</p>
+ <p>If you didn't request a password reset, please ignore this email. Your password will remain unchanged.</p>
+ </div>
+ `,
+ text: `
+Hi ${name},
+
+You requested to reset your password for your Karakeep account. Visit this link to reset your password:
+${resetUrl}
+
+This link will expire in 1 hour.
+
+If you didn't request a password reset, please ignore this email. Your password will remain unchanged.
+ `,
+ };
+
+ await transporter.sendMail(mailOptions);
+ return token;
+}
diff --git a/packages/trpc/routers/users.test.ts b/packages/trpc/routers/users.test.ts
index 21ee3a7b..03e5d590 100644
--- a/packages/trpc/routers/users.test.ts
+++ b/packages/trpc/routers/users.test.ts
@@ -1,11 +1,46 @@
-import { assert, beforeEach, describe, expect, test } from "vitest";
-
-import { assets, AssetTypes, bookmarks } from "@karakeep/db/schema";
+import { eq } from "drizzle-orm";
+import { assert, beforeEach, describe, expect, test, vi } from "vitest";
+
+import {
+ assets,
+ AssetTypes,
+ bookmarks,
+ passwordResetTokens,
+ users,
+} from "@karakeep/db/schema";
import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
import type { CustomTestContext } from "../testUtils";
+import * as emailModule from "../email";
import { defaultBeforeEach, getApiCaller } from "../testUtils";
+// Mock server config with all required properties - MUST be before any imports that use config
+vi.mock("@karakeep/shared/config", async (original) => {
+ const mod = (await original()) as typeof import("@karakeep/shared/config");
+ return {
+ ...mod,
+ default: {
+ ...mod.default,
+ email: {
+ smtp: {
+ host: "test-smtp.example.com",
+ port: 587,
+ secure: false,
+ user: "test@example.com",
+ password: "test-password",
+ from: "test@example.com",
+ },
+ },
+ },
+ };
+});
+
+// Mock email functions
+vi.mock("../email", () => ({
+ sendPasswordResetEmail: vi.fn().mockResolvedValue(undefined),
+ sendVerificationEmail: vi.fn().mockResolvedValue(undefined),
+}));
+
beforeEach<CustomTestContext>(defaultBeforeEach(false));
describe("User Routes", () => {
@@ -475,4 +510,254 @@ describe("User Routes", () => {
),
).toBe(true);
});
+
+ describe("Password Reset", () => {
+ test<CustomTestContext>("forgotPassword - successful email sending", async ({
+ unauthedAPICaller,
+ }) => {
+ // Create a user first
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "reset@test.com",
+ password: "pass1234",
+ confirmPassword: "pass1234",
+ });
+
+ // With mocked email service, this should succeed
+ const result = await unauthedAPICaller.users.forgotPassword({
+ email: "reset@test.com",
+ });
+
+ expect(result.success).toBe(true);
+
+ // Verify that the email function was called with correct parameters
+ expect(emailModule.sendPasswordResetEmail).toHaveBeenCalledWith(
+ "reset@test.com",
+ "Test User",
+ user.id,
+ );
+ });
+
+ test<CustomTestContext>("forgotPassword - non-existing user", async ({
+ unauthedAPICaller,
+ }) => {
+ // Should not reveal if user exists or not
+ const result = await unauthedAPICaller.users.forgotPassword({
+ email: "nonexistent@test.com",
+ });
+
+ expect(result.success).toBe(true);
+ });
+
+ test<CustomTestContext>("forgotPassword - OAuth user (no password)", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ // Create a user without password (OAuth user)
+ await db.insert(users).values({
+ name: "OAuth User",
+ email: "oauth@test.com",
+ password: null,
+ });
+
+ // Should not send reset email for OAuth users
+ const result = await unauthedAPICaller.users.forgotPassword({
+ email: "oauth@test.com",
+ });
+
+ expect(result.success).toBe(true);
+ });
+
+ test<CustomTestContext>("resetPassword - valid token", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ // Create a user
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "validreset@test.com",
+ password: "oldpass123",
+ confirmPassword: "oldpass123",
+ });
+
+ // Create a password reset token directly in the database
+ const token = "valid-reset-token";
+ const expires = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
+
+ await db.insert(passwordResetTokens).values({
+ userId: user.id,
+ token,
+ expires,
+ });
+
+ // Reset the password
+ const result = await unauthedAPICaller.users.resetPassword({
+ token,
+ newPassword: "newpass123",
+ });
+
+ expect(result.success).toBe(true);
+
+ // Verify the token was consumed (deleted)
+ const remainingTokens = await db
+ .select()
+ .from(passwordResetTokens)
+ .where(eq(passwordResetTokens.token, token));
+
+ expect(remainingTokens).toHaveLength(0);
+
+ // The password reset was successful if we got here without errors
+ });
+
+ test<CustomTestContext>("resetPassword - invalid token", async ({
+ unauthedAPICaller,
+ }) => {
+ await expect(
+ unauthedAPICaller.users.resetPassword({
+ token: "invalid-token",
+ newPassword: "newpass123",
+ }),
+ ).rejects.toThrow(/Invalid or expired reset token/);
+ });
+
+ test<CustomTestContext>("resetPassword - expired token", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ // Create a user
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "expiredtoken@test.com",
+ password: "oldpass123",
+ confirmPassword: "oldpass123",
+ });
+
+ // Create an expired password reset token
+ const token = "expired-reset-token";
+ const expires = new Date(Date.now() - 60 * 60 * 1000); // 1 hour ago (expired)
+
+ await db.insert(passwordResetTokens).values({
+ userId: user.id,
+ token,
+ expires,
+ });
+
+ await expect(
+ unauthedAPICaller.users.resetPassword({
+ token,
+ newPassword: "newpass123",
+ }),
+ ).rejects.toThrow(/Invalid or expired reset token/);
+
+ // Verify the expired token was cleaned up
+ const remainingTokens = await db
+ .select()
+ .from(passwordResetTokens)
+ .where(eq(passwordResetTokens.token, token));
+
+ expect(remainingTokens).toHaveLength(0);
+ });
+
+ test<CustomTestContext>("resetPassword - user not found", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ // Create a user first, then delete them to create an orphaned token
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "orphaned@test.com",
+ password: "oldpass123",
+ confirmPassword: "oldpass123",
+ });
+
+ // Create a password reset token
+ const token = "orphaned-token";
+ const expires = new Date(Date.now() + 60 * 60 * 1000);
+
+ await db.insert(passwordResetTokens).values({
+ userId: user.id,
+ token,
+ expires,
+ });
+
+ // Delete the user to make the token orphaned
+ // Due to foreign key cascade, this will also delete the token
+ // So we expect "Invalid or expired reset token" instead of "User not found"
+ await db.delete(users).where(eq(users.id, user.id));
+
+ await expect(
+ unauthedAPICaller.users.resetPassword({
+ token,
+ newPassword: "newpass123",
+ }),
+ ).rejects.toThrow(/Invalid or expired reset token/);
+ });
+ test<CustomTestContext>("resetPassword - password validation", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ // Create a user
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "validation@test.com",
+ password: "oldpass123",
+ confirmPassword: "oldpass123",
+ });
+
+ // Create a password reset token
+ const token = "validation-token";
+ const expires = new Date(Date.now() + 60 * 60 * 1000);
+
+ await db.insert(passwordResetTokens).values({
+ userId: user.id,
+ token,
+ expires,
+ });
+
+ // Try to reset with a password that's too short
+ await expect(
+ unauthedAPICaller.users.resetPassword({
+ token,
+ newPassword: "123", // Too short
+ }),
+ ).rejects.toThrow();
+ });
+
+ test<CustomTestContext>("resetPassword - token reuse prevention", async ({
+ db,
+ unauthedAPICaller,
+ }) => {
+ // Create a user
+ const user = await unauthedAPICaller.users.create({
+ name: "Test User",
+ email: "reuse@test.com",
+ password: "oldpass123",
+ confirmPassword: "oldpass123",
+ });
+
+ // Create a password reset token
+ const token = "reuse-token";
+ const expires = new Date(Date.now() + 60 * 60 * 1000);
+
+ await db.insert(passwordResetTokens).values({
+ userId: user.id,
+ token,
+ expires,
+ });
+
+ // Use the token once
+ await unauthedAPICaller.users.resetPassword({
+ token,
+ newPassword: "newpass123",
+ });
+
+ // Try to use the same token again
+ await expect(
+ unauthedAPICaller.users.resetPassword({
+ token,
+ newPassword: "anotherpass123",
+ }),
+ ).rejects.toThrow(/Invalid or expired reset token/);
+ });
+ });
});
diff --git a/packages/trpc/routers/users.ts b/packages/trpc/routers/users.ts
index ebe7d96f..79f06057 100644
--- a/packages/trpc/routers/users.ts
+++ b/packages/trpc/routers/users.ts
@@ -11,13 +11,16 @@ import {
bookmarks,
bookmarkTags,
highlights,
+ passwordResetTokens,
tagsOnBookmarks,
users,
userSettings,
+ verificationTokens,
} from "@karakeep/db/schema";
import { deleteUserAssets } from "@karakeep/shared/assetdb";
import serverConfig from "@karakeep/shared/config";
import {
+ zResetPasswordSchema,
zSignUpSchema,
zUpdateUserSettingsSchema,
zUserSettingsSchema,
@@ -26,7 +29,7 @@ import {
} from "@karakeep/shared/types/users";
import { generatePasswordSalt, hashPassword, validatePassword } from "../auth";
-import { sendVerificationEmail, verifyEmailToken } from "../email";
+import { sendPasswordResetEmail, sendVerificationEmail } from "../email";
import {
adminProcedure,
authedProcedure,
@@ -36,6 +39,46 @@ import {
router,
} from "../index";
+export async function verifyEmailToken(
+ db: Context["db"],
+ email: string,
+ token: string,
+): Promise<boolean> {
+ const verificationToken = await db.query.verificationTokens.findFirst({
+ where: (vt, { and, eq }) =>
+ and(eq(vt.identifier, email), eq(vt.token, token)),
+ });
+
+ if (!verificationToken) {
+ return false;
+ }
+
+ if (verificationToken.expires < new Date()) {
+ // Clean up expired token
+ await db
+ .delete(verificationTokens)
+ .where(
+ and(
+ eq(verificationTokens.identifier, email),
+ eq(verificationTokens.token, token),
+ ),
+ );
+ return false;
+ }
+
+ // Clean up used token
+ await db
+ .delete(verificationTokens)
+ .where(
+ and(
+ eq(verificationTokens.identifier, email),
+ eq(verificationTokens.token, token),
+ ),
+ );
+
+ return true;
+}
+
export async function createUserRaw(
db: Context["db"],
input: {
@@ -563,7 +606,7 @@ export const usersAppRouter = router({
}),
)
.mutation(async ({ input, ctx }) => {
- const isValid = await verifyEmailToken(input.email, input.token);
+ const isValid = await verifyEmailToken(ctx.db, input.email, input.token);
if (!isValid) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -639,4 +682,115 @@ export const usersAppRouter = router({
});
}
}),
+ forgotPassword: publicProcedure
+ .use(
+ createRateLimitMiddleware({
+ name: "users.forgotPassword",
+ windowMs: 15 * 60 * 1000,
+ maxRequests: 3,
+ }),
+ )
+ .input(
+ z.object({
+ email: z.string().email(),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ if (!serverConfig.email.smtp) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Email service is not configured",
+ });
+ }
+
+ const user = await ctx.db.query.users.findFirst({
+ where: eq(users.email, input.email),
+ });
+
+ if (!user) {
+ // Don't reveal if user exists or not for security
+ return { success: true };
+ }
+
+ // Only send reset email for users with passwords (local accounts)
+ if (!user.password) {
+ return { success: true };
+ }
+
+ try {
+ await sendPasswordResetEmail(input.email, user.name, user.id);
+ return { success: true };
+ } catch (error) {
+ console.error("Failed to send password reset email:", error);
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Failed to send password reset email",
+ });
+ }
+ }),
+ resetPassword: publicProcedure
+ .use(
+ createRateLimitMiddleware({
+ name: "users.resetPassword",
+ windowMs: 5 * 60 * 1000,
+ maxRequests: 10,
+ }),
+ )
+ .input(zResetPasswordSchema)
+ .mutation(async ({ input, ctx }) => {
+ const token = input.token;
+ const resetToken = await ctx.db.query.passwordResetTokens.findFirst({
+ where: eq(passwordResetTokens.token, token),
+ with: {
+ user: {
+ columns: {
+ id: true,
+ },
+ },
+ },
+ });
+
+ if (!resetToken) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Invalid or expired reset token",
+ });
+ }
+
+ if (resetToken.expires < new Date()) {
+ // Clean up expired token
+ await ctx.db
+ .delete(passwordResetTokens)
+ .where(eq(passwordResetTokens.token, token));
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Invalid or expired reset token",
+ });
+ }
+
+ if (!resetToken.user) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "User not found",
+ });
+ }
+
+ // Generate new password hash
+ const newSalt = generatePasswordSalt();
+ const hashedPassword = await hashPassword(input.newPassword, newSalt);
+
+ // Update user password
+ await ctx.db
+ .update(users)
+ .set({
+ password: hashedPassword,
+ salt: newSalt,
+ })
+ .where(eq(users.id, resetToken.user.id));
+
+ await ctx.db
+ .delete(passwordResetTokens)
+ .where(eq(passwordResetTokens.token, token));
+ return { success: true };
+ }),
});