Panel admin propio: gestión de noticias
- lib/admin.ts: isAdmin(session) por allowlist ADMIN_EMAILS (normalizada) o gmlevel de AzerothCore (account_access, resiliente si no existe). ADMIN_EMAILS/ADMIN_GMLEVEL en .env.local. - lib/admin-news.ts: listNews / createNews / deleteNews (home_noticia). - Routes /api/admin/news (POST, guard isAdmin) y /api/admin/news/[id] (DELETE). - Páginas /admin (dashboard) y /admin/news (protegidas: no-admin -> redirect). AdminNewsManager (cliente: crear + listar + borrar). Verificado: /admin sin permiso redirige, APIs 403; el pipeline crear noticia -> se renderiza en la home (SSR) funciona. Turnstile bloquea el login por curl (correcto). Pendiente admin: precios de servicios, foros, sitios de voto, usuarios. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { redirect } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { isAdmin } from '@/lib/admin'
|
||||
import { listNews } from '@/lib/admin-news'
|
||||
import { AdminNewsManager } from '@/components/AdminNewsManager'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function AdminNewsPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations('Admin')
|
||||
const session = await getSession()
|
||||
if (!session.bnetId) redirect({ href: '/login', locale })
|
||||
if (!(await isAdmin(session))) redirect({ href: '/', locale })
|
||||
const news = await listNews()
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="mb-6 text-2xl font-bold text-amber-500">{t('manageNews')}</h1>
|
||||
<AdminNewsManager initialNews={news} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { redirect, Link } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { isAdmin } from '@/lib/admin'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function AdminPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations('Admin')
|
||||
const session = await getSession()
|
||||
if (!session.bnetId) redirect({ href: '/login', locale })
|
||||
if (!(await isAdmin(session))) redirect({ href: '/', locale })
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<h1 className="mb-6 text-2xl font-bold text-amber-500">{t('title')}</h1>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<Link href="/admin/news" className="text-sky-400 hover:underline">
|
||||
{t('manageNews')}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { getSession } from '@/lib/session'
|
||||
import { isAdmin } from '@/lib/admin'
|
||||
import { deleteNews } from '@/lib/admin-news'
|
||||
|
||||
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getSession()
|
||||
if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
|
||||
const { id } = await params
|
||||
await deleteNews(Number(id))
|
||||
return Response.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { getSession } from '@/lib/session'
|
||||
import { isAdmin } from '@/lib/admin'
|
||||
import { createNews } from '@/lib/admin-news'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSession()
|
||||
if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
|
||||
let b: Record<string, string> = {}
|
||||
try { b = await request.json() } catch { return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) }
|
||||
const titulo = String(b.titulo ?? '').trim()
|
||||
const contenido = String(b.contenido ?? '').trim()
|
||||
const enlace = String(b.enlace ?? '').trim() || null
|
||||
if (!titulo || !contenido) return Response.json({ success: false, error: 'emptyError' })
|
||||
const id = await createNews(titulo, contenido, enlace)
|
||||
return Response.json({ success: true, id })
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations, useLocale } from 'next-intl'
|
||||
import type { NewsItem } from '@/lib/admin-news'
|
||||
|
||||
export function AdminNewsManager({ initialNews }: { initialNews: NewsItem[] }) {
|
||||
const t = useTranslations('Admin')
|
||||
const locale = useLocale()
|
||||
const [news, setNews] = useState(initialNews)
|
||||
const [titulo, setTitulo] = useState('')
|
||||
const [contenido, setContenido] = useState('')
|
||||
const [enlace, setEnlace] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function create(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (busy || !titulo.trim() || !contenido.trim()) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/admin/news', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ titulo, contenido, enlace }),
|
||||
})
|
||||
const data: { success?: boolean; id?: number } = await res.json()
|
||||
if (data.success && data.id) {
|
||||
setNews([{ id: data.id, titulo, fecha: new Date().toISOString(), enlace: enlace || null }, ...news])
|
||||
setTitulo('')
|
||||
setContenido('')
|
||||
setEnlace('')
|
||||
} else setError(t('genericError'))
|
||||
} catch {
|
||||
setError(t('genericError'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (!confirm(t('confirmDelete'))) return
|
||||
const res = await fetch(`/api/admin/news/${id}`, { method: 'DELETE', credentials: 'same-origin' })
|
||||
const data: { success?: boolean } = await res.json()
|
||||
if (data.success) setNews(news.filter((n) => n.id !== id))
|
||||
}
|
||||
|
||||
const field = 'w-full rounded border border-amber-900/60 bg-[#2c1e14] px-3 py-2'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={create} className="mb-8 space-y-3 rounded-lg border border-amber-900/60 bg-[#241812] p-4">
|
||||
<input value={titulo} onChange={(e) => setTitulo(e.target.value)} placeholder={t('newsTitle')} maxLength={200} className={field} />
|
||||
<textarea value={contenido} onChange={(e) => setContenido(e.target.value)} placeholder={t('newsContent')} rows={5} className={field} />
|
||||
<input value={enlace} onChange={(e) => setEnlace(e.target.value)} placeholder={t('newsLink')} className={field} />
|
||||
<button type="submit" disabled={busy} className="rounded bg-amber-600 px-4 py-2 font-semibold text-[#1b120b] disabled:opacity-60">
|
||||
{busy ? t('creating') : t('create')}
|
||||
</button>
|
||||
{error && <p className="text-red-400">{error}</p>}
|
||||
</form>
|
||||
|
||||
{news.length === 0 ? (
|
||||
<p className="text-amber-200/70">{t('noNews')}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-amber-900/40">
|
||||
{news.map((n) => (
|
||||
<li key={n.id} className="flex items-center justify-between gap-4 py-3">
|
||||
<div>
|
||||
<p className="font-semibold text-amber-300">{n.titulo}</p>
|
||||
{n.fecha && <p className="text-xs text-amber-200/50">{new Date(n.fecha).toLocaleString(locale)}</p>}
|
||||
</div>
|
||||
<button onClick={() => remove(n.id)} className="rounded border border-red-900/60 px-3 py-1 text-sm text-red-400 hover:bg-red-950/40">
|
||||
{t('delete')}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { RowDataPacket, ResultSetHeader } from 'mysql2'
|
||||
import { db, DB } from './db'
|
||||
|
||||
export interface NewsItem {
|
||||
id: number
|
||||
titulo: string
|
||||
fecha: string | null
|
||||
enlace: string | null
|
||||
}
|
||||
|
||||
export async function listNews(): Promise<NewsItem[]> {
|
||||
const [rows] = await db(DB.default).query<RowDataPacket[]>(
|
||||
'SELECT id, titulo, fecha_publicacion, enlace FROM home_noticia ORDER BY fecha_publicacion DESC',
|
||||
)
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
titulo: r.titulo,
|
||||
fecha: r.fecha_publicacion ? new Date(r.fecha_publicacion).toISOString() : null,
|
||||
enlace: r.enlace || null,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function createNews(titulo: string, contenido: string, enlace: string | null): Promise<number> {
|
||||
const [res] = await db(DB.default).query<ResultSetHeader>(
|
||||
'INSERT INTO home_noticia (titulo, contenido, fecha_publicacion, enlace) VALUES (?, ?, NOW(), ?)',
|
||||
[titulo, contenido, enlace || null],
|
||||
)
|
||||
return res.insertId
|
||||
}
|
||||
|
||||
export async function deleteNews(id: number): Promise<void> {
|
||||
await db(DB.default).query('DELETE FROM home_noticia WHERE id = ?', [id])
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { RowDataPacket } from 'mysql2'
|
||||
import { db, DB } from './db'
|
||||
import { normalizeEmail } from './bnet'
|
||||
import type { SessionData } from './session'
|
||||
|
||||
/** ¿Es administrador? Por allowlist de email (ADMIN_EMAILS) o por gmlevel de AzerothCore. */
|
||||
export async function isAdmin(session: SessionData): Promise<boolean> {
|
||||
if (!session.bnetId) return false
|
||||
|
||||
const allow = (process.env.ADMIN_EMAILS || '')
|
||||
.split(',')
|
||||
.map((e) => normalizeEmail(e))
|
||||
.filter(Boolean)
|
||||
if (session.bnetEmail && allow.includes(normalizeEmail(session.bnetEmail))) return true
|
||||
|
||||
try {
|
||||
const level = Number(process.env.ADMIN_GMLEVEL || 3)
|
||||
const [rows] = await db(DB.auth).query<RowDataPacket[]>(
|
||||
'SELECT gmlevel FROM account_access WHERE id = ? ORDER BY gmlevel DESC LIMIT 1',
|
||||
[session.accountId ?? 0],
|
||||
)
|
||||
if (rows[0] && Number(rows[0].gmlevel) >= level) return true
|
||||
} catch {
|
||||
// account_access puede no existir (acore sin poblar)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -265,5 +265,22 @@
|
||||
"loginToPost": "Sign in to participate.",
|
||||
"emptyError": "Fill in all fields.",
|
||||
"genericError": "Something went wrong. Please try again later."
|
||||
},
|
||||
"Admin": {
|
||||
"title": "Admin panel",
|
||||
"dashboard": "Dashboard",
|
||||
"news": "News",
|
||||
"manageNews": "Manage news",
|
||||
"newsTitle": "Title",
|
||||
"newsContent": "Content (HTML)",
|
||||
"newsLink": "Link (optional)",
|
||||
"create": "Create news",
|
||||
"creating": "Creating…",
|
||||
"delete": "Delete",
|
||||
"confirmDelete": "Delete this news item?",
|
||||
"noNews": "No news.",
|
||||
"forbidden": "You do not have permission to access here.",
|
||||
"emptyError": "Fill in the title and content.",
|
||||
"genericError": "Something went wrong."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,5 +265,22 @@
|
||||
"loginToPost": "Inicia sesión para participar.",
|
||||
"emptyError": "Completa todos los campos.",
|
||||
"genericError": "Algo ha salido mal. Inténtalo más tarde."
|
||||
},
|
||||
"Admin": {
|
||||
"title": "Panel de administración",
|
||||
"dashboard": "Panel",
|
||||
"news": "Noticias",
|
||||
"manageNews": "Gestionar noticias",
|
||||
"newsTitle": "Título",
|
||||
"newsContent": "Contenido (HTML)",
|
||||
"newsLink": "Enlace (opcional)",
|
||||
"create": "Crear noticia",
|
||||
"creating": "Creando…",
|
||||
"delete": "Borrar",
|
||||
"confirmDelete": "¿Borrar esta noticia?",
|
||||
"noNews": "No hay noticias.",
|
||||
"forbidden": "No tienes permiso para acceder aquí.",
|
||||
"emptyError": "Completa el título y el contenido.",
|
||||
"genericError": "Algo ha salido mal."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user