diff --git a/CLAUDE.md b/CLAUDE.md index edabf82..b62645e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,13 +97,17 @@ The central organizing unit is the **Collective** (household group), not the ind - Trash retains deleted items/notes for 7 days before permanent deletion. - If the sole admin deletes their account, the system auto-promotes the oldest member before allowing deletion. -## Auth Architecture (Keycloak → Supabase) +## Auth Architecture (Keycloak → GoTrue → Supabase) -Auth flows through Keycloak as the OIDC IdP. Supabase validates Keycloak-issued JWTs. Supabase RLS uses `auth.uid()` which reads the `sub` claim from the JWT (must be a UUID v4 — Keycloak default). +**Strategy: Option B — GoTrue as OIDC proxy.** +The app authenticates with Keycloak (PKCE), then exchanges the Keycloak token with GoTrue for a Supabase JWT. Supabase RLS uses `auth.uid()` which resolves to the GoTrue user UUID (mapped from Keycloak `sub`). GoTrue maintains `auth.users`. -The Keycloak RS256 public key must be set as `JWT_SECRET` in Supabase. **Rotating Keycloak signing keys requires updating Supabase `JWT_SECRET` simultaneously.** +Key decisions: +- **Self-registration enabled** in Keycloak — users can create accounts on the Keycloak login screen. +- **Invitations are link-only** — no email sent. Admin generates a link and shares it manually. +- **Multiple collectives per user** — a user can belong to several collectives and switch between them in the sidebar. -Logout must invalidate both sides: call `keycloak.logout()` (invalidates Keycloak session) AND clear the Supabase client locally. +Logout must invalidate both sides: call `keycloak.logout()` (invalidates Keycloak session) AND clear the Supabase client session locally. ## Sync Strategy diff --git a/keycloak/realm-export.json b/keycloak/realm-export.json index 2cc1254..10ac16b 100644 --- a/keycloak/realm-export.json +++ b/keycloak/realm-export.json @@ -4,7 +4,7 @@ "displayName": "Colectivo", "enabled": true, "sslRequired": "external", - "registrationAllowed": false, + "registrationAllowed": true, "loginWithEmailAllowed": true, "duplicateEmailsAllowed": false, "resetPasswordAllowed": true, diff --git a/plan/fase-1-auth-colectivo.md b/plan/fase-1-auth-colectivo.md index f7246ba..64f1cbf 100644 --- a/plan/fase-1-auth-colectivo.md +++ b/plan/fase-1-auth-colectivo.md @@ -25,28 +25,24 @@ #### 1.2 Integración Keycloak → Supabase -El JWT que emite Keycloak debe ser validable por Supabase RLS. Hay dos estrategias; elegir una antes de implementar: +> **✅ 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. -**Opción A — JWT de Keycloak directo en Supabase (recomendada para self-hosted):** -- Configurar `JWT_SECRET` de Supabase con la clave pública de Keycloak (RS256) -- Supabase valida el token de Keycloak directamente en RLS via `auth.uid()` -- Sin intermediarios, latencia mínima -- Requiere que el claim `sub` de Keycloak sea el UUID que identifica al usuario en Supabase +- [ ] Configurar Keycloak como OIDC Identity Provider en GoTrue (`supabase/config.toml`) +- [ ] Flujo: app obtiene token de Keycloak → lo intercambia por token de GoTrue → usa token de GoTrue para Supabase +- [ ] Verificar que `auth.uid()` en las políticas RLS resuelve correctamente +- [ ] Test de integración: token de Keycloak → exchange con GoTrue → query a Supabase con RLS → respuesta correcta -**Opción B — GoTrue como proxy OIDC:** -- Supabase Auth recibe el token de Keycloak, lo valida y emite su propio JWT -- Más aislamiento, pero un salto extra en cada request +#### 1.3 Invitaciones (sin email) -> **Decisión recomendada: Opción A.** Más simple operacionalmente en self-hosted. El `sub` de Keycloak se convierte en el `auth.uid()` de Supabase. +> **✅ 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. -- [ ] Configurar `supabase/config.toml` con Keycloak como OIDC externo -- [ ] Verificar que `auth.uid()` en las políticas RLS resuelve al `sub` de Keycloak -- [ ] Test de integración: token de Keycloak → query a Supabase con RLS activo → respuesta correcta - -#### 1.3 Edge Functions - -- [ ] `invite-member`: genera token único, inserta invitación, envía email vía Resend -- [ ] `accept-invitation`: valida token + expiración, une al usuario al colectivo, invalida el token +- [ ] `collective_invitations` tiene `token` único (UUID), `expires_at`, `role`, y `accepted_at` +- [ ] Pantalla `/collective/manage` genera el link de invitación y muestra un botón "Copy link" +- [ ] `/invitation/[token]` — el invitado abre el link, se autentica si no lo está, y acepta +- [ ] Lógica de aceptación en un server action de SvelteKit (no Edge Function): validar token + expiración, insertar en `collective_members`, marcar `accepted_at` #### 1.4 Internacionalización (i18n) @@ -69,24 +65,24 @@ Librería: **Paraglide JS** (`@inlang/paraglide-sveltekit`). Compile-time, tree- - Refresco silencioso del token antes de expiración (`updateToken(60)`) - [ ] Rutas protegidas: hook `+layout.server.ts` que verifica sesión y redirige si no hay token válido - [ ] Flujo de onboarding post-primer-login: - - Detectar si el usuario no tiene colectivo → redirigir a `/onboarding` + - Detectar si el usuario no pertenece a ningún colectivo → redirigir a `/onboarding` - Opción A: Crear nuevo colectivo (nombre + emoji) - - Opción B: Introducir link de invitación -- [ ] Pantalla `/collective/manage` — miembros, roles, botón invitar, expulsar -- [ ] Pantalla `/invitation/[token]` — aceptar invitación (el usuario debe estar autenticado en Keycloak) -- [ ] Selector de colectivo activo en la barra de navegación principal -- [ ] Store Svelte `collective` — colectivo activo, lista de miembros, rol del usuario actual + - Opción B: Pegar link de invitación recibido +- [ ] Pantalla `/collective/manage` — miembros, roles, botón "Generate invite link" (copia al clipboard), expulsar +- [ ] Pantalla `/invitation/[token]` — aceptar invitación (el usuario debe estar autenticado) +- [ ] **Selector de colectivo activo** en la barra lateral — un usuario puede pertenecer a varios colectivos y cambiar entre ellos +- [ ] Store Svelte `collective` — colectivo activo, lista de miembros, rol del usuario actual, lista de todos los colectivos del usuario - [ ] 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 ~40 emoji curados, tap para seleccionar - - *Upload* — input de fichero; preview antes de subir; subida a Supabase Storage bucket `avatars`; soporte para recortar en proporción 1:1 + - *Upload* — input de fichero; **recorte en el navegador en proporción 1:1** (usar `cropperjs` o similar); 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 - [ ] Componente `Avatar.svelte` — renderiza según `avatar_type`: initials (letra sobre fondo slate), emoji, o `` con fallback a initials si la URL falla -> **Nota sobre registro de nuevos usuarios:** Con Keycloak, el registro lo gestiona Keycloak (su propia pantalla o self-registration habilitado en el realm). No hay pantalla de registro en SvelteKit. Si un usuario llega desde un link de invitación sin cuenta, Keycloak gestiona el registro y luego redirige de vuelta al callback de invitación. +> **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.