Registro + activación por email en Next.js (SRP6 + nodemailer)
- lib/mail.ts: transporte SMTP (nodemailer, mismas creds Gmail que Django). - lib/register.ts: registerAccount (valida, comprueba email existente, crea fila en home_accountactivation reutilizando la tabla de Django, envía email de activación); activateAccount (crea battlenet_accounts con SRP6 v2 + account con SRP6 Grunt, como activate_account_view: expansion=2, battlenet_index=1; borra la activación). - Route handlers /api/auth/register y /api/auth/activate. - Páginas app/[locale]/register (RegisterForm cliente) y app/[locale]/activate-account (ActivateClient auto-POST al abrir el enlace). Textos en messages (Register, Activate). Validado: validaciones (missingFields/passwordMismatch/invalidEmail/passwordTooLong); ciclo completo activación->crea bnet+account->login OK con la cuenta creada (needsSelection=false), password mala->invalidCredentials. Todo en TS, crypto validada. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
|
||||
const ERROR_KEYS = ['invalidLink', 'expiredLink'] as const
|
||||
|
||||
export function ActivateClient({ hash }: { hash: string }) {
|
||||
const t = useTranslations('Activate')
|
||||
const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading')
|
||||
const [errorKey, setErrorKey] = useState<string>('invalidLink')
|
||||
const ran = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (ran.current) return // evita doble POST en StrictMode
|
||||
ran.current = true
|
||||
if (!hash) {
|
||||
setErrorKey('invalidLink')
|
||||
setState('error')
|
||||
return
|
||||
}
|
||||
fetch('/api/auth/activate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hash }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d: { success?: boolean; error?: string }) => {
|
||||
if (d.success) setState('ok')
|
||||
else {
|
||||
setErrorKey((ERROR_KEYS as readonly string[]).includes(d.error ?? '') ? d.error! : 'invalidLink')
|
||||
setState('error')
|
||||
}
|
||||
})
|
||||
.catch(() => setState('error'))
|
||||
}, [hash])
|
||||
|
||||
return (
|
||||
<div className="text-center">
|
||||
{state === 'loading' && <p>{t('activating')}</p>}
|
||||
{state === 'ok' && (
|
||||
<>
|
||||
<p className="text-green-400">{t('success')}</p>
|
||||
<p className="mt-4">
|
||||
<Link href="/login" className="text-sky-400 underline">
|
||||
{t('goLogin')}
|
||||
</Link>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{state === 'error' && <p className="text-red-400">{t(errorKey)}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { setRequestLocale } from 'next-intl/server'
|
||||
import { ActivateClient } from './ActivateClient'
|
||||
|
||||
export default async function ActivatePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
searchParams: Promise<{ act?: string }>
|
||||
}) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const { act } = await searchParams
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-lg px-4 py-12">
|
||||
<ActivateClient hash={act ?? ''} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
const ERROR_KEYS = [
|
||||
'missingFields',
|
||||
'passwordMismatch',
|
||||
'passwordTooLong',
|
||||
'invalidEmail',
|
||||
'emailExists',
|
||||
'recruiterNotFound',
|
||||
] as const
|
||||
|
||||
export function RegisterForm() {
|
||||
const t = useTranslations('Register')
|
||||
const [form, setForm] = useState({ password: '', confPassword: '', email: '', confEmail: '', recruiter: '' })
|
||||
const [accepted, setAccepted] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
|
||||
function upd(k: string, v: string) {
|
||||
setForm((f) => ({ ...f, [k]: v }))
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (busy || !accepted) return
|
||||
setBusy(true)
|
||||
setMessage(null)
|
||||
try {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
const data: { success?: boolean; error?: string } = await res.json()
|
||||
if (data.success) {
|
||||
setMessage({ ok: true, text: t('success') })
|
||||
} else {
|
||||
const key = (ERROR_KEYS as readonly string[]).includes(data.error ?? '') ? data.error! : 'genericError'
|
||||
setMessage({ ok: false, text: t(key) })
|
||||
}
|
||||
} catch {
|
||||
setMessage({ ok: false, text: t('genericError') })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const input = 'w-full rounded border border-amber-900/60 bg-[#2c1e14] px-3 py-2'
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-sm">
|
||||
<p className="mb-4 text-sm text-amber-200/70">{t('info')}</p>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<input type="password" maxLength={16} placeholder={t('password')} value={form.password} onChange={(e) => upd('password', e.target.value)} className={input} />
|
||||
<input type="password" maxLength={16} placeholder={t('confPassword')} value={form.confPassword} onChange={(e) => upd('confPassword', e.target.value)} className={input} />
|
||||
<input type="email" placeholder={t('email')} value={form.email} onChange={(e) => upd('email', e.target.value)} className={input} />
|
||||
<input type="email" placeholder={t('confEmail')} value={form.confEmail} onChange={(e) => upd('confEmail', e.target.value)} className={input} />
|
||||
<input type="text" maxLength={12} placeholder={t('recruiter')} value={form.recruiter} onChange={(e) => upd('recruiter', e.target.value)} className={input} />
|
||||
<label className="flex items-start gap-2 text-left text-sm">
|
||||
<input type="checkbox" checked={accepted} onChange={(e) => setAccepted(e.target.checked)} className="mt-1" />
|
||||
<span>{t('terms')}</span>
|
||||
</label>
|
||||
<button type="submit" disabled={busy || !accepted} className="w-full rounded bg-amber-600 px-4 py-2 font-semibold text-[#1b120b] disabled:opacity-60">
|
||||
{busy ? t('creating') : t('submit')}
|
||||
</button>
|
||||
</form>
|
||||
{message && <p className={`mt-3 text-center ${message.ok ? 'text-green-400' : 'text-red-400'}`}>{message.text}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { RegisterForm } from './RegisterForm'
|
||||
|
||||
export default async function RegisterPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations('Register')
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="mb-6 text-center text-2xl font-bold text-amber-500">{t('title')}</h1>
|
||||
<RegisterForm />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { activateAccount } from '@/lib/register'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let hash = ''
|
||||
try {
|
||||
const body = await request.json()
|
||||
hash = String(body.hash ?? '')
|
||||
} catch {
|
||||
return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
|
||||
}
|
||||
if (!hash) return Response.json({ success: false, error: 'invalidLink' })
|
||||
|
||||
const ip = (request.headers.get('x-forwarded-for') || '0.0.0.0').split(',')[0].trim()
|
||||
const result = await activateAccount(hash, ip)
|
||||
return Response.json(result)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { registerAccount } from '@/lib/register'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let body: Record<string, string>
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
|
||||
}
|
||||
const result = await registerAccount({
|
||||
password: body.password ?? '',
|
||||
confPassword: body.confPassword ?? '',
|
||||
email: body.email ?? '',
|
||||
confEmail: body.confEmail ?? '',
|
||||
recruiter: body.recruiter ?? '',
|
||||
})
|
||||
return Response.json(result)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
// Transporte SMTP (mismas credenciales que usaba Django: Gmail por defecto).
|
||||
let transporter: nodemailer.Transporter | null = null
|
||||
|
||||
function getTransporter(): nodemailer.Transporter {
|
||||
if (!transporter) {
|
||||
transporter = nodemailer.createTransport({
|
||||
host: process.env.EMAIL_HOST,
|
||||
port: Number(process.env.EMAIL_PORT || 587),
|
||||
secure: process.env.EMAIL_USE_SSL === 'True', // true para 465, false para 587 (STARTTLS)
|
||||
auth: {
|
||||
user: process.env.EMAIL_HOST_USER,
|
||||
pass: process.env.EMAIL_HOST_PASSWORD,
|
||||
},
|
||||
})
|
||||
}
|
||||
return transporter
|
||||
}
|
||||
|
||||
export async function sendMail(to: string, subject: string, html: string): Promise<boolean> {
|
||||
try {
|
||||
await getTransporter().sendMail({
|
||||
from: process.env.EMAIL_HOST_USER,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
})
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('sendMail error:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import crypto from 'node:crypto'
|
||||
import type { ResultSetHeader, RowDataPacket } from 'mysql2'
|
||||
import { db, DB } from './db'
|
||||
import { normalizeEmail, bnetMakeRegistration, gameMakeRegistration, makeGameAccountUsername } from './bnet'
|
||||
import { sendMail } from './mail'
|
||||
|
||||
const GMAIL_RE = /^[a-zA-Z0-9._%+-]+@gmail\.com$/
|
||||
|
||||
export interface RegisterInput {
|
||||
password: string
|
||||
confPassword: string
|
||||
email: string
|
||||
confEmail: string
|
||||
recruiter?: string
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
function activationEmailHtml(email: string, link: string): string {
|
||||
return `<!DOCTYPE html><html lang="es"><body style="font-family:Arial,sans-serif;background:#1b120b;color:#e8dccb;padding:24px">
|
||||
<div style="max-width:600px;margin:0 auto;background:#241812;border:1px solid #4a3320;border-radius:8px;padding:28px">
|
||||
<h1 style="color:#d79602;margin:0 0 8px">Nova WoW</h1>
|
||||
<h2 style="margin:0 0 16px">Activa tu cuenta</h2>
|
||||
<p>La cuenta <strong>${email}</strong> se ha creado. Pulsa para activarla (enlace válido 1 hora):</p>
|
||||
<p><a href="${link}" style="display:inline-block;padding:12px 22px;background:#d79602;color:#1b120b;text-decoration:none;border-radius:6px;font-weight:bold">Activar cuenta</a></p>
|
||||
<p style="font-size:13px;word-break:break-all"><a href="${link}" style="color:#2471a9">${link}</a></p>
|
||||
</div></body></html>`
|
||||
}
|
||||
|
||||
export async function registerAccount(input: RegisterInput): Promise<Result> {
|
||||
const password = (input.password || '').trim()
|
||||
const confPassword = (input.confPassword || '').trim()
|
||||
const email = (input.email || '').trim()
|
||||
const confEmail = (input.confEmail || '').trim()
|
||||
const recruiter = (input.recruiter || '').trim()
|
||||
|
||||
if (!password || !email) return { success: false, error: 'missingFields' }
|
||||
if (password !== confPassword) return { success: false, error: 'passwordMismatch' }
|
||||
if (password.length > 16) return { success: false, error: 'passwordTooLong' }
|
||||
if (email !== confEmail || !GMAIL_RE.test(email)) return { success: false, error: 'invalidEmail' }
|
||||
|
||||
// ¿ya existe una cuenta Battle.net con ese email?
|
||||
try {
|
||||
const [rows] = await db(DB.auth).query<RowDataPacket[]>(
|
||||
'SELECT COUNT(*) AS n FROM battlenet_accounts WHERE email = ?',
|
||||
[normalizeEmail(email)],
|
||||
)
|
||||
if (Number(rows[0]?.n ?? 0) > 0) return { success: false, error: 'emailExists' }
|
||||
} catch {
|
||||
// BD de cuentas no disponible en dev: continuamos.
|
||||
}
|
||||
|
||||
// Borrar cualquier activación de registro pendiente para ese email
|
||||
await db(DB.default).query('DELETE FROM home_accountactivation WHERE email = ? AND old_email IS NULL', [email])
|
||||
|
||||
// Reclutador opcional
|
||||
let recruiterId = 0
|
||||
if (recruiter) {
|
||||
try {
|
||||
const [a] = await db(DB.auth).query<RowDataPacket[]>('SELECT id FROM account WHERE username = ?', [recruiter])
|
||||
if (a[0]) {
|
||||
recruiterId = a[0].id
|
||||
} else {
|
||||
const [c] = await db(DB.characters).query<RowDataPacket[]>(
|
||||
'SELECT account FROM characters WHERE name = ?',
|
||||
[recruiter],
|
||||
)
|
||||
if (c[0]) recruiterId = c[0].account
|
||||
else return { success: false, error: 'recruiterNotFound' }
|
||||
}
|
||||
} catch {
|
||||
return { success: false, error: 'recruiterNotFound' }
|
||||
}
|
||||
}
|
||||
|
||||
const hash = crypto.randomBytes(16).toString('hex') // 32 hex chars (col hash = CharField(32))
|
||||
await db(DB.default).query(
|
||||
'INSERT INTO home_accountactivation (email, password, recruiter_id, hash, created_at, is_used, is_new_email_used) VALUES (?, ?, ?, ?, NOW(), 0, 0)',
|
||||
[email, password, recruiterId, hash],
|
||||
)
|
||||
|
||||
const link = `${process.env.SITE_URL}/activate-account?act=${hash}`
|
||||
await sendMail(email, `Activación de la cuenta ${email} - Nova WoW`, activationEmailHtml(email, link))
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function activateAccount(hash: string, lastIp = '0.0.0.0'): Promise<Result> {
|
||||
const [rows] = await db(DB.default).query<RowDataPacket[]>(
|
||||
'SELECT id, email, password, recruiter_id, created_at FROM home_accountactivation WHERE hash = ? AND old_email IS NULL',
|
||||
[hash],
|
||||
)
|
||||
const act = rows[0]
|
||||
if (!act) return { success: false, error: 'invalidLink' }
|
||||
if (Date.now() - new Date(act.created_at).getTime() > 3600_000) return { success: false, error: 'expiredLink' }
|
||||
|
||||
const emailNorm = normalizeEmail(act.email)
|
||||
const password: string = act.password
|
||||
|
||||
// Cuenta Battle.net (SRP6 v2)
|
||||
const bnet = bnetMakeRegistration(emailNorm, password)
|
||||
const [bnetRes] = await db(DB.auth).query<ResultSetHeader>(
|
||||
'INSERT INTO battlenet_accounts (email, srp_version, salt, verifier) VALUES (?, ?, ?, ?)',
|
||||
[emailNorm, bnet.srpVersion, bnet.salt, bnet.verifier],
|
||||
)
|
||||
const bnetId = bnetRes.insertId
|
||||
|
||||
// Cuenta de juego (SRP6 Grunt)
|
||||
const gameUsername = makeGameAccountUsername(bnetId, 1)
|
||||
const game = gameMakeRegistration(gameUsername, password)
|
||||
await db(DB.auth).query<ResultSetHeader>(
|
||||
'INSERT INTO account (username, salt, verifier, email, reg_mail, recruiter, joindate, last_ip, expansion, battlenet_account, battlenet_index) ' +
|
||||
'VALUES (?, ?, ?, ?, ?, ?, NOW(), ?, ?, ?, 1)',
|
||||
[gameUsername, game.salt, game.verifier, emailNorm, emailNorm, act.recruiter_id || 0, lastIp, 2, bnetId],
|
||||
)
|
||||
|
||||
await db(DB.default).query('DELETE FROM home_accountactivation WHERE id = ?', [act.id])
|
||||
return { success: true }
|
||||
}
|
||||
@@ -44,5 +44,32 @@
|
||||
"noAccounts": "No game accounts were found linked to this Battle.net account.",
|
||||
"entering": "Entering…",
|
||||
"error": "The account could not be selected."
|
||||
},
|
||||
"Register": {
|
||||
"title": "Create account",
|
||||
"info": "Your account is Battle.net type: it is identified by your email. The password must be alphanumeric and up to 16 characters. The email must be a Gmail address.",
|
||||
"password": "Password",
|
||||
"confPassword": "Confirm password",
|
||||
"email": "Gmail email address",
|
||||
"confEmail": "Confirm email address",
|
||||
"recruiter": "Recruiter (optional)",
|
||||
"terms": "I accept the Rules, Terms and Conditions and Privacy Policy",
|
||||
"submit": "Create account",
|
||||
"creating": "Creating…",
|
||||
"success": "The account has been created. Check your email to activate it.",
|
||||
"missingFields": "Please fill in all fields.",
|
||||
"passwordMismatch": "Passwords do not match.",
|
||||
"passwordTooLong": "The password must not exceed 16 characters.",
|
||||
"invalidEmail": "The email address is not valid.",
|
||||
"emailExists": "An account with that email already exists.",
|
||||
"recruiterNotFound": "The recruiter entered does not exist.",
|
||||
"genericError": "Something went wrong. Please try again later."
|
||||
},
|
||||
"Activate": {
|
||||
"activating": "Activating your account…",
|
||||
"success": "Account activated! You can now sign in.",
|
||||
"invalidLink": "The link is invalid or has already been used.",
|
||||
"expiredLink": "The link has expired. Please request a new one.",
|
||||
"goLogin": "Go to sign in"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,5 +44,32 @@
|
||||
"noAccounts": "No se han encontrado cuentas de juego asociadas a esta cuenta Battle.net.",
|
||||
"entering": "Entrando…",
|
||||
"error": "No se pudo seleccionar la cuenta."
|
||||
},
|
||||
"Register": {
|
||||
"title": "Creación de cuenta",
|
||||
"info": "Tu cuenta es de tipo Battle.net: se identifica con tu correo electrónico. La contraseña debe ser alfanumérica y de hasta 16 caracteres. El correo debe ser de Gmail.",
|
||||
"password": "Contraseña",
|
||||
"confPassword": "Confirmar contraseña",
|
||||
"email": "Correo electrónico de Gmail",
|
||||
"confEmail": "Confirmar correo electrónico",
|
||||
"recruiter": "Reclutante (opcional)",
|
||||
"terms": "Acepto las Normas, los Términos y Condiciones y la Política de Privacidad",
|
||||
"submit": "Crear cuenta",
|
||||
"creating": "Creando…",
|
||||
"success": "La cuenta se ha creado. Revisa tu correo para activarla.",
|
||||
"missingFields": "Por favor, complete todos los campos.",
|
||||
"passwordMismatch": "Las contraseñas no coinciden.",
|
||||
"passwordTooLong": "La contraseña no debe exceder los 16 caracteres.",
|
||||
"invalidEmail": "El correo electrónico no es válido.",
|
||||
"emailExists": "Ya existe una cuenta con ese correo electrónico.",
|
||||
"recruiterNotFound": "El reclutador ingresado no existe.",
|
||||
"genericError": "Algo ha salido mal. Inténtalo más tarde."
|
||||
},
|
||||
"Activate": {
|
||||
"activating": "Activando tu cuenta…",
|
||||
"success": "¡Cuenta activada! Ya puedes iniciar sesión.",
|
||||
"invalidLink": "El enlace no es válido o ya se ha usado.",
|
||||
"expiredLink": "El enlace ha caducado. Solicita uno nuevo.",
|
||||
"goLogin": "Ir a iniciar sesión"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+19
@@ -12,12 +12,14 @@
|
||||
"mysql2": "^3.22.6",
|
||||
"next": "16.2.10",
|
||||
"next-intl": "^4.13.2",
|
||||
"nodemailer": "^9.0.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.2",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
@@ -2436,6 +2438,15 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||
@@ -6090,6 +6101,14 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
|
||||
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
|
||||
@@ -13,12 +13,14 @@
|
||||
"mysql2": "^3.22.6",
|
||||
"next": "16.2.10",
|
||||
"next-intl": "^4.13.2",
|
||||
"nodemailer": "^9.0.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.2",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
|
||||
Reference in New Issue
Block a user