feat: add Auth.js authentication with email verification and login/register forms

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
WilandClaude Sonnet 5 committed 2026-10-29 11:30:59 -05:00
1 parent d5c9aa0be9
commit 0135462bc7
16 files changed
+942 -2

No files matched your search

+18
View File
@@ -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=="],
+2
View File
@@ -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",
@@ -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;
+11
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
import { handlers } from "@/auth"
export const { GET, POST } = handlers
+87
View File
@@ -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 }
)
}
+34
View File
@@ -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))
}
+1 -1
View File
@@ -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);
+4 -1
View File
@@ -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`}
>
<body className="min-h-full flex flex-col">
<TooltipProvider>{children}</TooltipProvider>
<SessionProvider>
<TooltipProvider>{children}</TooltipProvider>
</SessionProvider>
</body>
</html>
);
+165
View File
@@ -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<string, string> = {
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 (
<Suspense fallback={null}>
<LoginForm />
</Suspense>
)
}
function LoginForm() {
const router = useRouter()
const searchParams = useSearchParams()
const [serverError, setServerError] = useState<string | null>(null)
const verified = searchParams.get("verified") === "1"
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard"
const form = useForm<LoginInput>({
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 (
<AppShell className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Iniciar sesión</CardTitle>
<CardDescription>
Usa tu correo @appisoftsas.com para entrar.
</CardDescription>
</CardHeader>
<CardContent>
{verified ? (
<p className="mb-3 rounded-lg bg-primary/10 p-2.5 text-sm text-primary">
Correo verificado correctamente. Ya puedes iniciar sesión.
</p>
) : null}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
{serverError ? (
<p className="rounded-lg bg-destructive/10 p-2.5 text-sm text-destructive">
{serverError}
</p>
) : null}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Correo</FormLabel>
<FormControl>
<Input
type="email"
placeholder="tu.nombre@appisoftsas.com"
autoComplete="email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Contraseña</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Tu contraseña"
autoComplete="current-password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? (
<>
<Loader2 className="animate-spin" />
Iniciando sesión...
</>
) : (
"Iniciar sesión"
)}
</Button>
<p className="text-center text-xs text-muted-foreground">
¿No tienes cuenta?{" "}
<Link href="/register" className="text-primary underline">
Regístrate
</Link>
</p>
</form>
</Form>
</CardContent>
</Card>
</AppShell>
)
}
+221
View File
@@ -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<string | null>(null)
const [verificationUrl, setVerificationUrl] = useState<string | null>(null)
const [serverError, setServerError] = useState<string | null>(null)
const form = useForm<RegisterInput>({
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 (
<AppShell className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Crear cuenta</CardTitle>
<CardDescription>
Solo correos @appisoftsas.com pueden registrarse.
</CardDescription>
</CardHeader>
<CardContent>
{successMessage ? (
<div className="space-y-3">
<p className="rounded-lg bg-primary/10 p-3 text-sm text-primary">
{successMessage}
</p>
{verificationUrl ? (
<p className="text-xs break-all text-muted-foreground">
Enlace de verificación (modo desarrollo):{" "}
<a
className="text-primary underline"
href={verificationUrl}
>
{verificationUrl}
</a>
</p>
) : null}
<Button className="w-full" render={<Link href="/login" />}>
Ir a iniciar sesión
</Button>
</div>
) : (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-3"
>
{serverError ? (
<p className="rounded-lg bg-destructive/10 p-2.5 text-sm text-destructive">
{serverError}
</p>
) : null}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Correo</FormLabel>
<FormControl>
<Input
type="email"
placeholder="tu.nombre@appisoftsas.com"
autoComplete="email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="displayName"
render={({ field }) => (
<FormItem>
<FormLabel>Nombre a mostrar</FormLabel>
<FormControl>
<Input
placeholder="Nombre y apellido"
autoComplete="name"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-3 sm:grid-cols-2">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Contraseña</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Mínimo 8 caracteres"
autoComplete="new-password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirmar contraseña</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Repite tu contraseña"
autoComplete="new-password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<Button
type="submit"
className="w-full"
disabled={form.formState.isSubmitting}
>
{form.formState.isSubmitting ? (
<>
<Loader2 className="animate-spin" />
Creando cuenta...
</>
) : (
"Crear cuenta"
)}
</Button>
<p className="text-center text-xs text-muted-foreground">
¿Ya tienes cuenta?{" "}
<Link href="/login" className="text-primary underline">
Inicia sesión
</Link>
</p>
</form>
</Form>
)}
</CardContent>
</Card>
</AppShell>
)
}
+78
View File
@@ -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
},
},
})
+185
View File
@@ -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<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue | null>(null)
function FormField<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({ ...props }: ControllerProps<TFieldValues, TName>) {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
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 <FormField>")
}
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<FormItemContextValue | null>(null)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-1.5", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
// Lightweight stand-in for Radix's `Slot`: this project uses Base UI
// primitives rather than Radix, so we merge the generated id/aria-* props
// onto the single child element ourselves instead of pulling in a new
// Radix-only dependency.
type SlotProps = React.HTMLAttributes<HTMLElement> & {
children?: React.ReactNode
}
function Slot({ children, ...slotProps }: SlotProps) {
if (!React.isValidElement(children)) {
return (children as React.ReactNode) ?? null
}
const child = children as React.ReactElement<Record<string, unknown>>
const childProps = child.props ?? {}
return React.cloneElement(child, {
...slotProps,
...childProps,
className: cn(
slotProps.className,
childProps.className as string | undefined
),
})
}
function FormControl({ ...props }: SlotProps) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-xs", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-xs", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}
+32
View File
@@ -0,0 +1,32 @@
import { z } from "zod"
// Anchored exactly to @appisoftsas.com — no subdomains/suffixes
// (e.g. user@appisoftsas.com.evil.com must be rejected).
const APPISOFT_EMAIL_REGEX = /^[^@\s]+@appisoftsas\.com$/i
export const registerSchema = z
.object({
email: z
.string()
.min(1, "El correo es obligatorio")
.regex(APPISOFT_EMAIL_REGEX, "Debe usar un correo @appisoftsas.com"),
displayName: z
.string()
.min(2, "El nombre debe tener al menos 2 caracteres")
.max(100, "El nombre es demasiado largo"),
password: z.string().min(8, "La contraseña debe tener al menos 8 caracteres"),
confirmPassword: z.string().min(1, "Confirma tu contraseña"),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Las contraseñas no coinciden",
path: ["confirmPassword"],
})
export type RegisterInput = z.infer<typeof registerSchema>
export const loginSchema = z.object({
email: z.string().min(1, "El correo es obligatorio"),
password: z.string().min(1, "La contraseña es obligatoria"),
})
export type LoginInput = z.infer<typeof loginSchema>
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server"
import { auth } from "@/auth"
// Next.js 16 renamed `middleware.ts` to `proxy.ts` (same semantics, new name).
// A valid Auth.js session here implies a verified email, since the
// Credentials provider's `authorize` rejects unverified users at login.
export default auth((req) => {
const isLoggedIn = !!req.auth
const { pathname } = req.nextUrl
if (pathname.startsWith("/dashboard") && !isLoggedIn) {
const loginUrl = new URL("/login", req.nextUrl)
loginUrl.searchParams.set("callbackUrl", pathname)
return NextResponse.redirect(loginUrl)
}
return NextResponse.next()
})
export const config = {
matcher: ["/dashboard/:path*"],
}
+66
View File
@@ -0,0 +1,66 @@
import type { DefaultSession } from "next-auth"
type AppRole = "admin" | "user"
declare module "next-auth" {
interface User {
id: string
email: string
displayName: string
role: AppRole
}
interface Session {
user: {
id: string
email: string
displayName: string
role: AppRole
} & DefaultSession["user"]
}
}
// `User` and `Session` are declared in `@auth/core/types` and only
// re-exported (via `export type { ... }`) from `next-auth`, so an
// augmentation of `next-auth` alone doesn't merge into the types actually
// used inside `NextAuth(...)`'s callback signatures. Augment the source
// module too.
declare module "@auth/core/types" {
interface User {
id: string
email: string
displayName: string
role: AppRole
}
interface Session {
user: {
id: string
email: string
displayName: string
role: AppRole
} & DefaultSession["user"]
}
}
declare module "next-auth/jwt" {
interface JWT {
id: string
email: string
displayName: string
role: AppRole
}
}
// Same re-export situation for `JWT`: declared in `@auth/core/jwt`, only
// re-exported from `next-auth/jwt`. Augment the source module too.
declare module "@auth/core/jwt" {
interface JWT {
id: string
email: string
displayName: string
role: AppRole
}
}
export {}