Compare commits
4 Commits
3e576426e1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 800ec60a53 | |||
| 3cccf758a8 | |||
| dea268d975 | |||
| c43f45eca1 |
@@ -258,6 +258,19 @@ Fases 3, 4/5 y 6 tocan carpetas de rutas distintas (`src/pages/alumno/*`, `src/p
|
||||
- **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.
|
||||
|
||||
@@ -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<HTMLDialogElement | 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();
|
||||
}, [error]);
|
||||
|
||||
async function uploadFotoFor(id: number): Promise<string | null> {
|
||||
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<string | null> {
|
||||
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<string, unknown> = {
|
||||
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' },
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { toast, toastAfterReload } from '@/lib/toast';
|
||||
import { imgUrl } from '@/lib/materialImg';
|
||||
import AgregarMaterial from './AgregarMaterial';
|
||||
|
||||
type Material = {
|
||||
@@ -9,9 +10,18 @@ type Material = {
|
||||
cantidad_disponible: number;
|
||||
cantidad_total: number;
|
||||
numero_inventario: string | null;
|
||||
imagen_path: 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 = {
|
||||
material_id: number;
|
||||
nombre: string;
|
||||
@@ -55,6 +65,7 @@ export default function CartProvider({
|
||||
maestros?: MaestroOpt[];
|
||||
}) {
|
||||
const [cart, setCart] = useState<Map<number, CartItem>>(new Map());
|
||||
const cartHydrated = useRef(false);
|
||||
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||
const firstFieldRef = useRef<HTMLSelectElement | null>(null);
|
||||
const [maestroId, setMaestroId] = useState<string>('');
|
||||
@@ -130,6 +141,43 @@ export default function CartProvider({
|
||||
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
|
||||
useEffect(() => {
|
||||
const dlg = dialogRef.current;
|
||||
@@ -191,46 +239,59 @@ export default function CartProvider({
|
||||
return (
|
||||
<CartContext.Provider value={value}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{materiales.map((m) => (
|
||||
<article
|
||||
key={m.id}
|
||||
className="card flex flex-col gap-3"
|
||||
data-cat={m.categoria?.id ?? 'sin'}
|
||||
data-nombre={m.nombre}
|
||||
data-numero-inventario={m.numero_inventario ?? ''}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold leading-snug">{m.nombre}</h3>
|
||||
{m.categoria && (
|
||||
{materiales.map((m) => {
|
||||
const src = imgUrl(m.imagen_path);
|
||||
return (
|
||||
<article
|
||||
key={m.id}
|
||||
className="card p-0 flex flex-col overflow-hidden"
|
||||
data-cat={m.categoria?.id ?? 'sin'}
|
||||
data-nombre={m.nombre}
|
||||
data-numero-inventario={m.numero_inventario ?? ''}
|
||||
>
|
||||
<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
|
||||
className="text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap uppercase tracking-wide"
|
||||
style={{
|
||||
background: 'color-mix(in oklab, var(--color-primary) 25%, white)',
|
||||
color: 'var(--color-ink)',
|
||||
border: '1.5px solid var(--color-primary)',
|
||||
}}
|
||||
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={DISPONIBLE_BADGE_STYLE}
|
||||
>
|
||||
{m.categoria.nombre}
|
||||
Disponible
|
||||
</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">
|
||||
<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>
|
||||
{m.descripcion && <p className="text-sm opacity-75 line-clamp-2">{m.descripcion}</p>}
|
||||
|
||||
<div className="mt-auto">
|
||||
<AgregarMaterial material={{ id: m.id, nombre: m.nombre, cantidad_disponible: m.cantidad_disponible }} />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
<dl className="text-xs opacity-70 grid grid-cols-2 gap-1">
|
||||
<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">
|
||||
<AgregarMaterial material={{ id: m.id, nombre: m.nombre, cantidad_disponible: m.cantidad_disponible }} />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
@@ -356,7 +417,7 @@ export default function CartProvider({
|
||||
id="notas-cart"
|
||||
className="input"
|
||||
rows={3}
|
||||
maxLength={250}
|
||||
maxLength={100}
|
||||
value={notas}
|
||||
onChange={(e) => setNotas(e.target.value)}
|
||||
placeholder="¿Para qué clase o proyecto lo necesitas?"
|
||||
@@ -364,7 +425,7 @@ export default function CartProvider({
|
||||
aria-describedby="notas-cart-hint"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -385,13 +446,23 @@ export default function CartProvider({
|
||||
<a href="/perfil" className="btn btn-primary">Completar perfil</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2 justify-end pt-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'}
|
||||
<div className="flex gap-2 justify-between items-center pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => { if (window.confirm('¿Vaciar todo el carrito?')) setCart(new Map()); }}
|
||||
disabled={loading || items.length === 0}
|
||||
>
|
||||
Vaciar
|
||||
</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>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { browserClient } from '@/lib/supabase';
|
||||
import { avatarInfo } from '@/lib/avatar';
|
||||
import { toast, toastAfterReload } from '@/lib/toast';
|
||||
import Avatar from './Avatar';
|
||||
@@ -22,7 +21,6 @@ type Props = {
|
||||
mode?: 'edit' | 'onboarding';
|
||||
};
|
||||
|
||||
const BUCKET = 'avatares';
|
||||
const SEM_OPTS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'];
|
||||
|
||||
export default function PerfilForm({
|
||||
@@ -69,18 +67,16 @@ export default function PerfilForm({
|
||||
? { ...info, src: previewUrl }
|
||||
: info;
|
||||
|
||||
async function uploadAvatar(): Promise<string | null> {
|
||||
if (!file) return null;
|
||||
const ext = (file.name.split('.').pop() ?? 'jpg').toLowerCase().replace(/[^a-z0-9]/g, '') || 'jpg';
|
||||
const path = `${userId}/${Date.now()}.${ext}`;
|
||||
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: upErr.message });
|
||||
return null;
|
||||
async function uploadAvatar(f: File): Promise<boolean> {
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
const res = await fetch('/api/upload/avatar', { 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 ?? 'Intenta de nuevo.' });
|
||||
return false;
|
||||
}
|
||||
return path;
|
||||
return true;
|
||||
}
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
@@ -89,9 +85,10 @@ export default function PerfilForm({
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
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) {
|
||||
nuevoPath = await uploadAvatar();
|
||||
await uploadAvatar(file);
|
||||
}
|
||||
const body: Record<string, string | number | null> = {
|
||||
matricula: matricula.trim() || null,
|
||||
@@ -100,7 +97,6 @@ export default function PerfilForm({
|
||||
body.semestre = semestre || null;
|
||||
body.tutor_id = tutorId ? Number(tutorId) : null;
|
||||
}
|
||||
if (nuevoPath !== undefined) body.foto_path = nuevoPath;
|
||||
|
||||
const res = await fetch('/api/profile', {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -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',
|
||||
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',
|
||||
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[] = [
|
||||
@@ -70,6 +71,7 @@ const adminNav: NavItem[] = [
|
||||
{ href: '/admin/inventario', label: 'Inventario', icon: 'box' },
|
||||
{ href: '/admin/estadisticas', label: 'Estadísticas', icon: 'stats' },
|
||||
{ href: '/admin/reportes', label: 'Reportes', icon: 'chart' },
|
||||
{ href: '/admin/usuarios', label: 'Usuarios', icon: 'users' },
|
||||
];
|
||||
const nav = isAdmin ? adminNav : alumnoNav;
|
||||
|
||||
|
||||
@@ -38,5 +38,70 @@ const { title = 'Sistema de Préstamos — Laboratorio UABC' } = Astro.props;
|
||||
Saltar al contenido
|
||||
</a>
|
||||
<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>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const truncate = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
|
||||
@@ -3,6 +3,8 @@ import { serverClient } from '@/lib/supabase';
|
||||
|
||||
const UABC_DOMAIN = '@uabc.edu.mx';
|
||||
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) => {
|
||||
const supabase = serverClient(context.cookies);
|
||||
@@ -56,6 +58,15 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
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') {
|
||||
return context.rewrite('/403');
|
||||
}
|
||||
|
||||
@@ -109,21 +109,6 @@ const estadoBadge = (e: Material['estado']) => {
|
||||
</div>
|
||||
</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 && (
|
||||
<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);">
|
||||
@@ -151,7 +136,7 @@ const estadoBadge = (e: Material['estado']) => {
|
||||
return (
|
||||
<article class="card p-0 flex flex-col overflow-hidden">
|
||||
<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);"
|
||||
>
|
||||
{src ? (
|
||||
@@ -163,14 +148,12 @@ const estadoBadge = (e: Material['estado']) => {
|
||||
<circle cx="8" cy="9" r="1.5" />
|
||||
</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 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>
|
||||
<span class="text-xs px-2 py-0.5 rounded-[2px] whitespace-nowrap shrink-0" style={badge.style}>
|
||||
{badge.label}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="uppercase font-semibold text-sm leading-snug line-clamp-2">{m.nombre}</h3>
|
||||
{m.categoria && (
|
||||
<p class="text-xs opacity-70 -mt-1">{m.categoria.nombre}</p>
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import AppLayout from '@/layouts/AppLayout.astro';
|
||||
import MarcarDevuelto from '@/components/admin/solicitudes/MarcarDevuelto.tsx';
|
||||
import VerDetalles from '@/components/admin/solicitudes/VerDetalles.tsx';
|
||||
import { truncate } from '@/lib/text';
|
||||
|
||||
const supabase = Astro.locals.supabase;
|
||||
const { data, error } = await supabase
|
||||
@@ -107,7 +108,7 @@ const tabs = [
|
||||
</div>
|
||||
{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);">
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
{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);">
|
||||
<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>
|
||||
)}
|
||||
<div class="mt-3 flex gap-2 flex-wrap">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import AppLayout from '@/layouts/AppLayout.astro';
|
||||
import AccionesSolicitud from '@/components/admin/solicitudes/AccionesSolicitud.tsx';
|
||||
import { truncate } from '@/lib/text';
|
||||
|
||||
const supabase = Astro.locals.supabase;
|
||||
const { data, error } = await supabase
|
||||
@@ -99,7 +100,7 @@ const tabs = [
|
||||
</div>
|
||||
{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);">
|
||||
<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>
|
||||
)}
|
||||
</td>
|
||||
@@ -135,7 +136,7 @@ const tabs = [
|
||||
</div>
|
||||
{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);">
|
||||
<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>
|
||||
)}
|
||||
<div class="mt-3">
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -56,7 +56,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
? rawMaestro
|
||||
: 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) {
|
||||
return Response.json({ error: 'Agrega al menos un material' }, { status: 400 });
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user