cached results and queries, add users and auth for admin

This commit is contained in:
Esteban Paz 2026-08-16 13:25:18 -05:00
parent 6787b3db50
commit 18a801a82e
22 changed files with 568 additions and 21 deletions

View File

@ -12,3 +12,12 @@ SUPABASE_URL="http://localhost:8000"
SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
SUPABASE_SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
POSTGRES_PASSWORD=postgres
# Admin auth — API key (para consumo externo de /api/admin/*)
ADMIN_API_KEY="CHANGE_ME_admin_api_key"
# Admin auth — credenciales del seed (solo se usan en scripts/seed-admin.ts)
ADMIN_EMAIL="admin@example.com"
ADMIN_USERNAME="admin"
ADMIN_PASSWORD="CHANGE_ME_password"
ADMIN_NOMBRE="Admin"

View File

@ -63,6 +63,7 @@ jobs:
TURNSTILE_SECRET_KEY="${{ secrets.TURNSTILE_SECRET_KEY }}"
CLOUDFLARE_API_TOKEN="${{ secrets.CLOUDFLARE_API_TOKEN }}"
CLOUDFLARE_ACCOUNT_ID="${{ secrets.CLOUDFLARE_ACCOUNT_ID }}"
ADMIN_API_KEY="${{ secrets.ADMIN_API_KEY }}"
ENVEOF
pm2 reload ecosystem.config.cjs --only ${{ env.APP_NAME }} --update-env || \
pm2 start ecosystem.config.cjs --only ${{ env.APP_NAME }} --update-env

View File

@ -0,0 +1,25 @@
-- AlterTable
ALTER TABLE "admins" ALTER COLUMN "auth_user_id" DROP NOT NULL;
-- AlterTable
ALTER TABLE "admins" ADD COLUMN "password_hash" TEXT;
-- CreateTable
CREATE TABLE "sessions" (
"id" UUID NOT NULL,
"token_hash" TEXT NOT NULL,
"admin_id" UUID NOT NULL,
"expires_at" TIMESTAMPTZ NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "sessions_token_hash_key" ON "sessions"("token_hash");
-- CreateIndex
CREATE INDEX "sessions_admin_id_idx" ON "sessions"("admin_id");
-- AddForeignKey
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_admin_id_fkey" FOREIGN KEY ("admin_id") REFERENCES "admins"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "admins" ADD COLUMN "username" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "admins_username_key" ON "admins"("username");

View File

@ -130,14 +130,31 @@ model FormVersion {
model Admin {
id String @id @default(uuid()) @db.Uuid
auth_user_id String @unique @db.Uuid
auth_user_id String? @unique @db.Uuid
email String @unique
username String? @unique
nombre String
password_hash String?
rol String @default("coordinador")
activo Boolean @default(true)
ultimo_acceso DateTime? @db.Timestamptz
created_at DateTime @default(now()) @db.Timestamptz
sessions Session[]
@@index([email])
@@map("admins")
}
model Session {
id String @id @default(uuid()) @db.Uuid
token_hash String @unique
admin_id String @db.Uuid
expires_at DateTime @db.Timestamptz
created_at DateTime @default(now()) @db.Timestamptz
admin Admin @relation(fields: [admin_id], references: [id], onDelete: Cascade)
@@index([admin_id])
@@map("sessions")
}

View File

@ -159,12 +159,14 @@ function translatePath(newLocale: string) {
}
{isAdmin && (
<li>
<a
class="hover:text-colorPrimary transition"
href="#"
>
Acceder
</a>
<form method="POST" action="/api/admin/auth/logout">
<button
type="submit"
class="w-full text-left text-white hover:text-colorPrimary transition cursor-pointer"
>
Salir
</button>
</form>
</li>
)}
</ul>
@ -215,12 +217,14 @@ function translatePath(newLocale: string) {
</div>
{isAdmin && (
<div class="hidden md:block">
<Button
class="px-4 py-2 uppercase"
title="Acceder"
url="#"
variant="secondary"
/>
<form method="POST" action="/api/admin/auth/logout">
<button
type="submit"
class="bg-[#22523F] text-[#EBE6D2] border-0 hover:bg-[#EBE6D2]/90 hover:text-tertiary uppercase rounded-none font-bold transition block text-center px-4 py-2 cursor-pointer"
>
Salir
</button>
</form>
</div>
)}
<div class="dropdown dropdown-end md:block hidden">

View File

@ -152,6 +152,7 @@
<script setup>
import { ref, computed, onMounted } from "vue";
import { cachedFetch } from "../../lib/adminCache";
const loading = ref(false);
const error = ref("");
@ -194,7 +195,7 @@ async function fetchStats() {
loading.value = true;
error.value = "";
try {
const res = await fetch("/api/admin/voluntarios/stats");
const res = await cachedFetch("/api/admin/voluntarios/stats", { ttl: 60 });
const json = await res.json();
if (!json.success) throw new Error(json.message || "Error al cargar estadísticas");
stats.value = json;

View File

@ -68,6 +68,7 @@
<script setup>
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
import { cachedFetch } from "../../lib/adminCache";
const props = defineProps({
field: { type: String, required: true },
@ -130,7 +131,7 @@ async function fetchOptions() {
params.set("limit", String(props.limit));
if (query.value.trim().length >= 2) params.set("q", query.value.trim());
try {
const res = await fetch(`/api/admin/voluntarios/distinct?${params.toString()}`);
const res = await cachedFetch(`/api/admin/voluntarios/distinct?${params.toString()}`, { ttl: 300 });
const json = await res.json();
if (version !== fetchVersion) return;
options.value = json.success ? json.data : [];

View File

@ -107,6 +107,7 @@
<script setup>
import { ref, computed, watch, onMounted } from "vue";
import { cachedFetch } from "../../lib/adminCache";
const props = defineProps({
numero: { type: [Number, String], required: true },
@ -234,7 +235,7 @@ async function fetchData() {
loading.value = true;
error.value = "";
try {
const res = await fetch(`/api/admin/voluntarios/${props.numero}`);
const res = await cachedFetch(`/api/admin/voluntarios/${props.numero}`, { ttl: 60 });
const json = await res.json();
if (!json.success) throw new Error(json.message || "Voluntario no encontrado");
data.value = json.data;

View File

@ -285,6 +285,7 @@
import { ref, reactive, computed, watch, onMounted, onUnmounted } from "vue";
import SearchableCombobox from "./SearchableCombobox.vue";
import VoluntarioDetail from "./VoluntarioDetail.vue";
import { cachedFetch } from "../../lib/adminCache";
const rows = ref([]);
const total = ref(0);
@ -484,7 +485,7 @@ async function fetchData() {
for (const f of responseFields) if (respFilters[f]) params.set(f, respFilters[f]);
try {
const res = await fetch(`/api/admin/voluntarios?${params.toString()}`);
const res = await cachedFetch(`/api/admin/voluntarios?${params.toString()}`, { ttl: 30 });
const json = await res.json();
if (!json.success) throw new Error(json.message || "Error al cargar");
rows.value = json.data;
@ -500,7 +501,7 @@ async function fetchData() {
async function fetchOptions() {
try {
const res = await fetch("/api/admin/voluntarios/options");
const res = await cachedFetch("/api/admin/voluntarios/options", { ttl: 300 });
const json = await res.json();
if (json.success) {
statuses.value = json.statuses;

63
src/lib/adminCache.ts Normal file
View File

@ -0,0 +1,63 @@
type CacheEntry = {
body: string;
status: number;
expiresAt: number;
};
const PREFIX = "adminCache:";
export interface CachedFetchOptions {
ttl?: number;
}
export async function cachedFetch(
url: string,
options: CachedFetchOptions = {}
): Promise<Response> {
const ttl = options.ttl ?? 60;
const key = PREFIX + url;
const now = Date.now();
try {
const raw = sessionStorage.getItem(key);
if (raw) {
const entry = JSON.parse(raw) as CacheEntry;
if (
entry &&
typeof entry.expiresAt === "number" &&
entry.expiresAt > now
) {
return new Response(entry.body, {
status: entry.status,
headers: { "Content-Type": "application/json" },
});
}
}
} catch {
// sessionStorage no disponible o entrada inválida
}
const res = await fetch(url);
if (res.ok) {
try {
const body = await res.text();
sessionStorage.setItem(
key,
JSON.stringify({
body,
status: res.status,
expiresAt: now + ttl * 1000,
})
);
return new Response(body, {
status: res.status,
headers: { "Content-Type": "application/json" },
});
} catch {
// no se pudo cachear, devolver la respuesta original
}
}
return res;
}

View File

@ -1,12 +1,51 @@
import { defineMiddleware } from "astro:middleware";
import { getSession, isApiAuthorized } from "./pages/api/lib/auth";
export const onRequest = defineMiddleware((context, next) => {
const { url, redirect } = context;
const { pathname } = url;
const LOCALES = new Set(["es", "en", "fr", "he", "uk", "pt", "ru", "rw", "kr"]);
export const onRequest = defineMiddleware(async (context, next) => {
const { url, request, redirect } = context;
const pathname = url.pathname;
if (pathname === "/admin" || pathname === "/admin/") {
return redirect("/es/admin", 302);
}
if (pathname.startsWith("/api/admin/")) {
const isAuthEndpoint =
pathname.endsWith("/auth/login") || pathname.endsWith("/auth/logout");
if (request.method !== "GET" && !isAuthEndpoint) {
return new Response(
JSON.stringify({ success: false, message: "Method not allowed" }),
{ status: 405, headers: { "Content-Type": "application/json" } }
);
}
if (!isAuthEndpoint && !(await isApiAuthorized(request))) {
return new Response(
JSON.stringify({ success: false, message: "Unauthorized" }),
{ status: 401, headers: { "Content-Type": "application/json" } }
);
}
return next();
}
const segments = pathname.split("/").filter(Boolean);
if (segments.length >= 2 && segments[1] === "admin") {
const locale = LOCALES.has(segments[0]) ? segments[0] : "es";
const isLogin = segments[2] === "login";
const session = await getSession(request);
if (!isLogin && !session) {
return redirect(`/${locale}/admin/login`, 302);
}
if (isLogin && session) {
return redirect(`/${locale}/admin`, 302);
}
}
return next();
});

View File

@ -0,0 +1,156 @@
---
import "../../../styles/global.css";
import "@fontsource/poppins/400.css";
import "@fontsource/poppins/500.css";
import "@fontsource/poppins/700.css";
import "@fontsource-variable/kameron";
import { Icon } from "astro-icon/components";
const { locale } = Astro.params;
const redirectTo = `/${locale}/admin`;
---
<!doctype html>
<html lang={locale} data-theme="cdrpj">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<title>Acceso administrativo</title>
</head>
<body class="font-primary">
<div class="min-h-screen flex">
<div
class="hidden lg:flex lg:w-1/2 relative flex-col justify-center items-center p-12 bg-gradient-to-br from-[#003421] to-[#22523F] text-colorPrimary overflow-hidden"
>
<img
src="/img/opacity-logo.png"
alt=""
aria-hidden="true"
class="absolute inset-0 h-full w-full object-cover opacity-15 mix-blend-luminosity pointer-events-none"
/>
<div class="relative z-10 flex flex-col items-center">
<div class="flex justify-center items-center gap-2 w-110 mb-10">
<img src=/img/logo-metalico.webp alt="Logo" class="w-32" />
<h1 class="font-secondary text-3xl xl:text-4xl font-bold text-colorSecondary mb-4 text-center">
Centro del Reino de Paz y Justicia
</h1>
</div>
<p class="text-lg text-colorPrimary/80 max-w-md text-center">
Panel de administración — acceso restringido
</p>
</div>
</div>
<div class="relative overflow-hidden flex-1 flex flex-col justify-center items-center p-8 bg-colorPrimary bg-gradient-to-br from-[#003421] to-[#22523F] lg:bg-none">
<img
src="/img/opacity-logo.png"
alt=""
aria-hidden="true"
class="absolute inset-0 h-full w-full object-cover opacity-15 mix-blend-luminosity pointer-events-none lg:hidden"
/>
<div class="relative z-10 w-full max-w-sm">
<div class="lg:hidden flex flex-col items-center mb-8">
<img src=/img/logo-metalico.webp alt="Logo" class="w-40 mb-4" />
</div>
<h2 class="font-secondary text-3xl font-bold text-colorPrimary lg:text-tertiary mb-1">
Iniciar sesión
</h2>
<p class="text-colorPrimary/80 lg:text-tertiary/70 mb-8">
Ingresa tus credenciales de administrador
</p>
<form id="login-form" class="space-y-4">
<div>
<label for="username" class="block text-sm font-medium text-colorPrimary lg:text-tertiary mb-1">
Usuario o correo
</label>
<input
id="username"
name="username"
type="text"
required
autocomplete="username"
class="input w-full bg-white border border-tertiary/20"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-colorPrimary lg:text-tertiary mb-1">
Contraseña
</label>
<div class="relative">
<input
id="password"
name="password"
type="password"
required
autocomplete="current-password"
class="input w-full bg-white border border-tertiary/20 pr-10"
/>
<button
type="button"
id="toggle-password"
aria-label="Mostrar u ocultar contraseña"
class="absolute right-2 top-1/2 -translate-y-1/2 text-tertiary hover:text-colorSecondary"
>
<Icon id="eye-open" name="ph:eye" class="w-5 h-5" />
<Icon id="eye-closed" name="ph:eye-slash" class="w-5 h-5 hidden" />
</button>
</div>
</div>
<p id="error" class="text-red-300 lg:text-error text-sm hidden"></p>
<button type="submit" class="btn btn-primary w-full">Entrar</button>
</form>
</div>
</div>
</div>
<script define:vars={{ redirectTo }}>
const form = document.getElementById("login-form");
const errorEl = document.getElementById("error");
const togglePassword = document.getElementById("toggle-password");
const passwordInput = document.getElementById("password");
const eyeOpen = document.getElementById("eye-open");
const eyeClosed = document.getElementById("eye-closed");
togglePassword.addEventListener("click", () => {
const show = passwordInput.type === "password";
passwordInput.type = show ? "text" : "password";
eyeOpen.classList.toggle("hidden", show);
eyeClosed.classList.toggle("hidden", !show);
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
errorEl.classList.add("hidden");
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
try {
const res = await fetch("/api/admin/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (res.ok) {
window.location.href = redirectTo;
return;
}
const data = await res.json().catch(() => ({}));
errorEl.textContent = data.message || "Credenciales inválidas";
errorEl.classList.remove("hidden");
} catch {
errorEl.textContent = "Error de conexión";
errorEl.classList.remove("hidden");
}
});
</script>
</body>
</html>

View File

@ -0,0 +1,64 @@
import type { APIRoute } from "astro";
import { prisma } from "../../lib/prisma";
import { createSession, sessionCookieFor, verifyPassword } from "../../lib/auth";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
const identifier = String(body?.username || body?.email || "")
.trim()
.toLowerCase();
const password = String(body?.password || "");
if (!identifier || !password) {
return Response.json(
{ success: false, message: "Credenciales requeridas" },
{ status: 400 }
);
}
const admin = await prisma.admin.findFirst({
where: {
OR: [
{ username: { equals: identifier, mode: "insensitive" } },
{ email: { equals: identifier, mode: "insensitive" } },
],
},
});
if (
!admin ||
!admin.activo ||
!admin.password_hash ||
!verifyPassword(password, admin.password_hash)
) {
return Response.json(
{ success: false, message: "Credenciales inválidas" },
{ status: 401 }
);
}
await prisma.admin.update({
where: { id: admin.id },
data: { ultimo_acceso: new Date() },
});
const token = await createSession(admin.id);
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Set-Cookie": sessionCookieFor(token),
},
});
} catch (error) {
console.error("Error en /api/admin/auth/login:", error);
return Response.json(
{ success: false, message: "Error en el login" },
{ status: 500 }
);
}
};

View File

@ -0,0 +1,16 @@
import type { APIRoute } from "astro";
import { clearSessionCookie, revokeSession } from "../../lib/auth";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
await revokeSession(request);
return new Response(null, {
status: 302,
headers: {
Location: "/",
"Set-Cookie": clearSessionCookie(),
},
});
};

View File

@ -237,6 +237,8 @@ export const GET: APIRoute = async ({ url }) => {
page,
limit,
totalPages: Math.ceil(total / limit),
}, {
headers: { "Cache-Control": "private, max-age=30, stale-while-revalidate=120" },
});
} catch (error) {
console.error("Error en /api/admin/voluntarios:", error);

View File

@ -86,6 +86,8 @@ export const GET: APIRoute = async ({ params }) => {
})),
},
form: { sections },
}, {
headers: { "Cache-Control": "private, max-age=60, stale-while-revalidate=120" },
});
} catch (error) {
console.error("Error en /api/admin/voluntarios/[numero]:", error);

View File

@ -45,6 +45,8 @@ export const GET: APIRoute = async ({ url }) => {
success: true,
field,
data: rows.map((r) => ({ value: r.value, count: r.count })),
}, {
headers: { "Cache-Control": "private, max-age=300, stale-while-revalidate=600" },
});
} catch (error) {
console.error("Error en /api/admin/voluntarios/distinct:", error);

View File

@ -45,6 +45,8 @@ export const GET: APIRoute = async () => {
.map((f) => f.form_version)
.filter((f): f is string => Boolean(f))
.sort(),
}, {
headers: { "Cache-Control": "private, max-age=300, stale-while-revalidate=600" },
});
} catch (error) {
console.error("Error en /api/admin/voluntarios/options:", error);

View File

@ -127,6 +127,8 @@ export const GET: APIRoute = async () => {
paises: formatPais,
statuses: statuses.map((s) => ({ value: s.status, count: s._count._all })),
condiciones: condicionesObj,
}, {
headers: { "Cache-Control": "private, max-age=60, stale-while-revalidate=300" },
});
} catch (error) {
console.error("Error en /api/admin/voluntarios/stats:", error);

129
src/pages/api/lib/auth.ts Normal file
View File

@ -0,0 +1,129 @@
import {
createHash,
randomBytes,
scryptSync,
timingSafeEqual,
} from "node:crypto";
import { prisma } from "./prisma";
const SESSION_COOKIE = "cdrdpyj_admin";
const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
export interface AuthSession {
adminId: string;
email: string;
nombre: string;
rol: string;
}
export function sessionCookieName(): string {
return SESSION_COOKIE;
}
export function hashPassword(password: string): string {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(password, salt, 64).toString("hex");
return `${salt}:${hash}`;
}
export function verifyPassword(password: string, stored: string): boolean {
const [salt, hash] = stored.split(":");
if (!salt || !hash) return false;
const test = scryptSync(password, salt, 64);
const ref = Buffer.from(hash, "hex");
return test.length === ref.length && timingSafeEqual(test, ref);
}
function sha256(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function timingSafeEqualStr(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
return ab.length === bb.length && timingSafeEqual(ab, bb);
}
export function sessionCookieFor(token: string): string {
const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
return `${SESSION_COOKIE}=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=${Math.floor(
SESSION_TTL_MS / 1000
)}${secure}`;
}
export function clearSessionCookie(): string {
const secure = process.env.NODE_ENV === "production" ? "; Secure" : "";
return `${SESSION_COOKIE}=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0${secure}`;
}
function readSessionToken(request: Request): string | null {
const cookie = request.headers.get("cookie");
if (!cookie) return null;
const entry = cookie
.split(";")
.map((c) => c.trim())
.find((c) => c.startsWith(`${SESSION_COOKIE}=`));
if (!entry) return null;
return entry.slice(SESSION_COOKIE.length + 1) || null;
}
export async function createSession(adminId: string): Promise<string> {
const token = randomBytes(32).toString("base64url");
await prisma.session.create({
data: {
token_hash: sha256(token),
admin_id: adminId,
expires_at: new Date(Date.now() + SESSION_TTL_MS),
},
});
return token;
}
export async function getSession(request: Request): Promise<AuthSession | null> {
const token = readSessionToken(request);
if (!token) return null;
const session = await prisma.session.findUnique({
where: { token_hash: sha256(token) },
include: { admin: true },
});
if (
!session ||
session.expires_at.getTime() <= Date.now() ||
!session.admin.activo
) {
return null;
}
return {
adminId: session.admin.id,
email: session.admin.email,
nombre: session.admin.nombre,
rol: session.admin.rol,
};
}
export async function revokeSession(request: Request): Promise<void> {
const token = readSessionToken(request);
if (!token) return;
try {
await prisma.session.delete({ where: { token_hash: sha256(token) } });
} catch {
// sesión inexistente, no hacer nada
}
}
export async function isApiAuthorized(request: Request): Promise<boolean> {
if (await getSession(request)) return true;
const apiKey = process.env.ADMIN_API_KEY;
if (!apiKey) return false;
const key =
request.headers.get("x-api-key") ??
request.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
if (!key) return false;
return timingSafeEqualStr(key, apiKey);
}

View File

@ -80,6 +80,11 @@ export async function sendEmailCf(data: Record<string, string>, to: string, type
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
if (!apiToken || !accountId) {
await sendEmail(data, to, type);
return;
}
try {
const client = new Cloudflare({ apiToken });
const { subject } = TEMPLATE_URLS[type];