From 942aac691225f4895c159a0260890ad2c576e0c9 Mon Sep 17 00:00:00 2001 From: MohamedBassem Date: Thu, 22 Feb 2024 15:32:40 +0000 Subject: feature: Add support for credentials registration and sign in --- .../migration.sql | 2 + .../migration.sql | 23 +++ packages/db/prisma/schema.prisma | 5 +- .../web/app/signin/components/CredentialsForm.tsx | 222 +++++++++++++++++++++ packages/web/app/signin/components/SignInForm.tsx | 35 +++- packages/web/app/signin/page.tsx | 18 +- packages/web/components/ui/separator.tsx | 31 +++ packages/web/components/ui/tabs.tsx | 55 +++++ packages/web/lib/types/api/users.ts | 13 ++ packages/web/package.json | 2 + packages/web/server/api/routers/_app.ts | 2 + packages/web/server/api/routers/users.ts | 45 +++++ packages/web/server/api/trpc.ts | 2 +- packages/web/server/auth.ts | 81 +++++++- yarn.lock | 49 +++++ 15 files changed, 565 insertions(+), 20 deletions(-) create mode 100644 packages/db/prisma/migrations/20240221104430_add_password_support/migration.sql create mode 100644 packages/db/prisma/migrations/20240222152033_name_and_email_required/migration.sql create mode 100644 packages/web/app/signin/components/CredentialsForm.tsx create mode 100644 packages/web/components/ui/separator.tsx create mode 100644 packages/web/components/ui/tabs.tsx create mode 100644 packages/web/lib/types/api/users.ts create mode 100644 packages/web/server/api/routers/users.ts 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>({ + resolver: zodResolver(signInSchema), + }); + + return ( +
+ { + const resp = await signIn("credentials", { + redirect: false, + email: value.email, + password: value.password, + }); + if (!resp || !resp?.ok) { + setSigninError(true); + return; + } + router.replace("/"); + })} + > +
+ {signinError && ( +

+ Incorrect username or password +

+ )} + { + return ( + + Email + + + + + + ); + }} + /> + { + return ( + + Password + + + + + + ); + }} + /> + + Sign In + +
+
+ + ); +} + +function SignUp() { + const form = useForm>({ + resolver: zodResolver(zSignUpSchema), + }); + const [errorMessage, setErrorMessage] = useState(""); + + const router = useRouter(); + + const createUserMutation = api.users.create.useMutation(); + + return ( +
+ { + 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("/"); + })} + > +
+ {errorMessage && ( +

{errorMessage}

+ )} + { + return ( + + Name + + + + + + ); + }} + /> + { + return ( + + Email + + + + + + ); + }} + /> + { + return ( + + Password + + + + + + ); + }} + /> + { + return ( + + Confirm Password + + + + + + ); + }} + /> + + Sign Up + +
+
+ + ); +} + +export default function CredentialsForm() { + return ( + + + Sign In + Sign Up + + + + + + + + + ); +} 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 ( -
- {Object.values(providers).map((provider) => ( -
- -
- ))} +
+ + + {providerValues && ( + <> +
+
+ Or +
+
+
+ {providerValues.map((provider) => ( +
+ +
+ ))} +
+ + )}
); } 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 ( -
-
+
+
- + - Hoarder +

Hoarder

-
+
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, + React.ComponentPropsWithoutRef +>( + ( + { className, orientation = "horizontal", decorative = true, ...props }, + ref, + ) => ( + + ), +); +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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsList.displayName = TabsPrimitive.List.displayName; + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +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; +} diff --git a/yarn.lock b/yarn.lock index e3fac3fd..1c0d4fb9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -616,7 +616,9 @@ __metadata: "@radix-ui/react-dialog": "npm:^1.0.5" "@radix-ui/react-dropdown-menu": "npm:^2.0.6" "@radix-ui/react-label": "npm:^2.0.2" + "@radix-ui/react-separator": "npm:^1.0.3" "@radix-ui/react-slot": "npm:^1.0.2" + "@radix-ui/react-tabs": "npm:^1.0.4" "@radix-ui/react-toast": "npm:^1.1.5" "@tanstack/react-query": "npm:^5.20.5" "@tanstack/react-query-devtools": "npm:^5.21.0" @@ -1541,6 +1543,26 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-separator@npm:^1.0.3": + version: 1.0.3 + resolution: "@radix-ui/react-separator@npm:1.0.3" + dependencies: + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/react-primitive": "npm:1.0.3" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/87bcde47343f2bc4439a0dc34381f557905d9b3c1e8c5a0d32ceea62a8ef84f3abf671c5cb29309fc87759ad41d39af619ba546cf54109d64c8746e3ca683de3 + languageName: node + linkType: hard + "@radix-ui/react-slot@npm:1.0.2, @radix-ui/react-slot@npm:^1.0.2": version: 1.0.2 resolution: "@radix-ui/react-slot@npm:1.0.2" @@ -1557,6 +1579,33 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-tabs@npm:^1.0.4": + version: 1.0.4 + resolution: "@radix-ui/react-tabs@npm:1.0.4" + dependencies: + "@babel/runtime": "npm:^7.13.10" + "@radix-ui/primitive": "npm:1.0.1" + "@radix-ui/react-context": "npm:1.0.1" + "@radix-ui/react-direction": "npm:1.0.1" + "@radix-ui/react-id": "npm:1.0.1" + "@radix-ui/react-presence": "npm:1.0.1" + "@radix-ui/react-primitive": "npm:1.0.3" + "@radix-ui/react-roving-focus": "npm:1.0.4" + "@radix-ui/react-use-controllable-state": "npm:1.0.1" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10c0/79699a921f5c2e890e0e496a751d9c2a7c4017eff8e52f094389e993263332881353bdd27b8cc123c906b36743e803eec7f32fdbb4d413328cba0a37d6413339 + languageName: node + linkType: hard + "@radix-ui/react-toast@npm:^1.1.5": version: 1.1.5 resolution: "@radix-ui/react-toast@npm:1.1.5" -- cgit v1.2.3-70-g09d2