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:
1 parent
d5c9aa0be9
commit
0135462bc7
16 files changed
+942
-2
No files matched your search
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/auth"
|
||||
|
||||
export const { GET, POST } = handlers
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user