From eb0f8d8d5f58d5d80763808930da29ec22e400bc Mon Sep 17 00:00:00 2001 From: Dominik Stahl Date: Wed, 28 May 2025 13:08:07 +0200 Subject: [PATCH] feat: implement credentials login implements the credentials login functionality --- package.json | 6 ++- src/app/login/page.tsx | 4 -- src/auth.ts | 80 ++++++++++++++++++++++++++---- src/components/labeled-input.tsx | 11 +++- src/components/user/login-form.tsx | 66 ++++++++++++++++-------- src/hooks/useZodForm.tsx | 13 +++++ src/login.ts | 27 ++++++++++ src/register.ts | 60 ++++++++++++++++++++++ src/validation.ts | 42 ++++++++++++++++ yarn.lock | 47 ++++++++++++++++++ 10 files changed, 321 insertions(+), 35 deletions(-) create mode 100644 src/hooks/useZodForm.tsx create mode 100644 src/login.ts create mode 100644 src/register.ts create mode 100644 src/validation.ts diff --git a/package.json b/package.json index cf0b1fa..a2687f3 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,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", @@ -31,6 +32,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", @@ -39,7 +41,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..5dd3000 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, @@ -43,8 +41,6 @@ export default async function LoginPage() { - {providerMap.length > 0} - {providerMap.map((provider) => ( ) { return (
@@ -24,7 +27,13 @@ export default function LabeledInput({ defaultValue={value} id={name} name={name} + {...rest} /> + {error && ( +

+ {error} +

+ )}
); } diff --git a/src/components/user/login-form.tsx b/src/components/user/login-form.tsx index a8e6382..5cf6ba8 100644 --- a/src/components/user/login-form.tsx +++ b/src/components/user/login-form.tsx @@ -1,38 +1,59 @@ -import { signIn } from '@/auth'; +'use client'; + import LabeledInput from '@/components/labeled-input'; import { Button } from '@/components/custom-ui/button'; -import { AuthError } from 'next-auth'; -import { redirect } from 'next/navigation'; -const SIGNIN_ERROR_URL = '/error'; +import useZodForm from '@/hooks/useZodForm'; +import { loginSchema } from '@/validation'; +import { loginAction } from '@/login'; +import { useRouter } from 'next/navigation'; export default function LoginForm() { + 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 ( -
{ - 'use server'; - try { - await signIn('credentials', formData); - } catch (error) { - if (error instanceof AuthError) { - return redirect(`${SIGNIN_ERROR_URL}?error=${error.type}`); - } - throw error; - } - }} - > +
+
+ {formState.errors.root?.message && ( +

{formState.errors.root?.message}

+ )} +
); } diff --git a/src/hooks/useZodForm.tsx b/src/hooks/useZodForm.tsx new file mode 100644 index 0000000..7529f3e --- /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, + }); +} \ No newline at end of file diff --git a/src/login.ts b/src/login.ts new file mode 100644 index 0000000..2e4c107 --- /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.", + }; + } +} \ No newline at end of file diff --git a/src/register.ts b/src/register.ts new file mode 100644 index 0000000..6cacd82 --- /dev/null +++ b/src/register.ts @@ -0,0 +1,60 @@ +"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, 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, + }, + }); + + 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", + }; + } +} \ No newline at end of file diff --git a/src/validation.ts b/src/validation.ts new file mode 100644 index 0000000..572be1c --- /dev/null +++ b/src/validation.ts @@ -0,0 +1,42 @@ +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({ + 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.username), { + message: 'Password cannot contain username', + }) diff --git a/yarn.lock b/yarn.lock index 4c79534..bf464b7 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.30" "@types/react": "npm:19.1.6" "@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