Compare commits

...

6 Commits

Author SHA1 Message Date
LakG 800ec60a53 Cambios verificados (working tree previamente probado)
- src/lib/text.ts: helper truncate() reutilizable.
- SolicitudCart, DesbanearButton, vistas admin y endpoint solicitudes:
  ajustes usando el helper.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-09 09:04:38 -07:00
LakG 3cccf758a8 Bitácora: v1.7 en producción (overlay + baneos + carrito persistente + fix upload) 2026-08-27 10:23:16 -07:00
LakG dea268d975 v1.7: overlay carga, baneos, carrito persistente, fix upload
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 <a href> y submits de <form> 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
2026-08-27 10:18:04 -07:00
LakG c43f45eca1 Migración 0007 v1.7: tabla baneos + helper is_banned() 2026-08-27 10:18:04 -07:00
LakG 3e576426e1 Fix: guard typeof process en supabase.ts para no romper hidratación
Sin este guard el bundle de browser (islands que importan browserClient)
evalúa process.env al importar el módulo y revienta con ReferenceError,
tumbando la hidratación de PerfilForm y MaterialForm. En producción esto
provocaba que los botones "Guardar" hicieran submit nativo del <form>
sin action, cayendo en /login en vez de disparar el endpoint.

El orden process → import.meta.env se mantiene para el server (necesario
por el fix de Docker que no expone .env.production durante docker build).

Bitácora: primera ronda de QA manual con agent-browser post v1.6.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-26 07:35:32 -07:00
LakG fe1ef7b70d Bitácora: v1.6 en producción (docente + combobox + sonido + wrap + self-heal fix) 2026-08-25 11:39:52 -07:00
22 changed files with 1077 additions and 118 deletions
+39
View File
@@ -8,6 +8,10 @@ astro dev --background
Manage the background server with `astro dev stop`, `astro dev status`, and `astro dev logs`. Manage the background server with `astro dev stop`, `astro dev status`, and `astro dev logs`.
## Testing
Todo testing manual en el navegador (probar un flujo, verificar un fix visual, QA de una feature) se hace con la skill **agent-browser**, no con curl ni asunciones. Usar el bypass temporal `?preview=alumno|admin` del middleware (solo activo en `DEV`) para simular sesión sin hacer login real, y revertirlo antes de terminar.
## Documentation ## Documentation
Full documentation: https://docs.astro.build Full documentation: https://docs.astro.build
@@ -240,3 +244,38 @@ Fases 3, 4/5 y 6 tocan carpetas de rutas distintas (`src/pages/alumno/*`, `src/p
- **Aprendizajes registrados**: (a) los agentes fallando por límite de sesión son un modo de degradación normal cuando el trabajo por agente es grande — la mitigación es dividir mejor los tracks (agentes P y H fueron los más grandes, ambos cortaron); alternativa: preparar prompts para que cada agente escriba archivos incrementalmente y el orquestador pueda completar los huecos con menos contexto; (b) Docker Compose lee `.env` **implícitamente** para expansión de `${VAR}` en el YAML pero NO lee `env_file` para expansión — para vars en `build.args`, o bien renombras `.env.production` a `.env`, o exportas al shell antes del compose (elegimos lo segundo, menos disruptivo con la config existente); (c) `Storage RLS` con path por carpeta = uid es el patrón limpio para "cada quien sube la suya" — `(storage.foldername(name))[1] = auth.uid()::text` en el `using`/`with check`, no necesita RPC ni endpoint intermedio; (d) para RPCs con fallback lógico (como `crear_solicitud` autocompletando el tutor), poner la lógica en la BD y no en el endpoint es más robusto porque el mismo comportamiento aplica si algún día se llama la RPC desde otro cliente; (e) `CLAUDE.md` es symlink a `AGENTS.md` (aprendido en la sesión anterior, sigue vigente — editar el path real). - **Aprendizajes registrados**: (a) los agentes fallando por límite de sesión son un modo de degradación normal cuando el trabajo por agente es grande — la mitigación es dividir mejor los tracks (agentes P y H fueron los más grandes, ambos cortaron); alternativa: preparar prompts para que cada agente escriba archivos incrementalmente y el orquestador pueda completar los huecos con menos contexto; (b) Docker Compose lee `.env` **implícitamente** para expansión de `${VAR}` en el YAML pero NO lee `env_file` para expansión — para vars en `build.args`, o bien renombras `.env.production` a `.env`, o exportas al shell antes del compose (elegimos lo segundo, menos disruptivo con la config existente); (c) `Storage RLS` con path por carpeta = uid es el patrón limpio para "cada quien sube la suya" — `(storage.foldername(name))[1] = auth.uid()::text` en el `using`/`with check`, no necesita RPC ni endpoint intermedio; (d) para RPCs con fallback lógico (como `crear_solicitud` autocompletando el tutor), poner la lógica en la BD y no en el endpoint es más robusto porque el mismo comportamiento aplica si algún día se llama la RPC desde otro cliente; (e) `CLAUDE.md` es symlink a `AGENTS.md` (aprendido en la sesión anterior, sigue vigente — editar el path real).
- **Deliberadamente NO tocado**: horarios académicos automáticos (fuera de alcance explícito — usuario eligió textarea manual); notificaciones al maestro cuando se aprueba un vale (backlog); comprimir avatares antes de subir (si pesan mucho se agrega después); PWA y browser notifications (backlog viejo). - **Deliberadamente NO tocado**: horarios académicos automáticos (fuera de alcance explícito — usuario eligió textarea manual); notificaciones al maestro cuando se aprueba un vale (backlog); comprimir avatares antes de subir (si pesan mucho se agrega después); PWA y browser notifications (backlog viejo).
- **Deploy completado**: **sistema en producción con perfil + onboarding + home reformulado + maestros CRUD + upload arreglado + 5 bugs mobile arreglados en https://prestamos.buglabs.dev**. - **Deploy completado**: **sistema en producción con perfil + onboarding + home reformulado + maestros CRUD + upload arreglado + 5 bugs mobile arreglados en https://prestamos.buglabs.dev**.
- **2026-08-25 — v1.6: rol docente + combobox maestros/tutores + sonido notif + fecha 1 día + wrap admin + fix self-heal profile**. Iteración operativa reportada por usuarios reales del laboratorio, más un bug crítico de login descubierto la misma sesión.
- **Bug crítico previo (self-heal)**: usuarios `@uabc.edu.mx` haciendo login vía OAuth entraban con `user` pero **sin fila en `prestamos.profiles`** — la app los trataba como "no logueado". Root cause: coexisten 2 triggers `AFTER INSERT` en `auth.users`: `on_auth_user_created` (del proyecto vecino que usa `public.profiles`) y `prestamos_on_auth_user_created` (nuestro). El del vecino se ejecuta primero por orden alfabético y por razón no diagnosticada el nuestro no dispara consistentemente en producción — verificado que la función `prestamos.handle_new_user()` funciona bien manualmente (insert vía backfill regresó 8 filas de 8 users). Fix aplicado: (a) migración 0005 con policy `profiles_insert_self` (with check `id = auth.uid() and rol = 'alumno'` — impide auto-promoción a admin); (b) middleware.ts con self-heal: si `user` existe y `!profile` y email `@uabc.edu.mx`, hace `upsert` al vuelo con nombre desde `user_metadata.full_name`; (c) backfill manual en prod para los 8 users existentes. Aprendizaje: cuando 2 apps comparten una instancia de Supabase, cualquier trigger `AFTER INSERT` en `auth.users` es riesgoso — mejor no depender del trigger y hacer self-heal server-side desde el middleware. En 0006 se amplió la policy a `rol in ('alumno','docente')`.
- **Decisiones acordadas con el usuario antes de tocar código**: (1) lista de maestros = admin la va agregando manualmente desde `/admin/maestros` (no seed inicial masivo — la lista suele ser estable); (2) separar tutores de maestros = **flag `es_tutor` en la misma tabla** — todos los maestros aparecen en el combobox del checkout; solo los marcados con `es_tutor=true` aparecen en el select de tutor del perfil (mismo CRUD, un checkbox extra); (3) docente = **`profiles.matricula` reusada con label dinámico** "Matrícula" para alumno / "Número de empleado" para docente (cero columna nueva, cero migración de datos); (4) errores sha512/CORS de `beacon.min.js` = **el usuario los desactiva en Cloudflare dashboard** (Analytics & Logs → Web Analytics → toggle off para el dominio), no requiere código.
- **Migración `0006_docente_maestros_v16.sql`** aplicada limpia (`BEGIN`/`COMMIT` propios, mismo patrón que 0001-0005): `profiles.rol` check ahora acepta `'alumno'|'docente'|'admin'`; `maestros.es_tutor boolean not null default false` con `update ... where nombre='Sin especificar'` para marcarlo como tutor fallback (no romper flows existentes); policy `profiles_insert_self` reescrita ahora permite `rol in ('alumno','docente')`; RPC `crear_solicitud` **cambio de firma** de `(text, text, jsonb)` a `(int, text, jsonb)` — recibe `p_maestro_id` en vez de texto libre, y la lógica es: si rol='docente' usa el propio `profiles.nombre` como `maestro_responsable`; si alumno con `p_maestro_id` valida y guarda ese nombre; si alumno sin `p_maestro_id` cae al `tutor_id` del perfil; si tampoco hay tutor, raise `maestro_responsable_requerido`. El drop de la firma vieja es explícito con `drop function if exists prestamos.crear_solicitud(text, text, jsonb)`.
- **Ejecución**: 1 agente Explore para background (falló por límite en la ronda anterior — esta vez no hizo falta explorar), 1 agente en paralelo para el track más grande (perfil + onboarding + `es_tutor` en MaestroForm/endpoints/maestros.astro), y el orquestador hizo el resto directo en el hilo principal (endpoint `/api/solicitudes` con `maestro_id`, `catalogo.astro` con SSR de maestros + `esDocente`, `SolicitudCart.tsx` con `<select>` combobox + guard docente + `maxLength=250` en notas, wrap `break-words` en 4 vistas admin, sonido en `BadgeSolicitudes.tsx`, mover audio a `public/audio/`, fecha default `enDias(1)` en `AccionesSolicitud.tsx`). El agente completó su track limpio, cero conflictos con lo que hice.
- **UX del combobox maestros**: el `<option value="">` inicial muestra "— Usar mi tutor ({tutorNombre}) —" (o "— Elige un maestro —" si no tiene tutor). El fallback backend sigue activo: si el alumno no elige nadie, la RPC usa el `tutor_id` del perfil. Si escoge otro, guarda ese nombre. Doble defensa contra "diferencia de escritura entre humanos" (la razón que dio el usuario para pedir el combobox).
- **UX del docente**: en el checkout, el bloque de maestro simplemente NO se renderiza (`{!esDocente && ...}`); el `perfilCompleto` es `!!matricula` (sin tutor). En perfil/onboarding, semestre y tutor no aparecen; label matrícula → "Número de empleado"; badge del header muestra "Docente". Registro de docente: el admin promueve manualmente vía SQL o desde Studio (`update prestamos.profiles set rol='docente' where email=...`), igual patrón que admin. No hay UI para auto-registrarse como docente — evita abuso.
- **Sonido de notificación**: archivo movido de `src/audio/` (Astro no sirve archivos de `src/` estáticamente) a `public/audio/sonido_notificacion.mp3` — accesible en `/audio/sonido_notificacion.mp3` (verificado 200 `audio/mpeg` post-deploy). `BadgeSolicitudes.tsx` en el handler INSERT: `new Audio('/audio/sonido_notificacion.mp3').play().catch(() => {})` — el catch cubre la política de autoplay (muchos navegadores bloquean sonido sin interacción previa; como el admin ya interactuó al hacer login, en la práctica funciona).
- **Fecha default 1 día**: cambio de una línea en `AccionesSolicitud.tsx:44` (`useState(enDias(7))` → `useState(enDias(1))`). El admin puede cambiarla si quiere; el default solo era muy generoso.
- **Fix del desbordamiento**: `maxLength={500}` → `250` en el textarea del motivo en `SolicitudCart.tsx`, mismo cap aplicado en el backend (`.slice(0, 250)` en `/api/solicitudes/index.ts` como defensa). Wrap con `break-words` (Tailwind = `overflow-wrap: break-word`) agregado en cada renglón donde aparece `Maestro:` o `Motivo:` en las 3 vistas admin (index/activos/historial, tanto tabla desktop como cards mobile) y en el `<dd>` de `VerDetalles.tsx`. El `notas` de `VerDetalles` ya tenía `whitespace-pre-wrap` — quedó intacto.
- **Verificación**: `npm run build` limpio (1.52s). Deploy: 2 commits (migración + código), push con token efímero, `ssh buglabs '... set -a && . .env.production && set +a && docker compose up -d --build'` (mismo patrón que 0004). Container healthy en 23s. Smoke test post-deploy: `/login` → 200; `/audio/sonido_notificacion.mp3` → 200 con `content-type: audio/mpeg` (audio sirve correctamente); BD verifica `profiles_rol_check` incluye docente, 3 maestros existentes con `es_tutor` booleano correcto.
- **Deploy completado**: **v1.6 en producción en https://prestamos.buglabs.dev**. El usuario se encarga de: (a) desactivar Cloudflare Web Analytics para el dominio (bug de sha512/CORS); (b) marcar como docente vía SQL a las cuentas que corresponda.
- **2026-08-27 — v1.7: overlay de carga, lista negra de cuentas, carrito persistente + fix definitivo del upload**. 3 features + 1 cambio UX + 2 bugs residuales. Fase 1 con Bash/grep + logs de Storage en prod (sin Explore agents — bug root cause obvio en los logs), Fase 2 sin Plan agent (contexto claro), 2 preguntas al usuario (modelo de baneos, estrategia fix upload), plan escrito y aprobado, ejecución con migración yo + 3 agentes paralelos.
- **Bug del upload — root cause definitivo (via logs storage de prod)**: `"role":"anon"` + `"error":"new row violates row-level security policy"` (código 42501). Causa real: `browserClient()` en `src/lib/supabase.ts` usa `createBrowserClient` de `@supabase/ssr` — en el browser NO puede leer las cookies de auth porque están seteadas con `httpOnly: true` (correcto por seguridad, JS del browser no las puede leer nunca). Al hacer `.storage.from(bucket).upload()`, la request sale con `Authorization: Bearer <anon_key>` (sin JWT del user), Storage la evalúa como `role: 'anon'`, y RLS rechaza. **Los fixes previos (Dockerfile ARG PUBLIC_*, guard typeof process en supabase.ts) NO tocaban este bug** — solo aseguraban que la URL del Supabase estuviera horneada en el bundle, pero el bundle nunca pudo autenticar contra Storage. Fix elegido: **endpoints proxy server-side** que reciben multipart, validan la sesión con la cookie httpOnly (server sí la lee via `serverClient(cookies)`), y suben con `serviceClient()` (bypass RLS). Cookies siguen httpOnly — cero riesgo XSS.
- **Decisiones tomadas con el usuario**: (1) baneos = **tabla separada** `prestamos.baneos(profile_id, razon, banned_at, banned_by, expires_at, unbanned_at, unbanned_by)` — historial completo + soporte para baneos temporales (aunque UI solo expone permanentes esta ronda); (2) fix upload = endpoints proxy; (3) overlay de carga literal (blur + spinner) según petición del usuario — no ClientRouter (evita side effects en islands y Realtime).
- **Migración `0007_baneos.sql`** aplicada limpia: nueva tabla `baneos` con partial unique index `where unbanned_at is null` (garantiza máximo 1 baneo activo por profile), RLS `baneos_admin_all` (admin CRUD) + `baneos_read_self` (user ve los suyos para /banned), helper SQL `prestamos.is_banned(uid) returns boolean` `security definer stable` que retorna true si existe baneo activo no expirado — invocable por el middleware vía `.rpc('is_banned', {p_uid})`.
- **3 agentes paralelos** con contratos aislados — **los 3 completos limpios en un pase** (a diferencia de v1.6 donde P y H cortaron por límite de sesión):
- **B · Baneos + panel + middleware guard** (9 archivos): `banned.astro` (pantalla completa patrón /403, muestra razón + fecha + expires + quién baneó + botón signout), `POST /api/admin/baneos` (guards admin + no-self-ban + razón 5-500 chars + expires_at futuro opcional, 23505 → 409), `POST /api/admin/baneos/[id]/desbanear` (update idempotente con `where unbanned_at is null`, 404 si ya inactivo), `BanearForm.tsx` (dialog con textarea razón + contador; deshabilitado con tooltip si adminSelf), `DesbanearButton.tsx` (window.confirm simple), `/admin/usuarios.astro` (2 queries paralelas profiles + baneos activos, cruce en memoria, tabla desktop + cards mobile), middleware con nuevo array `BANNED_ALLOWED = ['/banned', '/api/auth/signout']` para escapar del rewrite y evitar loop, RPC `is_banned` solo se llama si `profile && rol !== 'admin'` (evita costo en admin/no-login), nav admin `+ Usuarios`.
- **U · Uploads proxy** (4 archivos): `POST /api/upload/material-foto/[id].ts` (guard admin, valida mime `/^image\//` + `size <= 2MB`, path `${id}/${Date.now()}.${ext}`, sube con `serviceClient().storage.from('materiales-fotos').upload({upsert:true, contentType})`, actualiza `materiales.imagen_path` con locals.supabase), `POST /api/upload/avatar.ts` (guard user autenticado, path `${uid}/${Date.now()}.${ext}` al bucket `avatares`, actualiza `profiles.foto_path`), refactor `MaterialForm.tsx` `uploadFotoFor` para fetch multipart al endpoint (eliminó import de browserClient y del PATCH cliente del imagen_path — el endpoint ya lo persiste, evita doble escritura), refactor `PerfilForm.tsx` mismo patrón. Decisión no obvia: `uploadFotoFor` mantiene el patrón toast+return-null (no throw) del original — preserva la UX de "material creado sin foto" si el upload falla, en vez de bloquear la creación completa. Anotado en comentario: el "Quitar foto" en edit-mode nunca se propagaba a la BD (bug preexistente fuera de scope).
- **X · UX chico** (3 archivos): `Layout.astro` con `<div id="page-loader" hidden>` + estilos `position:fixed backdrop-filter:blur(8px)` + spinner mono animado (respeta prefers-reduced-motion) + script inline que captura clicks en `<a href>` y submits de `<form>` mismo origen (guardas: modifier keys, target=_blank, anchor#, javascript:/mailto:/tel:, cross-origin, defaultPrevented — este último es clave: los forms fetch-managed llaman preventDefault en su onSubmit React, así que el overlay no se dispara falsamente para ellos), `pageshow` limpia el overlay por bfcache. `SolicitudCart.tsx` con useEffect hidratar+persistir `labre:cart:v1` en localStorage + guard `cartHydrated` (evita que la primera pasada del effect pise el localStorage antes de leerlo) + botón Vaciar en footer del checkout con `window.confirm` (solo dentro del bloque perfilCompleto). `admin/inventario/index.astro` eliminado el `<script>` inline de auto-submit debounced que se había agregado en v1.5.
- **Verificación combinada**: `npm run build` limpio (2.74s en dev; 2.64s en el agente B). Sin conflictos de merge. Smoke test público post-deploy: `/login` → 200, `/banned` → 302 (protegido, redirige a login sin sesión), `/admin/usuarios` → 302 (protegido), `/api/upload/avatar` → 302 (middleware protegiendo API antes del handler). Container healthy en ~90s.
- **Aprendizajes registrados**: (a) los "fixes" del bug de upload en sesiones previas (Dockerfile ARG, guard typeof process) NUNCA fueron el fix real — solo eran precondiciones necesarias. El bug real requiere abandonar la idea de que el `browserClient` pueda hablar directo con Storage cuando las cookies son httpOnly. Diagnóstico definitivo llegó por leer logs de `supabase-storage` container donde el error 42501/anon estaba explícito; no era necesario reproducir en browser. (b) Contratos de agentes aislados por CARPETA (no por archivo) escalan mucho mejor — 3 agentes editaron 3 conjuntos disjuntos de rutas/componentes/endpoints sin overhead de coordinación. (c) El uso de `preventDefault` como señal semántica funciona bien: cualquier form que llama `preventDefault()` en su `onSubmit` React no dispara el overlay global, sin necesidad de opt-out explícito por form. (d) `partial unique index` `where unbanned_at is null` es el patrón limpio para "máximo un baneo activo por profile" — evita constraint compleja y da el error 23505 traducible.
- **Deliberadamente NO tocado**: (i) super admin (mencionado por usuario como consideración futura); (ii) baneo automático por rate limiting; (iii) UI para expires_at en baneos (columna existe, se puede exponer si se pide); (iv) compresión de imágenes cliente-side (si las fotos que sube el admin regularmente pasan de 2MB se agrega); (v) el bug preexistente de "Quitar foto" en MaterialForm que no propagaba null a la BD.
- **Deploy completado**: 2 commits (migración + código), push con token efímero de Gitea, `set -a && . .env.production && set +a && docker compose up -d --build` en buglabs (patrón del build args). Container healthy. **v1.7 en producción en https://prestamos.buglabs.dev**. El usuario puede empezar a: (a) crear/actualizar fotos de materiales y perfil (bug arreglado); (b) banear cuentas problemáticas desde `/admin/usuarios`.
- **2026-08-26 — Primera ronda de QA manual con agent-browser (post v1.6)**. Se siguió el proceso de `CLAUDE.md`/`AGENTS.md`: bypass temporal `?preview=alumno|admin|docente` en el middleware (solo `import.meta.env.DEV`), revertido al terminar (`git diff src/middleware.ts` queda limpio).
- **Bug crítico encontrado y arreglado — `process is not defined` rompía la hidratación de 3 formularios en producción**: `src/lib/supabase.ts` leía `process.env.X ?? import.meta.env.X` (orden fijado en la sesión del 24-ago para el bug de upload en Docker). En el browser `process` no existe como global — evaluar `process.env` revienta con `ReferenceError` **antes** de que el `??` pueda caer al fallback. Como el módulo se evalúa completo al importarse (aunque solo se use `browserClient`), esto tumbaba la hidratación de **`PerfilForm.tsx`** (usado en `/perfil` y `/onboarding`) y de **`MaterialForm.tsx`** (admin, alta/edición de material) — los tres quedaban sin JS: los botones "Guardar" hacían un submit nativo del `<form>` (sin `action`, sin querystring) que caía en `/login` en vez de llamar al endpoint. Confirmado con red real: antes del fix, `PATCH /api/profile` nunca se disparaba (submit nativo); con el fix, sí, y persiste correctamente. Fix: guard `typeof process !== 'undefined'` antes de leer `process.env` en las 3 constantes de `supabase.ts` — mantiene la prioridad process→import.meta.env para el server (necesaria por el fix de Docker de esa sesión) sin tocar `process` en el bundle de browser. `avatar.ts` y `materialImg.ts` ya tenían el orden inverso (`import.meta.env` primero) por eso nunca mostraron el bug. **Pendiente: este fix vive solo en el working tree, no se ha commiteado ni desplegado** — el bug sigue viivo en producción hasta que se despliegue.
- **Hallazgo operativo, no bug de código — no había ningún perfil `rol='admin'`** en la base de datos de producción al momento de probar (los 10 perfiles reales eran todos `alumno`/`docente`). Bloqueaba por completo `/admin/*`. Con confirmación del usuario, se promovió `amado.garcia.ramirez@uabc.edu.mx` a `admin` vía `PATCH` directo a PostgREST con `service_role`. No se investigó la causa de cómo se quedó sin admin (posble que nunca se re-promovió tras alguna migración, o que el admin real usaba otro correo ya no presente) — si vuelve a pasar, vale la pena revisar.
- **Incidente de infraestructura durante la sesión**: el túnel Cloudflare de buglabs cayó a mitad de las pruebas (error 1033, "tunnel not connected") — tumbó `supabase.buglabs.dev`, `prestamos.buglabs.dev` y el SSH a buglabs simultáneamente (los tres pasan por el mismo túnel). Se detectó por una request de ~14s seguida de fallos consistentes, confirmado con `curl` externo a los 3 hosts. El usuario lo resolvió revisando el servidor físicamente; no se necesitó ninguna acción de este lado. Aprendizaje: si `supabase.buglabs.dev` empieza a fallar con 530/1033 en medio de una sesión de dev, sospechar del túnel completo antes de asumir un bug de código — afecta a la vez prod, dev local (comparten DB) y el acceso SSH.
- **Error propio durante el QA — datos de prueba escritos sobre perfiles reales**: para probar el guardado de `/perfil` se necesitó una cookie "sticky" (`preview_rol`) además del querystring, porque los `fetch()` que dispara el propio formulario no llevan `?preview=`. La query de impersonación (`.eq('rol','alumno').limit(1)`, sin `order by`) no es determinística entre requests — dos escrituras de prueba consecutivas cayeron en **dos alumnos reales distintos** (Saul Guzman Garcia y Sergio Paolo Piñuelas Manzo), y un intento de "limpiar" con valores `null` cayó en un **tercer** alumno (Eduardo Avitia Castro) que no había sido tocado antes. Se revirtieron a `null` los dos perfiles que sí se escribieron con data de prueba (`QATEST0001`/sem 3/tutor 1); el tercero (Eduardo) ya estaba en `null` cuando se le escribió `null` encima, así que lo más probable es que no se perdiera nada real — coincide con el patrón de otro alumno real (Romell) que también tiene el perfil sin llenar. Aprendizaje para la próxima vez que se necesite probar una escritura contra un bypass basado en rol: **fijar el id exacto del perfil de prueba primero** (`select id from profiles where email = '...'`) en vez de un `.limit(1)` sin orden, precisamente para que esto no vuelva a pasar.
- **Verificado sin hallazgos**: alumno (home, catálogo con buscador+carrito+checkout, guard de perfil incompleto, mis-préstamos), docente (catálogo sin campo de maestro, perfil con label "Número de empleado"), admin (panel con KPIs y actividad reciente, las 3 vistas de solicitudes con wrap correcto incluso con un vale real lleno de emoji spam de un alumno, `VerDetalles` con historial de audit_log correcto, grid de inventario con fotos/placeholder, categorías, maestros con columna `es_tutor`, estadísticas con las 3 gráficas de recharts, export CSV). Un falso positivo: el donut de "Distribución de estados" salía en blanco en un screenshot `--full` (full-page stitched) de agent-browser pero pintaba bien en un screenshot de viewport normal — confirmado que es un artefacto de la herramienta de captura, no un bug de la app (el SVG/paths están completos y correctos en el DOM).
- **Nota de producto, no bug**: en `/admin/maestros`, de los 3 maestros solo "Sin especificar" tiene `es_tutor=true` — por eso el combobox de tutor en `/perfil` solo ofrece esa opción. Si se quiere que los alumnos puedan elegir a "Maria Angelica" o "Monica Cristina" como tutor real, hay que marcarles el checkbox "Es tutor" desde el CRUD.
- **Pendiente de decisión del usuario**: si desplegar el fix de `process is not defined` a producción ahora (bug activo en prod: nadie puede guardar su perfil ni el admin editar materiales) — commit + push + `docker compose up -d --build` en buglabs, mismo patrón de siempre.
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { browserClient } from '@/lib/supabase';
import { imgUrl } from '@/lib/materialImg'; import { imgUrl } from '@/lib/materialImg';
import { toast } from '@/lib/toast'; import { toast } from '@/lib/toast';
import UnidadesManager from './UnidadesManager'; import UnidadesManager from './UnidadesManager';
@@ -25,8 +24,6 @@ type Props = {
compact?: boolean; compact?: boolean;
}; };
const BUCKET = 'materiales-fotos';
export default function MaterialForm({ mode, material, categorias, compact }: Props) { export default function MaterialForm({ mode, material, categorias, compact }: Props) {
const dialogRef = useRef<HTMLDialogElement | null>(null); const dialogRef = useRef<HTMLDialogElement | null>(null);
const firstFieldRef = useRef<HTMLInputElement | null>(null); const firstFieldRef = useRef<HTMLInputElement | null>(null);
@@ -99,18 +96,16 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
if (error) errorRef.current?.focus(); if (error) errorRef.current?.focus();
}, [error]); }, [error]);
async function uploadFotoFor(id: number): Promise<string | null> { async function uploadFotoFor(materialId: number, f: File): Promise<string | null> {
if (!file) return null; const fd = new FormData();
const ext = (file.name.split('.').pop() ?? 'jpg').toLowerCase().replace(/[^a-z0-9]/g, ''); fd.append('file', f);
const path = `${id}/${Date.now()}.${ext || 'jpg'}`; const res = await fetch(`/api/upload/material-foto/${materialId}`, { method: 'POST', body: fd });
const { error: upErr } = await browserClient() const json = await res.json().catch(() => ({}));
.storage.from(BUCKET) if (!res.ok) {
.upload(path, file, { upsert: true, contentType: file.type || undefined }); toast({ kind: 'error', title: 'La foto no se subió', description: json?.error ?? 'El material se guardó sin foto.' });
if (upErr) {
toast({ kind: 'error', title: 'La foto no se subió', description: 'El material se guardó sin foto.' });
return null; return null;
} }
return path; return json.path as string;
} }
const submit = async (e: React.FormEvent) => { 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(() => ({})); const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json?.error ?? 'No se pudo crear'); 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) { if (file && json.id) {
const path = await uploadFotoFor(json.id); await uploadFotoFor(json.id, file);
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.' });
}
}
} }
} else { } else {
// edit — sube nueva foto primero (si hay) y luego PATCH con el path incluido // edit — sube nueva foto primero (si hay); el endpoint persiste imagen_path
let newImagenPath: string | null | undefined = undefined;
if (file && material) { if (file && material) {
const path = await uploadFotoFor(material.id); await uploadFotoFor(material.id, file);
if (path) newImagenPath = path;
} }
const patch: Record<string, unknown> = { const patch: Record<string, unknown> = {
nombre: nombreT, nombre: nombreT,
@@ -181,7 +164,7 @@ export default function MaterialForm({ mode, material, categorias, compact }: Pr
trackeado_por_unidad: trackeado, trackeado_por_unidad: trackeado,
}; };
if (!trackeado) patch.cantidad_total = cantidad; 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}`, { const res = await fetch(`/api/admin/materiales/${material!.id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -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<HTMLDialogElement | null>(null);
const firstFieldRef = useRef<HTMLTextAreaElement | null>(null);
const errorRef = useRef<HTMLParagraphElement | null>(null);
const [razon, setRazon] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<>
<button
type="button"
className="btn btn-ghost"
style={{
minHeight: '36px',
paddingBlock: '0.25rem',
color: adminSelf ? undefined : 'var(--color-danger-text)',
opacity: adminSelf ? 0.5 : 1,
cursor: adminSelf ? 'not-allowed' : undefined,
}}
onClick={open}
disabled={adminSelf}
title={adminSelf ? 'No puedes banearte a ti mismo' : undefined}
aria-label={adminSelf ? 'No puedes banearte a ti mismo' : `Banear a ${label}`}
>
Banear
</button>
<dialog
ref={dialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,28rem)]"
>
<form onSubmit={submit} className="p-5 sm:p-6 flex flex-col gap-4" autoComplete="off">
<div className="flex items-start justify-between gap-3">
<h2 id={titleId} className="text-lg font-semibold">
Banear a {label}
</h2>
<button
type="button"
onClick={close}
aria-label="Cerrar"
className="rounded-lg p-1 hover:bg-black/5 transition-colors leading-none text-xl"
>
<span aria-hidden="true">×</span>
</button>
</div>
<p className="text-sm" style={{ color: 'var(--color-pencil)' }}>
El usuario no podrá acceder al sistema hasta ser desbaneado.
</p>
<div>
<label className="label" htmlFor={`${titleId}-razon`}>
Razón del baneo
</label>
<textarea
ref={firstFieldRef}
id={`${titleId}-razon`}
className="input"
rows={4}
value={razon}
onChange={(e) => setRazon(e.target.value)}
required
minLength={5}
maxLength={500}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${titleId}-err` : undefined}
placeholder="Explica el motivo — el usuario lo verá en su pantalla de bloqueo."
/>
<p className="text-xs mt-1" style={{ color: 'var(--color-pencil)' }}>
{razon.length}/500
</p>
</div>
{error && (
<p
ref={errorRef}
id={`${titleId}-err`}
role="alert"
tabIndex={-1}
className="text-sm"
style={{ color: 'var(--color-danger-text)' }}
>
{error}
</p>
)}
<div className="flex gap-2 justify-end pt-2">
<button type="button" className="btn btn-ghost" onClick={close} disabled={loading}>
Cancelar
</button>
<button
type="submit"
className="btn"
style={{ background: 'var(--color-danger)', color: 'var(--color-ink)' }}
disabled={loading}
>
{loading ? 'Baneando…' : 'Banear'}
</button>
</div>
</form>
</dialog>
</>
);
}
@@ -0,0 +1,69 @@
import { useRef, useState } from 'react';
import { toastAfterReload } from '@/lib/toast';
type Props = {
baneoId: number;
nombre: string;
};
export default function DesbanearButton({ baneoId, nombre }: Props) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const errorDialogRef = useRef<HTMLDialogElement | null>(null);
const titleId = `desbanear-err-${baneoId}`;
const submit = async () => {
if (loading) return;
if (!window.confirm(`¿Desbanear a ${nombre}? Recuperará acceso inmediato.`)) return;
setLoading(true);
try {
const res = await fetch(`/api/admin/baneos/${baneoId}/desbanear`, { method: 'POST' });
const json = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(json?.error ?? 'No se pudo desbanear');
toastAfterReload({
title: 'Usuario desbaneado',
description: nombre,
kind: 'success',
});
location.reload();
} catch (err) {
setError(err instanceof Error ? err.message : 'Error inesperado');
errorDialogRef.current?.showModal();
setLoading(false);
}
};
return (
<>
<button
type="button"
className="btn btn-primary"
style={{ minHeight: '36px', paddingBlock: '0.25rem' }}
onClick={submit}
disabled={loading}
>
{loading ? 'Desbaneando…' : 'Desbanear'}
</button>
<dialog
ref={errorDialogRef}
aria-labelledby={titleId}
className="rounded-lg p-0 bg-white text-[color:var(--color-ink)] border-2 border-[color:var(--color-ink)] shadow-[var(--shadow-hard)] backdrop:bg-black/40 w-[min(92vw,26rem)]"
>
<div className="p-5 sm:p-6 flex flex-col gap-4">
<h2 id={titleId} className="text-lg font-semibold">
No se pudo desbanear
</h2>
<p className="text-sm" style={{ color: 'var(--color-danger-text)' }}>
{error}
</p>
<div className="flex justify-end pt-2">
<button type="button" className="btn btn-primary" onClick={() => errorDialogRef.current?.close()}>
Entendido
</button>
</div>
</div>
</dialog>
</>
);
}
+113 -42
View File
@@ -1,5 +1,6 @@
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { toast, toastAfterReload } from '@/lib/toast'; import { toast, toastAfterReload } from '@/lib/toast';
import { imgUrl } from '@/lib/materialImg';
import AgregarMaterial from './AgregarMaterial'; import AgregarMaterial from './AgregarMaterial';
type Material = { type Material = {
@@ -9,9 +10,18 @@ type Material = {
cantidad_disponible: number; cantidad_disponible: number;
cantidad_total: number; cantidad_total: number;
numero_inventario: string | null; numero_inventario: string | null;
imagen_path: string | null;
categoria: { id: number; nombre: string } | null; categoria: { id: number; nombre: string } | null;
}; };
// El catálogo sólo lista materiales con estado 'disponible' (ver catalogo.astro),
// así que el chip siempre coincide con el mismo estilo que usa el inventario del admin.
const DISPONIBLE_BADGE_STYLE = {
background: 'color-mix(in oklab, var(--color-positive) 18%, white)',
color: 'var(--color-ink)',
border: '1.5px solid var(--color-positive)',
} as const;
type CartItem = { type CartItem = {
material_id: number; material_id: number;
nombre: string; nombre: string;
@@ -55,6 +65,7 @@ export default function CartProvider({
maestros?: MaestroOpt[]; maestros?: MaestroOpt[];
}) { }) {
const [cart, setCart] = useState<Map<number, CartItem>>(new Map()); const [cart, setCart] = useState<Map<number, CartItem>>(new Map());
const cartHydrated = useRef(false);
const dialogRef = useRef<HTMLDialogElement | null>(null); const dialogRef = useRef<HTMLDialogElement | null>(null);
const firstFieldRef = useRef<HTMLSelectElement | null>(null); const firstFieldRef = useRef<HTMLSelectElement | null>(null);
const [maestroId, setMaestroId] = useState<string>(''); const [maestroId, setMaestroId] = useState<string>('');
@@ -130,6 +141,43 @@ export default function CartProvider({
dialogRef.current?.close(); dialogRef.current?.close();
}; };
// Hidrata carrito desde localStorage al montar
useEffect(() => {
if (typeof window === 'undefined') return;
try {
const raw = window.localStorage.getItem('labre:cart:v1');
if (raw) {
const arr = JSON.parse(raw);
if (Array.isArray(arr)) {
const next = new Map<number, CartItem>();
for (const i of arr) {
if (i && typeof i.material_id === 'number' && typeof i.nombre === 'string') {
next.set(i.material_id, {
material_id: i.material_id,
nombre: i.nombre,
cantidad: clamp(Number(i.cantidad) || 1, Number(i.cantidad_disponible) || 1),
cantidad_disponible: Number(i.cantidad_disponible) || 1,
descripcion: typeof i.descripcion === 'string' ? i.descripcion : '',
});
}
}
if (next.size > 0) setCart(next);
}
}
} catch { /* localStorage bloqueado o JSON inválido */ }
cartHydrated.current = true;
}, []);
// Persiste carrito a localStorage cuando cambia (después de la hidratación inicial)
useEffect(() => {
if (typeof window === 'undefined') return;
if (!cartHydrated.current) return;
try {
if (cart.size === 0) window.localStorage.removeItem('labre:cart:v1');
else window.localStorage.setItem('labre:cart:v1', JSON.stringify(Array.from(cart.values())));
} catch { /* localStorage bloqueado */ }
}, [cart]);
// Cerrar al click en backdrop // Cerrar al click en backdrop
useEffect(() => { useEffect(() => {
const dlg = dialogRef.current; const dlg = dialogRef.current;
@@ -191,46 +239,59 @@ export default function CartProvider({
return ( return (
<CartContext.Provider value={value}> <CartContext.Provider value={value}>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{materiales.map((m) => ( {materiales.map((m) => {
<article const src = imgUrl(m.imagen_path);
key={m.id} return (
className="card flex flex-col gap-3" <article
data-cat={m.categoria?.id ?? 'sin'} key={m.id}
data-nombre={m.nombre} className="card p-0 flex flex-col overflow-hidden"
data-numero-inventario={m.numero_inventario ?? ''} data-cat={m.categoria?.id ?? 'sin'}
> data-nombre={m.nombre}
<div className="flex items-start justify-between gap-2"> data-numero-inventario={m.numero_inventario ?? ''}
<h3 className="font-semibold leading-snug">{m.nombre}</h3> >
{m.categoria && ( <div
className="relative aspect-square w-full bg-[color:var(--color-chalk)] grid place-items-center"
style={{ borderBottom: '2px solid var(--color-ink)' }}
>
{src ? (
<img src={src} alt={m.nombre} className="w-full h-full object-cover" loading="lazy" />
) : (
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="var(--color-ink)" strokeWidth={1.5} aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="1" />
<path d="M3 16l5-5 4 4 3-3 6 6" />
<circle cx="8" cy="9" r="1.5" />
</svg>
)}
<span <span
className="text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap uppercase tracking-wide" className="absolute bottom-1.5 right-1.5 text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap shadow-[var(--shadow-hard-sm)]"
style={{ style={DISPONIBLE_BADGE_STYLE}
background: 'color-mix(in oklab, var(--color-primary) 25%, white)',
color: 'var(--color-ink)',
border: '1.5px solid var(--color-primary)',
}}
> >
{m.categoria.nombre} Disponible
</span> </span>
)} </div>
</div>
{m.descripcion && <p className="text-sm opacity-75 line-clamp-2">{m.descripcion}</p>} <div className="p-4 flex flex-col gap-3 flex-1">
<h3 className="font-semibold leading-snug">{m.nombre}</h3>
{m.categoria && <p className="text-xs opacity-70 -mt-1">{m.categoria.nombre}</p>}
<dl className="text-xs opacity-70 grid grid-cols-2 gap-1"> {m.descripcion && <p className="text-sm opacity-75 line-clamp-2">{m.descripcion}</p>}
<dt className="opacity-60">Inventario</dt>
<dd className="text-right font-mono">{m.numero_inventario ?? '—'}</dd>
<dt className="opacity-60">Disponibles</dt>
<dd className="text-right" style={{ fontVariantNumeric: 'tabular-nums' }}>
{m.cantidad_disponible} / {m.cantidad_total}
</dd>
</dl>
<div className="mt-auto"> <dl className="text-xs opacity-70 grid grid-cols-2 gap-1">
<AgregarMaterial material={{ id: m.id, nombre: m.nombre, cantidad_disponible: m.cantidad_disponible }} /> <dt className="opacity-60">Inventario</dt>
</div> <dd className="text-right font-mono">{m.numero_inventario ?? '—'}</dd>
</article> <dt className="opacity-60">Disponibles</dt>
))} <dd className="text-right" style={{ fontVariantNumeric: 'tabular-nums' }}>
{m.cantidad_disponible} / {m.cantidad_total}
</dd>
</dl>
<div className="mt-auto">
<AgregarMaterial material={{ id: m.id, nombre: m.nombre, cantidad_disponible: m.cantidad_disponible }} />
</div>
</div>
</article>
);
})}
</div> </div>
{items.length > 0 && ( {items.length > 0 && (
@@ -356,7 +417,7 @@ export default function CartProvider({
id="notas-cart" id="notas-cart"
className="input" className="input"
rows={3} rows={3}
maxLength={250} maxLength={100}
value={notas} value={notas}
onChange={(e) => setNotas(e.target.value)} onChange={(e) => setNotas(e.target.value)}
placeholder="¿Para qué clase o proyecto lo necesitas?" placeholder="¿Para qué clase o proyecto lo necesitas?"
@@ -364,7 +425,7 @@ export default function CartProvider({
aria-describedby="notas-cart-hint" aria-describedby="notas-cart-hint"
/> />
<p id="notas-cart-hint" className="text-xs opacity-60 mt-1"> <p id="notas-cart-hint" className="text-xs opacity-60 mt-1">
Ejemplo: Clase de Electrónica Analógica · Prof. Gómez · práctica 3. (Opcional, máx. 250 caracteres) Ejemplo: Clase de Electrónica Analógica · Prof. Gómez. (Opcional, máx. 100 caracteres)
</p> </p>
</div> </div>
@@ -385,13 +446,23 @@ export default function CartProvider({
<a href="/perfil" className="btn btn-primary">Completar perfil</a> <a href="/perfil" className="btn btn-primary">Completar perfil</a>
</div> </div>
) : ( ) : (
<div className="flex gap-2 justify-end pt-2"> <div className="flex gap-2 justify-between items-center pt-2">
<button type="button" className="btn btn-ghost" onClick={closeCheckout} disabled={loading}> <button
Cancelar type="button"
</button> className="btn btn-ghost"
<button type="submit" className="btn btn-primary" disabled={loading || ok || items.length === 0}> onClick={() => { if (window.confirm('¿Vaciar todo el carrito?')) setCart(new Map()); }}
{loading ? 'Enviando…' : 'Enviar solicitud'} disabled={loading || items.length === 0}
>
Vaciar
</button> </button>
<div className="flex gap-2">
<button type="button" className="btn btn-ghost" onClick={closeCheckout} disabled={loading}>
Cancelar
</button>
<button type="submit" className="btn btn-primary" disabled={loading || ok || items.length === 0}>
{loading ? 'Enviando…' : 'Enviar solicitud'}
</button>
</div>
</div> </div>
)} )}
</form> </form>
+12 -16
View File
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { browserClient } from '@/lib/supabase';
import { avatarInfo } from '@/lib/avatar'; import { avatarInfo } from '@/lib/avatar';
import { toast, toastAfterReload } from '@/lib/toast'; import { toast, toastAfterReload } from '@/lib/toast';
import Avatar from './Avatar'; import Avatar from './Avatar';
@@ -22,7 +21,6 @@ type Props = {
mode?: 'edit' | 'onboarding'; mode?: 'edit' | 'onboarding';
}; };
const BUCKET = 'avatares';
const SEM_OPTS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12']; const SEM_OPTS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'];
export default function PerfilForm({ export default function PerfilForm({
@@ -69,18 +67,16 @@ export default function PerfilForm({
? { ...info, src: previewUrl } ? { ...info, src: previewUrl }
: info; : info;
async function uploadAvatar(): Promise<string | null> { async function uploadAvatar(f: File): Promise<boolean> {
if (!file) return null; const fd = new FormData();
const ext = (file.name.split('.').pop() ?? 'jpg').toLowerCase().replace(/[^a-z0-9]/g, '') || 'jpg'; fd.append('file', f);
const path = `${userId}/${Date.now()}.${ext}`; const res = await fetch('/api/upload/avatar', { method: 'POST', body: fd });
const { error: upErr } = await browserClient() const json = await res.json().catch(() => ({}));
.storage.from(BUCKET) if (!res.ok) {
.upload(path, file, { upsert: true, contentType: file.type || undefined }); toast({ kind: 'error', title: 'La foto no se subió', description: json?.error ?? 'Intenta de nuevo.' });
if (upErr) { return false;
toast({ kind: 'error', title: 'La foto no se subió', description: upErr.message });
return null;
} }
return path; return true;
} }
const submit = async (e: React.FormEvent) => { const submit = async (e: React.FormEvent) => {
@@ -89,9 +85,10 @@ export default function PerfilForm({
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
let nuevoPath: string | null | undefined = undefined; // La foto se sube por su propio endpoint (que ya persiste foto_path).
// El PATCH /api/profile solo maneja matricula/semestre/tutor.
if (file) { if (file) {
nuevoPath = await uploadAvatar(); await uploadAvatar(file);
} }
const body: Record<string, string | number | null> = { const body: Record<string, string | number | null> = {
matricula: matricula.trim() || null, matricula: matricula.trim() || null,
@@ -100,7 +97,6 @@ export default function PerfilForm({
body.semestre = semestre || null; body.semestre = semestre || null;
body.tutor_id = tutorId ? Number(tutorId) : null; body.tutor_id = tutorId ? Number(tutorId) : null;
} }
if (nuevoPath !== undefined) body.foto_path = nuevoPath;
const res = await fetch('/api/profile', { const res = await fetch('/api/profile', {
method: 'PATCH', method: 'PATCH',
+2
View File
@@ -57,6 +57,7 @@ const ICONS = {
chart: 'M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z', chart: 'M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z',
logout: 'M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75', logout: 'M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75',
dashboard: 'M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5m.75-9l3-3 2.148 2.148A12.061 12.061 0 0116.5 7.605', dashboard: 'M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5m.75-9l3-3 2.148 2.148A12.061 12.061 0 0116.5 7.605',
users: 'M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z',
}; };
const alumnoNav: NavItem[] = [ const alumnoNav: NavItem[] = [
@@ -70,6 +71,7 @@ const adminNav: NavItem[] = [
{ href: '/admin/inventario', label: 'Inventario', icon: 'box' }, { href: '/admin/inventario', label: 'Inventario', icon: 'box' },
{ href: '/admin/estadisticas', label: 'Estadísticas', icon: 'stats' }, { href: '/admin/estadisticas', label: 'Estadísticas', icon: 'stats' },
{ href: '/admin/reportes', label: 'Reportes', icon: 'chart' }, { href: '/admin/reportes', label: 'Reportes', icon: 'chart' },
{ href: '/admin/usuarios', label: 'Usuarios', icon: 'users' },
]; ];
const nav = isAdmin ? adminNav : alumnoNav; const nav = isAdmin ? adminNav : alumnoNav;
+65
View File
@@ -38,5 +38,70 @@ const { title = 'Sistema de Préstamos — Laboratorio UABC' } = Astro.props;
Saltar al contenido Saltar al contenido
</a> </a>
<slot /> <slot />
<div id="page-loader" hidden aria-hidden="true" aria-live="polite">
<div class="page-loader-inner">
<svg class="page-loader-spinner" viewBox="0 0 24 24" width="40" height="40" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M12 3a9 9 0 1 0 9 9" />
</svg>
</div>
</div>
<style is:global>
#page-loader {
position: fixed; inset: 0; z-index: 9999;
background: color-mix(in oklab, var(--color-surface) 60%, transparent);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: grid; place-items: center;
}
#page-loader[hidden] { display: none; }
.page-loader-spinner {
color: var(--color-ink);
animation: labre-spin 0.9s linear infinite;
}
@keyframes labre-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
.page-loader-spinner { animation: none; }
}
</style>
<script is:inline>
(() => {
const overlay = document.getElementById('page-loader');
if (!overlay) return;
const show = () => { overlay.hidden = false; };
document.addEventListener('click', (e) => {
const a = (e.target instanceof Element) ? e.target.closest('a[href]') : null;
if (!a) return;
if (e.defaultPrevented) return;
if (e.button !== 0) return;
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
if (a.target && a.target !== '_self') return;
const href = a.getAttribute('href') || '';
if (!href || href.startsWith('#') || href.startsWith('javascript:') || href.startsWith('mailto:') || href.startsWith('tel:')) return;
try {
const url = new URL(a.href, location.href);
if (url.origin !== location.origin) return;
if (url.pathname === location.pathname && url.search === location.search) return;
} catch { return; }
show();
}, true);
document.addEventListener('submit', (e) => {
if (e.defaultPrevented) return;
const form = e.target;
if (!(form instanceof HTMLFormElement)) return;
try {
const action = form.getAttribute('action');
if (action) {
const url = new URL(action, location.href);
if (url.origin !== location.origin) return;
}
} catch { return; }
show();
}, true);
window.addEventListener('pageshow', () => { overlay.hidden = true; });
})();
</script>
</body> </body>
</html> </html>
+7 -3
View File
@@ -4,9 +4,13 @@ import type { AstroCookies } from 'astro';
const SCHEMA = 'prestamos'; const SCHEMA = 'prestamos';
const SUPABASE_URL = process.env.PUBLIC_SUPABASE_URL ?? import.meta.env.PUBLIC_SUPABASE_URL; // `process` no existe en el bundle de browser (islands con client:load que
const SUPABASE_ANON_KEY = process.env.PUBLIC_SUPABASE_ANON_KEY ?? import.meta.env.PUBLIC_SUPABASE_ANON_KEY; // importan browserClient) — hay que checar typeof antes de leerlo o revienta
const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY ?? import.meta.env.SUPABASE_SERVICE_ROLE_KEY; // la hidratación con "process is not defined" en vez de caer al fallback.
const hasProcess = typeof process !== 'undefined';
const SUPABASE_URL = (hasProcess ? process.env.PUBLIC_SUPABASE_URL : undefined) ?? import.meta.env.PUBLIC_SUPABASE_URL;
const SUPABASE_ANON_KEY = (hasProcess ? process.env.PUBLIC_SUPABASE_ANON_KEY : undefined) ?? import.meta.env.PUBLIC_SUPABASE_ANON_KEY;
const SUPABASE_SERVICE_KEY = (hasProcess ? process.env.SUPABASE_SERVICE_ROLE_KEY : undefined) ?? import.meta.env.SUPABASE_SERVICE_ROLE_KEY;
// ponytail: solo para preview local sin login. Bypass RLS con service_role. // ponytail: solo para preview local sin login. Bypass RLS con service_role.
// Su uso queda tras un flag de DEV en el middleware. // Su uso queda tras un flag de DEV en el middleware.
+1
View File
@@ -0,0 +1 @@
export const truncate = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
+11
View File
@@ -3,6 +3,8 @@ import { serverClient } from '@/lib/supabase';
const UABC_DOMAIN = '@uabc.edu.mx'; const UABC_DOMAIN = '@uabc.edu.mx';
const PUBLIC_ROUTES = ['/login', '/api/auth/signin', '/api/auth/callback', '/api/auth/signout']; const PUBLIC_ROUTES = ['/login', '/api/auth/signin', '/api/auth/callback', '/api/auth/signout'];
// Rutas siempre accesibles para un user baneado (sino, loop de rewrite)
const BANNED_ALLOWED = ['/banned', '/api/auth/signout'];
export const onRequest = defineMiddleware(async (context, next) => { export const onRequest = defineMiddleware(async (context, next) => {
const supabase = serverClient(context.cookies); const supabase = serverClient(context.cookies);
@@ -56,6 +58,15 @@ export const onRequest = defineMiddleware(async (context, next) => {
return context.redirect('/'); return context.redirect('/');
} }
// Ban guard: si el user está baneado (y no es admin), toda ruta cae a /banned
// salvo la propia /banned y el signout. RPC is_banned respeta expires_at.
if (user && context.locals.profile && context.locals.profile.rol !== 'admin') {
const { data: banned } = await supabase.rpc('is_banned', { p_uid: user.id });
if (banned === true && !BANNED_ALLOWED.includes(pathname)) {
return context.rewrite('/banned');
}
}
if (pathname.startsWith('/admin') && context.locals.profile?.rol !== 'admin') { if (pathname.startsWith('/admin') && context.locals.profile?.rol !== 'admin') {
return context.rewrite('/403'); return context.rewrite('/403');
} }
+5 -22
View File
@@ -109,21 +109,6 @@ const estadoBadge = (e: Material['estado']) => {
</div> </div>
</form> </form>
<script is:inline>
(() => {
const form = document.getElementById('inv-filter-form');
if (!form) return;
const q = form.querySelector('input[name="q"]');
const selects = form.querySelectorAll('select');
let t;
q && q.addEventListener('input', () => {
clearTimeout(t);
t = setTimeout(() => form.submit(), 250);
});
selects.forEach((s) => s.addEventListener('change', () => form.submit()));
})();
</script>
{matError && ( {matError && (
<div class="card mb-6" role="alert" style="border-color: color-mix(in oklab, var(--color-danger) 30%, transparent);"> <div class="card mb-6" role="alert" style="border-color: color-mix(in oklab, var(--color-danger) 30%, transparent);">
<p class="text-sm" style="color: var(--color-danger-text);"> <p class="text-sm" style="color: var(--color-danger-text);">
@@ -151,7 +136,7 @@ const estadoBadge = (e: Material['estado']) => {
return ( return (
<article class="card p-0 flex flex-col overflow-hidden"> <article class="card p-0 flex flex-col overflow-hidden">
<div <div
class="aspect-square w-full bg-[color:var(--color-chalk)] grid place-items-center" class="relative aspect-square w-full bg-[color:var(--color-chalk)] grid place-items-center"
style="border-bottom: 2px solid var(--color-ink);" style="border-bottom: 2px solid var(--color-ink);"
> >
{src ? ( {src ? (
@@ -163,14 +148,12 @@ const estadoBadge = (e: Material['estado']) => {
<circle cx="8" cy="9" r="1.5" /> <circle cx="8" cy="9" r="1.5" />
</svg> </svg>
)} )}
<span class="absolute bottom-1.5 right-1.5 text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap shadow-[var(--shadow-hard-sm)]" style={badge.style}>
{badge.label}
</span>
</div> </div>
<div class="p-3 flex flex-col gap-2 flex-1"> <div class="p-3 flex flex-col gap-2 flex-1">
<div class="flex items-start justify-between gap-2"> <h3 class="uppercase font-semibold text-sm leading-snug line-clamp-2">{m.nombre}</h3>
<h3 class="uppercase font-semibold text-sm leading-snug line-clamp-2">{m.nombre}</h3>
<span class="text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap shrink-0" style={badge.style}>
{badge.label}
</span>
</div>
{m.categoria && ( {m.categoria && (
<p class="text-xs opacity-70 -mt-1">{m.categoria.nombre}</p> <p class="text-xs opacity-70 -mt-1">{m.categoria.nombre}</p>
)} )}
+3 -2
View File
@@ -2,6 +2,7 @@
import AppLayout from '@/layouts/AppLayout.astro'; import AppLayout from '@/layouts/AppLayout.astro';
import MarcarDevuelto from '@/components/admin/solicitudes/MarcarDevuelto.tsx'; import MarcarDevuelto from '@/components/admin/solicitudes/MarcarDevuelto.tsx';
import VerDetalles from '@/components/admin/solicitudes/VerDetalles.tsx'; import VerDetalles from '@/components/admin/solicitudes/VerDetalles.tsx';
import { truncate } from '@/lib/text';
const supabase = Astro.locals.supabase; const supabase = Astro.locals.supabase;
const { data, error } = await supabase const { data, error } = await supabase
@@ -107,7 +108,7 @@ const tabs = [
</div> </div>
{r.notas && ( {r.notas && (
<div class="text-xs mt-1.5 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);"> <div class="text-xs mt-1.5 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
<span class="opacity-60">Motivo:</span> <span class="break-words">{r.notas}</span> <span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
</div> </div>
)} )}
</td> </td>
@@ -162,7 +163,7 @@ const tabs = [
<div class="text-sm mt-1 opacity-80">Devolver antes de <span style="font-variant-numeric: tabular-nums;">{r.fecha_devolucion_estimada ? fmtFecha.format(new Date(r.fecha_devolucion_estimada + 'T00:00:00')) : '—'}</span></div> <div class="text-sm mt-1 opacity-80">Devolver antes de <span style="font-variant-numeric: tabular-nums;">{r.fecha_devolucion_estimada ? fmtFecha.format(new Date(r.fecha_devolucion_estimada + 'T00:00:00')) : '—'}</span></div>
{r.notas && ( {r.notas && (
<p class="text-sm mt-2 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);"> <p class="text-sm mt-2 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
<span class="opacity-60">Motivo:</span> <span class="break-words">{r.notas}</span> <span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
</p> </p>
)} )}
<div class="mt-3 flex gap-2 flex-wrap"> <div class="mt-3 flex gap-2 flex-wrap">
+3 -2
View File
@@ -1,6 +1,7 @@
--- ---
import AppLayout from '@/layouts/AppLayout.astro'; import AppLayout from '@/layouts/AppLayout.astro';
import AccionesSolicitud from '@/components/admin/solicitudes/AccionesSolicitud.tsx'; import AccionesSolicitud from '@/components/admin/solicitudes/AccionesSolicitud.tsx';
import { truncate } from '@/lib/text';
const supabase = Astro.locals.supabase; const supabase = Astro.locals.supabase;
const { data, error } = await supabase const { data, error } = await supabase
@@ -99,7 +100,7 @@ const tabs = [
</div> </div>
{r.notas && ( {r.notas && (
<div class="text-xs mt-1.5 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);"> <div class="text-xs mt-1.5 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
<span class="opacity-60">Motivo:</span> <span class="break-words">{r.notas}</span> <span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
</div> </div>
)} )}
</td> </td>
@@ -135,7 +136,7 @@ const tabs = [
</div> </div>
{r.notas && ( {r.notas && (
<p class="text-sm mt-2 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);"> <p class="text-sm mt-2 opacity-80 border-l-2 pl-2" style="border-color: color-mix(in oklab, var(--color-primary) 40%, transparent);">
<span class="opacity-60">Motivo:</span> <span class="break-words">{r.notas}</span> <span class="opacity-60">Motivo:</span> <span class="break-words">{truncate(r.notas, 50)}</span>
</p> </p>
)} )}
<div class="mt-3"> <div class="mt-3">
+188
View File
@@ -0,0 +1,188 @@
---
import AppLayout from '@/layouts/AppLayout.astro';
import BanearForm from '@/components/admin/usuarios/BanearForm.tsx';
import DesbanearButton from '@/components/admin/usuarios/DesbanearButton.tsx';
type ProfileRow = {
id: string;
nombre: string | null;
email: string;
matricula: string | null;
rol: 'alumno' | 'docente' | 'admin';
};
type BaneoRow = {
id: number;
profile_id: string;
razon: string;
banned_at: string;
};
const meId = Astro.locals.user?.id ?? null;
const [{ data: profiles, error: perr }, { data: baneos }] = await Promise.all([
Astro.locals.supabase
.from('profiles')
.select('id, nombre, email, matricula, rol')
.order('nombre'),
Astro.locals.supabase
.from('baneos')
.select('id, profile_id, razon, banned_at')
.is('unbanned_at', null),
]);
const baneosByProfile = new Map<string, BaneoRow>();
for (const b of (baneos ?? []) as BaneoRow[]) baneosByProfile.set(b.profile_id, b);
const usuarios = ((profiles ?? []) as ProfileRow[]).map((p) => ({
...p,
baneo: baneosByProfile.get(p.id) ?? null,
}));
const rolLabel = (r: ProfileRow['rol']) =>
r === 'admin' ? 'Admin' : r === 'docente' ? 'Docente' : 'Alumno';
const fmtDate = (iso: string) =>
new Intl.DateTimeFormat('es-MX', {
dateStyle: 'medium',
timeZone: 'America/Tijuana',
}).format(new Date(iso));
const trunc = (s: string, n = 80) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
---
<AppLayout title="Usuarios — Admin">
<div class="max-w-5xl">
<header class="mb-6">
<h1 class="text-2xl md:text-3xl font-semibold">Usuarios</h1>
<p class="text-sm opacity-70 mt-1">Perfiles de todos los usuarios. Banea a quien haga mal uso del sistema.</p>
</header>
{perr && (
<div class="card mb-6" role="alert" style="border-color: color-mix(in oklab, var(--color-danger) 30%, transparent);">
<p class="text-sm" style="color: var(--color-danger-text);">
No se pudieron cargar los usuarios. Recarga la página.
</p>
</div>
)}
{!perr && usuarios.length === 0 && (
<div class="card text-center">
<h2 class="text-lg font-semibold mb-1">Sin usuarios aún</h2>
<p class="text-sm opacity-70">Se listarán aquí en cuanto alguien inicie sesión.</p>
</div>
)}
{!perr && usuarios.length > 0 && (
<>
{/* Desktop */}
<div class="hidden md:block card p-0 overflow-hidden">
<table class="w-full text-sm">
<thead class="text-left" style="background: color-mix(in oklab, var(--color-ink) 4%, transparent);">
<tr>
<th class="px-4 py-3 font-medium">Nombre</th>
<th class="px-4 py-3 font-medium">Email</th>
<th class="px-4 py-3 font-medium">Rol</th>
<th class="px-4 py-3 font-medium">Estado</th>
<th class="px-4 py-3 font-medium text-right">Acciones</th>
</tr>
</thead>
<tbody>
{usuarios.map((u) => {
const isSelf = u.id === meId;
return (
<tr class="border-t align-top" style="border-color: color-mix(in oklab, var(--color-ink) 8%, transparent);">
<td class="px-4 py-3">
<div class="font-medium break-words">{u.nombre ?? '—'}</div>
{u.matricula && <div class="text-xs opacity-60 mt-0.5" style="font-variant-numeric: tabular-nums;">{u.matricula}</div>}
</td>
<td class="px-4 py-3 break-all text-xs">{u.email}</td>
<td class="px-4 py-3">
<span
class="text-xs uppercase tracking-wide px-2 py-0.5 rounded-[2px]"
style={`border: 1.5px solid var(--color-ink); ${u.rol === 'admin' ? 'background: var(--color-secondary);' : ''}`}
>{rolLabel(u.rol)}</span>
</td>
<td class="px-4 py-3">
{u.baneo ? (
<div class="flex flex-col gap-1">
<span
class="text-xs uppercase tracking-wide px-2 py-0.5 rounded-[2px] w-fit"
style="background: var(--color-danger); color: var(--color-ink); border: 1.5px solid var(--color-ink);"
>Baneado</span>
<span class="text-xs opacity-70 break-words">{trunc(u.baneo.razon)}</span>
<span class="text-[11px] opacity-50">desde {fmtDate(u.baneo.banned_at)}</span>
</div>
) : (
<span class="text-xs uppercase tracking-wide" style="color: var(--color-primary);">Activo</span>
)}
</td>
<td class="px-4 py-3">
<div class="flex gap-2 justify-end">
{u.baneo ? (
<DesbanearButton baneoId={u.baneo.id} nombre={u.nombre ?? u.email} client:load />
) : (
<BanearForm
profile={{ id: u.id, nombre: u.nombre, email: u.email }}
adminSelf={isSelf}
client:load
/>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* Mobile */}
<div class="md:hidden flex flex-col gap-3">
{usuarios.map((u) => {
const isSelf = u.id === meId;
return (
<article class="card flex flex-col gap-3">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h3 class="font-semibold break-words">{u.nombre ?? '—'}</h3>
<p class="text-xs opacity-70 break-all">{u.email}</p>
<p class="text-xs opacity-60 mt-1">
{rolLabel(u.rol)}{u.matricula ? ` · ${u.matricula}` : ''}
</p>
</div>
{u.baneo ? (
<span
class="text-xs uppercase tracking-wide px-2 py-0.5 rounded-[2px] shrink-0"
style="background: var(--color-danger); color: var(--color-ink); border: 1.5px solid var(--color-ink);"
>Baneado</span>
) : (
<span class="text-xs uppercase tracking-wide shrink-0" style="color: var(--color-primary);">Activo</span>
)}
</div>
{u.baneo && (
<div class="text-xs opacity-80 break-words">
<strong>Razón:</strong> {trunc(u.baneo.razon, 120)}
<div class="opacity-60 mt-1">desde {fmtDate(u.baneo.banned_at)}</div>
</div>
)}
<div class="flex gap-2 justify-end">
{u.baneo ? (
<DesbanearButton baneoId={u.baneo.id} nombre={u.nombre ?? u.email} client:load />
) : (
<BanearForm
profile={{ id: u.id, nombre: u.nombre, email: u.email }}
adminSelf={isSelf}
client:load
/>
)}
</div>
</article>
);
})}
</div>
</>
)}
</div>
</AppLayout>
@@ -0,0 +1,30 @@
import type { APIRoute } from 'astro';
export const prerender = false;
export const POST: APIRoute = async ({ locals, params }) => {
if (locals.profile?.rol !== 'admin') {
return Response.json({ error: 'No autorizado' }, { status: 403 });
}
const id = Number(params.id);
if (!Number.isInteger(id) || id <= 0) {
return Response.json({ error: 'ID inválido' }, { status: 400 });
}
const { data, error } = await locals.supabase
.from('baneos')
.update({ unbanned_at: new Date().toISOString(), unbanned_by: locals.user!.id })
.eq('id', id)
.is('unbanned_at', null)
.select('id');
if (error) {
return Response.json({ error: 'No se pudo desbanear' }, { status: 500 });
}
if (!data || data.length === 0) {
return Response.json({ error: 'Baneo no encontrado o ya inactivo' }, { status: 404 });
}
return Response.json({ ok: true });
};
+67
View File
@@ -0,0 +1,67 @@
import type { APIRoute } from 'astro';
export const prerender = false;
export const POST: APIRoute = async ({ request, locals }) => {
if (locals.profile?.rol !== 'admin') {
return Response.json({ error: 'No autorizado' }, { status: 403 });
}
let body: { profile_id?: unknown; razon?: unknown; expires_at?: unknown };
try {
body = await request.json();
} catch {
return Response.json({ error: 'JSON inválido' }, { status: 400 });
}
const profile_id = typeof body.profile_id === 'string' ? body.profile_id.trim() : '';
if (!profile_id) {
return Response.json({ error: 'profile_id es obligatorio' }, { status: 400 });
}
if (profile_id === locals.user!.id) {
return Response.json({ error: 'No puedes banearte a ti mismo' }, { status: 400 });
}
const razon = typeof body.razon === 'string' ? body.razon.trim().slice(0, 500) : '';
if (razon.length < 5) {
return Response.json({ error: 'La razón debe tener al menos 5 caracteres' }, { status: 400 });
}
let expires_at: string | null = null;
if (body.expires_at != null && body.expires_at !== '') {
if (typeof body.expires_at !== 'string') {
return Response.json({ error: 'expires_at debe ser string ISO' }, { status: 400 });
}
const d = new Date(body.expires_at);
if (Number.isNaN(d.getTime())) {
return Response.json({ error: 'expires_at inválido' }, { status: 400 });
}
if (d <= new Date()) {
return Response.json({ error: 'expires_at debe ser futuro' }, { status: 400 });
}
expires_at = d.toISOString();
}
const { data, error } = await locals.supabase
.from('baneos')
.insert({
profile_id,
razon,
expires_at,
banned_by: locals.user!.id,
})
.select('id')
.single();
if (error) {
if ((error as { code?: string }).code === '23505') {
return Response.json(
{ error: 'El usuario ya tiene un baneo activo — desbaneálo primero' },
{ status: 409 },
);
}
return Response.json({ error: 'No se pudo crear el baneo' }, { status: 500 });
}
return Response.json({ id: data.id }, { status: 201 });
};
+1 -1
View File
@@ -56,7 +56,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
? rawMaestro ? rawMaestro
: null; : null;
const notas = typeof body.notas === 'string' && body.notas.trim() ? body.notas.trim().slice(0, 250) : null; const notas = typeof body.notas === 'string' && body.notas.trim() ? body.notas.trim().slice(0, 100) : null;
if (!Array.isArray(body.items) || body.items.length === 0) { if (!Array.isArray(body.items) || body.items.length === 0) {
return Response.json({ error: 'Agrega al menos un material' }, { status: 400 }); return Response.json({ error: 'Agrega al menos un material' }, { status: 400 });
+58
View File
@@ -0,0 +1,58 @@
import type { APIRoute } from 'astro';
import { serviceClient } from '@/lib/supabase';
export const prerender = false;
const BUCKET = 'avatares';
const MAX_BYTES = 2 * 1024 * 1024;
const extFrom = (file: File): string => {
const fromName = file.name?.split('.').pop()?.toLowerCase().replace(/[^a-z0-9]/g, '');
if (fromName) return fromName;
const fromMime = file.type?.split('/')[1]?.toLowerCase().replace(/[^a-z0-9]/g, '');
return fromMime || 'jpg';
};
export const POST: APIRoute = async ({ request, locals }) => {
const user = locals.user;
if (!user) return Response.json({ error: 'no autorizado' }, { status: 401 });
let form: FormData;
try {
form = await request.formData();
} catch {
return Response.json({ error: 'form-data inválido' }, { status: 400 });
}
const file = form.get('file');
if (!(file instanceof File)) {
return Response.json({ error: 'Archivo faltante' }, { status: 400 });
}
if (!/^image\//.test(file.type)) {
return Response.json({ error: 'Solo se permiten imágenes' }, { status: 400 });
}
if (file.size > MAX_BYTES) {
return Response.json({ error: 'La imagen supera 2 MB' }, { status: 400 });
}
const path = `${user.id}/${Date.now()}.${extFrom(file)}`;
const { error: upErr } = await serviceClient()
.storage.from(BUCKET)
.upload(path, file, { upsert: true, contentType: file.type });
if (upErr) {
return Response.json({ error: upErr.message || 'No se pudo subir' }, { status: 500 });
}
const { error: patchErr } = await locals.supabase
.from('profiles')
.update({ foto_path: path })
.eq('id', user.id);
if (patchErr) {
return Response.json({ error: 'Foto subida pero no vinculada' }, { status: 500 });
}
return Response.json({ ok: true, path });
};
@@ -0,0 +1,67 @@
import type { APIRoute } from 'astro';
import { serviceClient } from '@/lib/supabase';
export const prerender = false;
const BUCKET = 'materiales-fotos';
const MAX_BYTES = 2 * 1024 * 1024;
const parseId = (raw: string | undefined) => {
const id = Number(raw);
return Number.isInteger(id) && id > 0 ? id : null;
};
const extFrom = (file: File): string => {
const fromName = file.name?.split('.').pop()?.toLowerCase().replace(/[^a-z0-9]/g, '');
if (fromName) return fromName;
const fromMime = file.type?.split('/')[1]?.toLowerCase().replace(/[^a-z0-9]/g, '');
return fromMime || 'jpg';
};
export const POST: APIRoute = async ({ request, locals, params }) => {
if (locals.profile?.rol !== 'admin') {
return Response.json({ error: 'No autorizado' }, { status: 403 });
}
const materialId = parseId(params.id);
if (!materialId) return Response.json({ error: 'ID inválido' }, { status: 400 });
let form: FormData;
try {
form = await request.formData();
} catch {
return Response.json({ error: 'form-data inválido' }, { status: 400 });
}
const file = form.get('file');
if (!(file instanceof File)) {
return Response.json({ error: 'Archivo faltante' }, { status: 400 });
}
if (!/^image\//.test(file.type)) {
return Response.json({ error: 'Solo se permiten imágenes' }, { status: 400 });
}
if (file.size > MAX_BYTES) {
return Response.json({ error: 'La imagen supera 2 MB' }, { status: 400 });
}
const path = `${materialId}/${Date.now()}.${extFrom(file)}`;
const { error: upErr } = await serviceClient()
.storage.from(BUCKET)
.upload(path, file, { upsert: true, contentType: file.type });
if (upErr) {
return Response.json({ error: upErr.message || 'No se pudo subir' }, { status: 500 });
}
const { error: patchErr } = await locals.supabase
.from('materiales')
.update({ imagen_path: path })
.eq('id', materialId);
if (patchErr) {
return Response.json({ error: 'Foto subida pero no vinculada' }, { status: 500 });
}
return Response.json({ ok: true, path });
};
+85
View File
@@ -0,0 +1,85 @@
---
import Layout from '@/layouts/Layout.astro';
import BrandMark from '@/components/BrandMark.astro';
const user = Astro.locals.user;
if (!user) return Astro.redirect('/login');
type BanRow = {
razon: string;
banned_at: string;
expires_at: string | null;
banned_by: { nombre: string | null } | null;
};
const { data } = await Astro.locals.supabase
.from('baneos')
.select('razon, banned_at, expires_at, banned_by:profiles!banned_by(nombre)')
.eq('profile_id', user.id)
.is('unbanned_at', null)
.maybeSingle();
const ban = data as BanRow | null;
if (!ban) return Astro.redirect('/');
const fmtDate = (iso: string) =>
new Intl.DateTimeFormat('es-MX', {
dateStyle: 'long',
timeStyle: 'short',
timeZone: 'America/Tijuana',
}).format(new Date(iso));
const bannedAtStr = fmtDate(ban.banned_at);
const expiresStr = ban.expires_at ? fmtDate(ban.expires_at) : null;
const byName = ban.banned_by?.nombre ?? null;
Astro.response.status = 403;
---
<Layout title="Cuenta bloqueada — LabPréstamos">
<main id="main" class="min-h-screen grid place-items-center p-4">
<div class="w-full max-w-lg">
<div class="text-center mb-6">
<div class="inline-grid place-items-center w-16 h-16 rounded-[2px] p-4 mb-4" style="background: var(--color-danger); border: 2px solid var(--color-ink); box-shadow: var(--shadow-hard);">
<BrandMark class="w-full h-full" style="color: var(--color-ink);" />
</div>
<h1 class="text-2xl md:text-3xl font-semibold uppercase tracking-wide">Cuenta bloqueada</h1>
<p class="text-sm mt-2" style="color: var(--color-pencil);">Tu acceso al sistema de préstamos fue suspendido.</p>
</div>
<div class="card-raised">
<dl class="flex flex-col gap-4 text-sm">
<div>
<dt class="text-xs uppercase tracking-wide mb-1" style="color: var(--color-pencil);">Motivo</dt>
<dd class="whitespace-pre-wrap break-words">{ban.razon}</dd>
</div>
<div class="grid sm:grid-cols-2 gap-4">
<div>
<dt class="text-xs uppercase tracking-wide mb-1" style="color: var(--color-pencil);">Fecha del bloqueo</dt>
<dd>{bannedAtStr}</dd>
</div>
{expiresStr && (
<div>
<dt class="text-xs uppercase tracking-wide mb-1" style="color: var(--color-pencil);">Expira</dt>
<dd>{expiresStr}</dd>
</div>
)}
{byName && (
<div>
<dt class="text-xs uppercase tracking-wide mb-1" style="color: var(--color-pencil);">Bloqueado por</dt>
<dd>{byName}</dd>
</div>
)}
</div>
</dl>
<p class="text-sm mt-6" style="color: var(--color-pencil);">
Si crees que esto es un error, contacta al Laboratorio de Sistemas Computacionales.
</p>
<form method="POST" action="/api/auth/signout" class="mt-6">
<button type="submit" class="btn btn-primary w-full">Cerrar sesión</button>
</form>
</div>
</div>
</main>
</Layout>
+63
View File
@@ -0,0 +1,63 @@
-- 0007_baneos.sql
-- Lista negra de cuentas: tabla prestamos.baneos con historial
-- (soporta baneos permanentes y temporales via expires_at, aunque la UI
-- de esta ronda solo expone permanentes). Helper is_banned() para middleware.
begin;
create table if not exists prestamos.baneos (
id bigserial primary key,
profile_id uuid not null references prestamos.profiles(id) on delete cascade,
razon text not null,
banned_at timestamptz not null default now(),
banned_by uuid references prestamos.profiles(id),
expires_at timestamptz,
unbanned_at timestamptz,
unbanned_by uuid references prestamos.profiles(id)
);
-- Un solo baneo activo por profile (unbanned_at IS NULL) — partial unique index
create unique index if not exists baneos_profile_activo_uniq
on prestamos.baneos (profile_id)
where unbanned_at is null;
create index if not exists baneos_banned_at_idx
on prestamos.baneos (banned_at desc);
alter table prestamos.baneos enable row level security;
-- Admin lee/escribe todo
drop policy if exists baneos_admin_all on prestamos.baneos;
create policy baneos_admin_all on prestamos.baneos
for all to authenticated
using (prestamos.is_admin())
with check (prestamos.is_admin());
-- User autenticado lee los suyos (para /banned mostrar la razón)
drop policy if exists baneos_read_self on prestamos.baneos;
create policy baneos_read_self on prestamos.baneos
for select to authenticated
using (profile_id = auth.uid());
grant select, insert, update, delete on prestamos.baneos to authenticated, service_role;
grant usage, select on prestamos.baneos_id_seq to authenticated, service_role;
-- Helper: baneo activo (no desbaneado y no expirado)
create or replace function prestamos.is_banned(p_uid uuid)
returns boolean
language sql
stable
security definer
set search_path = prestamos, pg_temp
as $$
select exists (
select 1 from prestamos.baneos
where profile_id = p_uid
and unbanned_at is null
and (expires_at is null or expires_at > now())
);
$$;
grant execute on function prestamos.is_banned(uuid) to anon, authenticated;
commit;