From a2a5eee49e0a5fbfac44535babe81b6409d65052 Mon Sep 17 00:00:00 2001 From: Dominik Stahl Date: Wed, 21 May 2025 14:29:00 +0200 Subject: [PATCH 01/13] chore: add development docker environment adds a docker environment for development. can be started using: `docker compose -f docker-compose.dev.yml up --watch --build` --- Dockerfile.dev | 17 +++++++++++++++++ docker-compose.dev.yml | 27 +++++++++++++++++++++++++++ entrypoint.dev.sh | 10 ++++++++++ package.json | 3 ++- 4 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 Dockerfile.dev create mode 100644 docker-compose.dev.yml create mode 100644 entrypoint.dev.sh diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..e95e39a --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,17 @@ +FROM node:22-alpine@sha256:152270cd4bd094d216a84cbc3c5eb1791afb05af00b811e2f0f04bdc6c473602 + +WORKDIR /app + +RUN corepack enable +COPY package.json yarn.lock .yarnrc.yml ./ +RUN yarn install --frozen-lockfile + +COPY . . + +ENV NODE_ENV=development +ENV NEXT_TELEMETRY_DISABLED=1 + +EXPOSE 3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["/bin/ash", "entrypoint.dev.sh"] \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..3aa4174 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,27 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile.dev + ports: + - '3000:3000' + environment: + - AUTH_SECRET=secret + - AUTH_URL=http://localhost:3000 + - DATABASE_URL=file:/data/db.sqlite + env_file: + - .env.local + volumes: + - ./data:/data + develop: + watch: + - action: sync + path: ./src + target: /app/src + ignore: + - node_modules/ + - action: rebuild + path: package.json + - action: sync+restart + path: prisma + target: /app/prisma diff --git a/entrypoint.dev.sh b/entrypoint.dev.sh new file mode 100644 index 0000000..ec103bf --- /dev/null +++ b/entrypoint.dev.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +echo "Running start script with user $(whoami) and NODE_ENV $NODE_ENV" +if [ -d "prisma" ]; then + echo "Syncing Prisma database" + yarn prisma:generate + yarn prisma:db:push +fi + +exec yarn dev diff --git a/package.json b/package.json index cf0b1fa..bbd8516 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "prisma:generate": "dotenv -e .env.local -- prisma generate", "prisma:studio": "dotenv -e .env.local -- prisma studio", "prisma:db:push": "dotenv -e .env.local -- prisma db push", - "prisma:migrate:reset": "dotenv -e .env.local -- prisma migrate reset" + "prisma:migrate:reset": "dotenv -e .env.local -- prisma migrate reset", + "dev_container": "docker compose -f docker-compose.dev.yml up --watch --build" }, "dependencies": { "@auth/prisma-adapter": "^2.9.1", From 16cde64761582cdb69d25f474de3759d95bc3412 Mon Sep 17 00:00:00 2001 From: Dominik Stahl Date: Wed, 21 May 2025 14:32:20 +0200 Subject: [PATCH 02/13] docs: add note about docker development environment --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 1f474b5..e9319f3 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,14 @@ This project is built with a modern tech stack: password: password ``` +**Docker Development Environment:** + +- The docker development environment can be started with the following command: + +```bash +yarn dev_container +``` + **Self-Hosting with Docker (Planned):** - A Docker image and `docker-compose.yml` file will be provided in the future to allow for easy self-hosting of the MeetUP application. This setup will also include database services. Instructions will be updated here once available. From 0c93778c5a4bc3856da91f33ecf27d9b7b846d34 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 9 Jun 2025 09:00:24 +0000 Subject: [PATCH 03/13] chore(deps): update node.js to 41e4389 --- Dockerfile.dev | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile.dev b/Dockerfile.dev index e95e39a..4467c5f 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM node:22-alpine@sha256:152270cd4bd094d216a84cbc3c5eb1791afb05af00b811e2f0f04bdc6c473602 +FROM node:22-alpine@sha256:41e4389f3d988d2ed55392df4db1420ad048ae53324a8e2b7c6d19508288107e WORKDIR /app From bb2006ded7e0fa33629a2e1ac765f622ff9e6af7 Mon Sep 17 00:00:00 2001 From: Dominik Stahl Date: Wed, 28 May 2025 13:08:07 +0200 Subject: [PATCH 04/13] feat: implement credentials login implements the credentials login functionality --- package.json | 6 ++- src/app/login/page.tsx | 4 -- src/auth.ts | 81 ++++++++++++++++++++++++++---- src/components/labeled-input.tsx | 7 ++- src/components/user/login-form.tsx | 66 ++++++++++++++++-------- src/hooks/useZodForm.tsx | 13 +++++ src/login.ts | 27 ++++++++++ src/register.ts | 60 ++++++++++++++++++++++ src/validation.ts | 40 +++++++++++++++ yarn.lock | 47 +++++++++++++++++ 10 files changed, 316 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,9 @@ 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..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..10a2a0e --- /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', + }; + } +} diff --git a/src/validation.ts b/src/validation.ts new file mode 100644 index 0000000..2c501e6 --- /dev/null +++ b/src/validation.ts @@ -0,0 +1,40 @@ +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 From d4de7876cc0df7936ee06f87bbe229774ed032f9 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Mon, 9 Jun 2025 21:01:08 +0000 Subject: [PATCH 05/13] chore(deps): update dependency @types/react to v19.1.7 --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index bbd8516..b21bc2c 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "@eslint/eslintrc": "3.3.1", "@tailwindcss/postcss": "4.1.8", "@types/node": "22.15.30", - "@types/react": "19.1.6", + "@types/react": "19.1.7", "@types/react-dom": "19.1.6", "dotenv-cli": "8.0.0", "eslint": "9.28.0", diff --git a/yarn.lock b/yarn.lock index 4c79534..b8999d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1659,12 +1659,12 @@ __metadata: languageName: node linkType: hard -"@types/react@npm:19.1.6": - version: 19.1.6 - resolution: "@types/react@npm:19.1.6" +"@types/react@npm:19.1.7": + version: 19.1.7 + resolution: "@types/react@npm:19.1.7" dependencies: csstype: "npm:^3.0.2" - checksum: 10c0/8b10b198e28997b3c57559750f8bcf5ae7b33c554b16b6f4fe2ece1d4de6a2fc8cb53e7effe08ec9cb939d2f479eb97c5e08aac2cf83b10a90164fe451cc8ea2 + checksum: 10c0/3bb8fb865debad4328b0d623e1c669f2ee90e9302638a64e65a0a1c61efca4f4ef91f58b55ff94075358c190d80bb8472a5823c6901d8cdc9009dd436a1dcab1 languageName: node linkType: hard @@ -3863,7 +3863,7 @@ __metadata: "@radix-ui/react-tabs": "npm:^1.1.11" "@tailwindcss/postcss": "npm:4.1.8" "@types/node": "npm:22.15.30" - "@types/react": "npm:19.1.6" + "@types/react": "npm:19.1.7" "@types/react-dom": "npm:19.1.6" class-variance-authority: "npm:^0.7.1" clsx: "npm:^2.1.1" From 3569ccc18e57063f403277378b4e337b3c1da258 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 10 Jun 2025 03:01:07 +0000 Subject: [PATCH 06/13] chore(deps): update dependency @types/node to v22.15.31 --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index b21bc2c..dd8c545 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "devDependencies": { "@eslint/eslintrc": "3.3.1", "@tailwindcss/postcss": "4.1.8", - "@types/node": "22.15.30", + "@types/node": "22.15.31", "@types/react": "19.1.7", "@types/react-dom": "19.1.6", "dotenv-cli": "8.0.0", diff --git a/yarn.lock b/yarn.lock index b8999d1..cbe92bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1641,12 +1641,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:22.15.30": - version: 22.15.30 - resolution: "@types/node@npm:22.15.30" +"@types/node@npm:22.15.31": + version: 22.15.31 + resolution: "@types/node@npm:22.15.31" dependencies: undici-types: "npm:~6.21.0" - checksum: 10c0/ca330ac0e7fd502686d6df115fcc606aba46fd334220f749bbba2f639accdadcb23f7900603ceccdc8240be736739cad5c0b87c0fa92c9255a4dff245f07d664 + checksum: 10c0/ef7d5dc890da41cfd554d35ab8998bc18be9e3a0caa642e720599ac4410a94a4879766e52b3c9cafa06c66b7b8aebdc51f322cf67df23a6489927890196a316d languageName: node linkType: hard @@ -3862,7 +3862,7 @@ __metadata: "@radix-ui/react-switch": "npm:^1.2.4" "@radix-ui/react-tabs": "npm:^1.1.11" "@tailwindcss/postcss": "npm:4.1.8" - "@types/node": "npm:22.15.30" + "@types/node": "npm:22.15.31" "@types/react": "npm:19.1.7" "@types/react-dom": "npm:19.1.6" class-variance-authority: "npm:^0.7.1" From abae5c74d50e0ef058dcd8589ac5704938bfaa01 Mon Sep 17 00:00:00 2001 From: Renovate Bot Date: Tue, 10 Jun 2025 21:00:48 +0000 Subject: [PATCH 07/13] fix(deps): update dependency tailwind-merge to v3.3.1 --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index cbe92bd..0f17374 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4955,9 +4955,9 @@ __metadata: linkType: hard "tailwind-merge@npm:^3.2.0": - version: 3.3.0 - resolution: "tailwind-merge@npm:3.3.0" - checksum: 10c0/a50cd141100486f98541dfab3705712af5860556689b7496dc6b0284374f02d12d5471f0f40035f6bb8b1c749c422060a1f3e5f8900057d8a7786b111c8472e6 + version: 3.3.1 + resolution: "tailwind-merge@npm:3.3.1" + checksum: 10c0/b84c6a78d4669fa12bf5ab8f0cdc4400a3ce0a7c006511af4af4be70bb664a27466dbe13ee9e3b31f50ddf6c51d380e8192ce0ec9effce23ca729d71a9f63818 languageName: node linkType: hard From 6c479e80d6c585631f4c0bb053e8dad75f67e209 Mon Sep 17 00:00:00 2001 From: Micha Date: Sun, 8 Jun 2025 00:53:27 +0200 Subject: [PATCH 08/13] feat: enhance login form with sign-up input-fields and autocomplete attributes --- src/components/labeled-input.tsx | 3 + src/components/user/login-form.tsx | 88 ++++++++++++++++++++++++------ 2 files changed, 74 insertions(+), 17 deletions(-) diff --git a/src/components/labeled-input.tsx b/src/components/labeled-input.tsx index 250dd5f..94563dc 100644 --- a/src/components/labeled-input.tsx +++ b/src/components/labeled-input.tsx @@ -7,12 +7,14 @@ export default function LabeledInput({ placeholder, value, name, + autocomplete, }: { type: 'text' | 'email' | 'password'; label: string; placeholder?: string; value?: string; name?: string; + autocomplete?: string; }) { return (
@@ -24,6 +26,7 @@ export default function LabeledInput({ defaultValue={value} id={name} name={name} + autoComplete={autocomplete} />
); diff --git a/src/components/user/login-form.tsx b/src/components/user/login-form.tsx index a8e6382..2ed61e4 100644 --- a/src/components/user/login-form.tsx +++ b/src/components/user/login-form.tsx @@ -1,19 +1,27 @@ +'use client'; import { signIn } from '@/auth'; import LabeledInput from '@/components/labeled-input'; import { Button } from '@/components/custom-ui/button'; import { AuthError } from 'next-auth'; import { redirect } from 'next/navigation'; +import { useState } from 'react'; const SIGNIN_ERROR_URL = '/error'; export default function LoginForm() { + const [isSignUp, setIsSignUp] = useState(false); + return (
{ - 'use server'; + 'use client'; try { - await signIn('credentials', formData); + 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}`); @@ -22,24 +30,70 @@ export default function LoginForm() { } }} > - - + {isSignUp ? ( + <> + + + + + + + ) : ( + <> + + + + )}
-
From 386d72d9141f574b30506b87ed422941cdc178a4 Mon Sep 17 00:00:00 2001 From: Micha Date: Sun, 8 Jun 2025 00:56:37 +0200 Subject: [PATCH 09/13] fix: correct typo in Prisma command in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e9319f3..d9ca71b 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ This project is built with a modern tech stack: yarn prisma:generate ``` ```bash - yarn prisa:db:push + yarn prisma:db:push ``` - Run the following command to apply migrations and generate Prisma Client: ```bash From 9183117a20c7921fc51016cd84695bcca1256660 Mon Sep 17 00:00:00 2001 From: Micha Date: Tue, 10 Jun 2025 09:23:09 +0200 Subject: [PATCH 10/13] feat: add form reset functionality and ref to login form --- src/components/user/login-form.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/components/user/login-form.tsx b/src/components/user/login-form.tsx index 2ed61e4..8a00749 100644 --- a/src/components/user/login-form.tsx +++ b/src/components/user/login-form.tsx @@ -4,15 +4,18 @@ import LabeledInput from '@/components/labeled-input'; import { Button } from '@/components/custom-ui/button'; import { AuthError } from 'next-auth'; import { redirect } from 'next/navigation'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; const SIGNIN_ERROR_URL = '/error'; export default function LoginForm() { const [isSignUp, setIsSignUp] = useState(false); + const formRef = useRef(null); + return (
{ 'use client'; @@ -91,7 +94,10 @@ export default function LoginForm() { From a351a9017dad6ac423945b339fe10616adde74c9 Mon Sep 17 00:00:00 2001 From: Micha Date: Sun, 8 Jun 2025 01:04:17 +0200 Subject: [PATCH 11/13] fix: add cursor pointer to button variants for improved interactivity --- src/components/custom-ui/button.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/custom-ui/button.tsx b/src/components/custom-ui/button.tsx index 5b53423..2a25f66 100644 --- a/src/components/custom-ui/button.tsx +++ b/src/components/custom-ui/button.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/lib/utils'; const buttonVariants = cva( - "radius-lg inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-button transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "radius-lg inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-button transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { From 171f0ae09914f973a4b55984643210e6d422a0a9 Mon Sep 17 00:00:00 2001 From: Micha Date: Mon, 9 Jun 2025 10:19:34 +0200 Subject: [PATCH 12/13] fix: simplify button styling by removing unnecessary hover classes --- src/app/logout/page.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/app/logout/page.tsx b/src/app/logout/page.tsx index c819a45..38311da 100644 --- a/src/app/logout/page.tsx +++ b/src/app/logout/page.tsx @@ -25,11 +25,7 @@ export default function SignOutPage() { - From d1e6f0339b057601d0c9a263b16e047e11547bdd Mon Sep 17 00:00:00 2001 From: Dominik Stahl Date: Wed, 28 May 2025 13:08:07 +0200 Subject: [PATCH 13/13] feat: implement credentials login implements the credentials login functionality --- package.json | 6 +- src/app/login/page.tsx | 12 +- src/auth.ts | 81 +++++++- src/components/labeled-input.tsx | 7 +- src/components/user/login-form.tsx | 305 ++++++++++++++++++++--------- src/hooks/useZodForm.tsx | 13 ++ src/login.ts | 27 +++ src/register.ts | 63 ++++++ src/validation.ts | 57 ++++++ yarn.lock | 47 +++++ 10 files changed, 505 insertions(+), 113 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 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..463f7c1 100644 --- a/src/components/user/login-form.tsx +++ b/src/components/user/login-form.tsx @@ -1,107 +1,224 @@ '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); + if (isSignUp) { + return ( + + ); + } 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 ? ( - <> - - - - - - - ) : ( - <> - - - - )} -
- - -
- + ); } 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