fix(auth+pwa): resolve stuck-loading race condition and PWA build error
Auth: `getSession()` in a SvelteKit load function races with Supabase's async localStorage init and can return null for a valid session, triggering a spurious login() redirect. Moved auth redirect to (app)/+layout.svelte via $effect, which correctly waits for onAuthStateChange to fire. Also fixed collective data never loading on page refresh (INITIAL_SESSION event, not SIGNED_IN). PWA: SvelteKit blocks Workbox imports in src/service-worker.ts, preventing @vite-pwa/sveltekit from finding the compiled SW output. Switched to generateSW strategy (plugin auto-generates the SW). Custom SW deferred to Fase 2b using src/sw.ts (a filename SvelteKit does not intercept). Docs: added gotchas 6–8 to CLAUDE.md covering both root causes so they are not reintroduced. Updated plan/fase-2b with the sw.ts workaround note. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
84
CLAUDE.md
84
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 <file> # 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.
|
||||
|
||||
5
Justfile
5
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
|
||||
|
||||
73
README.md
73
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:**
|
||||
|
||||
@@ -10,7 +10,9 @@ export function getSupabase(): SupabaseClient<Database> {
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -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: '✓' },
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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,22 +18,25 @@
|
||||
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 === '/') {
|
||||
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 if ($page.url.pathname === '/') {
|
||||
} else {
|
||||
goto('/lists');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
/// <reference no-default-lib="true"/>
|
||||
/// <reference lib="esnext" />
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -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}']
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
9
infra/kong-start.sh
Executable file
9
infra/kong-start.sh
Executable file
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 `<img>` 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 `<img>` con fallback a initials
|
||||
|
||||
> **Auto-registro:** Self-registration está habilitado en Keycloak. Un usuario sin cuenta que recibe un link de invitación puede registrarse en la pantalla de Keycloak y volver al callback de invitación automáticamente.
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
3
setup.sh
3
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
|
||||
|
||||
@@ -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,7 +93,12 @@ VALUES
|
||||
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '44444444-4444-4444-4444-444444444444', 'guest')
|
||||
ON CONFLICT (collective_id, user_id) DO NOTHING;
|
||||
|
||||
-- ── Sample shopping list ───────────────────────────────────────────────────────
|
||||
-- ── 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;
|
||||
@@ -44,8 +111,10 @@ VALUES
|
||||
('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) ────────────────────
|
||||
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()),
|
||||
@@ -58,3 +127,5 @@ VALUES
|
||||
ON CONFLICT (collective_id, name) DO UPDATE SET
|
||||
use_count = EXCLUDED.use_count,
|
||||
last_used_at = EXCLUDED.last_used_at;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
Reference in New Issue
Block a user