From d5b00a86dc4d2a33e8942b1bbf69caedaa302466 Mon Sep 17 00:00:00 2001 From: adevopg Date: Sun, 12 Jul 2026 23:55:34 +0000 Subject: [PATCH] =?UTF-8?q?Panel=20admin=20propio:=20gesti=C3=B3n=20de=20n?= =?UTF-8?q?oticias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- web-next/app/[locale]/admin/news/page.tsx | 24 +++++++ web-next/app/[locale]/admin/page.tsx | 28 ++++++++ web-next/app/api/admin/news/[id]/route.ts | 11 +++ web-next/app/api/admin/news/route.ts | 16 +++++ web-next/components/AdminNewsManager.tsx | 83 +++++++++++++++++++++++ web-next/lib/admin-news.ts | 33 +++++++++ web-next/lib/admin.ts | 27 ++++++++ web-next/messages/en.json | 17 +++++ web-next/messages/es.json | 17 +++++ 9 files changed, 256 insertions(+) create mode 100644 web-next/app/[locale]/admin/news/page.tsx create mode 100644 web-next/app/[locale]/admin/page.tsx create mode 100644 web-next/app/api/admin/news/[id]/route.ts create mode 100644 web-next/app/api/admin/news/route.ts create mode 100644 web-next/components/AdminNewsManager.tsx create mode 100644 web-next/lib/admin-news.ts create mode 100644 web-next/lib/admin.ts diff --git a/web-next/app/[locale]/admin/news/page.tsx b/web-next/app/[locale]/admin/news/page.tsx new file mode 100644 index 0000000..d824b23 --- /dev/null +++ b/web-next/app/[locale]/admin/news/page.tsx @@ -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 ( +
+

{t('manageNews')}

+ +
+ ) +} diff --git a/web-next/app/[locale]/admin/page.tsx b/web-next/app/[locale]/admin/page.tsx new file mode 100644 index 0000000..393439b --- /dev/null +++ b/web-next/app/[locale]/admin/page.tsx @@ -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 ( +
+

{t('title')}

+
    +
  • + + {t('manageNews')} + +
  • +
+
+ ) +} diff --git a/web-next/app/api/admin/news/[id]/route.ts b/web-next/app/api/admin/news/[id]/route.ts new file mode 100644 index 0000000..c02681e --- /dev/null +++ b/web-next/app/api/admin/news/[id]/route.ts @@ -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 }) +} diff --git a/web-next/app/api/admin/news/route.ts b/web-next/app/api/admin/news/route.ts new file mode 100644 index 0000000..03540bf --- /dev/null +++ b/web-next/app/api/admin/news/route.ts @@ -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 = {} + 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 }) +} diff --git a/web-next/components/AdminNewsManager.tsx b/web-next/components/AdminNewsManager.tsx new file mode 100644 index 0000000..931be77 --- /dev/null +++ b/web-next/components/AdminNewsManager.tsx @@ -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(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 ( +
+
+ setTitulo(e.target.value)} placeholder={t('newsTitle')} maxLength={200} className={field} /> +