Adds a tiny planned-phase doc for visual feedback during async operations. - Single SVG-circle Spinner.svelte component, three sizes (sm/md/lg = 16 / 24 / 40 px), color via currentColor (theme-aware), motion-reduce-aware, role="status" + sr-only m.loading() for a11y. - Integration at 5 existing sites that already render m.loading() text or own a `loading` flag: auth splash, /search, /notes/archive, CreateCollectiveModal "creating" state, /collective/manage sub-sections. - 5 unit + 3 e2e tests planned. No DB migration. No new Paraglide strings. Rebrand: MVP2 reopens from 7/7 ✅ to in-progress 7/8 ✅. Headers in fases 9–15 bump "·N/7" + "fases 9 → 15" to "·N/8" + "fases 9 → 16". README + CLAUDE.md updated. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
162 lines
11 KiB
Markdown
162 lines
11 KiB
Markdown
### Fase 13 — Server administration
|
|
|
|
**MVP2 · 5/8.** Quinta fase del ciclo MVP2 (rama `mvp2`, fases 9 → 16).
|
|
|
|
**Estado: 📋 Planificada. Mayor salto de scope hasta ahora — introduce un rol global ortogonal a los roles de colectivo, un área `/admin` nueva, RPCs con privilegios elevados, y un audit log. Requiere Fase 12 para los toggles de visibilidad a nivel servidor.**
|
|
|
|
**Objetivo: dar al operador del servidor herramientas para gestionar la salud de la instancia sin tocar SQL en producción — listar y borrar colectivos huérfanos, expulsar miembros problemáticos, fijar visibilidad por defecto de secciones a nivel servidor, y dejar trazabilidad de cada acción.**
|
|
|
|
**Decisiones clave:**
|
|
- **Rol global aparte del modelo de colectivo.** Un `server_admin` puede no pertenecer a ningún colectivo. Tabla `server_admins (user_id pk)`. No flag en `users` para evitar tener que regrabar JWT al promote/demote — la verificación es una query a esta tabla.
|
|
- **Bootstrap por seed o ENV.** En arranque, si la tabla está vacía y `SERVER_ADMIN_EMAIL` está seteado, promover ese email a admin (idempotente).
|
|
- **RPCs SECURITY DEFINER con check explícito** `if not is_server_admin(auth.uid()) then raise exception 'forbidden'`. No se exponen tablas con RLS open para admins.
|
|
- **Audit log inmutable**. Tabla `admin_actions` append-only (no UPDATE/DELETE policy). Cada RPC admin escribe una fila.
|
|
- **UI separada** en `/admin/*`. Layout propio. No se cuela con el shell del usuario normal (cambio visual evidente — banner rojo).
|
|
|
|
---
|
|
|
|
#### 13.1 Modelo: rol + audit log
|
|
|
|
**Fichero:** `supabase/migrations/023_server_admin.sql`.
|
|
|
|
```sql
|
|
create table public.server_admins (
|
|
user_id uuid primary key references public.users(id) on delete cascade,
|
|
granted_by uuid null references public.users(id) on delete set null,
|
|
granted_at timestamptz not null default now()
|
|
);
|
|
alter table public.server_admins enable row level security;
|
|
|
|
create policy "select_self_or_admin" on public.server_admins
|
|
for select using (
|
|
user_id = auth.uid()
|
|
or exists (select 1 from public.server_admins where user_id = auth.uid())
|
|
);
|
|
-- Sin INSERT/UPDATE/DELETE policy: solo SECURITY DEFINER RPCs pueden tocarla.
|
|
|
|
create or replace function public.is_server_admin(p_user uuid default auth.uid())
|
|
returns boolean language sql stable security definer
|
|
set search_path = public, auth
|
|
as $$ select exists (select 1 from public.server_admins where user_id = p_user) $$;
|
|
|
|
create table public.admin_actions (
|
|
id uuid primary key default gen_random_uuid(),
|
|
actor_id uuid not null references public.users(id) on delete restrict,
|
|
action text not null,
|
|
target_type text not null,
|
|
target_id uuid null,
|
|
payload jsonb not null default '{}'::jsonb,
|
|
created_at timestamptz not null default now()
|
|
);
|
|
alter table public.admin_actions enable row level security;
|
|
|
|
create policy "select_admin_only" on public.admin_actions
|
|
for select using (public.is_server_admin());
|
|
-- Append-only: ningún UPDATE/DELETE policy. Solo INSERT desde RPCs SECURITY DEFINER.
|
|
|
|
create index admin_actions_actor_idx on public.admin_actions (actor_id, created_at desc);
|
|
create index admin_actions_target_idx on public.admin_actions (target_type, target_id);
|
|
```
|
|
|
|
**Tareas:**
|
|
|
|
- [ ] **13.1.1** Migración 023.
|
|
- [ ] **13.1.2** Bootstrap: script `infra/db-init/10-server-admin-seed.sh` que lee `SERVER_ADMIN_EMAIL` y, si la tabla está vacía, inserta la fila correspondiente vía join a `auth.users`. Idempotente.
|
|
- [ ] **13.1.3** Añadir `SERVER_ADMIN_EMAIL` a `.env.erosi.example` con comentario explicativo.
|
|
- [ ] **13.1.4** Tests pgTAP `supabase/tests/013_server_admin.sql`: `is_server_admin()` true/false; non-admin no puede SELECT `server_admins` ajeno; non-admin no puede ver `admin_actions`.
|
|
|
|
---
|
|
|
|
#### 13.2 RPCs admin
|
|
|
|
**Fichero:** `supabase/migrations/024_admin_rpcs.sql`.
|
|
|
|
Funciones (todas SECURITY DEFINER con check `if not is_server_admin() then raise exception 'forbidden' using errcode = 'P0001'`):
|
|
|
|
```sql
|
|
-- Gestión de admins (solo otro admin puede promote/demote; el último admin no puede demoteárse).
|
|
create function public.grant_server_admin(p_user uuid) returns void ...
|
|
create function public.revoke_server_admin(p_user uuid) returns void ...
|
|
|
|
-- Colectivos
|
|
create function public.admin_list_collectives(p_search text default null, p_limit int default 50)
|
|
returns table (id uuid, name text, member_count int, created_at timestamptz, deleted_at timestamptz) ...
|
|
create function public.admin_soft_delete_collective(p_collective_id uuid, p_reason text) returns void ...
|
|
create function public.admin_restore_collective(p_collective_id uuid) returns void ...
|
|
create function public.admin_hard_delete_collective(p_collective_id uuid) returns void ...
|
|
-- Solo si deleted_at not null y han pasado >= 30 días, o force flag explícito.
|
|
|
|
-- Miembros
|
|
create function public.admin_remove_member(p_collective_id uuid, p_user uuid, p_reason text) returns void ...
|
|
|
|
-- Section visibility por defecto a nivel servidor (Fase 12 dependency)
|
|
create function public.admin_set_default_section(p_section text, p_enabled boolean) returns void ...
|
|
-- Escribe en una tabla nueva server_settings (key, value jsonb) — se crea aquí.
|
|
```
|
|
|
|
**Tareas:**
|
|
|
|
- [ ] **13.2.1** Migración 024 con todas las RPCs + tabla `server_settings (key text primary key, value jsonb)`.
|
|
- [ ] **13.2.2** Cada RPC: chequeo de admin → inserción en `admin_actions` → ejecución. Wrap en transacción.
|
|
- [ ] **13.2.3** Guard "último admin": `revoke_server_admin` falla si el target es el único en `server_admins`.
|
|
- [ ] **13.2.4** Integrar `server_settings.default_sections` en `public.section_enabled(...)` de Fase 12 — añade una capa por encima del colectivo: precedencia final **server → collective → user → default true**.
|
|
- [ ] **13.2.5** Tests pgTAP `supabase/tests/014_admin_rpcs.sql`: cada RPC denegada para non-admin; soft-delete escribe audit; hard-delete bloqueado antes de los 30 días sin force; promote/demote audit; último admin no puede demoteárse.
|
|
|
|
---
|
|
|
|
#### 13.3 UI `/admin/*`
|
|
|
|
**Ficheros:** nueva ruta `apps/web/src/routes/(admin)/`. Layout propio.
|
|
|
|
- [ ] **13.3.1** `(admin)/+layout.ts`: `load` redirige a `/` si `is_server_admin()` (vía RPC) devuelve false. Sin SSR — `ssr: false`.
|
|
- [ ] **13.3.2** `(admin)/+layout.svelte`: banner rojo `bg-red-600 text-white` con texto "Admin mode — actions are logged". Sidebar simple: Colectivos, Admins, Audit log, Servidor.
|
|
- [ ] **13.3.3** `/admin/collectives` — tabla paginada con search por nombre, columnas: nombre, miembros, creado, estado (activo / soft-deleted). Acciones por fila: ver, soft-delete (modal con reason obligatorio), restore, hard-delete (modal de confirmación + checkbox "Entiendo que es irreversible").
|
|
- [ ] **13.3.4** `/admin/collectives/[id]` — detalle: miembros con rol, botón "Expulsar" por miembro (modal con reason). Sección "Acciones recientes" filtrando `admin_actions` por este `target_id`.
|
|
- [ ] **13.3.5** `/admin/admins` — lista de `server_admins`. Botón "Promote user" abre modal con email lookup (`auth.users where email = ...`). Botón "Revoke" por fila (deshabilitado si es uno mismo o si es el único).
|
|
- [ ] **13.3.6** `/admin/audit` — feed reverse-chronological de `admin_actions`, filtros por actor, acción, fecha. Paginación a 50.
|
|
- [ ] **13.3.7** `/admin/server` — defaults de sección (Fase 12): 4 toggles + servicio info (versión, uptime — usar `select pg_postmaster_start_time(), version()` vía RPC).
|
|
|
|
---
|
|
|
|
#### 13.4 Acceso al menú
|
|
|
|
- [ ] **13.4.1** Si `$isServerAdmin` (store derivado de un `is_server_admin()` cacheado al login), añadir entrada "Admin" en el menú de usuario del sidebar/drawer (separador + ícono `Shield` rojo).
|
|
- [ ] **13.4.2** Refrescar el flag al `INITIAL_SESSION` / `SIGNED_IN` / `TOKEN_REFRESHED`.
|
|
|
|
---
|
|
|
|
#### 13.5 Tests
|
|
|
|
- [ ] **13.5.1** Vitest integración `tests/integration/server-admin.test.ts`: precedencia de section-visibility con la capa server activa; RPCs denegadas para no-admin; audit log se escribe; hard-delete cascade no rompe FK fuera del colectivo.
|
|
- [ ] **13.5.2** Playwright `tests/e2e/admin.test.ts` (nuevo helper `loginAsAdmin`):
|
|
- SA-01 admin promovido vía seed accede a `/admin`; usuario normal redirigido a `/`.
|
|
- SA-02 admin soft-deletea un colectivo → miembros no pueden entrar (redirect a `/onboarding`); admin restore lo recupera.
|
|
- SA-03 admin apaga "Notes" a nivel servidor → todos los colectivos lo ven OFF aunque el admin de colectivo lo ponga ON localmente.
|
|
- SA-04 admin promueve a otro usuario; el segundo accede a `/admin`; el primero intenta revocarse a sí mismo siendo el único restante → bloqueado.
|
|
|
|
---
|
|
|
|
#### 13.Z Verificación + cierre
|
|
|
|
- [ ] **13.Z.1** `just test-all` verde. Suma esperada: +6 pgTAP + ~4 vitest + 4 playwright.
|
|
- [ ] **13.Z.2** Revisión manual de seguridad: `select * from pg_proc where proname like 'admin\_%' and prosecdef` debe coincidir con la lista esperada de RPCs SECURITY DEFINER (todas y solo las nuestras).
|
|
- [ ] **13.Z.3** Smoke en prod: crear un server_admin vía bootstrap, listar colectivos, ver audit log. No tocar datos reales.
|
|
- [ ] **13.Z.4** Documentar en `docs/history/fase-13-server-admin.md` + sección "Server administration" en `docs/deployment.md` (cómo promover el primer admin via `SERVER_ADMIN_EMAIL`).
|
|
|
|
---
|
|
|
|
### Riesgos / notas
|
|
|
|
- **Hard delete cascade**. Borrar un colectivo borra `shopping_lists` → `shopping_items` → `shopping_item_tags` → (Fase 11) `item_tags`. Todas las FK ya tienen `on delete cascade`; verificar en `EXPLAIN`. Confirmar también que `users` no se ve afectado.
|
|
- **JWT no contiene el flag admin** (el rol se chequea por query, no por claim). Compromiso: una query extra al login + cada vez que el store se hidrata. Beneficio: promote/revoke instantáneo sin re-login. Si esto se vuelve caliente, mover a custom claim vía hook `auth.users` → `raw_app_meta_data`.
|
|
- **Audit append-only sin policy DELETE** funciona en runtime, pero `postgres` superuser puede borrar. Aceptable: el modelo de amenaza es "admin malicioso del producto", no "operador con shell en el servidor".
|
|
- **Bootstrap idempotente** importante — si ya hay admins, el script no debe degradar nada. Test con `pgTAP`: segunda invocación con `SERVER_ADMIN_EMAIL` distinto no toca filas previas.
|
|
|
|
### Scope explícito fuera
|
|
|
|
- 2FA obligatorio para admins. Bonito, no MVP.
|
|
- Roles intermedios (read-only admin). Solo `server_admin` binario.
|
|
- Webhooks o notificaciones de acciones admin. Solo audit table.
|
|
- Importar/exportar colectivos como JSON. Solo CRUD destructivo + creación de usuario normal.
|
|
- Admin sobre `auth.users` directamente. Solo a nivel `public` (colectivos, miembros, settings).
|