diff --git a/CLAUDE.md b/CLAUDE.md index 838cf96..63ec452 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,12 +8,37 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Status -**Fase 0 complete. Fase 1 in progress.** +**Fase 0 complete. Fase 1 complete. ✅ Acceptance test passed (all 5 users authenticate; `auth.uid()` resolves correctly in RLS).** - `README.md` — full development plan, confirmed tech stack, Justfile reference, and technical warnings - `analysis/analisis-funcional.md` — complete functional specification (domain model, use cases, data models, business rules) - `plan/fase-*.md` — phase-by-phase implementation plans (Fase 0 through Fase 4) +### Fase 1 — what has been built + +- `supabase/migrations/001_users.sql` — `users` table, `handle_new_user` trigger +- `supabase/migrations/002_collectives.sql` — `collectives`, `collective_members`, `collective_invitations`; auto-promote-oldest-admin trigger +- `supabase/migrations/003_rls.sql` — full RLS policies, `is_active_member`/`is_member`/`is_admin` helpers, `accept_invitation()` function +- `supabase/migrations/004_storage.sql` — `avatars` bucket (private, signed URLs) +- `apps/web/src/lib/supabase.ts` — Supabase singleton with PKCE flow +- `apps/web/src/lib/auth.ts` — `login()` / `logout()` via `supabase.auth.signInWithOAuth` +- `apps/web/src/lib/stores/auth.ts` — `currentUser`, `authLoading`, `isAuthenticated`, `displayName` +- `apps/web/src/lib/stores/collective.ts` — `currentCollective`, `userCollectives`, `collectiveMembers` +- `apps/web/src/lib/components/Avatar.svelte` — initials / emoji / upload with deterministic colour hash +- `apps/web/src/lib/components/ImageCropper.svelte` — lazy-loaded cropperjs, 1:1, 256×256 WebP output +- `apps/web/src/routes/+layout.svelte` — auth state listener, collective bootstrap +- `apps/web/src/routes/(app)/+layout.ts` — SSR disabled (`ssr: false`); no auth check here (see gotcha below) +- `apps/web/src/routes/(app)/+layout.svelte` — sidebar with collective switcher; auth redirect via `$effect` +- `apps/web/src/routes/auth/callback/+page.svelte` — PKCE code exchange +- `apps/web/src/routes/onboarding/+page.svelte` — create collective or join via link +- `apps/web/src/routes/(app)/collective/manage/+page.svelte` — members, roles, invite link generator +- `apps/web/src/routes/invitation/[token]/+page.svelte` — accept invitation (auth-aware) +- `apps/web/src/routes/(app)/settings/+page.svelte` — display name, avatar, language, sign out +- `apps/web/messages/en.json` + `es.json` — all Fase 1 strings +- `packages/types/src/database.ts` — manually seeded from migrations (update with `just db-types` once CLI is wired) +- `setup.sh` — idempotent local dev setup (prerequisites, .env, Docker, migrations, seed, Paraglide) +- `infra/kong-start.sh` — Kong container entrypoint; substitutes `${VAR}` placeholders in `kong.yml` before Kong starts (Kong 2.8 does not do this natively) + ## Local Dev: Required /etc/hosts Entry GoTrue (Supabase Auth) and the browser both must resolve `keycloak` to reach the Keycloak container. Add this line to `/etc/hosts` on the development machine: @@ -41,8 +66,8 @@ Without this, the OAuth redirect URL (`http://keycloak:8080/...`) built by Keycl ``` apps/web/src/lib/ - supabase.ts # Supabase singleton client - auth.ts # keycloak-js OIDC client + session helpers + supabase.ts # Supabase singleton client (PKCE, GoTrue auth) + auth.ts # login() / logout() via supabase.auth.signInWithOAuth (Keycloak provider) stores/ # Svelte global stores sync/ # offline queue + conflict resolution components/ # reusable UI components @@ -66,8 +91,11 @@ infra/ ## Development Commands (Justfile) ```bash +just setup # first-time (and idempotent) local environment setup just dev # start docker-compose.dev.yml + SvelteKit dev server -just dev-stop # stop local environment +just stop # stop all services (Docker stack + Vite dev server) +just dev-stop # stop Docker stack only +just dev-clean # stop Docker stack and remove all volumes (full reset) just db-reset # drop + migrate + seed (dev) just db-migrate # apply pending migrations (dev) just db-seed # apply supabase/seed.sql to the running dev db @@ -80,15 +108,24 @@ just restore supabase # restore a specific dump just logs # docker compose logs -f (prod) ``` -## Local Dev Endpoints +## Local Dev Endpoints and Credentials -| Service | URL | Notes | +| Service | URL | Credentials (dev only) | |---|---|---| -| SvelteKit app | http://localhost:5173 | | -| Supabase Studio | http://localhost:54323 | | -| Supabase API (Kong) | http://localhost:8001 | Port 8000 reserved by other services | -| Keycloak Admin | http://localhost:8080/admin | admin / admin (dev only) | -| PostgreSQL | localhost:5432 | postgres / postgres (dev only) | +| SvelteKit app | http://localhost:5173 | — | +| Supabase Studio | http://localhost:54323 | — | +| Supabase API (Kong) | http://localhost:8001 | apikey: `PUBLIC_SUPABASE_ANON_KEY` from `.env` | +| Keycloak Admin | http://localhost:8080/admin | `admin` / `admin` | +| PostgreSQL | localhost:5432 | `postgres` / `postgres` | + +**Dev secrets (set in `.env`, never commit to prod):** + +| Variable | Dev value | +|---|---| +| `POSTGRES_PASSWORD` | `postgres` | +| `SUPABASE_JWT_SECRET` | `super-secret-jwt-token-with-at-least-32-characters-long` | +| `KEYCLOAK_ADMIN` / `KEYCLOAK_ADMIN_PASSWORD` | `admin` / `admin` | +| `KEYCLOAK_CLIENT_SECRET` | `gotrue-dev-secret` (client secret for `colectivo-web` in GoTrue) | ## Domain Model @@ -116,7 +153,27 @@ Key decisions: - **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 session locally. +Logout is a single call — `supabase.auth.signOut()` — which clears the GoTrue session. Keycloak's own SSO session persists (user won't be prompted for credentials on next login until the Keycloak session expires), which is acceptable for this app. + +**Non-obvious integration requirements (all implemented; do not remove):** + +1. **`colectivo-web` is a confidential client** — GoTrue needs a client secret to exchange codes with Keycloak. `GOTRUE_EXTERNAL_KEYCLOAK_SECRET` must match Keycloak's client secret (`gotrue-dev-secret` in dev). + +2. **Custom `openid` client scope in Keycloak** — GoTrue v2.158.1 sends `scope=profile email` (no `openid`) to Keycloak, ignoring `GOTRUE_EXTERNAL_KEYCLOAK_SCOPES`. Without `openid` in the token scope, Keycloak's userinfo endpoint returns 401. Fix: a custom client scope named `openid` with `include.in.token.scope=true` is set as a default scope on `colectivo-web`, so Keycloak injects `openid` even when not requested. + +3. **`GOTRUE_DISABLE_SIGNUP: "false"`** — GoTrue's signup flag blocks OIDC-based user creation too. Must be `false`; Keycloak is the gatekeeper for who can register. + +4. **`auth.users` pre-seeded with Keycloak UUIDs** — GoTrue generates its own UUIDs on first login. `seed.sql` pre-inserts both `auth.users` (with `instance_id='00000000-0000-0000-0000-000000000000'` and empty-string token fields) and `auth.identities` (provider=keycloak, provider_id=Keycloak sub). This ensures `auth.uid()` = seed UUID = FK in all public tables. + +5. **Kong env var substitution via `kong-start.sh`** — Kong 2.8 does not interpolate `${VAR}` in declarative config files. `infra/kong-start.sh` runs `perl -pe 's/\$\{(\w+)\}/$ENV{$1}/ge'` on `kong.yml` before starting Kong. Without this, Kong loads the literal string `${SUPABASE_ANON_KEY}` as the API key, rejecting all requests. + +**SvelteKit + Supabase auth gotchas (do not remove these patterns):** + +6. **Do not call `getSession()` in a SvelteKit `load` function to gate auth.** Supabase JS v2 initialises its session from localStorage asynchronously (`_recoverAndRefresh`). In a `load` function that runs immediately on mount, `getSession()` can return `null` for a valid session before initialisation completes — causing `login()` to fire and redirect to Keycloak unnecessarily. The correct pattern: `(app)/+layout.ts` only sets `ssr: false`, and `(app)/+layout.svelte` uses a `$effect` that redirects when `!$authLoading && !$isAuthenticated`. This waits for `onAuthStateChange` to fire (which is authoritative). + +7. **`onAuthStateChange` fires `INITIAL_SESSION` on page load, not `SIGNED_IN`.** `SIGNED_IN` only fires immediately after a login flow. Load collective data whenever the session is present, regardless of event type — otherwise collectives are never loaded on page refresh. Pattern in root `+layout.svelte`: `if (session) { await loadUserCollectives(...) }` covering `INITIAL_SESSION`, `SIGNED_IN`, and `TOKEN_REFRESHED`. + +8. **PWA service worker: do not use `injectManifest` with a file named `service-worker.ts`.** SvelteKit intercepts any file at `src/service-worker.[jt]s` and enforces a hard restriction: only `$service-worker` and `$env/static/public` may be imported — Workbox modules are blocked. With `injectManifest`, `@vite-pwa/sveltekit` tries to find the compiled SW output at `.svelte-kit/output/client/service-worker.js`, but SvelteKit never produces it (compilation fails on the blocked imports). Fix for Fase 1–2a: use `generateSW` strategy (plugin auto-generates the SW, no custom source needed). Fix for Fase 2b when a custom SW is needed: name the file `src/sw.ts` — SvelteKit does not recognise it as a service worker — and set `strategies: 'injectManifest'`, `filename: 'sw.ts'` in `vite.config.ts`. ## Sync Strategy @@ -131,7 +188,8 @@ Logout must invalidate both sides: call `keycloak.logout()` (invalidates Keycloa ## Critical Platform Gotchas **iOS Safari / PWA:** -- `keycloak-js` silent token refresh uses iframes (`checkLoginIframe`) — Safari blocks third-party cookies in iframes. **Set `checkLoginIframe: false`** and use `updateToken()` manually instead. +- The app uses Supabase OAuth PKCE flow (`signInWithOAuth({ provider: 'keycloak' })`). No keycloak-js, no iframe-based silent refresh — Safari-compatible by design. +- GoTrue session is persisted in `localStorage` and auto-refreshed by the Supabase client. No manual `updateToken()` needed. - Background Sync API is not supported in Safari. Implement a manual fallback using the `online` event. - Push notifications require iOS 16.4+ and the PWA must be installed to the home screen. - Test the shopping session mode on a real iPhone during development, not only in DevTools. diff --git a/Justfile b/Justfile index 053edb8..684bf50 100644 --- a/Justfile +++ b/Justfile @@ -20,6 +20,11 @@ dev: {{dc_dev}} up -d pnpm --filter @colectivo/web dev +# Stop all services (Docker stack + SvelteKit dev server) +stop: + {{dc_dev}} down + -pkill -f "vite" 2>/dev/null || true + # Stop local Docker stack dev-stop: {{dc_dev}} down diff --git a/README.md b/README.md index c47eda7..3c37966 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,8 @@ colectivo/ │ └── web/ # SvelteKit — web + PWA │ ├── src/ │ │ ├── lib/ -│ │ │ ├── supabase.ts # cliente Supabase singleton -│ │ │ ├── auth.ts # cliente OIDC (keycloak-js) + helpers de sesión +│ │ │ ├── supabase.ts # cliente Supabase singleton (PKCE, GoTrue auth) +│ │ │ ├── auth.ts # login() / logout() via supabase.auth.signInWithOAuth │ │ │ ├── stores/ # Svelte stores globales │ │ │ ├── sync/ # lógica offline + cola de operaciones │ │ │ └── components/ # componentes UI reutilizables @@ -78,7 +78,8 @@ colectivo/ ├── infra/ │ ├── docker-compose.dev.yml # Supabase + Keycloak dev (Kong en puerto 8001) │ ├── docker-compose.prod.yml # producción en VPS -│ ├── kong.yml # Kong declarativo (rutas API) +│ ├── kong.yml # Kong declarativo (rutas API, con ${VAR} placeholders) +│ ├── kong-start.sh # entrypoint Kong: sustituye ${VAR} vía perl antes de arrancar │ ├── db-init/ │ │ └── 00-role-passwords.sh # ajusta passwords de roles internos de Supabase al arrancar │ └── scripts/ @@ -103,8 +104,11 @@ colectivo/ ## Justfile — Comandos de referencia ```bash +just setup # preparación del entorno local (idempotente, seguro relanzar) just dev # levanta docker-compose.dev.yml + SvelteKit dev server -just dev-stop # para el entorno local +just stop # para todos los servicios (Docker stack + servidor Vite) +just dev-stop # para solo el stack de Docker +just dev-clean # para Docker y elimina todos los volúmenes (reset completo) just db-reset # drop + migrate + seed (dev) just db-migrate # aplica migraciones pendientes (dev) just db-seed # aplica supabase/seed.sql a la BD dev en marcha @@ -121,15 +125,34 @@ just logs # docker compose logs -f (prod) --- -## Endpoints en desarrollo local +## Endpoints y credenciales en desarrollo local -| Servicio | URL | Notas | -|----------|-----|-------| -| App SvelteKit | http://localhost:5173 | | -| Supabase Studio | http://localhost:54323 | | -| Supabase API (Kong) | http://localhost:8001 | Puerto 8000 puede estar ocupado por otros servicios | -| Keycloak Admin | http://localhost:8080/admin | admin / admin (solo dev) | -| PostgreSQL | localhost:5432 | postgres / postgres (solo dev) | +| Servicio | URL | Credenciales (solo dev) | +|----------|-----|-------------------------| +| App SvelteKit | http://localhost:5173 | — | +| Supabase Studio | http://localhost:54323 | — | +| Supabase API (Kong) | http://localhost:8001 | apikey: `PUBLIC_SUPABASE_ANON_KEY` del `.env` | +| Keycloak Admin | http://localhost:8080/admin | `admin` / `admin` | +| PostgreSQL | localhost:5432 | `postgres` / `postgres` | + +**Secretos dev (generados en `.env`, nunca usar en producción):** + +| Variable | Valor dev | +|----------|-----------| +| `POSTGRES_PASSWORD` | `postgres` | +| `SUPABASE_JWT_SECRET` | `super-secret-jwt-token-with-at-least-32-characters-long` | +| `KEYCLOAK_ADMIN` / `KEYCLOAK_ADMIN_PASSWORD` | `admin` / `admin` | +| `KEYCLOAK_CLIENT_SECRET` | `gotrue-dev-secret` (secreto del cliente `colectivo-web` en GoTrue) | + +**Usuarios de prueba** (definidos en `keycloak/realm-export.json` y `supabase/seed.sql`): + +| Usuario | Email | Contraseña | Rol en el colectivo de prueba | +|---------|-------|-----------|-------------------------------| +| `ana` | ana@dev.local | `test1234` | Admin | +| `borja` | borja@dev.local | `test1234` | Member | +| `carmen` | carmen@dev.local | `test1234` | Member | +| `david` | david@dev.local | `test1234` | Guest | +| `eva` | eva@dev.local | `test1234` | (sin colectivo — para testing de onboarding) | --- @@ -139,12 +162,13 @@ Antes de arrancar la Fase 0, confirmar: | Decisión | Estado | |----------|--------| -| Estrategia JWT Keycloak → Supabase (Opción A vs B) | Pendiente — **Opción A recomendada** | +| Estrategia JWT Keycloak → Supabase (Opción A vs B) | ✅ **Opción B — GoTrue como proxy OIDC** | | Base de datos de Keycloak en prod | ✅ **PostgreSQL independiente** | | Entorno de producción dockerizado | ✅ **Ya operativo** | | Reverse proxy en producción | ✅ **nginx existente, gestionado externamente** | -| Self-registration habilitado en Keycloak o invitación obligatoria | Pendiente | -| Proveedor SMTP para Keycloak y Resend (emails de invitación) | Pendiente — **Resend recomendado** | +| Self-registration habilitado en Keycloak o invitación obligatoria | ✅ **Self-registration habilitado en Keycloak** | +| Invitaciones: email o link manual | ✅ **Link-only — admin genera link y lo comparte manualmente** | +| Proveedor SMTP | ✅ **No necesario para invitaciones** — Resend queda solo para Keycloak reset-password si se activa | | Almacenamiento de backups | Pendiente — **Backblaze B2 recomendado** | | Dominio y DNS del VPS | Pendiente | | Subdominio para Keycloak | Pendiente — `auth.tudominio.com` recomendado | @@ -156,10 +180,18 @@ Antes de arrancar la Fase 0, confirmar: ## Advertencias Técnicas -**Keycloak y JWT con Supabase RLS:** -- Supabase RLS usa `auth.uid()` que lee el claim `sub` del JWT. Verificar que el `sub` de Keycloak es un UUID v4 estándar — por defecto lo es, pero algunos realms lo configuran diferente. -- En la Opción A (JWT directo), la clave pública de Keycloak (RS256) debe estar en `JWT_SECRET` de Supabase. Si rotas las claves de firma en Keycloak, debes actualizar Supabase también. -- Keycloak tiene sesiones propias independientes de Supabase. Un `logout()` debe invalidar en ambos lados: llamar a `keycloak.logout()` (que invalida en Keycloak) y limpiar el cliente de Supabase localmente. +**Keycloak y JWT con Supabase RLS (Opción B — GoTrue como proxy OIDC):** +- GoTrue recibe el token de Keycloak, lo valida contra el discovery endpoint de Keycloak, y emite su propio JWT de Supabase. `auth.uid()` resuelve al UUID de GoTrue (mapeado desde el `sub` de Keycloak). +- El `sub` del realm de Keycloak es un UUID v4 estándar — verificado en las pruebas de aceptación. +- No es necesario gestionar `JWT_SECRET` manualmente con la clave pública de Keycloak. GoTrue usa el endpoint OIDC de Keycloak para validar tokens. +- El logout (`supabase.auth.signOut()`) invalida la sesión de GoTrue. La sesión SSO de Keycloak persiste hasta que expire — comportamiento esperado para esta app. + +**Integración GoTrue + Keycloak — requisitos no obvios (ya implementados):** +- **Cliente `colectivo-web` confidencial:** GoTrue necesita un secreto de cliente (`KEYCLOAK_CLIENT_SECRET=gotrue-dev-secret`) para el intercambio de código. Sin esto, GoTrue falla con "missing OAuth secret". +- **Scope `openid` personalizado en Keycloak:** GoTrue v2.158.1 envía `scope=profile email` a Keycloak ignorando `GOTRUE_EXTERNAL_KEYCLOAK_SCOPES`. El endpoint userinfo de Keycloak requiere `openid` en el token. Solución: scope de cliente personalizado llamado `openid` con `include.in.token.scope=true` configurado como scope por defecto en `colectivo-web`. +- **`GOTRUE_DISABLE_SIGNUP: "false"`:** El flag bloquea también la creación de usuarios vía OIDC. Debe ser `false`; Keycloak es el guardián del registro. +- **`auth.users` pre-sembrado con UUIDs de Keycloak:** `seed.sql` inserta `auth.users` (con `instance_id='00000000-0000-0000-0000-000000000000'` y campos de token como strings vacíos, no NULL) y `auth.identities`. Garantiza que `auth.uid()` coincida con las claves foráneas en tablas públicas. +- **Kong 2.8 no interpola variables de entorno:** `infra/kong-start.sh` ejecuta `perl` para sustituir `${VAR}` en `kong.yml` antes de arrancar Kong. Sin esto, Kong carga la cadena literal y rechaza todas las peticiones con 401. **Keycloak self-hosted en producción:** - Keycloak 24+ requiere mínimo 512MB de RAM asignados. En un VPS pequeño (2GB), monitorizar el consumo conjunto con Supabase. @@ -171,9 +203,10 @@ Antes de arrancar la Fase 0, confirmar: - Esto simplifica la configuración de Supabase pero requiere configurar los Identity Providers en Keycloak Admin Console. **iOS Safari y PWA:** +- La autenticación usa el flujo PKCE de Supabase OAuth (`signInWithOAuth({ provider: 'keycloak' })`). No hay keycloak-js ni iframes de refresco silencioso — compatible con Safari de base. +- La sesión de GoTrue se persiste en `localStorage` y se refresca automáticamente; no requiere `updateToken()` manual. - Background Sync API no está soportada en Safari. Implementar fallback manual con el evento `online`. - Push notifications solo disponibles desde iOS 16.4 y únicamente con la PWA instalada en pantalla de inicio. -- `keycloak-js` usa iframes para el refresco silencioso del token (`checkLoginIframe`). Deshabilitarlo en la config (`checkLoginIframe: false`) porque Safari bloquea cookies de terceros en iframes — usar `updateToken()` manual en su lugar. - Probar el Modo Compra en un iPhone real en paralelo al desarrollo, no solo en DevTools. **Supabase Realtime self-hosted:** diff --git a/apps/web/src/lib/supabase.ts b/apps/web/src/lib/supabase.ts index a1943df..859cffd 100644 --- a/apps/web/src/lib/supabase.ts +++ b/apps/web/src/lib/supabase.ts @@ -10,7 +10,9 @@ export function getSupabase(): SupabaseClient { auth: { autoRefreshToken: true, persistSession: true, - detectSessionInUrl: true, + // detectSessionInUrl is disabled: the /auth/callback page explicitly calls + // exchangeCodeForSession so there is only one code-exchange attempt. + detectSessionInUrl: false, // PKCE flow — required for SPA/PWA OAuth flowType: 'pkce' } diff --git a/apps/web/src/routes/(app)/+layout.svelte b/apps/web/src/routes/(app)/+layout.svelte index 4e06a31..b301c22 100644 --- a/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/web/src/routes/(app)/+layout.svelte @@ -2,6 +2,7 @@ import type { Snippet } from 'svelte'; import { page } from '$app/stores'; import { isAuthenticated, authLoading, displayName } from '$lib/stores/auth'; + import { login } from '$lib/auth'; let { children }: { children: Snippet } = $props(); import { currentCollective, userCollectives } from '$lib/stores/collective'; @@ -9,6 +10,15 @@ import Avatar from '$lib/components/Avatar.svelte'; import * as m from '$lib/paraglide/messages'; + // When auth resolves as unauthenticated, redirect to Keycloak. + // This runs after onAuthStateChange fires (authLoading = false), so there + // is no race with Supabase's async localStorage initialisation. + $effect(() => { + if (!$authLoading && !$isAuthenticated) { + login(); + } + }); + const navItems = [ { href: '/lists', label: () => m.nav_lists(), icon: '🛒' }, { href: '/tasks', label: () => m.nav_tasks(), icon: '✓' }, diff --git a/apps/web/src/routes/(app)/+layout.ts b/apps/web/src/routes/(app)/+layout.ts index ae0a9ba..020eeff 100644 --- a/apps/web/src/routes/(app)/+layout.ts +++ b/apps/web/src/routes/(app)/+layout.ts @@ -1,21 +1,9 @@ -import { browser } from '$app/environment'; -import { getSupabase } from '$lib/supabase'; -import { login } from '$lib/auth'; - -// No SSR — this is a client-rendered PWA +// No SSR — this is a client-rendered PWA. +// Auth redirect is handled in +layout.svelte via the onAuthStateChange store, +// NOT here. Using getSession() in a load function races with Supabase's async +// localStorage initialisation and can return null for a valid session. export const ssr = false; -export async function load() { - if (!browser) return {}; - - const { - data: { session } - } = await getSupabase().auth.getSession(); - - if (!session) { - // Not authenticated — redirect to Keycloak - await login(); - } - +export function load() { return {}; } diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index 21293cc..877fbfa 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -4,19 +4,11 @@ import { goto } from '$app/navigation'; import { page } from '$app/stores'; import { getSupabase } from '$lib/supabase'; - import { login } from '$lib/auth'; import { currentUser, authLoading } from '$lib/stores/auth'; import { userCollectives, currentCollective } from '$lib/stores/collective'; import { ParaglideJS } from '@inlang/paraglide-sveltekit'; import { i18n } from '$lib/i18n'; - // Routes that don't require authentication - const PUBLIC_ROUTES = ['/auth', '/invitation']; - - function isPublicRoute(pathname: string): boolean { - return PUBLIC_ROUTES.some((r) => pathname.startsWith(r)); - } - onMount(() => { const supabase = getSupabase(); @@ -26,20 +18,23 @@ currentUser.set(session?.user ?? null); authLoading.set(false); - if (!session && !isPublicRoute($page.url.pathname)) { - // No session on a protected route → redirect to Keycloak - await login(); - return; - } - - if (session && event === 'SIGNED_IN') { + if (session) { + // Load collectives on every auth event that provides a session: + // INITIAL_SESSION (page refresh), SIGNED_IN (post-login), TOKEN_REFRESHED. await loadUserCollectives(session.user.id); - // Post-login routing: onboarding if user has no collective - if ($userCollectives.length === 0 && $page.url.pathname === '/') { - goto('/onboarding'); - } else if ($page.url.pathname === '/') { - goto('/lists'); + if (event === 'SIGNED_IN') { + // Post-login: send the user where they belong. + // Only redirect when coming from the callback or the root — not on + // token refreshes or when the user is already on a real route. + const path = $page.url.pathname; + if (path === '/auth/callback' || path === '/') { + if ($userCollectives.length === 0) { + goto('/onboarding'); + } else { + goto('/lists'); + } + } } } }); diff --git a/apps/web/src/service-worker.ts b/apps/web/src/service-worker.ts deleted file mode 100644 index 3345011..0000000 --- a/apps/web/src/service-worker.ts +++ /dev/null @@ -1,50 +0,0 @@ -/// -/// -/// -/// - -import { build, files, version } from '$service-worker'; -import { precacheAndRoute } from 'workbox-precaching'; -import { registerRoute, NavigationRoute } from 'workbox-routing'; -import { NetworkFirst, CacheFirst } from 'workbox-strategies'; -import { BackgroundSyncPlugin } from 'workbox-background-sync'; - -declare const self: ServiceWorkerGlobalScope; - -const CACHE_NAME = `colectivo-${version}`; -const ASSETS = [...build, ...files]; - -// Precache all static assets -precacheAndRoute(ASSETS.map((url) => ({ url, revision: version }))); - -// Cache-first for static assets (JS, CSS, fonts, icons) -registerRoute( - ({ request }) => - request.destination === 'style' || - request.destination === 'script' || - request.destination === 'font' || - request.destination === 'image', - new CacheFirst({ cacheName: `${CACHE_NAME}-assets` }) -); - -// Network-first for app routes (HTML navigation) -registerRoute( - new NavigationRoute( - new NetworkFirst({ - cacheName: `${CACHE_NAME}-pages`, - networkTimeoutSeconds: 3 - }) - ) -); - -// Background Sync for pending offline operations -const bgSyncPlugin = new BackgroundSyncPlugin('pending-ops-queue', { - maxRetentionTime: 24 * 60 // 24 hours -}); - -// Listen for messages from the app -self.addEventListener('message', (event) => { - if (event.data?.type === 'SKIP_WAITING') { - self.skipWaiting(); - } -}); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index aec0995..19a1e23 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -11,10 +11,11 @@ export default defineConfig({ }), sveltekit(), SvelteKitPWA({ - srcDir: './src', - mode: 'development', - strategies: 'injectManifest', - filename: 'service-worker.ts', + // strategies defaults to 'generateSW' — the plugin auto-generates the service + // worker. We do NOT use 'injectManifest' here because SvelteKit's own Vite + // plugin intercepts src/service-worker.ts and blocks external imports (Workbox). + // In Fase 2b we will introduce a custom SW as src/sw.ts (a filename SvelteKit + // does not recognise as a service worker) and switch to injectManifest then. scope: '/', base: '/', manifest: { @@ -33,9 +34,6 @@ export default defineConfig({ { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' } ] }, - injectManifest: { - globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}'] - }, workbox: { globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}'] }, diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml index 3a4161f..687d50d 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -47,11 +47,17 @@ services: GOTRUE_SITE_URL: ${PUBLIC_APP_URL:-http://localhost:5173} GOTRUE_JWT_SECRET: ${SUPABASE_JWT_SECRET} GOTRUE_JWT_EXP: 3600 - GOTRUE_DISABLE_SIGNUP: "true" + # Allow OIDC-based user creation (Keycloak is the gatekeeper). + # Direct email/password signup is blocked at the Kong level (no /auth/v1/signup route exposed). + GOTRUE_DISABLE_SIGNUP: "false" # OIDC: Keycloak as external provider GOTRUE_EXTERNAL_KEYCLOAK_ENABLED: "true" GOTRUE_EXTERNAL_KEYCLOAK_CLIENT_ID: ${PUBLIC_KEYCLOAK_CLIENT_ID:-colectivo-web} - GOTRUE_EXTERNAL_KEYCLOAK_SECRET: "" + # colectivo-web is a confidential client in Keycloak (required for GoTrue code exchange). + # Dev value matches keycloak/realm-export.json. Override in .env for prod. + GOTRUE_EXTERNAL_KEYCLOAK_SECRET: ${KEYCLOAK_CLIENT_SECRET:-gotrue-dev-secret} + # openid is required so the access token is an OIDC token and the userinfo endpoint accepts it. + GOTRUE_EXTERNAL_KEYCLOAK_SCOPES: "openid profile email" # GoTrue fetches Keycloak's OIDC discovery doc at this URL (Docker-internal hostname). # The browser also uses this hostname — add "127.0.0.1 keycloak" to /etc/hosts. GOTRUE_EXTERNAL_KEYCLOAK_URL: http://keycloak:8080/realms/colectivo @@ -146,6 +152,11 @@ services: kong: image: kong:2.8.1 restart: unless-stopped + # kong.yml uses ${SUPABASE_ANON_KEY} / ${SUPABASE_SERVICE_KEY} placeholders. + # Kong 2.8 does NOT substitute env vars in declarative config automatically. + # kong-start.sh uses perl to substitute ${VAR} from the container environment + # before Kong loads the config. + entrypoint: ["/bin/bash", "/home/kong/kong-start.sh"] ports: - "8001:8000" # HTTP API (8000 may conflict; use http://localhost:8001) - "8443:8443" # HTTPS API @@ -162,7 +173,8 @@ services: SUPABASE_ANON_KEY: ${PUBLIC_SUPABASE_ANON_KEY} SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY} volumes: - - ./kong.yml:/home/kong/kong.yml:ro + - ./kong.yml:/home/kong/temp.yml:ro + - ./kong-start.sh:/home/kong/kong-start.sh:ro # ── Supabase Studio ─────────────────────────────────────────────────────── studio: diff --git a/infra/kong-start.sh b/infra/kong-start.sh new file mode 100755 index 0000000..e3e2eb6 --- /dev/null +++ b/infra/kong-start.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# kong-start.sh — substitute ${VAR} placeholders in kong.yml, then start Kong. +# Called as the Kong container entrypoint in docker-compose.dev.yml. +# Kong 2.8 does not natively interpolate env vars in declarative config files. +set -euo pipefail + +perl -pe 's/\$\{(\w+)\}/$ENV{$1}/ge' /home/kong/temp.yml > /home/kong/kong.yml + +exec /docker-entrypoint.sh kong docker-start diff --git a/keycloak/realm-export.json b/keycloak/realm-export.json index 683580a..501cb9e 100644 --- a/keycloak/realm-export.json +++ b/keycloak/realm-export.json @@ -2,45 +2,435 @@ "id": "colectivo", "realm": "colectivo", "displayName": "Colectivo", + "notBefore": 0, + "defaultSignatureAlgorithm": "RS256", + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 3600, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 5, "enabled": true, "sslRequired": "external", - "registrationAllowed": true, + "registrationAllowed": false, + "registrationEmailAsUsername": false, + "rememberMe": false, + "verifyEmail": false, "loginWithEmailAllowed": true, "duplicateEmailsAllowed": false, "resetPasswordAllowed": true, "editUsernameAllowed": false, "bruteForceProtected": true, - "accessTokenLifespan": 3600, - "refreshTokenMaxReuse": 0, - "offlineSessionMaxLifespanEnabled": false, + "permanentLockout": false, + "maxTemporaryLockouts": 0, + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "defaultRole": { + "id": "53a87905-0c43-41e0-b088-95e62fc2ab47", + "name": "default-roles-colectivo", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false, + "containerId": "colectivo" + }, + "requiredCredentials": [ + "password" + ], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpPolicyCodeReusable": false, + "otpSupportedApplications": [ + "totpAppFreeOTPName", + "totpAppGoogleName", + "totpAppMicrosoftAuthenticatorName" + ], + "localizationTexts": {}, + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": [ + "ES256" + ], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyExtraOrigins": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": [ + "ES256" + ], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "not specified", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "webAuthnPolicyPasswordlessExtraOrigins": [], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": [ + "offline_access" + ] + } + ], + "clientScopeMappings": { + "account": [ + { + "client": "account-console", + "roles": [ + "manage-account", + "view-groups" + ] + } + ] + }, "clients": [ { - "clientId": "colectivo-web", - "name": "Colectivo Web App", + "id": "e602a4ed-1b0e-4c5e-ab62-1dcb67f0430f", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/colectivo/account/", + "surrogateAuthRequired": false, "enabled": true, - "publicClient": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/colectivo/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, "standardFlowEnabled": true, "implicitFlowEnabled": false, "directAccessGrantsEnabled": false, "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, "protocol": "openid-connect", "attributes": { - "pkce.code.challenge.method": "S256" + "post.logout.redirect.uris": "+" }, - "redirectUris": [ - "http://localhost:5173/*", - "http://localhost:3000/*", - "http://localhost:8001/auth/v1/callback", - "http://keycloak:8080/auth/v1/callback" - ], - "webOrigins": [ - "http://localhost:5173", - "http://localhost:3000", - "http://localhost:8001" - ], - "fullScopeAllowed": true, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, "defaultClientScopes": [ "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "60e4a8dc-4fa2-4c08-a9ac-2d1f8df16115", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/colectivo/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/colectivo/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "2a601995-c699-42c2-bcf7-fe224e3a6a81", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "3f5ddb14-1cb6-4c0d-95d3-837c920ed0df", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "3b31a13d-5f66-4a68-ae17-f19de2db62e2", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "70c159b1-dd13-48f2-97db-6e08f174f1ff", + "clientId": "colectivo-web", + "name": "Colectivo Web App", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "secret": "gotrue-dev-secret", + "redirectUris": [ + "http://localhost:3000/*", + "http://localhost:5173/*", + "http://localhost:8001/auth/v1/callback" + ], + "webOrigins": [ + "http://localhost:3000", + "http://localhost:5173", + "http://localhost:8001" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "openid", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "29f4d79d-acab-4d0d-91ac-06fd7357ec55", + "clientId": "realm-management", + "name": "${client_realm-management}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": {}, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "65550f90-9861-4e94-82e2-a64ab6414af1", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/colectivo/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/admin/colectivo/console/*" + ], + "webOrigins": [ + "+" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "ef110cf5-dc7c-4030-a81f-0eb862f225df", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", "profile", "roles", "email" @@ -53,92 +443,1412 @@ ] } ], - "users": [ + "clientScopes": [ { - "id": "11111111-1111-1111-1111-111111111111", - "username": "ana", - "email": "ana@dev.local", - "emailVerified": true, - "enabled": true, - "firstName": "Ana", - "lastName": "García", - "credentials": [ + "id": "43cc5c5a-a0f1-440f-af21-f9b787254cb4", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${emailScopeConsentText}" + }, + "protocolMappers": [ { - "type": "password", - "value": "test1234", - "temporary": false + "id": "07e3ec5d-0309-4d8a-b692-a77ac16ab296", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "1763586b-d803-499b-a8cc-19e99bcba57e", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } } ] }, { - "id": "22222222-2222-2222-2222-222222222222", - "username": "borja", - "email": "borja@dev.local", - "emailVerified": true, - "enabled": true, - "firstName": "Borja", - "lastName": "López", - "credentials": [ + "id": "55a32b72-4e90-48ab-bdfa-ee5819351647", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${profileScopeConsentText}" + }, + "protocolMappers": [ { - "type": "password", - "value": "test1234", - "temporary": false + "id": "5a099bf0-f566-4eff-bbec-b45f30c39181", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "5fdaa43d-2441-420d-82e4-870141519bda", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "f63b50c9-1772-422f-8aff-4258d07a2d12", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "ff677dc7-b368-41c9-acc4-5b14914a298d", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "long" + } + }, + { + "id": "9ca466fd-f7f6-453b-bfd0-1e36313c1aee", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "id": "6b10f0f7-b05b-4842-9c66-c87b59131afd", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "b9df0edd-dcc9-4628-9f82-cc5e1a778543", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "a3319ff1-8563-4659-a139-72bbb46ae5ae", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "c28b7ef9-d114-460e-8221-7a23897c46e2", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "5dd4e760-1dc8-43a1-b4ed-0fccc0a491a7", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "297ce0d1-0c90-4f27-a414-9f6a98a37924", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "id": "4cadb013-a54a-474c-a83b-97e2f042b767", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "4aba17be-7a4a-4150-ac0e-22c32704b4c1", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "47d263d9-f7de-4a18-8f92-7babc062d4ba", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } } ] }, { - "id": "33333333-3333-3333-3333-333333333333", - "username": "carmen", - "email": "carmen@dev.local", - "emailVerified": true, - "enabled": true, - "firstName": "Carmen", - "lastName": "Martínez", - "credentials": [ + "id": "12d9f262-622d-4f53-876a-3dcb2f0ea938", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${phoneScopeConsentText}" + }, + "protocolMappers": [ { - "type": "password", - "value": "test1234", - "temporary": false + "id": "dd5600b1-0924-46a7-aae9-d1905b4429d1", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "910bbb61-9266-492d-8279-c1c30e16256c", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } } ] }, { - "id": "44444444-4444-4444-4444-444444444444", - "username": "david", - "email": "david@dev.local", - "emailVerified": true, - "enabled": true, - "firstName": "David", - "lastName": "Fernández", - "credentials": [ + "id": "f3027af3-7c0e-48f0-9094-625b4d125d6f", + "name": "acr", + "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ { - "type": "password", - "value": "test1234", - "temporary": false + "id": "db9b568b-752b-444a-a70d-6fb62bea48f6", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "introspection.token.claim": "true", + "access.token.claim": "true" + } } ] }, { - "id": "55555555-5555-5555-5555-555555555555", - "username": "eva", - "email": "eva@dev.local", - "emailVerified": true, - "enabled": true, - "firstName": "Eva", - "lastName": "Ruiz", - "credentials": [ + "id": "3e5fce11-0894-4b37-ad1f-31132d98a4c9", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ { - "type": "password", - "value": "test1234", - "temporary": false + "id": "316b595a-120d-4bb7-bccd-448ef501cedd", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + }, + { + "id": "ceeb2310-3124-4236-8730-e38fc9c1bff9", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "89bd1531-da9e-4f95-afc1-4b67a4412a02", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + }, + { + "id": "1ac6db43-af2c-4fa4-8ff2-71467fcac544", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "df83ea60-efb9-4a8d-a461-52acfb2e0309", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "f9167181-c4af-461d-873e-1a90c68f178c", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "consent.screen.text": "${addressScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "aa176105-9d4a-41f2-890f-c8f4f9ac9274", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "introspection.token.claim": "true", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "4dd3096f-beac-4ba8-8266-ceb826243aed", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false", + "consent.screen.text": "" + }, + "protocolMappers": [ + { + "id": "07454787-bb63-44ea-987b-09c02b6b70ed", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + } + ] + }, + { + "id": "5e5c0c52-b29d-418b-ac94-c1b1f6052330", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "consent.screen.text": "${rolesScopeConsentText}" + }, + "protocolMappers": [ + { + "id": "5fa0a8a5-e7e6-4eb0-b795-0a584914be8b", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String" + } + }, + { + "id": "28f2db5c-96dc-4c6c-a725-012446420e38", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + }, + { + "id": "a5aafb14-f527-460c-b759-fbc4e9d484f5", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "a09a9441-c626-4a26-8723-475d2d4b2356", + "name": "openid", + "description": "OpenID Connect scope — ensures access token includes openid scope claim for userinfo endpoint", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + } + } + ], + "defaultDefaultClientScopes": [ + "role_list", + "profile", + "email", + "roles", + "web-origins", + "acr" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "address", + "phone", + "microprofile-jwt" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "referrerPolicy": "no-referrer", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection": "1; mode=block", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "eventsEnabled": false, + "eventsListeners": [ + "jboss-logging" + ], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "identityProviders": [], + "identityProviderMappers": [], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "a5263f5f-0b80-4b29-b0ba-9a0d80e69533", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "d77dd0b8-d310-44a4-a38e-e2d9db83e8ce", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "5b08a208-a7d9-49aa-9a51-fc3825b83868", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-full-name-mapper", + "oidc-address-mapper", + "oidc-usermodel-property-mapper", + "saml-role-list-mapper", + "saml-user-attribute-mapper", + "saml-user-property-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-sha256-pairwise-sub-mapper" + ] + } + }, + { + "id": "bbb169ee-0347-4522-b141-05a66abb6f91", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "b048beb3-c7cf-4153-887d-12d5ef09d3df", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-usermodel-attribute-mapper", + "oidc-usermodel-property-mapper", + "oidc-full-name-mapper", + "oidc-address-mapper", + "saml-role-list-mapper", + "saml-user-attribute-mapper", + "saml-user-property-mapper", + "oidc-sha256-pairwise-sub-mapper" + ] + } + }, + { + "id": "dbf10af0-c5c9-462c-9e8d-9f1b9a6bfcae", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": [ + "true" + ], + "client-uris-must-match": [ + "true" + ] + } + }, + { + "id": "0a42adf2-e54e-4ca9-9ab5-c23278a2486d", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "8a195ea3-5818-42b1-82ad-cef49d834bb4", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": [ + "200" + ] + } + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "90f1e5e6-3334-4d2d-8341-4eb79932f43e", + "name": "hmac-generated-hs512", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "HS512" + ] + } + }, + { + "id": "67819849-b4cf-4df6-9f83-08c0b4a202c2", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "310dd178-0fdb-4f3c-96ae-80026762eabd", + "name": "rsa-enc-generated", + "providerId": "rsa-enc-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "RSA-OAEP" + ] + } + }, + { + "id": "da8480d9-f2e9-4dc6-a4c3-9a6439c3fa9b", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + } + ] + }, + "internationalizationEnabled": false, + "supportedLocales": [], + "authenticationFlows": [ + { + "id": "01a21375-2f08-4b27-8555-d87f4036eee1", + "alias": "Account verification options", + "description": "Method with which to verity the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false + } + ] + }, + { + "id": "fbaa51a0-b2f3-4306-be95-a3d10932a33b", + "alias": "Browser - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "ba72b4d0-3c5a-436c-8957-b77eb7724a92", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "57ad8f14-7aed-4f44-9c22-d22c73764d42", + "alias": "First broker login - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "9d70edb3-3acc-4098-b403-931de571d8e2", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Account verification options", + "userSetupAllowed": false + } + ] + }, + { + "id": "c29275cd-a589-4625-a432-648297ebe2e1", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "6db1ccd0-781c-4b27-870b-3ed016ea599f", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false + } + ] + }, + { + "id": "058822b3-f060-4660-a8de-86e0c9cc361c", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "First broker login - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "4d582d7d-69fe-435b-b450-7612f98562d5", + "alias": "browser", + "description": "browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 25, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "forms", + "userSetupAllowed": false + } + ] + }, + { + "id": "310dcaca-cb8c-4e1f-b647-4b16c9b00b14", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-secret-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-x509", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "7762bf8c-52fe-4d01-bfaf-f68f8ac2ed57", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "e0ae3351-a961-46b5-a4da-4e7e95b5eff2", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "da6cdb3d-0f03-4991-a93a-9971e795f6f3", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "User creation or linking", + "userSetupAllowed": false + } + ] + }, + { + "id": "060880c1-4b76-47ca-b355-b56d81ad7bd0", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Browser - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "d0c67220-d44e-4432-9f74-8ec7b2bcbfc4", + "alias": "registration", + "description": "registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "registration form", + "userSetupAllowed": false + } + ] + }, + { + "id": "61250053-362b-4568-a82f-94937f164b16", + "alias": "registration form", + "description": "registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-password-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 50, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-recaptcha-action", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 60, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-terms-and-conditions", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 70, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "239c37dd-0bdb-4fad-91b0-1a688221c17a", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-credential-email", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 40, + "autheticatorFlow": true, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "d1f850c5-ea71-4732-9e01-3ab59a246af0", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false } ] } ], - "scopeMappings": [], - "clientScopeMappings": {}, - "roles": { - "realm": [], - "client": {} + "authenticatorConfig": [ + { + "id": "21e84058-848d-4a35-9565-6342f5973908", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "5edcc002-5926-4513-88dc-5b3483f14e16", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "TERMS_AND_CONDITIONS", + "name": "Terms and Conditions", + "providerId": "TERMS_AND_CONDITIONS", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": false, + "defaultAction": false, + "priority": 60, + "config": {} + }, + { + "alias": "webauthn-register", + "name": "Webauthn Register", + "providerId": "webauthn-register", + "enabled": true, + "defaultAction": false, + "priority": 70, + "config": {} + }, + { + "alias": "webauthn-register-passwordless", + "name": "Webauthn Register Passwordless", + "providerId": "webauthn-register-passwordless", + "enabled": true, + "defaultAction": false, + "priority": 80, + "config": {} + }, + { + "alias": "VERIFY_PROFILE", + "name": "Verify Profile", + "providerId": "VERIFY_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 90, + "config": {} + }, + { + "alias": "delete_credential", + "name": "Delete Credential", + "providerId": "delete_credential", + "enabled": true, + "defaultAction": false, + "priority": 100, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "firstBrokerLoginFlow": "first broker login", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaExpiresIn": "120", + "cibaAuthRequestedUserHint": "login_hint", + "oauth2DeviceCodeLifespan": "600", + "oauth2DevicePollingInterval": "5", + "parRequestUriLifespan": "60", + "cibaInterval": "5", + "realmReusableOtpCode": "false" + }, + "keycloakVersion": "24.0.5", + "userManagedAccessAllowed": false, + "clientProfiles": { + "profiles": [] + }, + "clientPolicies": { + "policies": [] } -} +} \ No newline at end of file diff --git a/plan/fase-1-auth-colectivo.md b/plan/fase-1-auth-colectivo.md index 64f1cbf..c311d65 100644 --- a/plan/fase-1-auth-colectivo.md +++ b/plan/fase-1-auth-colectivo.md @@ -4,24 +4,24 @@ #### 1.1 Base de datos -- [ ] Migración: tabla `users` — espejo de `auth.users` de Supabase, poblada por trigger al primer login OIDC: +- [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 -- [ ] Migración: tabla `collectives` con campos `id`, `name`, `emoji`, `created_by`, `created_at` -- [ ] Migración: tabla `collective_members` con `role: enum(admin | member | guest)` -- [ ] Migración: tabla `collective_invitations` con token, expiración y estado -- [ ] **RLS completo** — políticas críticas: +- [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` -- [ ] Trigger: `on_auth_user_created` — sincroniza el perfil de Keycloak (display_name, email) a `users` en cada login -- [ ] Trigger: promoción automática al miembro más antiguo si el último admin elimina su cuenta (RN-10) -- [ ] Función SQL: `is_active_member(collective_id, user_id)` — usada en todas las políticas RLS -- [ ] Bucket Supabase Storage: `avatars` — privado, acceso vía signed URLs, un fichero por usuario (`{user_id}/avatar`) +- [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 @@ -30,57 +30,57 @@ > `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. -- [ ] 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 +- [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. -- [ ] `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` +- [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. -- [ ] Instalar y configurar `@inlang/paraglide-sveltekit` con el plugin de Vite -- [ ] Crear ficheros de mensajes: `messages/en.json` y `messages/es.json` +- [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 -- [ ] Cambio de idioma en runtime: actualizar `users.language` en Supabase + llamar a `setLanguageTag()` de Paraglide — sin recarga de página -- [ ] Traducir todas las cadenas UI de la Fase 1 (onboarding, gestión de colectivo, settings) +- [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 -- [ ] Integración de `keycloak-js` en SvelteKit: - - Inicialización en `+layout.ts` con `checkLoginIframe: false` (compatibilidad PWA/Safari) - - Store Svelte `auth` — expone `user`, `token`, `isAuthenticated`, `login()`, `logout()` - - `login()` redirige al login page de Keycloak; el callback vuelve a la app con el token - - 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: +- [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 -- [ ] 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: +- [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 ~40 emoji curados, tap para seleccionar - - *Upload* — input de fichero; **recorte en el navegador en proporción 1:1** (usar `cropperjs` o similar); subida a Supabase Storage bucket `avatars` + - *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 -- [ ] Componente `Avatar.svelte` — renderiza según `avatar_type`: initials (letra sobre fondo slate), emoji, o `` con fallback a initials si la URL falla +- [x] Componente `Avatar.svelte` — renderiza según `avatar_type`: initials (color determinista por hash del nombre), emoji, o `` 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. diff --git a/plan/fase-2b-realtime-modo-compra.md b/plan/fase-2b-realtime-modo-compra.md index 0fe0ba1..027d7e4 100644 --- a/plan/fase-2b-realtime-modo-compra.md +++ b/plan/fase-2b-realtime-modo-compra.md @@ -17,9 +17,11 @@ - [ ] Esquema local en IndexedDB: `pending_ops`, `listas_cache`, `items_cache` - [ ] Cola de operaciones pendientes: cada mutación se escribe en `pending_ops` antes de llamar a Supabase - [ ] Service Worker (`@vite-pwa/sveltekit` + Workbox): + - **NOTA TÉCNICA:** SvelteKit intercepta `src/service-worker.ts` y bloquea imports externos (solo permite `$service-worker` y `$env/static/public`). Para usar Workbox con `injectManifest` hay que nombrar el fichero `src/sw.ts` (SvelteKit no lo reconoce como SW) y configurar `strategies: 'injectManifest'`, `filename: 'sw.ts'` en `vite.config.ts`. Actualmente el plugin usa `generateSW` (SW auto-generado, precaching básico). - Cache-first para assets estáticos - Network-first con fallback a caché para rutas de la app - Background sync para flush de `pending_ops` al recuperar conexión + - Fallback manual `online` event (Safari no soporta Background Sync API) - [ ] Fallback manual de sync: al detectar `online` en el evento del navegador, flush de la cola (cubre Safari que no soporta Background Sync API) - [ ] Indicador de estado de sincronización en la UI: `sincronizado | sincronizando | sin conexión` diff --git a/setup.sh b/setup.sh index 50a0fff..5ed4408 100755 --- a/setup.sh +++ b/setup.sh @@ -138,6 +138,9 @@ SUPABASE_SERVICE_ROLE_KEY=${SERVICE_KEY} # ── Keycloak ────────────────────────────────────────────────────────────────── KEYCLOAK_ADMIN=admin KEYCLOAK_ADMIN_PASSWORD=admin +# Client secret for colectivo-web (confidential client, used by GoTrue). +# Dev-only value — matches keycloak/realm-export.json. Override for production. +KEYCLOAK_CLIENT_SECRET=gotrue-dev-secret # ── Public endpoints ────────────────────────────────────────────────────────── PUBLIC_SUPABASE_URL=http://localhost:8001 diff --git a/supabase/seed.sql b/supabase/seed.sql index 9b2cfb3..1572d3f 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -1,11 +1,73 @@ -- seed.sql — development seed data --- Mirrors Keycloak test users (UUIDs must match realm-export.json) --- Password for all users: test1234 +-- UUIDs are fixed and match both keycloak/realm-export.json and auth.users below. +-- Password for all Keycloak test users: test1234 --- ── Users ───────────────────────────────────────────────────────────────────── --- Inserted into the public.users table (synced from Keycloak via trigger in Fase 1) --- For Fase 0 we insert directly since the trigger isn't written yet. +-- ── GoTrue auth.users — pre-seed so auth.uid() returns the same UUIDs ──────── +-- GoTrue normally generates its own UUIDs on first OIDC login. By pre-seeding +-- auth.users + auth.identities with the known Keycloak sub UUIDs, GoTrue finds +-- the existing identity on login and reuses the same UUID. This keeps auth.uid() +-- consistent with all foreign keys in public.* tables. +INSERT INTO auth.users ( + instance_id, id, aud, role, email, + email_confirmed_at, + -- Token fields must be '' not NULL; GoTrue's Go struct uses string (not *string) + confirmation_token, recovery_token, email_change_token_new, email_change, + raw_app_meta_data, raw_user_meta_data, + is_super_admin, created_at, updated_at +) +VALUES + ('00000000-0000-0000-0000-000000000000', '11111111-1111-1111-1111-111111111111', 'authenticated', 'authenticated', 'ana@dev.local', + now(), '', '', '', '', + '{"provider":"keycloak","providers":["keycloak"]}', + '{"full_name":"Ana García","email":"ana@dev.local","email_verified":true}', + false, now(), now()), + ('00000000-0000-0000-0000-000000000000', '22222222-2222-2222-2222-222222222222', 'authenticated', 'authenticated', 'borja@dev.local', + now(), '', '', '', '', + '{"provider":"keycloak","providers":["keycloak"]}', + '{"full_name":"Borja López","email":"borja@dev.local","email_verified":true}', + false, now(), now()), + ('00000000-0000-0000-0000-000000000000', '33333333-3333-3333-3333-333333333333', 'authenticated', 'authenticated', 'carmen@dev.local', + now(), '', '', '', '', + '{"provider":"keycloak","providers":["keycloak"]}', + '{"full_name":"Carmen Martínez","email":"carmen@dev.local","email_verified":true}', + false, now(), now()), + ('00000000-0000-0000-0000-000000000000', '44444444-4444-4444-4444-444444444444', 'authenticated', 'authenticated', 'david@dev.local', + now(), '', '', '', '', + '{"provider":"keycloak","providers":["keycloak"]}', + '{"full_name":"David Fernández","email":"david@dev.local","email_verified":true}', + false, now(), now()), + ('00000000-0000-0000-0000-000000000000', '55555555-5555-5555-5555-555555555555', 'authenticated', 'authenticated', 'eva@dev.local', + now(), '', '', '', '', + '{"provider":"keycloak","providers":["keycloak"]}', + '{"full_name":"Eva Ruiz","email":"eva@dev.local","email_verified":true}', + false, now(), now()) +ON CONFLICT (id) DO NOTHING; +-- ── GoTrue auth.identities — link Keycloak provider_id to the pre-seeded UUIDs ─ +INSERT INTO auth.identities ( + provider_id, user_id, identity_data, provider, + created_at, updated_at +) +VALUES + ('11111111-1111-1111-1111-111111111111', '11111111-1111-1111-1111-111111111111', + '{"sub":"11111111-1111-1111-1111-111111111111","iss":"http://keycloak:8080/realms/colectivo","email":"ana@dev.local","full_name":"Ana García","email_verified":true}', + 'keycloak', now(), now()), + ('22222222-2222-2222-2222-222222222222', '22222222-2222-2222-2222-222222222222', + '{"sub":"22222222-2222-2222-2222-222222222222","iss":"http://keycloak:8080/realms/colectivo","email":"borja@dev.local","full_name":"Borja López","email_verified":true}', + 'keycloak', now(), now()), + ('33333333-3333-3333-3333-333333333333', '33333333-3333-3333-3333-333333333333', + '{"sub":"33333333-3333-3333-3333-333333333333","iss":"http://keycloak:8080/realms/colectivo","email":"carmen@dev.local","full_name":"Carmen Martínez","email_verified":true}', + 'keycloak', now(), now()), + ('44444444-4444-4444-4444-444444444444', '44444444-4444-4444-4444-444444444444', + '{"sub":"44444444-4444-4444-4444-444444444444","iss":"http://keycloak:8080/realms/colectivo","email":"david@dev.local","full_name":"David Fernández","email_verified":true}', + 'keycloak', now(), now()), + ('55555555-5555-5555-5555-555555555555', '55555555-5555-5555-5555-555555555555', + '{"sub":"55555555-5555-5555-5555-555555555555","iss":"http://keycloak:8080/realms/colectivo","email":"eva@dev.local","full_name":"Eva Ruiz","email_verified":true}', + 'keycloak', now(), now()) +ON CONFLICT (provider_id, provider) DO NOTHING; + +-- ── public.users — synced from auth.users via trigger on login ──────────────── +-- Pre-seeded here too so the data is available before first login. INSERT INTO public.users (id, email, display_name, language, avatar_type) VALUES ('11111111-1111-1111-1111-111111111111', 'ana@dev.local', 'Ana García', 'es', 'initials'), @@ -31,30 +93,39 @@ VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '44444444-4444-4444-4444-444444444444', 'guest') ON CONFLICT (collective_id, user_id) DO NOTHING; --- ── Sample shopping list ─────────────────────────────────────────────────────── -INSERT INTO public.shopping_lists (id, collective_id, name, status, created_by) -VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Weekly shop', 'active', '11111111-1111-1111-1111-111111111111') -ON CONFLICT (id) DO NOTHING; +-- ── Phase 2a seed data (shopping lists, items, frequency) ───────────────────── +-- Guarded: only inserted when the tables exist (Phase 2a migrations applied). +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'shopping_lists') THEN + INSERT INTO public.shopping_lists (id, collective_id, name, status, created_by) + VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Weekly shop', 'active', '11111111-1111-1111-1111-111111111111') + ON CONFLICT (id) DO NOTHING; -INSERT INTO public.shopping_items (list_id, name, quantity, unit, sort_order, created_by) -VALUES - ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Milk', 2, 'L', 1, '11111111-1111-1111-1111-111111111111'), - ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Bread', 1, NULL, 2, '22222222-2222-2222-2222-222222222222'), - ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Eggs', 1, 'doz', 3, '11111111-1111-1111-1111-111111111111'), - ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Coffee', 1, NULL, 4, '33333333-3333-3333-3333-333333333333'), - ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Apples', 6, NULL, 5, '22222222-2222-2222-2222-222222222222') -ON CONFLICT DO NOTHING; + INSERT INTO public.shopping_items (list_id, name, quantity, unit, sort_order, created_by) + VALUES + ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Milk', 2, 'L', 1, '11111111-1111-1111-1111-111111111111'), + ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Bread', 1, NULL, 2, '22222222-2222-2222-2222-222222222222'), + ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Eggs', 1, 'doz', 3, '11111111-1111-1111-1111-111111111111'), + ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Coffee', 1, NULL, 4, '33333333-3333-3333-3333-333333333333'), + ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Apples', 6, NULL, 5, '22222222-2222-2222-2222-222222222222') + ON CONFLICT DO NOTHING; + END IF; --- ── Item frequency baseline (from pre-existing items above) ──────────────────── -INSERT INTO public.item_frequency (collective_id, name, use_count, last_used_at) -VALUES - ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'milk', 5, now()), - ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bread', 4, now()), - ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'eggs', 3, now()), - ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'coffee', 3, now()), - ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'apples', 2, now()), - ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'butter', 2, now()), - ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'yogurt', 1, now()) -ON CONFLICT (collective_id, name) DO UPDATE SET - use_count = EXCLUDED.use_count, - last_used_at = EXCLUDED.last_used_at; + IF EXISTS (SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'item_frequency') THEN + INSERT INTO public.item_frequency (collective_id, name, use_count, last_used_at) + VALUES + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'milk', 5, now()), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bread', 4, now()), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'eggs', 3, now()), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'coffee', 3, now()), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'apples', 2, now()), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'butter', 2, now()), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'yogurt', 1, now()) + ON CONFLICT (collective_id, name) DO UPDATE SET + use_count = EXCLUDED.use_count, + last_used_at = EXCLUDED.last_used_at; + END IF; +END $$;