Files
collective-lists/plan/fase-1-auth-colectivo.md
Oier Bravo Urtasun b297d253d9 fix(auth+pwa): resolve stuck-loading race condition and PWA build error
Auth: `getSession()` in a SvelteKit load function races with Supabase's async
localStorage init and can return null for a valid session, triggering a spurious
login() redirect. Moved auth redirect to (app)/+layout.svelte via $effect, which
correctly waits for onAuthStateChange to fire. Also fixed collective data never
loading on page refresh (INITIAL_SESSION event, not SIGNED_IN).

PWA: SvelteKit blocks Workbox imports in src/service-worker.ts, preventing
@vite-pwa/sveltekit from finding the compiled SW output. Switched to generateSW
strategy (plugin auto-generates the SW). Custom SW deferred to Fase 2b using
src/sw.ts (a filename SvelteKit does not intercept).

Docs: added gotchas 6–8 to CLAUDE.md covering both root causes so they are not
reintroduced. Updated plan/fase-2b with the sw.ts workaround note.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 21:44:14 +02:00

90 lines
6.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
### Fase 1 — Autenticación y Colectivo
**Duración estimada: 23 semanas**
**Objetivo: la base del dominio. Sin esto no existe nada más.**
#### 1.1 Base de datos
- [x] Migración: tabla `users` — espejo de `auth.users` de Supabase, poblada por trigger al primer login OIDC:
- `display_name`, `email`
- `language: enum(en | es)` — default `en`, sobrescrito por preferencia de navegador en primer login
- `avatar_type: enum(initials | emoji | upload)` — default `initials`
- `avatar_emoji: text | null`
- `avatar_url: text | null` — URL firmada de Supabase Storage
- [x] Migración: tabla `collectives` con campos `id`, `name`, `emoji`, `created_by`, `created_at`
- [x] Migración: tabla `collective_members` con `role: enum(admin | member | guest)`
- [x] Migración: tabla `collective_invitations` con token, expiración y estado
- [x] **RLS completo** — políticas críticas:
- Un usuario solo puede leer colectivos a los que pertenece
- Solo administradores pueden insertar en `collective_invitations`
- Solo administradores pueden actualizar roles en `collective_members`
- Un usuario solo puede actualizar su propia fila en `users`
- [x] Trigger: `on_auth_user_created` — sincroniza el perfil de Keycloak (display_name, email) a `users` en cada login
- [x] Trigger: promoción automática al miembro más antiguo si el último admin elimina su cuenta (RN-10)
- [x] Función SQL: `is_active_member(collective_id, user_id)` — usada en todas las políticas RLS
- [x] Bucket Supabase Storage: `avatars` — privado, acceso vía signed URLs, un fichero por usuario (`{user_id}/avatar`)
#### 1.2 Integración Keycloak → Supabase
> **✅ DECISIÓN: Opción B — GoTrue como proxy OIDC.**
> GoTrue recibe el token de Keycloak, lo valida y emite su propio JWT de Supabase.
> `auth.uid()` resuelve al UUID de GoTrue (que mapea al `sub` de Keycloak).
> Esto mantiene `auth.users` de Supabase poblado y permite usar todas las helpers de GoTrue.
- [x] Configurar Keycloak como OIDC Identity Provider en GoTrue (`infra/docker-compose.dev.yml``GOTRUE_EXTERNAL_KEYCLOAK_*`)
- [x] Flujo: `signInWithOAuth({ provider: 'keycloak' })` → Keycloak PKCE → GoTrue callback → Supabase session
- [x] Verificar que `auth.uid()` en las políticas RLS resuelve correctamente — ✅ curl acceptance test: los 5 usuarios autenticados vía Keycloak→GoTrue tienen `sub` correcto y las políticas RLS devuelven solo sus datos
- [x] Test de integración: token de Keycloak → exchange con GoTrue → query a Supabase con RLS → respuesta correcta — ✅ todos los 5 usuarios pasan
#### 1.3 Invitaciones (sin email)
> **✅ DECISIÓN: sin email.** Las invitaciones se crean como un link que el admin copia manualmente y comparte por cualquier canal (WhatsApp, etc.). No se integra Resend ni se envían emails.
- [x] `collective_invitations` tiene `token` único (UUID), `expires_at`, `role`, y `accepted_at`
- [x] Pantalla `/collective/manage` genera el link de invitación y muestra un botón "Copy link"
- [x] `/invitation/[token]` — el invitado abre el link, se autentica si no lo está, y acepta
- [x] Lógica de aceptación via función SQL `accept_invitation(p_token)` SECURITY DEFINER (no Edge Function)
#### 1.4 Internacionalización (i18n)
Librería: **Paraglide JS** (`@inlang/paraglide-sveltekit`). Compile-time, tree-shaken por idioma, sin overhead en runtime.
- [x] Instalar y configurar `@inlang/paraglide-sveltekit` con el plugin de Vite
- [x] Crear ficheros de mensajes: `messages/en.json` y `messages/es.json`
- [ ] Configurar detección de idioma: `Accept-Language` header en primer load → guardado en `users.language` tras login
- [x] Cambio de idioma en runtime: actualizar `users.language` en Supabase + llamar a `setLanguageTag()` de Paraglide — sin recarga de página
- [x] Traducir todas las cadenas UI de la Fase 1 (onboarding, gestión de colectivo, settings)
> Las cadenas de UI van en `messages/`. Nunca hardcodear texto visible al usuario directamente en componentes.
#### 1.5 Frontend
- [x] Integración de Supabase OAuth en SvelteKit (PKCE, no keycloak-js):
- `supabase.auth.signInWithOAuth({ provider: 'keycloak' })` — compatible con Safari sin iframes
- Store Svelte `auth` — expone `currentUser`, `authLoading`, `isAuthenticated`, `displayName`
- `login()` redirige a Keycloak vía GoTrue; callback en `/auth/callback` intercambia el code
- Refresco automático de sesión por el cliente de Supabase (localStorage)
- [x] Rutas protegidas: `(app)/+layout.ts` con `ssr: false` que redirige si no hay sesión
- [x] Flujo de onboarding post-primer-login:
- Detectar si el usuario no pertenece a ningún colectivo → redirigir a `/onboarding`
- Opción A: Crear nuevo colectivo (nombre + emoji)
- Opción B: Pegar link de invitación recibido
- [x] Pantalla `/collective/manage` — miembros, roles, botón "Generate invite link" (copia al clipboard), expulsar
- [x] Pantalla `/invitation/[token]` — aceptar invitación (el usuario debe estar autenticado)
- [x] **Selector de colectivo activo** en la barra lateral — un usuario puede pertenecer a varios colectivos y cambiar entre ellos
- [x] Store Svelte `collective` — colectivo activo, lista de miembros, rol del usuario actual, lista de todos los colectivos del usuario
- [x] Pantalla `/settings` — configuración de usuario:
- Campo display name (input de texto, guardado automático con debounce)
- Selector de avatar con tres modos:
- *Initials* — preview generado en tiempo real desde el display name
- *Emoji* — grid de 32 emoji curados, tap para seleccionar
- *Upload* — input de fichero; recorte en el navegador 1:1 con `cropperjs`; subida a Supabase Storage bucket `avatars`
- Selector de idioma (English / Español) con efecto inmediato
- Enlace externo a Keycloak Account Console para cambio de email/contraseña
- [x] Componente `Avatar.svelte` — renderiza según `avatar_type`: initials (color determinista por hash del nombre), emoji, o `<img>` con fallback a initials
> **Auto-registro:** Self-registration está habilitado en Keycloak. Un usuario sin cuenta que recibe un link de invitación puede registrarse en la pantalla de Keycloak y volver al callback de invitación automáticamente.
**Criterio de aceptación:** los 5 usuarios de prueba pueden autenticarse contra Keycloak, llegar a la app con sesión activa, y `auth.uid()` en Supabase resuelve correctamente para las políticas RLS.
---