diff --git a/package.json b/package.json index dd8c545..50be21b 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@fortawesome/free-regular-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", + "@hookform/resolvers": "^5.0.1", "@prisma/client": "^6.9.0", "@radix-ui/react-dropdown-menu": "^2.1.14", "@radix-ui/react-hover-card": "^1.1.13", @@ -32,6 +33,7 @@ "@radix-ui/react-slot": "^1.2.2", "@radix-ui/react-switch": "^1.2.4", "@radix-ui/react-tabs": "^1.1.11", + "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.511.0", @@ -40,7 +42,9 @@ "next-themes": "^0.4.6", "react": "^19.0.0", "react-dom": "^19.0.0", - "tailwind-merge": "^3.2.0" + "react-hook-form": "^7.56.4", + "tailwind-merge": "^3.2.0", + "zod": "^3.25.20" }, "devDependencies": { "@eslint/eslintrc": "3.3.1", diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index adaa1c3..2933872 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -6,8 +6,6 @@ import { Button } from '@/components/custom-ui/button'; import Image from 'next/image'; import { Separator } from '@/components/custom-ui/separator'; import Logo from '@/components/logo'; - -import '@/app/globals.css'; import { Card, CardContent, @@ -28,12 +26,12 @@ export default async function LoginPage() { } return ( -
-
-
+
+
+
-
+
@@ -43,8 +41,6 @@ export default async function LoginPage() { - {providerMap.length > 0} - {providerMap.map((provider) => ( ) { return (
@@ -27,7 +30,9 @@ export default function LabeledInput({ id={name} name={name} autoComplete={autocomplete} + {...rest} /> + {error &&

{error}

}
); } diff --git a/src/components/user/login-form.tsx b/src/components/user/login-form.tsx index 8a00749..14bb850 100644 --- a/src/components/user/login-form.tsx +++ b/src/components/user/login-form.tsx @@ -1,107 +1,214 @@ 'use client'; -import { signIn } from '@/auth'; + +import React, { useState, useRef } from 'react'; +import { useRouter } from 'next/navigation'; + import LabeledInput from '@/components/labeled-input'; import { Button } from '@/components/custom-ui/button'; -import { AuthError } from 'next-auth'; -import { redirect } from 'next/navigation'; -import { useRef, useState } from 'react'; +import useZodForm from '@/hooks/useZodForm'; +import { loginSchema, registerSchema } from '@/validation'; +import { loginAction } from '@/login'; +import { registerAction } from '@/register'; -const SIGNIN_ERROR_URL = '/error'; +function LoginFormElement({ + setIsSignUp, + formRef, +}: { + setIsSignUp: (value: boolean | ((prev: boolean) => boolean)) => void; + formRef?: React.RefObject; +}) { + const { handleSubmit, formState, register, setError } = + useZodForm(loginSchema); + const router = useRouter(); + + const onSubmit = handleSubmit(async (data) => { + try { + const { error } = await loginAction(data); + + if (error) { + setError('root', { + message: error, + }); + return; + } else { + router.push('/home'); + router.refresh(); + return; + } + } catch (error: unknown) { + if (error instanceof Error) + setError('root', { + message: error?.message, + }); + else + setError('root', { + message: 'An unknown error occurred.', + }); + } + }); + + return ( +
+ + +
+ + +
+
+ {formState.errors.root?.message && ( +

{formState.errors.root?.message}

+ )} +
+ + ); +} + +function RegisterFormElement({ + setIsSignUp, + formRef, +}: { + setIsSignUp: (value: boolean | ((prev: boolean) => boolean)) => void; + formRef?: React.RefObject; +}) { + const { handleSubmit, formState, register, setError } = + useZodForm(registerSchema); + + const onSubmit = handleSubmit(async (data) => { + try { + const { error } = await registerAction(data); + + if (error) { + setError('root', { + message: error, + }); + return; + } else { + formRef?.current?.reset(); + setIsSignUp(false); + // TODO: Show registration success message (reminder to verify email) + return; + } + } catch (error: unknown) { + if (error instanceof Error) + setError('root', { + message: error?.message, + }); + else + setError('root', { + message: 'An unknown error occurred.', + }); + } + }); + + return ( +
+ + + + + + +
+ + +
+
+ {formState.errors.root?.message && ( +

{formState.errors.root?.message}

+ )} +
+ + ); +} export default function LoginForm() { const [isSignUp, setIsSignUp] = useState(false); const formRef = useRef(null); - return ( -
{ - 'use client'; - try { - if (isSignUp) { - // handle sign up logic here - } else { - await signIn('credentials', formData); - } - } catch (error) { - if (error instanceof AuthError) { - return redirect(`${SIGNIN_ERROR_URL}?error=${error.type}`); - } - throw error; - } - }} - > - {isSignUp ? ( - <> - - - - - - - ) : ( - <> - - - - )} -
- - -
- - ); + if (isSignUp) { + return ; + } + return ; } diff --git a/src/hooks/useZodForm.tsx b/src/hooks/useZodForm.tsx new file mode 100644 index 0000000..1b1ebed --- /dev/null +++ b/src/hooks/useZodForm.tsx @@ -0,0 +1,13 @@ +import { zodResolver } from '@hookform/resolvers/zod/dist/zod.js'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +export default function useZodForm< + Schema extends z.Schema, + Values extends z.infer, +>(schema: Schema, defaultValues?: Values) { + return useForm({ + resolver: zodResolver(schema), + defaultValues, + }); +} diff --git a/src/login.ts b/src/login.ts new file mode 100644 index 0000000..9582bc3 --- /dev/null +++ b/src/login.ts @@ -0,0 +1,27 @@ +'use server'; + +import { z } from 'zod'; +import { loginSchema } from './validation'; +import { signIn } from '@/auth'; + +export async function loginAction(data: z.infer) { + try { + await signIn('credentials', { + ...data, + redirect: false, + }); + + return { + error: undefined, + }; + } catch (error: unknown) { + if (error instanceof Error) { + return { + error: error.message.toString(), + }; + } + return { + error: 'An unknown error occurred.', + }; + } +} diff --git a/src/register.ts b/src/register.ts new file mode 100644 index 0000000..7ef06ca --- /dev/null +++ b/src/register.ts @@ -0,0 +1,63 @@ +'use server'; + +import type { z } from 'zod'; +import bcrypt from 'bcryptjs'; +import { registerSchema } from './validation'; +import { prisma } from '@/prisma'; + +export async function registerAction(data: z.infer) { + try { + const result = await registerSchema.safeParseAsync(data); + + if (!result.success) { + return { + error: result.error.errors[0].message, + }; + } + + const { email, password, firstName, lastName, username } = result.data; + + const user = await prisma.user.findUnique({ + where: { + email, + }, + }); + + if (user) { + return { + error: 'User already exist with this email', + }; + } + + const passwordHash = await bcrypt.hash(password, 10); + + await prisma.$transaction(async (tx) => { + const { id } = await tx.user.create({ + data: { + email, + name: username, + password_hash: passwordHash, + first_name: firstName, + last_name: lastName, + emailVerified: new Date(), // TODO: handle email verification + }, + }); + + await tx.account.create({ + data: { + userId: id, + type: 'credentials', + provider: 'credentials', + providerAccountId: id, + }, + }); + }); + + return {}; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (_error) { + return { + error: 'System error. Please contact support', + }; + } +} diff --git a/src/validation.ts b/src/validation.ts new file mode 100644 index 0000000..c265c9d --- /dev/null +++ b/src/validation.ts @@ -0,0 +1,57 @@ +import zod from 'zod'; + +export const loginSchema = zod.object({ + email: zod + .string() + .email('Invalid email address') + .min(3, 'Email is required'), + password: zod.string().min(1, 'Password is required'), +}); + +export const registerSchema = zod + .object({ + firstName: zod + .string() + .min(1, 'First name is required') + .max(32, 'First name must be at most 32 characters long'), + lastName: zod + .string() + .min(1, 'Last name is required') + .max(32, 'Last name must be at most 32 characters long'), + email: zod + .string() + .email('Invalid email address') + .min(3, 'Email is required'), + password: zod + .string() + .min(8, 'Password must be at least 8 characters long') + .max(128, 'Password must be at most 128 characters long'), + confirmPassword: zod + .string() + .min(8, 'Password must be at least 8 characters long') + .max(128, 'Password must be at most 128 characters long'), + username: zod + .string() + .min(3, 'Username is required') + .max(32, 'Username must be at most 32 characters long') + .regex( + /^[a-zA-Z0-9_]+$/, + 'Username can only contain letters, numbers, and underscores', + ), + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Passwords do not match', + path: ['confirmPassword'], + }) + .refine( + (data) => + !data.password.includes(data.firstName) && + !data.password.includes(data.lastName) && + !data.password.includes(data.email) && + !data.password.includes(data.username), + { + message: + 'Username cannot contain your first name, last name, email, or username itself', + path: ['password'], + }, + ); diff --git a/yarn.lock b/yarn.lock index 0f17374..a09881e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -264,6 +264,17 @@ __metadata: languageName: node linkType: hard +"@hookform/resolvers@npm:^5.0.1": + version: 5.1.0 + resolution: "@hookform/resolvers@npm:5.1.0" + dependencies: + "@standard-schema/utils": "npm:^0.3.0" + peerDependencies: + react-hook-form: ^7.55.0 + checksum: 10c0/5bd28ef58a182102f40b7fa2bc73a5e423c8bcf9cb25fee91cb8933026e7efba6f9adfda1ee637294de59c5467c938f4fb99c77a73bbb8c6180482c69d31cbbd + languageName: node + linkType: hard + "@humanfs/core@npm:^0.19.1": version: 0.19.1 resolution: "@humanfs/core@npm:0.19.1" @@ -1429,6 +1440,13 @@ __metadata: languageName: node linkType: hard +"@standard-schema/utils@npm:^0.3.0": + version: 0.3.0 + resolution: "@standard-schema/utils@npm:0.3.0" + checksum: 10c0/6eb74cd13e52d5fc74054df51e37d947ef53f3ab9e02c085665dcca3c38c60ece8d735cebbdf18fbb13c775fbcb9becb3f53109b0e092a63f0f7389ce0993fd0 + languageName: node + linkType: hard + "@swc/counter@npm:0.1.3": version: 0.1.3 resolution: "@swc/counter@npm:0.1.3" @@ -2138,6 +2156,15 @@ __metadata: languageName: node linkType: hard +"bcryptjs@npm:^3.0.2": + version: 3.0.2 + resolution: "bcryptjs@npm:3.0.2" + bin: + bcrypt: bin/bcrypt + checksum: 10c0/a0923cac99f83e913f8f4e4f42df6a27c6593b24d509900331d1280c4050b1544e602a0ac67b43f7bb5c969991c3ed77fd72f19b7dc873be8ee794da3d925c7e + languageName: node + linkType: hard + "brace-expansion@npm:^1.1.7": version: 1.1.11 resolution: "brace-expansion@npm:1.1.11" @@ -3851,6 +3878,7 @@ __metadata: "@fortawesome/free-regular-svg-icons": "npm:^6.7.2" "@fortawesome/free-solid-svg-icons": "npm:^6.7.2" "@fortawesome/react-fontawesome": "npm:^0.2.2" + "@hookform/resolvers": "npm:^5.0.1" "@prisma/client": "npm:^6.9.0" "@radix-ui/react-dropdown-menu": "npm:^2.1.14" "@radix-ui/react-hover-card": "npm:^1.1.13" @@ -3865,6 +3893,7 @@ __metadata: "@types/node": "npm:22.15.31" "@types/react": "npm:19.1.7" "@types/react-dom": "npm:19.1.6" + bcryptjs: "npm:^3.0.2" class-variance-authority: "npm:^0.7.1" clsx: "npm:^2.1.1" dotenv-cli: "npm:8.0.0" @@ -3880,10 +3909,12 @@ __metadata: prisma: "npm:6.9.0" react: "npm:^19.0.0" react-dom: "npm:^19.0.0" + react-hook-form: "npm:^7.56.4" tailwind-merge: "npm:^3.2.0" tailwindcss: "npm:4.1.8" tw-animate-css: "npm:1.3.4" typescript: "npm:5.8.3" + zod: "npm:^3.25.20" languageName: unknown linkType: soft @@ -4376,6 +4407,15 @@ __metadata: languageName: node linkType: hard +"react-hook-form@npm:^7.56.4": + version: 7.57.0 + resolution: "react-hook-form@npm:7.57.0" + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + checksum: 10c0/6db0b44b2e88d4db541514e96557723e39381ce9f71b3787bf041635f829143dbd0ae46a1f6c16dee23afe3413fd25539484ba02bf2a35d90aaa1b7483193ea9 + languageName: node + linkType: hard + "react-is@npm:^16.13.1": version: 16.13.1 resolution: "react-is@npm:16.13.1" @@ -5337,3 +5377,10 @@ __metadata: checksum: 10c0/dceb44c28578b31641e13695d200d34ec4ab3966a5729814d5445b194933c096b7ced71494ce53a0e8820685d1d010df8b2422e5bf2cdea7e469d97ffbea306f languageName: node linkType: hard + +"zod@npm:^3.25.20": + version: 3.25.56 + resolution: "zod@npm:3.25.56" + checksum: 10c0/3800f01d4b1df932b91354eb1e648f69cc7e5561549e6d2bf83827d930a5f33bbf92926099445f6fc1ebb64ca9c6513ef9ae5e5409cfef6325f354bcf6fc9a24 + languageName: node + linkType: hard