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`} >
-