From 0135462bc7231e54ac816e4915c05f6c963d1a22 Mon Sep 17 00:00:00 2001 From: Wil Date: Thu, 29 Oct 2026 11:30:59 -0500 Subject: [PATCH] feat: add Auth.js authentication with email verification and login/register forms Co-Authored-By: Claude Sonnet 5 --- bun.lock | 18 ++ package.json | 2 + .../migration.sql | 12 + prisma/schema.prisma | 11 + src/app/api/auth/[...nextauth]/route.ts | 3 + src/app/api/auth/register/route.ts | 87 +++++++ src/app/api/auth/verify-email/route.ts | 34 +++ src/app/globals.css | 2 +- src/app/layout.tsx | 5 +- src/app/login/page.tsx | 165 +++++++++++++ src/app/register/page.tsx | 221 ++++++++++++++++++ src/auth.ts | 78 +++++++ src/components/ui/form.tsx | 185 +++++++++++++++ src/lib/validations/auth.ts | 32 +++ src/proxy.ts | 23 ++ src/types/next-auth.d.ts | 66 ++++++ 16 files changed, 942 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20261029160422_add_verification_token/migration.sql create mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/app/api/auth/register/route.ts create mode 100644 src/app/api/auth/verify-email/route.ts create mode 100644 src/app/login/page.tsx create mode 100644 src/app/register/page.tsx create mode 100644 src/auth.ts create mode 100644 src/components/ui/form.tsx create mode 100644 src/lib/validations/auth.ts create mode 100644 src/proxy.ts create mode 100644 src/types/next-auth.d.ts diff --git a/bun.lock b/bun.lock index 95b542d..4c61ecd 100644 --- a/bun.lock +++ b/bun.lock @@ -9,10 +9,12 @@ "@hookform/resolvers": "^6.0.2", "@prisma/adapter-mariadb": "^7.10.1", "@prisma/client": "^7.10.1", + "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "cn": "^0.5.0", "lucide-react": "^1.64.0", "next": "16.4.3", + "next-auth": "^5.0.0-beta.32", "next-themes": "^0.4.6", "react": "19.3.0", "react-dom": "19.3.0", @@ -43,6 +45,8 @@ "@ark/util": ["@ark/util@0.56.8", "", {}, "sha512-XLiQyGfpJcws1zCtp2Cva8s1oheCdFn1Rt6JSyC4NnkDrpwCJnlaiawuuOc1Z97vMH+jEzW5vyyf0xOju5wUQA=="], + "@auth/core": ["@auth/core@0.41.3", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7.0.7 || ^8.0.5" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], @@ -257,6 +261,8 @@ "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], + "@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="], + "@prisma/adapter-mariadb": ["@prisma/adapter-mariadb@7.10.1", "", { "dependencies": { "@prisma/driver-adapter-utils": "7.10.1", "mariadb": "3.5.4" } }, "sha512-wB7ZU1NpPlONqzMsCHMyXsNUwjNr2w72PAp3KimJVMCyXzrCmzhxdbxGgWFgT6N9RX+xnujYiWUN3AQF85TIQA=="], "@prisma/client": ["@prisma/client@7.10.1", "", { "dependencies": { "@prisma/client-runtime-utils": "7.10.1" }, "peerDependencies": { "prisma": "*", "typescript": ">=5.4.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-5tgAbFGh//zVOC6WPNVgziOErJHYkUE+wmqCwbIyq6IrTXUYtv9y2iBFGh/Owo99Mx0+cr0j1FOrxkYBMuyhaQ=="], @@ -529,6 +535,8 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.11.34", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA=="], + "bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="], + "better-result": ["better-result@2.10.0", "", {}, "sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw=="], "brace-expansion": ["brace-expansion@1.1.21", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw=="], @@ -943,6 +951,8 @@ "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "jose": ["jose@6.2.14", "", {}, "sha512-3JasLuki1NRLQvY4wCoC37KZk4qxoBx2k2aFqLxT84IezqW0bkJKnNtxnZj/Fu1xsQivoc/lytxaRHJoGLjJ1A=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], @@ -1051,6 +1061,8 @@ "next": ["next@16.4.3", "", { "dependencies": { "@next/env": "16.4.3", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.4.3", "@next/swc-darwin-x64": "16.4.3", "@next/swc-linux-arm64-gnu": "16.4.3", "@next/swc-linux-arm64-musl": "16.4.3", "@next/swc-linux-x64-gnu": "16.4.3", "@next/swc-linux-x64-musl": "16.4.3", "@next/swc-win32-arm64-msvc": "16.4.3", "@next/swc-win32-x64-msvc": "16.4.3", "sharp": "^0.35.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-OqxKTGi9u80FydydsMNYNjnuje9Au+kvHlt2GZ//ftdpDHm1dxKqAaENoOOTX5wKzn+9vaKUNIfZeyZUuLhCnA=="], + "next-auth": ["next-auth@5.0.0-beta.32", "", { "dependencies": { "@auth/core": "0.41.3" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", "nodemailer": "^7.0.7 || ^8.0.5", "react": "^18.2.0 || ^19.0.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q=="], + "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], "node-exports-info": ["node-exports-info@1.6.2", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag=="], @@ -1059,6 +1071,8 @@ "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], + "oauth4webapi": ["oauth4webapi@3.8.8", "", {}, "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -1129,6 +1143,10 @@ "powershell-utils": ["powershell-utils@0.2.1", "", {}, "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A=="], + "preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="], + + "preact-render-to-string": ["preact-render-to-string@6.5.11", "", { "peerDependencies": { "preact": ">=10" } }, "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prettier": ["prettier@3.9.10", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA=="], diff --git a/package.json b/package.json index 2cf99e9..948205b 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,12 @@ "@hookform/resolvers": "^6.0.2", "@prisma/adapter-mariadb": "^7.10.1", "@prisma/client": "^7.10.1", + "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "cn": "^0.5.0", "lucide-react": "^1.64.0", "next": "16.4.3", + "next-auth": "^5.0.0-beta.32", "next-themes": "^0.4.6", "react": "19.3.0", "react-dom": "19.3.0", diff --git a/prisma/migrations/20261029160422_add_verification_token/migration.sql b/prisma/migrations/20261029160422_add_verification_token/migration.sql new file mode 100644 index 0000000..70fc4c4 --- /dev/null +++ b/prisma/migrations/20261029160422_add_verification_token/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE `verification_tokens` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `identifier` VARCHAR(191) NOT NULL, + `token` VARCHAR(191) NOT NULL, + `expires` DATETIME(3) NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `verification_tokens_token_key`(`token`), + INDEX `verification_tokens_identifier_idx`(`identifier`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 71798ab..e70f348 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -60,6 +60,17 @@ model User { @@map("users") } +model VerificationToken { + id Int @id @default(autoincrement()) + identifier String + token String @unique + expires DateTime + createdAt DateTime @default(now()) @map("created_at") + + @@index([identifier]) + @@map("verification_tokens") +} + model Meeting { id Int @id @default(autoincrement()) uuid String @unique diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..9879351 --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/auth" + +export const { GET, POST } = handlers diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 0000000..7208fcd --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -0,0 +1,87 @@ +import crypto from "node:crypto" + +import { NextResponse } from "next/server" +import bcrypt from "bcryptjs" + +import { prisma } from "@/lib/prisma" +import { registerSchema } from "@/lib/validations/auth" + +export async function POST(request: Request) { + let body: unknown + + try { + body = await request.json() + } catch { + return NextResponse.json({ error: "Cuerpo de solicitud inválido" }, { status: 400 }) + } + + const parsed = registerSchema.safeParse(body) + + if (!parsed.success) { + return NextResponse.json( + { + error: "Datos inválidos", + issues: parsed.error.flatten().fieldErrors, + }, + { status: 400 } + ) + } + + const { displayName, password } = parsed.data + const normalizedEmail = parsed.data.email.toLowerCase() + + const existingUser = await prisma.user.findUnique({ + where: { email: normalizedEmail }, + }) + + if (existingUser) { + return NextResponse.json( + { error: "Ya existe una cuenta con ese correo" }, + { status: 409 } + ) + } + + const hashedPassword = await bcrypt.hash(password, 10) + + const user = await prisma.user.create({ + data: { + email: normalizedEmail, + password: hashedPassword, + displayName, + emailVerifiedAt: null, + }, + }) + + // Clean up any stale tokens for this email before issuing a new one. + await prisma.verificationToken.deleteMany({ + where: { identifier: normalizedEmail }, + }) + + const token = crypto.randomBytes(32).toString("hex") + const expires = new Date(Date.now() + 24 * 60 * 60 * 1000) + + await prisma.verificationToken.create({ + data: { + identifier: normalizedEmail, + token, + expires, + }, + }) + + const origin = process.env.AUTH_URL ?? new URL(request.url).origin + const verificationUrl = `${origin}/api/auth/verify-email?token=${token}` + + // No real email-sending service configured yet: log the link so it can be + // used manually in development, and also return it in dev responses. + console.log(`[auth] Verification link for ${normalizedEmail}: ${verificationUrl}`) + + return NextResponse.json( + { + message: + "Cuenta creada. Revisa la consola/logs del servidor para obtener el enlace de verificación.", + userId: user.id, + ...(process.env.NODE_ENV !== "production" ? { verificationUrl } : {}), + }, + { status: 201 } + ) +} diff --git a/src/app/api/auth/verify-email/route.ts b/src/app/api/auth/verify-email/route.ts new file mode 100644 index 0000000..b7edf71 --- /dev/null +++ b/src/app/api/auth/verify-email/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server" + +import { prisma } from "@/lib/prisma" + +export async function GET(request: Request) { + const url = new URL(request.url) + const token = url.searchParams.get("token") + + if (!token) { + return NextResponse.redirect(new URL("/login?verifyError=missing_token", url.origin)) + } + + const verificationToken = await prisma.verificationToken.findUnique({ + where: { token }, + }) + + if (!verificationToken) { + return NextResponse.redirect(new URL("/login?verifyError=invalid_token", url.origin)) + } + + if (verificationToken.expires < new Date()) { + await prisma.verificationToken.delete({ where: { token } }) + return NextResponse.redirect(new URL("/login?verifyError=expired_token", url.origin)) + } + + await prisma.user.update({ + where: { email: verificationToken.identifier }, + data: { emailVerifiedAt: new Date() }, + }) + + await prisma.verificationToken.delete({ where: { token } }) + + return NextResponse.redirect(new URL("/login?verified=1", url.origin)) +} diff --git a/src/app/globals.css b/src/app/globals.css index 1ba501f..1c939f5 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -14,7 +14,7 @@ --color-meet-bg-mint-start: var(--meet-bg-mint-start); --color-meet-bg-mint-end: var(--meet-bg-mint-end); --color-meet-focus-ring: var(--meet-focus-ring); - --font-sans: var(--font-sans); + --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; --font-mono: var(--font-geist-mono); --font-heading: var(--font-sans); --color-sidebar-ring: var(--sidebar-ring); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index deed442..a5eb79b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import { SessionProvider } from "next-auth/react"; import { TooltipProvider } from "@/components/ui/tooltip"; import "./globals.css"; @@ -25,7 +26,9 @@ export default function RootLayout({ children }: LayoutProps<"/">) { className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} > - {children} + + {children} + ); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..2e418c1 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,165 @@ +"use client" + +import { Suspense, useState } from "react" +import Link from "next/link" +import { useRouter, useSearchParams } from "next/navigation" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { signIn } from "next-auth/react" +import { Loader2 } from "lucide-react" + +import { AppShell } from "@/components/shell/app-shell" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { loginSchema, type LoginInput } from "@/lib/validations/auth" + +const ERROR_MESSAGES: Record = { + email_not_verified: + "Debes verificar tu correo antes de iniciar sesión. Revisa el enlace de verificación enviado al registrarte.", + invalid_credentials: "Correo o contraseña incorrectos.", + default: "No se pudo iniciar sesión. Verifica tus datos e intenta de nuevo.", +} + +export default function LoginPage() { + return ( + + + + ) +} + +function LoginForm() { + const router = useRouter() + const searchParams = useSearchParams() + const [serverError, setServerError] = useState(null) + + const verified = searchParams.get("verified") === "1" + const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard" + + const form = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { email: "", password: "" }, + }) + + async function onSubmit(values: LoginInput) { + setServerError(null) + + const result = await signIn("credentials", { + ...values, + redirect: false, + }) + + if (result?.error) { + const code = result.code ?? result.error + setServerError(ERROR_MESSAGES[code] ?? ERROR_MESSAGES.default) + return + } + + router.push(callbackUrl) + } + + return ( + + + + Iniciar sesión + + Usa tu correo @appisoftsas.com para entrar. + + + + {verified ? ( +

+ Correo verificado correctamente. Ya puedes iniciar sesión. +

+ ) : null} + +
+ + {serverError ? ( +

+ {serverError} +

+ ) : null} + + ( + + Correo + + + + + + )} + /> + + ( + + Contraseña + + + + + + )} + /> + + + +

+ ¿No tienes cuenta?{" "} + + Regístrate + +

+ + +
+
+
+ ) +} diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx new file mode 100644 index 0000000..001a158 --- /dev/null +++ b/src/app/register/page.tsx @@ -0,0 +1,221 @@ +"use client" + +import { useState } from "react" +import Link from "next/link" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { Loader2 } from "lucide-react" + +import { AppShell } from "@/components/shell/app-shell" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { registerSchema, type RegisterInput } from "@/lib/validations/auth" + +export default function RegisterPage() { + const [successMessage, setSuccessMessage] = useState(null) + const [verificationUrl, setVerificationUrl] = useState(null) + const [serverError, setServerError] = useState(null) + + const form = useForm({ + resolver: zodResolver(registerSchema), + defaultValues: { + email: "", + displayName: "", + password: "", + confirmPassword: "", + }, + }) + + async function onSubmit(values: RegisterInput) { + setServerError(null) + setSuccessMessage(null) + setVerificationUrl(null) + + try { + const response = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + }) + + const data = await response.json() + + if (!response.ok) { + setServerError(data?.error ?? "No se pudo crear la cuenta") + return + } + + setSuccessMessage( + data?.message ?? + "Cuenta creada. Revisa la consola/logs del servidor para el enlace de verificación." + ) + setVerificationUrl(data?.verificationUrl ?? null) + form.reset() + } catch { + setServerError("No se pudo conectar con el servidor. Intenta de nuevo.") + } + } + + return ( + + + + Crear cuenta + + Solo correos @appisoftsas.com pueden registrarse. + + + + {successMessage ? ( +
+

+ {successMessage} +

+ {verificationUrl ? ( +

+ Enlace de verificación (modo desarrollo):{" "} + + {verificationUrl} + +

+ ) : null} + +
+ ) : ( +
+ + {serverError ? ( +

+ {serverError} +

+ ) : null} + + ( + + Correo + + + + + + )} + /> + + ( + + Nombre a mostrar + + + + + + )} + /> + +
+ ( + + Contraseña + + + + + + )} + /> + + ( + + Confirmar contraseña + + + + + + )} + /> +
+ + + +

+ ¿Ya tienes cuenta?{" "} + + Inicia sesión + +

+ + + )} +
+
+
+ ) +} diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..5fa1af8 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,78 @@ +import NextAuth, { CredentialsSignin } from "next-auth" +import Credentials from "next-auth/providers/credentials" +import bcrypt from "bcryptjs" + +import { prisma } from "@/lib/prisma" + +class InvalidCredentialsError extends CredentialsSignin { + code = "invalid_credentials" +} + +class EmailNotVerifiedError extends CredentialsSignin { + code = "email_not_verified" +} + +export const { handlers, auth, signIn, signOut } = NextAuth({ + session: { strategy: "jwt" }, + pages: { + signIn: "/login", + }, + providers: [ + Credentials({ + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials) { + const email = credentials?.email + const password = credentials?.password + + if (typeof email !== "string" || typeof password !== "string") { + throw new InvalidCredentialsError() + } + + const user = await prisma.user.findUnique({ + where: { email: email.toLowerCase() }, + }) + + if (!user) { + throw new InvalidCredentialsError() + } + + const passwordMatches = await bcrypt.compare(password, user.password) + if (!passwordMatches) { + throw new InvalidCredentialsError() + } + + if (!user.emailVerifiedAt) { + throw new EmailNotVerifiedError() + } + + return { + id: String(user.id), + email: user.email, + displayName: user.displayName, + role: user.role, + } + }, + }), + ], + callbacks: { + async jwt({ token, user }) { + if (user) { + token.id = user.id + token.email = user.email + token.displayName = user.displayName + token.role = user.role + } + return token + }, + async session({ session, token }) { + session.user.id = token.id + session.user.email = token.email + session.user.displayName = token.displayName + session.user.role = token.role + return session + }, + }, +}) diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 0000000..9003d31 --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,185 @@ +"use client" + +import * as React from "react" +import { + Controller, + FormProvider, + useFormContext, + useFormState, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form" +import { cn } from "cn" + +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName +} + +const FormFieldContext = React.createContext(null) + +function FormField< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ ...props }: ControllerProps) { + return ( + + + + ) +} + +function useFormField() { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState } = useFormContext() + const formState = useFormState({ name: fieldContext?.name }) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext ?? { id: "" } + const fieldState = getFieldState(fieldContext.name, formState) + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext(null) + +function FormItem({ className, ...props }: React.ComponentProps<"div">) { + const id = React.useId() + + return ( + +
+ + ) +} + +function FormLabel({ + className, + ...props +}: React.ComponentProps) { + const { error, formItemId } = useFormField() + + return ( +