From dea268d9759bfcbbcfbf635f379e67f94469796c Mon Sep 17 00:00:00 2001 From: Lak-G Date: Thu, 27 Aug 2026 10:18:04 -0700 Subject: [PATCH] v1.7: overlay carga, baneos, carrito persistente, fix upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lista negra: - Migración 0007: tabla baneos con historial (soporta expires_at futuro) - Helper is_banned() usado por middleware - Middleware guard: user baneado → rewrite /banned (admin exento) - Página /banned con razón/fecha/expires + botón signout - Panel /admin/usuarios con banear/desbanear (razón obligatoria, self-ban bloqueado) - Nav admin nueva entrada Usuarios + icono users Fix upload de fotos (bug definitivo): - Root cause: browserClient no autentica al Storage (cookies httpOnly no legibles desde JS) - Nuevos endpoints proxy /api/upload/material-foto/[id] y /avatar - Validan sesión con cookie httpOnly, suben con service_role (bypass RLS) - Refactor MaterialForm y PerfilForm para usar fetch multipart - Cookies httpOnly intactas (cero riesgo XSS) Overlay de carga: - Layout.astro con #page-loader (backdrop-filter blur + spinner mono) - Script inline captura clicks en y submits de
mismo origen - Filtros: modifier keys, target=_blank, anchor#, cross-origin, defaultPrevented - Respeta prefers-reduced-motion; pageshow limpia por bfcache Carrito persistente + vaciar: - SolicitudCart: useEffect hidrata/persiste labre:cart:v1 en localStorage - Guard cartHydrated evita pisar en montaje inicial - Botón "Vaciar" en checkout con window.confirm (solo con perfilCompleto) Filtro inventario sin auto-submit: - Removido script debounced que hacía form.submit() en cada tecla - Botón "Filtrar" existente sigue funcionando --- .../admin/inventario/MaterialForm.tsx | 43 ++-- src/components/admin/usuarios/BanearForm.tsx | 175 ++++++++++++++++ .../admin/usuarios/DesbanearButton.tsx | 43 ++++ src/components/alumno/SolicitudCart.tsx | 60 +++++- src/components/profile/PerfilForm.tsx | 28 ++- src/layouts/AppLayout.astro | 2 + src/layouts/Layout.astro | 65 ++++++ src/middleware.ts | 11 + src/pages/admin/inventario/index.astro | 15 -- src/pages/admin/usuarios.astro | 188 ++++++++++++++++++ src/pages/api/admin/baneos/[id]/desbanear.ts | 30 +++ src/pages/api/admin/baneos/index.ts | 67 +++++++ src/pages/api/upload/avatar.ts | 58 ++++++ src/pages/api/upload/material-foto/[id].ts | 67 +++++++ src/pages/banned.astro | 85 ++++++++ 15 files changed, 870 insertions(+), 67 deletions(-) create mode 100644 src/components/admin/usuarios/BanearForm.tsx create mode 100644 src/components/admin/usuarios/DesbanearButton.tsx create mode 100644 src/pages/admin/usuarios.astro create mode 100644 src/pages/api/admin/baneos/[id]/desbanear.ts create mode 100644 src/pages/api/admin/baneos/index.ts create mode 100644 src/pages/api/upload/avatar.ts create mode 100644 src/pages/api/upload/material-foto/[id].ts create mode 100644 src/pages/banned.astro diff --git a/src/components/admin/inventario/MaterialForm.tsx b/src/components/admin/inventario/MaterialForm.tsx index c54e097..5ec34f6 100644 --- a/src/components/admin/inventario/MaterialForm.tsx +++ b/src/components/admin/inventario/MaterialForm.tsx @@ -1,5 +1,4 @@ import { useEffect, useRef, useState } from 'react'; -import { browserClient } from '@/lib/supabase'; import { imgUrl } from '@/lib/materialImg'; import { toast } from '@/lib/toast'; import UnidadesManager from './UnidadesManager'; @@ -25,8 +24,6 @@ type Props = { compact?: boolean; }; -const BUCKET = 'materiales-fotos'; - export default function MaterialForm({ mode, material, categorias, compact }: Props) { const dialogRef = useRef(null); const firstFieldRef = useRef(null); @@ -99,18 +96,16 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr if (error) errorRef.current?.focus(); }, [error]); - async function uploadFotoFor(id: number): Promise { - if (!file) return null; - const ext = (file.name.split('.').pop() ?? 'jpg').toLowerCase().replace(/[^a-z0-9]/g, ''); - const path = `${id}/${Date.now()}.${ext || 'jpg'}`; - const { error: upErr } = await browserClient() - .storage.from(BUCKET) - .upload(path, file, { upsert: true, contentType: file.type || undefined }); - if (upErr) { - toast({ kind: 'error', title: 'La foto no se subió', description: 'El material se guardó sin foto.' }); + async function uploadFotoFor(materialId: number, f: File): Promise { + const fd = new FormData(); + fd.append('file', f); + const res = await fetch(`/api/upload/material-foto/${materialId}`, { method: 'POST', body: fd }); + const json = await res.json().catch(() => ({})); + if (!res.ok) { + toast({ kind: 'error', title: 'La foto no se subió', description: json?.error ?? 'El material se guardó sin foto.' }); return null; } - return path; + return json.path as string; } const submit = async (e: React.FormEvent) => { @@ -151,26 +146,14 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr const json = await res.json().catch(() => ({})); if (!res.ok) throw new Error(json?.error ?? 'No se pudo crear'); - // 2) subir foto (si hay) y PATCH imagen_path + // 2) subir foto (si hay) — el endpoint ya persiste imagen_path if (file && json.id) { - const path = await uploadFotoFor(json.id); - if (path) { - const p = await fetch(`/api/admin/materiales/${json.id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ imagen_path: path }), - }); - if (!p.ok) { - toast({ kind: 'error', title: 'Foto subida pero no vinculada', description: 'Recarga y vuelve a intentar.' }); - } - } + await uploadFotoFor(json.id, file); } } else { - // edit — sube nueva foto primero (si hay) y luego PATCH con el path incluido - let newImagenPath: string | null | undefined = undefined; + // edit — sube nueva foto primero (si hay); el endpoint persiste imagen_path if (file && material) { - const path = await uploadFotoFor(material.id); - if (path) newImagenPath = path; + await uploadFotoFor(material.id, file); } const patch: Record = { nombre: nombreT, @@ -181,7 +164,7 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr trackeado_por_unidad: trackeado, }; if (!trackeado) patch.cantidad_total = cantidad; - if (newImagenPath !== undefined) patch.imagen_path = newImagenPath; + // ponytail: si el user quitó la foto (imagenPath=null y sin file), no lo PATCHeamos aquí — ese flow ya está fuera de scope de este fix const res = await fetch(`/api/admin/materiales/${material!.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, diff --git a/src/components/admin/usuarios/BanearForm.tsx b/src/components/admin/usuarios/BanearForm.tsx new file mode 100644 index 0000000..a42d6f6 --- /dev/null +++ b/src/components/admin/usuarios/BanearForm.tsx @@ -0,0 +1,175 @@ +import { useEffect, useRef, useState } from 'react'; +import { toastAfterReload } from '@/lib/toast'; + +type Props = { + profile: { id: string; nombre?: string | null; email: string }; + adminSelf: boolean; +}; + +export default function BanearForm({ profile, adminSelf }: Props) { + const dialogRef = useRef(null); + const firstFieldRef = useRef(null); + const errorRef = useRef(null); + const [razon, setRazon] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const titleId = `banear-${profile.id}`; + const label = profile.nombre?.trim() || profile.email; + + const open = () => { + if (adminSelf) return; + setError(null); + setRazon(''); + dialogRef.current?.showModal(); + queueMicrotask(() => firstFieldRef.current?.focus()); + }; + const close = () => { + if (loading) return; + dialogRef.current?.close(); + }; + + useEffect(() => { + const dlg = dialogRef.current; + if (!dlg) return; + const onClick = (e: MouseEvent) => { + if (e.target === dlg) close(); + }; + dlg.addEventListener('click', onClick); + return () => dlg.removeEventListener('click', onClick); + }); + + useEffect(() => { + if (error) errorRef.current?.focus(); + }, [error]); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (loading) return; + const trimmed = razon.trim(); + if (trimmed.length < 5) { + setError('La razón debe tener al menos 5 caracteres'); + return; + } + setLoading(true); + setError(null); + try { + const res = await fetch('/api/admin/baneos', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ profile_id: profile.id, razon: trimmed }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(json?.error ?? 'No se pudo banear'); + toastAfterReload({ + title: 'Usuario baneado', + description: label, + kind: 'success', + }); + dialogRef.current?.close(); + location.reload(); + } catch (err) { + setError(err instanceof Error ? err.message : 'Error inesperado'); + } finally { + setLoading(false); + } + }; + + return ( + <> + + + + +
+

+ Banear a {label} +

+ +
+ +

+ El usuario no podrá acceder al sistema hasta ser desbaneado. +

+ +
+ +