diff options
| author | MohamedBassem <me@mbassem.com> | 2024-02-22 15:32:40 +0000 |
|---|---|---|
| committer | MohamedBassem <me@mbassem.com> | 2024-02-22 15:32:40 +0000 |
| commit | 942aac691225f4895c159a0260890ad2c576e0c9 (patch) | |
| tree | 06a055fcd59c2753531f498ab58d0af4c7e8464c /packages | |
| parent | 08e7cbcfcb5e0b992d10ada324712c224b7a4d07 (diff) | |
| download | karakeep-942aac691225f4895c159a0260890ad2c576e0c9.tar.zst | |
feature: Add support for credentials registration and sign in
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/db/prisma/migrations/20240221104430_add_password_support/migration.sql | 2 | ||||
| -rw-r--r-- | packages/db/prisma/migrations/20240222152033_name_and_email_required/migration.sql | 23 | ||||
| -rw-r--r-- | packages/db/prisma/schema.prisma | 5 | ||||
| -rw-r--r-- | packages/web/app/signin/components/CredentialsForm.tsx | 222 | ||||
| -rw-r--r-- | packages/web/app/signin/components/SignInForm.tsx | 35 | ||||
| -rw-r--r-- | packages/web/app/signin/page.tsx | 18 | ||||
| -rw-r--r-- | packages/web/components/ui/separator.tsx | 31 | ||||
| -rw-r--r-- | packages/web/components/ui/tabs.tsx | 55 | ||||
| -rw-r--r-- | packages/web/lib/types/api/users.ts | 13 | ||||
| -rw-r--r-- | packages/web/package.json | 2 | ||||
| -rw-r--r-- | packages/web/server/api/routers/_app.ts | 2 | ||||
| -rw-r--r-- | packages/web/server/api/routers/users.ts | 45 | ||||
| -rw-r--r-- | packages/web/server/api/trpc.ts | 2 | ||||
| -rw-r--r-- | packages/web/server/auth.ts | 81 |
14 files changed, 516 insertions, 20 deletions
diff --git a/packages/db/prisma/migrations/20240221104430_add_password_support/migration.sql b/packages/db/prisma/migrations/20240221104430_add_password_support/migration.sql new file mode 100644 index 00000000..4c9b7b00 --- /dev/null +++ b/packages/db/prisma/migrations/20240221104430_add_password_support/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "password" TEXT; diff --git a/packages/db/prisma/migrations/20240222152033_name_and_email_required/migration.sql b/packages/db/prisma/migrations/20240222152033_name_and_email_required/migration.sql new file mode 100644 index 00000000..fa73b56e --- /dev/null +++ b/packages/db/prisma/migrations/20240222152033_name_and_email_required/migration.sql @@ -0,0 +1,23 @@ +/* + Warnings: + + - Made the column `email` on table `User` required. This step will fail if there are existing NULL values in that column. + - Made the column `name` on table `User` required. This step will fail if there are existing NULL values in that column. + +*/ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "emailVerified" DATETIME, + "password" TEXT, + "image" TEXT +); +INSERT INTO "new_User" ("email", "emailVerified", "id", "image", "name", "password") SELECT "email", "emailVerified", "id", "image", "name", "password" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 8a681a0b..3b6063a3 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -39,9 +39,10 @@ model Session { model User { id String @id @default(cuid()) - name String? - email String? @unique + name String + email String @unique emailVerified DateTime? + password String? image String? accounts Account[] sessions Session[] diff --git a/packages/web/app/signin/components/CredentialsForm.tsx b/packages/web/app/signin/components/CredentialsForm.tsx new file mode 100644 index 00000000..60b61156 --- /dev/null +++ b/packages/web/app/signin/components/CredentialsForm.tsx @@ -0,0 +1,222 @@ +"use client"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { ActionButton } from "@/components/ui/action-button"; +import { zSignUpSchema } from "@/lib/types/api/users"; +import { signIn } from "next-auth/react"; +import { useState } from "react"; +import { api } from "@/lib/trpc"; +import { useRouter } from "next/navigation"; +import { TRPCClientError } from "@trpc/client"; + +const signInSchema = z.object({ + email: z.string().email(), + password: z.string(), +}); + +function SignIn() { + const [signinError, setSigninError] = useState(false); + const router = useRouter(); + const form = useForm<z.infer<typeof signInSchema>>({ + resolver: zodResolver(signInSchema), + }); + + return ( + <Form {...form}> + <form + onSubmit={form.handleSubmit(async (value) => { + const resp = await signIn("credentials", { + redirect: false, + email: value.email, + password: value.password, + }); + if (!resp || !resp?.ok) { + setSigninError(true); + return; + } + router.replace("/"); + })} + > + <div className="flex w-full flex-col space-y-2"> + {signinError && ( + <p className="w-full text-center text-red-500"> + Incorrect username or password + </p> + )} + <FormField + control={form.control} + name="email" + render={({ field }) => { + return ( + <FormItem> + <FormLabel>Email</FormLabel> + <FormControl> + <Input type="text" placeholder="Email" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <FormField + control={form.control} + name="password" + render={({ field }) => { + return ( + <FormItem> + <FormLabel>Password</FormLabel> + <FormControl> + <Input type="password" placeholder="Password" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <ActionButton type="submit" loading={false}> + Sign In + </ActionButton> + </div> + </form> + </Form> + ); +} + +function SignUp() { + const form = useForm<z.infer<typeof zSignUpSchema>>({ + resolver: zodResolver(zSignUpSchema), + }); + const [errorMessage, setErrorMessage] = useState(""); + + const router = useRouter(); + + const createUserMutation = api.users.create.useMutation(); + + return ( + <Form {...form}> + <form + onSubmit={form.handleSubmit(async (value) => { + try { + await createUserMutation.mutateAsync(value); + } catch (e) { + if (e instanceof TRPCClientError) { + setErrorMessage(e.message); + } + return; + } + const resp = await signIn("credentials", { + redirect: false, + email: value.email, + password: value.password, + }); + if (!resp || !resp.ok) { + setErrorMessage("Hit an unexpected error while signing in"); + return; + } + router.replace("/"); + })} + > + <div className="flex w-full flex-col space-y-2"> + {errorMessage && ( + <p className="w-full text-center text-red-500">{errorMessage}</p> + )} + <FormField + control={form.control} + name="name" + render={({ field }) => { + return ( + <FormItem> + <FormLabel>Name</FormLabel> + <FormControl> + <Input type="text" placeholder="Name" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <FormField + control={form.control} + name="email" + render={({ field }) => { + return ( + <FormItem> + <FormLabel>Email</FormLabel> + <FormControl> + <Input type="text" placeholder="Email" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <FormField + control={form.control} + name="password" + render={({ field }) => { + return ( + <FormItem> + <FormLabel>Password</FormLabel> + <FormControl> + <Input type="password" placeholder="Password" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <FormField + control={form.control} + name="confirmPassword" + render={({ field }) => { + return ( + <FormItem> + <FormLabel>Confirm Password</FormLabel> + <FormControl> + <Input + type="password" + placeholder="Confirm Password" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <ActionButton type="submit" loading={false}> + Sign Up + </ActionButton> + </div> + </form> + </Form> + ); +} + +export default function CredentialsForm() { + return ( + <Tabs defaultValue="signin" className="w-full"> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="signin">Sign In</TabsTrigger> + <TabsTrigger value="signup">Sign Up</TabsTrigger> + </TabsList> + <TabsContent value="signin"> + <SignIn /> + </TabsContent> + <TabsContent value="signup"> + <SignUp /> + </TabsContent> + </Tabs> + ); +} diff --git a/packages/web/app/signin/components/SignInForm.tsx b/packages/web/app/signin/components/SignInForm.tsx index 0b625f1e..986718bf 100644 --- a/packages/web/app/signin/components/SignInForm.tsx +++ b/packages/web/app/signin/components/SignInForm.tsx @@ -1,16 +1,37 @@ import { getProviders } from "next-auth/react"; import SignInProviderButton from "./SignInProviderButton"; +import CredentialsForm from "./CredentialsForm"; export default async function SignInForm() { - const providers = (await getProviders()) ?? []; + const providers = await getProviders(); + let providerValues; + if (providers) { + providerValues = Object.values(providers).filter( + // Credentials are handled manually by the sign in form + (p) => p.id != "credentials", + ); + } return ( - <div> - {Object.values(providers).map((provider) => ( - <div key={provider.name}> - <SignInProviderButton provider={provider} /> - </div> - ))} + <div className="flex flex-col items-center space-y-2"> + <CredentialsForm /> + + {providerValues && ( + <> + <div className="flex w-full items-center"> + <div className="flex-1 grow border-t-2 border-gray-200"></div> + <span className="bg-white px-3 text-gray-500">Or</span> + <div className="flex-1 grow border-t-2 border-gray-200"></div> + </div> + <div className="space-y-2"> + {providerValues.map((provider) => ( + <div key={provider.id}> + <SignInProviderButton provider={provider} /> + </div> + ))} + </div> + </> + )} </div> ); } diff --git a/packages/web/app/signin/page.tsx b/packages/web/app/signin/page.tsx index 1556ff2c..f578a845 100644 --- a/packages/web/app/signin/page.tsx +++ b/packages/web/app/signin/page.tsx @@ -1,17 +1,23 @@ import { PackageOpen } from "lucide-react"; import SignInForm from "./components/SignInForm"; +import { redirect } from "next/dist/client/components/navigation"; +import { getServerAuthSession } from "@/server/auth"; export default async function SignInPage() { - // TODO Add support for email and credential signin form + const session = await getServerAuthSession(); + if (session) { + redirect("/"); + } + return ( - <div className="flex min-h-screen flex-col items-center justify-center"> - <div className="flex space-x-2"> + <div className="grid min-h-screen grid-rows-6 justify-center"> + <div className="row-span-2 flex w-96 items-center justify-center space-x-2"> <span> - <PackageOpen size="30" className="h-full" /> + <PackageOpen size="60" className="" /> </span> - <span className="text-4xl">Hoarder</span> + <p className="text-6xl">Hoarder</p> </div> - <div className="mt-20 flex w-96 flex-col items-center rounded-xl border border-gray-300 p-20"> + <div className="row-span-4 w-96"> <SignInForm /> </div> </div> diff --git a/packages/web/components/ui/separator.tsx b/packages/web/components/ui/separator.tsx new file mode 100644 index 00000000..3b9f2b84 --- /dev/null +++ b/packages/web/components/ui/separator.tsx @@ -0,0 +1,31 @@ +"use client"; + +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; + +import { cn } from "@/lib/utils"; + +const Separator = React.forwardRef< + React.ElementRef<typeof SeparatorPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref, + ) => ( + <SeparatorPrimitive.Root + ref={ref} + decorative={decorative} + orientation={orientation} + className={cn( + "bg-border shrink-0", + orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]", + className, + )} + {...props} + /> + ), +); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/packages/web/components/ui/tabs.tsx b/packages/web/components/ui/tabs.tsx new file mode 100644 index 00000000..990017db --- /dev/null +++ b/packages/web/components/ui/tabs.tsx @@ -0,0 +1,55 @@ +"use client"; + +import * as React from "react"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; + +import { cn } from "@/lib/utils"; + +const Tabs = TabsPrimitive.Root; + +const TabsList = React.forwardRef< + React.ElementRef<typeof TabsPrimitive.List>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> +>(({ className, ...props }, ref) => ( + <TabsPrimitive.List + ref={ref} + className={cn( + "bg-muted text-muted-foreground inline-flex h-10 items-center justify-center rounded-md p-1", + className, + )} + {...props} + /> +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef<typeof TabsPrimitive.Trigger>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger> +>(({ className, ...props }, ref) => ( + <TabsPrimitive.Trigger + ref={ref} + className={cn( + "ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm", + className, + )} + {...props} + /> +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef<typeof TabsPrimitive.Content>, + React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content> +>(({ className, ...props }, ref) => ( + <TabsPrimitive.Content + ref={ref} + className={cn( + "ring-offset-background focus-visible:ring-ring mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2", + className, + )} + {...props} + /> +)); +TabsContent.displayName = TabsPrimitive.Content.displayName; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/packages/web/lib/types/api/users.ts b/packages/web/lib/types/api/users.ts new file mode 100644 index 00000000..c2fe182a --- /dev/null +++ b/packages/web/lib/types/api/users.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +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), + confirmPassword: z.string(), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords don't match", + path: ["confirmPassword"], + }); diff --git a/packages/web/package.json b/packages/web/package.json index 3391ac69..32016419 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -17,7 +17,9 @@ "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-toast": "^1.1.5", "@tanstack/react-query": "^5.20.5", "@tanstack/react-query-devtools": "^5.21.0", diff --git a/packages/web/server/api/routers/_app.ts b/packages/web/server/api/routers/_app.ts index 2097b47d..b958ef8f 100644 --- a/packages/web/server/api/routers/_app.ts +++ b/packages/web/server/api/routers/_app.ts @@ -1,9 +1,11 @@ import { router } from "../trpc"; import { apiKeysAppRouter } from "./apiKeys"; import { bookmarksAppRouter } from "./bookmarks"; +import { usersAppRouter } from "./users"; export const appRouter = router({ bookmarks: bookmarksAppRouter, apiKeys: apiKeysAppRouter, + users: usersAppRouter, }); // export type definition of API export type AppRouter = typeof appRouter; diff --git a/packages/web/server/api/routers/users.ts b/packages/web/server/api/routers/users.ts new file mode 100644 index 00000000..aecec1d4 --- /dev/null +++ b/packages/web/server/api/routers/users.ts @@ -0,0 +1,45 @@ +import { zSignUpSchema } from "@/lib/types/api/users"; +import { publicProcedure, router } from "../trpc"; +import { Prisma, prisma } from "@hoarder/db"; +import { z } from "zod"; +import { hashPassword } from "@/server/auth"; +import { TRPCError } from "@trpc/server"; + +export const usersAppRouter = router({ + create: publicProcedure + .input(zSignUpSchema) + .output( + z.object({ + name: z.string(), + email: z.string(), + }), + ) + .mutation(async ({ input }) => { + try { + return await prisma.user.create({ + data: { + name: input.name, + email: input.email, + password: await hashPassword(input.password), + }, + select: { + name: true, + email: true, + }, + }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { + if (e.code === "P2002") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Email is already taken", + }); + } + } + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Something went wrong", + }); + } + }), +}); diff --git a/packages/web/server/api/trpc.ts b/packages/web/server/api/trpc.ts index e57d40d6..7df98372 100644 --- a/packages/web/server/api/trpc.ts +++ b/packages/web/server/api/trpc.ts @@ -31,7 +31,7 @@ export const publicProcedure = procedure; export const authedProcedure = procedure.use(function isAuthed(opts) { const user = opts.ctx.user; - if (!user) { + if (!user || !user.id) { throw new TRPCError({ code: "UNAUTHORIZED" }); } diff --git a/packages/web/server/auth.ts b/packages/web/server/auth.ts index b7391848..a63bcac4 100644 --- a/packages/web/server/auth.ts +++ b/packages/web/server/auth.ts @@ -5,8 +5,18 @@ import serverConfig from "@hoarder/shared/config"; import { prisma } from "@hoarder/db"; import { DefaultSession } from "next-auth"; import * as bcrypt from "bcrypt"; +import CredentialsProvider from "next-auth/providers/credentials"; import { randomBytes } from "crypto"; +import { Provider } from "next-auth/providers/index"; + +declare module "next-auth/jwt" { + export interface JWT { + user: { + id: string; + } & DefaultSession["user"]; + } +} declare module "next-auth" { /** @@ -19,19 +29,55 @@ declare module "next-auth" { } } -const providers = []; +const providers: Provider[] = [ + CredentialsProvider({ + // The name to display on the sign in form (e.g. "Sign in with...") + name: "Credentials", + credentials: { + email: { label: "Email", type: "email", placeholder: "Email" }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials) { + if (!credentials) { + return null; + } + + try { + return await validatePassword( + credentials?.email, + credentials?.password, + ); + } catch (e) { + return null; + } + }, + }), +]; if (serverConfig.auth.authentik) { providers.push(AuthentikProvider(serverConfig.auth.authentik)); } export const authOptions: NextAuthOptions = { - // Configure one or more authentication providers adapter: PrismaAdapter(prisma), providers: providers, + session: { + strategy: "jwt", + }, callbacks: { - session({ session, user }) { - session.user = { ...user }; + async jwt({ token, user }) { + if (user) { + token.user = { + id: user.id, + name: user.name, + email: user.email, + image: user.image, + }; + } + return token; + }, + async session({ session, token }) { + session.user = { ...token.user }; return session; }, }, @@ -110,3 +156,30 @@ export async function authenticateApiKey(key: string) { return apiKey.user; } + +export async function hashPassword(password: string) { + return bcrypt.hash(password, BCRYPT_SALT_ROUNDS); +} + +export async function validatePassword(email: string, password: string) { + const user = await prisma.user.findUnique({ + where: { + email, + }, + }); + + if (!user) { + throw new Error("User not found"); + } + + if (!user.password) { + throw new Error("This user doesn't have a password defined"); + } + + const validation = await bcrypt.compare(password, user.password); + if (!validation) { + throw new Error("Wrong password"); + } + + return user; +} |
