diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-07-10 20:50:19 +0000 |
|---|---|---|
| committer | Mohamed Bassem <me@mbassem.com> | 2025-07-12 12:20:41 +0000 |
| commit | 140311d7419fa2192e5149df8f589c3c3733a399 (patch) | |
| tree | ddf532bbf09e4f7c947854b5515c0e8674030645 | |
| parent | 385f9f0b055678420e820b8ed30e595871630e58 (diff) | |
| download | karakeep-140311d7419fa2192e5149df8f589c3c3733a399.tar.zst | |
feat: Support forget and reset password
| -rw-r--r-- | apps/web/app/forgot-password/page.tsx | 22 | ||||
| -rw-r--r-- | apps/web/app/reset-password/page.tsx | 36 | ||||
| -rw-r--r-- | apps/web/components/signin/CredentialsForm.tsx | 9 | ||||
| -rw-r--r-- | apps/web/components/signin/ForgotPasswordForm.tsx | 149 | ||||
| -rw-r--r-- | apps/web/components/signin/ResetPasswordForm.tsx | 181 | ||||
| -rw-r--r-- | packages/db/drizzle/0057_salty_carmella_unuscione.sql | 11 | ||||
| -rw-r--r-- | packages/db/drizzle/meta/0057_snapshot.json | 2206 | ||||
| -rw-r--r-- | packages/db/drizzle/meta/_journal.json | 7 | ||||
| -rw-r--r-- | packages/db/schema.ts | 27 | ||||
| -rw-r--r-- | packages/shared/types/users.ts | 8 | ||||
| -rw-r--r-- | packages/trpc/email.ts | 112 | ||||
| -rw-r--r-- | packages/trpc/routers/users.test.ts | 291 | ||||
| -rw-r--r-- | packages/trpc/routers/users.ts | 158 |
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'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 }; + }), }); |
