cached results and queries, add users and auth for admin
This commit is contained in:
parent
6787b3db50
commit
18a801a82e
|
|
@ -12,3 +12,12 @@ SUPABASE_URL="http://localhost:8000"
|
||||||
SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
SUPABASE_ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE"
|
||||||
SUPABASE_SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
|
SUPABASE_SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q"
|
||||||
POSTGRES_PASSWORD=postgres
|
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"
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,7 @@ jobs:
|
||||||
TURNSTILE_SECRET_KEY="${{ secrets.TURNSTILE_SECRET_KEY }}"
|
TURNSTILE_SECRET_KEY="${{ secrets.TURNSTILE_SECRET_KEY }}"
|
||||||
CLOUDFLARE_API_TOKEN="${{ secrets.CLOUDFLARE_API_TOKEN }}"
|
CLOUDFLARE_API_TOKEN="${{ secrets.CLOUDFLARE_API_TOKEN }}"
|
||||||
CLOUDFLARE_ACCOUNT_ID="${{ secrets.CLOUDFLARE_ACCOUNT_ID }}"
|
CLOUDFLARE_ACCOUNT_ID="${{ secrets.CLOUDFLARE_ACCOUNT_ID }}"
|
||||||
|
ADMIN_API_KEY="${{ secrets.ADMIN_API_KEY }}"
|
||||||
ENVEOF
|
ENVEOF
|
||||||
pm2 reload ecosystem.config.cjs --only ${{ env.APP_NAME }} --update-env || \
|
pm2 reload ecosystem.config.cjs --only ${{ env.APP_NAME }} --update-env || \
|
||||||
pm2 start ecosystem.config.cjs --only ${{ env.APP_NAME }} --update-env
|
pm2 start ecosystem.config.cjs --only ${{ env.APP_NAME }} --update-env
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "admins" ADD COLUMN "username" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "admins_username_key" ON "admins"("username");
|
||||||
|
|
@ -130,14 +130,31 @@ model FormVersion {
|
||||||
|
|
||||||
model Admin {
|
model Admin {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
auth_user_id String @unique @db.Uuid
|
auth_user_id String? @unique @db.Uuid
|
||||||
email String @unique
|
email String @unique
|
||||||
|
username String? @unique
|
||||||
nombre String
|
nombre String
|
||||||
|
password_hash String?
|
||||||
rol String @default("coordinador")
|
rol String @default("coordinador")
|
||||||
activo Boolean @default(true)
|
activo Boolean @default(true)
|
||||||
ultimo_acceso DateTime? @db.Timestamptz
|
ultimo_acceso DateTime? @db.Timestamptz
|
||||||
created_at DateTime @default(now()) @db.Timestamptz
|
created_at DateTime @default(now()) @db.Timestamptz
|
||||||
|
|
||||||
|
sessions Session[]
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
@@map("admins")
|
@@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")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -159,12 +159,14 @@ function translatePath(newLocale: string) {
|
||||||
}
|
}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<li>
|
<li>
|
||||||
<a
|
<form method="POST" action="/api/admin/auth/logout">
|
||||||
class="hover:text-colorPrimary transition"
|
<button
|
||||||
href="#"
|
type="submit"
|
||||||
>
|
class="w-full text-left text-white hover:text-colorPrimary transition cursor-pointer"
|
||||||
Acceder
|
>
|
||||||
</a>
|
Salir
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -215,12 +217,14 @@ function translatePath(newLocale: string) {
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div class="hidden md:block">
|
<div class="hidden md:block">
|
||||||
<Button
|
<form method="POST" action="/api/admin/auth/logout">
|
||||||
class="px-4 py-2 uppercase"
|
<button
|
||||||
title="Acceder"
|
type="submit"
|
||||||
url="#"
|
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"
|
||||||
variant="secondary"
|
>
|
||||||
/>
|
Salir
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div class="dropdown dropdown-end md:block hidden">
|
<div class="dropdown dropdown-end md:block hidden">
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,7 @@
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
|
import { cachedFetch } from "../../lib/adminCache";
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
|
|
@ -194,7 +195,7 @@ async function fetchStats() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/voluntarios/stats");
|
const res = await cachedFetch("/api/admin/voluntarios/stats", { ttl: 60 });
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (!json.success) throw new Error(json.message || "Error al cargar estadísticas");
|
if (!json.success) throw new Error(json.message || "Error al cargar estadísticas");
|
||||||
stats.value = json;
|
stats.value = json;
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
||||||
|
import { cachedFetch } from "../../lib/adminCache";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
field: { type: String, required: true },
|
field: { type: String, required: true },
|
||||||
|
|
@ -130,7 +131,7 @@ async function fetchOptions() {
|
||||||
params.set("limit", String(props.limit));
|
params.set("limit", String(props.limit));
|
||||||
if (query.value.trim().length >= 2) params.set("q", query.value.trim());
|
if (query.value.trim().length >= 2) params.set("q", query.value.trim());
|
||||||
try {
|
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();
|
const json = await res.json();
|
||||||
if (version !== fetchVersion) return;
|
if (version !== fetchVersion) return;
|
||||||
options.value = json.success ? json.data : [];
|
options.value = json.success ? json.data : [];
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted } from "vue";
|
import { ref, computed, watch, onMounted } from "vue";
|
||||||
|
import { cachedFetch } from "../../lib/adminCache";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
numero: { type: [Number, String], required: true },
|
numero: { type: [Number, String], required: true },
|
||||||
|
|
@ -234,7 +235,7 @@ async function fetchData() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
try {
|
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();
|
const json = await res.json();
|
||||||
if (!json.success) throw new Error(json.message || "Voluntario no encontrado");
|
if (!json.success) throw new Error(json.message || "Voluntario no encontrado");
|
||||||
data.value = json.data;
|
data.value = json.data;
|
||||||
|
|
|
||||||
|
|
@ -285,6 +285,7 @@
|
||||||
import { ref, reactive, computed, watch, onMounted, onUnmounted } from "vue";
|
import { ref, reactive, computed, watch, onMounted, onUnmounted } from "vue";
|
||||||
import SearchableCombobox from "./SearchableCombobox.vue";
|
import SearchableCombobox from "./SearchableCombobox.vue";
|
||||||
import VoluntarioDetail from "./VoluntarioDetail.vue";
|
import VoluntarioDetail from "./VoluntarioDetail.vue";
|
||||||
|
import { cachedFetch } from "../../lib/adminCache";
|
||||||
|
|
||||||
const rows = ref([]);
|
const rows = ref([]);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
|
|
@ -484,7 +485,7 @@ async function fetchData() {
|
||||||
for (const f of responseFields) if (respFilters[f]) params.set(f, respFilters[f]);
|
for (const f of responseFields) if (respFilters[f]) params.set(f, respFilters[f]);
|
||||||
|
|
||||||
try {
|
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();
|
const json = await res.json();
|
||||||
if (!json.success) throw new Error(json.message || "Error al cargar");
|
if (!json.success) throw new Error(json.message || "Error al cargar");
|
||||||
rows.value = json.data;
|
rows.value = json.data;
|
||||||
|
|
@ -500,7 +501,7 @@ async function fetchData() {
|
||||||
|
|
||||||
async function fetchOptions() {
|
async function fetchOptions() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/voluntarios/options");
|
const res = await cachedFetch("/api/admin/voluntarios/options", { ttl: 300 });
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (json.success) {
|
if (json.success) {
|
||||||
statuses.value = json.statuses;
|
statuses.value = json.statuses;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,51 @@
|
||||||
import { defineMiddleware } from "astro:middleware";
|
import { defineMiddleware } from "astro:middleware";
|
||||||
|
import { getSession, isApiAuthorized } from "./pages/api/lib/auth";
|
||||||
|
|
||||||
export const onRequest = defineMiddleware((context, next) => {
|
const LOCALES = new Set(["es", "en", "fr", "he", "uk", "pt", "ru", "rw", "kr"]);
|
||||||
const { url, redirect } = context;
|
|
||||||
const { pathname } = url;
|
export const onRequest = defineMiddleware(async (context, next) => {
|
||||||
|
const { url, request, redirect } = context;
|
||||||
|
const pathname = url.pathname;
|
||||||
|
|
||||||
if (pathname === "/admin" || pathname === "/admin/") {
|
if (pathname === "/admin" || pathname === "/admin/") {
|
||||||
return redirect("/es/admin", 302);
|
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();
|
return next();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -237,6 +237,8 @@ export const GET: APIRoute = async ({ url }) => {
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
totalPages: Math.ceil(total / limit),
|
totalPages: Math.ceil(total / limit),
|
||||||
|
}, {
|
||||||
|
headers: { "Cache-Control": "private, max-age=30, stale-while-revalidate=120" },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error en /api/admin/voluntarios:", error);
|
console.error("Error en /api/admin/voluntarios:", error);
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,8 @@ export const GET: APIRoute = async ({ params }) => {
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
form: { sections },
|
form: { sections },
|
||||||
|
}, {
|
||||||
|
headers: { "Cache-Control": "private, max-age=60, stale-while-revalidate=120" },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error en /api/admin/voluntarios/[numero]:", error);
|
console.error("Error en /api/admin/voluntarios/[numero]:", error);
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ export const GET: APIRoute = async ({ url }) => {
|
||||||
success: true,
|
success: true,
|
||||||
field,
|
field,
|
||||||
data: rows.map((r) => ({ value: r.value, count: r.count })),
|
data: rows.map((r) => ({ value: r.value, count: r.count })),
|
||||||
|
}, {
|
||||||
|
headers: { "Cache-Control": "private, max-age=300, stale-while-revalidate=600" },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error en /api/admin/voluntarios/distinct:", error);
|
console.error("Error en /api/admin/voluntarios/distinct:", error);
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,8 @@ export const GET: APIRoute = async () => {
|
||||||
.map((f) => f.form_version)
|
.map((f) => f.form_version)
|
||||||
.filter((f): f is string => Boolean(f))
|
.filter((f): f is string => Boolean(f))
|
||||||
.sort(),
|
.sort(),
|
||||||
|
}, {
|
||||||
|
headers: { "Cache-Control": "private, max-age=300, stale-while-revalidate=600" },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error en /api/admin/voluntarios/options:", error);
|
console.error("Error en /api/admin/voluntarios/options:", error);
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,8 @@ export const GET: APIRoute = async () => {
|
||||||
paises: formatPais,
|
paises: formatPais,
|
||||||
statuses: statuses.map((s) => ({ value: s.status, count: s._count._all })),
|
statuses: statuses.map((s) => ({ value: s.status, count: s._count._all })),
|
||||||
condiciones: condicionesObj,
|
condiciones: condicionesObj,
|
||||||
|
}, {
|
||||||
|
headers: { "Cache-Control": "private, max-age=60, stale-while-revalidate=300" },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error en /api/admin/voluntarios/stats:", error);
|
console.error("Error en /api/admin/voluntarios/stats:", error);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -80,6 +80,11 @@ export async function sendEmailCf(data: Record<string, string>, to: string, type
|
||||||
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
|
const apiToken = process.env.CLOUDFLARE_API_TOKEN;
|
||||||
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
|
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID;
|
||||||
|
|
||||||
|
if (!apiToken || !accountId) {
|
||||||
|
await sendEmail(data, to, type);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = new Cloudflare({ apiToken });
|
const client = new Cloudflare({ apiToken });
|
||||||
const { subject } = TEMPLATE_URLS[type];
|
const { subject } = TEMPLATE_URLS[type];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue