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

+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>
)
}