feat(sprint-4/etapa-1): Supabase auth + Google OAuth + LoginPage + rutas protegidas

- Cliente Supabase singleton en src/lib/supabase.ts
- Interfaces AuthUser / AuthService en src/auth/types.ts
- SupabaseAuthService: signInWithGoogle, signOut, getCurrentUser, onAuthStateChange
  con upsert a tabla users (platform_admin para @inquality.com.py, guest resto)
- AuthProvider (React Context) wrappea toda la app; expone user, loading, signIn, signOut
- LoginPage /login: logo InQuality color, título, fade-in, botón Google naranja #F59845
- RootComponent del router guarda todas las rutas — sin sesión → /login, con sesión + /login → /
- Script SQL: supabase/migrations/001_create_users.sql (tabla users + RLS)
- AppHeader: <Link> → <a href="/"> para evitar dependencia de RouterContext en tests
- 561/561 tests verdes, build sin errores TS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 23:53:12 -03:00
parent 2c57bd5bf4
commit 78e776df6b
14 changed files with 1089 additions and 18 deletions

475
sprints/sprint-4/BRIEF.md Normal file
View File

@@ -0,0 +1,475 @@
# Sprint 4 — BRIEF
> **Naturaleza del sprint:** refactorización arquitectural mayor.
> No hay features de producto nuevas visibles. El objetivo es habilitar colaboración multi-usuario sobre una base correcta.
> Este sprint es una excepción justificada al rebalanceo web/PDF/infra — es 100% infraestructura.
---
## Contexto y motivación
InQ ROI nació como herramienta single-user con persistencia local (IndexedDB/Dexie). El equipo de InQuality lo usa actualmente de forma individual y quiere pasar a un modelo colaborativo donde:
- Múltiples consultores pueden trabajar sobre procesos compartidos
- Existe identidad de usuario (quién creó, quién actualizó)
- Algunos elementos son privados del consultor, otros compartidos con el equipo
- Los recursos son un catálogo global del equipo, no duplicados por proceso
Para esto se requiere reemplazar IndexedDB con una base de datos centralizada, agregar autenticación, y rediseñar el modelo de datos de recursos.
**Dato importante:** no hay datos en producción que valgan la pena migrar. Se abandona IndexedDB limpiamente y se empieza de cero en la nueva base de datos.
---
## Decisiones estratégicas — NO reabrir en esta sprint
### Auth: Supabase ahora, Keycloak después
Se usa **Supabase** como BaaS para Sprint 4: auth + PostgreSQL + RLS. La razón es velocidad de implementación.
El código debe construir una **interfaz `AuthService`** que abstraiga el proveedor de autenticación. El objetivo es que la migración futura a Keycloak (servidor de identidad InQuality para múltiples productos) sea cambio de implementación, no reescritura de la app.
InQuality tiene infraestructura VPS (desa + prod) y la visión es Keycloak self-hosted como plataforma de identidad para todos sus productos. Eso es trabajo de un sprint futuro.
### Modelo de plataforma: InQuality como operador (Modelo A)
InQ ROI es una **plataforma de consultoría operada por InQuality**, no un SaaS peer-to-peer. InQuality controla quién entra, a qué tiene acceso, y qué puede hacer. Los clientes y consultores externos son guests invitados por InQuality.
Esto se implementa con un campo `platform_role` en la tabla de usuarios:
- `platform_admin` → staff InQuality, puede ver todos los procesos de todos los usuarios/grupos
- `member` → usuario estándar (consultor InQuality sin acceso global, o reservado para futuro)
- `guest` → cliente externo o consultor externo, solo ve lo que se le comparte explícitamente
En Sprint 4, todos los usuarios de InQuality reciben `platform_admin`. El rol `guest` se usa cuando se agregue el primer cliente externo.
**Migración a Modelo B (SaaS peer-to-peer) — posible, no planificada.**
Cuando InQuality decida que cada organización cliente debe ser autónoma (administrar sus propios usuarios, sin acceso implícito de InQuality), la migración consiste en: eliminar el bypass de `platform_admin`, agregar self-service de org, y cambiar las políticas RLS. El schema de Sprint 4 ya lo soporta sin reestructurar datos.
### Auth: providers múltiples según rol
- **Staff InQuality** (`platform_admin`): Google Workspace `@inquality.com.py`. Solo este dominio puede obtener `platform_admin`.
- **Guests** (clientes, consultores externos): cualquier email. Magic link o email+contraseña. No requieren Google Workspace.
Supabase soporta múltiples providers simultáneamente. En Sprint 4 solo se implementa Google OAuth para staff. La infraestructura para guest auth (magic link) se deja preparada pero no activada hasta que haya un cliente real.
### Online-only
Se elimina el requerimiento de offline. La herramienta requiere conectividad. Offline + sync se agrega al roadmap futuro.
### Sin auditoría en Sprint 4
La auditoría de cambios (quién cambió qué campo y cuándo) se difiere a un sprint dedicado. En Sprint 4 solo se captura `owner_id`, `created_by`, `updated_by`, `created_at`, `updated_at` en las tablas principales — suficiente para mostrar identidad en la UI sin el costo de instrumentar un audit log completo.
---
## Modelo de datos objetivo
### Entidades centrales y sus relaciones
El modelo tiene tres entidades de identidad/acceso y las entidades de negocio del producto. La separación es importante: `client` en un proceso es un atributo de texto, **no** una entidad con acceso al sistema.
```
Usuarios (identidad)
↓ pertenecen a
Grupos de usuario (agrupación de acceso)
↓ se usan en
Acceso a procesos (tabla de permisos)
↓ determina quién ve
Procesos → Actividades → Simulaciones (entidades de negocio)
Recursos (catálogo global, sin process_id)
```
### Usuarios
La tabla de usuarios combina Supabase Auth (fuente de verdad para credenciales) con una tabla `users` en la DB de la app para atributos de la plataforma.
```
users
id uuid PK (mismo UUID que auth.users de Supabase)
email text NOT NULL
name text
avatar_url text
platform_role 'platform_admin' | 'member' | 'guest'
created_at timestamptz DEFAULT now()
```
**Reglas de `platform_role`:**
- `platform_admin` → staff InQuality. Bypass de todos los controles de acceso: ve y puede editar todos los procesos. Solo usuarios con dominio `@inquality.com.py` obtienen este rol.
- `member` → usuario estándar. Ve solo lo que se le comparte explícitamente. Reservado para consultores InQuality sin acceso global o para uso futuro.
- `guest` → cliente externo o consultor externo. Idéntico a `member` en permisos. Se usa para distinguir visualmente en la UI quién es externo.
En Sprint 4, todos los usuarios de InQuality reciben `platform_admin`. El rol `guest` entra cuando llegue el primer cliente externo.
**El campo `platform_role` nunca hardcodea "inquality".** Si en el futuro InQuality decide operar el Modelo B (cada org autónoma), el bypass se elimina cambiando las políticas RLS — el campo permanece genérico.
### Grupos de usuario
Permiten compartir un proceso con un conjunto de personas sin listar a cada una individualmente. Útil para "todos los del cliente Acme" o "equipo de consultoría junior".
```
user_groups
id uuid PK
name text NOT NULL
created_by uuid → users.id
created_at timestamptz DEFAULT now()
group_memberships
user_id uuid → users.id
group_id uuid → user_groups.id
role 'owner' | 'member'
PRIMARY KEY (user_id, group_id)
```
### Acceso a procesos
Reemplaza el campo `visibility: 'private' | 'team'` del modelo anterior. El acceso es explícito: el owner de un proceso elige con quién compartirlo (usuarios individuales o grupos).
```
process_access
process_id uuid → processes.id
grantee_type 'user' | 'group'
grantee_id uuid (→ users.id si 'user', → user_groups.id si 'group')
permission 'view' | 'edit'
PRIMARY KEY (process_id, grantee_type, grantee_id)
```
Un proceso sin entradas en `process_access` es privado del owner. Un proceso compartido con el grupo "Equipo InQuality" es efectivamente de visibilidad de equipo. La tabla `process_access` es suficientemente flexible para Modelo A y Modelo B sin reestructurar.
### Procesos
```
processes
id uuid PK
name text
client text ← atributo descriptivo, NO entidad con acceso
currency text
frequency numeric
bpmn_xml text
owner_id uuid → users.id
created_by uuid → users.id
updated_by uuid → users.id
created_at timestamptz
updated_at timestamptz
```
Sin campo `visibility`. El acceso se determina completamente por `process_access` y por `platform_role` del usuario.
### Recursos — catálogo global
Este es el cambio más significativo de modelo. Los recursos dejan de ser por proceso y pasan a ser un catálogo global del equipo.
```
resources
id uuid PK
name text
type 'human' | 'system' | 'tool'
cost_per_hour numeric
created_by uuid → users.id
updated_by uuid → users.id
created_at timestamptz
updated_at timestamptz
```
Sin `process_id`. Sin control de acceso propio — los recursos son siempre visibles para todos los usuarios autenticados. Cualquier consultor puede crear, ver y editar recursos del catálogo.
Las asignaciones de recursos a actividades (`activity_resource_assignments`) referencian el `resource_id` del catálogo global.
### Actividades
Sin cambio de modelo respecto a Sprint 3. Se agrega `created_by`, `updated_by`, `created_at`, `updated_at`.
### Simulaciones
No tienen control de acceso propio — la regla de acceso se deriva del proceso al que pertenecen.
### Grupos de procesos y tags
Compartidos por todo el equipo. Sin campo de acceso. Sin `owner_id`.
---
## Interfaz AuthService — contrato obligatorio
Claude Code debe definir y respetar esta interfaz. No llamar a Supabase directamente desde componentes o stores.
```typescript
interface AuthUser {
id: string
email: string
name: string
avatarUrl?: string
}
interface AuthService {
signInWithGoogle(): Promise<void>
signOut(): Promise<void>
getCurrentUser(): Promise<AuthUser | null>
onAuthStateChange(callback: (user: AuthUser | null) => void): () => void
}
```
La implementación `SupabaseAuthService` implementa esta interfaz y es la única que toca Supabase directamente. Cuando llegue Keycloak, se escribe `KeycloakAuthService` que implementa la misma interfaz. El resto de la app no cambia.
---
## Capa de repositorios — contrato obligatorio
Actualmente existe `src/persistence/repositories.ts` con Dexie. Se reemplaza por implementaciones Supabase manteniendo el mismo patrón de repositorios.
```typescript
// El contrato (interface) no cambia, la implementación sí
interface ProcessRepository {
getAll(): Promise<Process[]>
getById(id: string): Promise<Process | undefined>
save(process: Process): Promise<void>
delete(id: string): Promise<void>
// ...
}
```
Los stores de Zustand llaman al repositorio. Los repositorios llaman a Supabase. Ningún componente toca Supabase directamente.
---
## Row-Level Security (RLS)
Las políticas de RLS de Supabase deben ser equivalentes a políticas PostgreSQL estándar. No usar features exclusivas de Supabase que no puedan recrearse en un PostgreSQL standalone cuando se migre a VPS.
### Helper function (crear una vez)
```sql
-- Retorna true si el usuario actual es platform_admin
CREATE OR REPLACE FUNCTION is_platform_admin()
RETURNS boolean AS $$
SELECT EXISTS (
SELECT 1 FROM users
WHERE id = auth.uid() AND platform_role = 'platform_admin'
);
$$ LANGUAGE sql SECURITY DEFINER;
```
### Políticas para `processes`
```sql
-- Ver: platform_admin ve todo | owner ve los suyos | acceso explícito
CREATE POLICY "select_processes" ON processes FOR SELECT
USING (
is_platform_admin()
OR owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM process_access
WHERE process_id = processes.id
AND (
(grantee_type = 'user' AND grantee_id = auth.uid())
OR
(grantee_type = 'group' AND grantee_id IN (
SELECT group_id FROM group_memberships WHERE user_id = auth.uid()
))
)
)
);
-- Insertar: cualquier usuario autenticado
CREATE POLICY "insert_processes" ON processes FOR INSERT
WITH CHECK (auth.uid() IS NOT NULL);
-- Actualizar: platform_admin | owner | quien tenga permiso 'edit'
CREATE POLICY "update_processes" ON processes FOR UPDATE
USING (
is_platform_admin()
OR owner_id = auth.uid()
OR EXISTS (
SELECT 1 FROM process_access
WHERE process_id = processes.id
AND permission = 'edit'
AND (
(grantee_type = 'user' AND grantee_id = auth.uid())
OR
(grantee_type = 'group' AND grantee_id IN (
SELECT group_id FROM group_memberships WHERE user_id = auth.uid()
))
)
)
);
-- Eliminar: solo el owner (platform_admin puede, por ser owner o por override)
CREATE POLICY "delete_processes" ON processes FOR DELETE
USING (is_platform_admin() OR owner_id = auth.uid());
```
### Políticas para `resources` (catálogo global)
```sql
-- Todos los autenticados pueden leer y escribir
CREATE POLICY "all_resources" ON resources
USING (auth.uid() IS NOT NULL)
WITH CHECK (auth.uid() IS NOT NULL);
```
### Políticas para `users`, `user_groups`, `group_memberships`, `process_access`
```sql
-- users: cualquier autenticado puede leer (para mostrar nombres), solo el propio usuario puede actualizar
CREATE POLICY "read_users" ON users FOR SELECT USING (auth.uid() IS NOT NULL);
CREATE POLICY "update_own_user" ON users FOR UPDATE USING (id = auth.uid());
-- user_groups: todos los autenticados leen; platform_admin o owner del grupo puede modificar
CREATE POLICY "read_groups" ON user_groups FOR SELECT USING (auth.uid() IS NOT NULL);
CREATE POLICY "manage_groups" ON user_groups FOR ALL
USING (is_platform_admin() OR created_by = auth.uid());
-- group_memberships: todos los autenticados leen; platform_admin o owner del grupo gestiona
CREATE POLICY "read_memberships" ON group_memberships FOR SELECT USING (auth.uid() IS NOT NULL);
CREATE POLICY "manage_memberships" ON group_memberships FOR ALL
USING (
is_platform_admin()
OR EXISTS (
SELECT 1 FROM user_groups
WHERE id = group_memberships.group_id AND created_by = auth.uid()
)
);
-- process_access: owner del proceso o platform_admin gestiona los accesos
CREATE POLICY "manage_process_access" ON process_access FOR ALL
USING (
is_platform_admin()
OR EXISTS (
SELECT 1 FROM processes WHERE id = process_access.process_id AND owner_id = auth.uid()
)
);
CREATE POLICY "read_process_access" ON process_access FOR SELECT
USING (auth.uid() IS NOT NULL);
```
### Nota sobre migración a Modelo B
Para migrar de Modelo A a Modelo B (orgs autónomas), los únicos cambios son:
1. Eliminar o condicionar el bypass de `is_platform_admin()` en las políticas
2. Agregar tabla `organizations` y `org_memberships`
3. Ajustar las políticas para que el scope sea `org_id` en lugar de plataforma global
El schema de datos no cambia. Las tablas `user_groups`, `group_memberships` y `process_access` son compatibles con ambos modelos.
---
## UX — cambios de superficie
### Login
Pantalla de login antes de cualquier contenido. Dos flujos:
**Staff InQuality** → Botón "Iniciar sesión con Google". Solo cuentas `@inquality.com.py` obtienen acceso. Al autenticarse, la app verifica el dominio y asigna `platform_role = 'platform_admin'` si no existe registro previo en la tabla `users`.
**Guests** (clientes, consultores externos) → En Sprint 4 este flujo no está activado. La infraestructura (campo `platform_role = 'guest'`, tabla `users`) se construye lista para habilitarlo. El botón de magic link / email no aparece en la UI de Sprint 4. Se activa cuando haya un cliente real que incorporar.
Al completar login, la app redirige al workspace. Sin login, cualquier ruta protegida redirige a `/login`.
### Biblioteca (LibraryPage)
- Toggle de filtro: "Todos" / "Míos"
- Badge o ícono que indica visibilidad (privado 🔒 / compartido)
- En cada tarjeta de proceso: "Actualizado por [nombre]" + timestamp relativo
- Botón para cambiar visibilidad de un proceso propio
### ResourcesPanel (en workspace)
El panel de recursos ya no es "crear recursos para este proceso". Pasa a ser "seleccionar desde catálogo global" + "agregar nuevo al catálogo". El catálogo vive en una nueva sección de la app (Settings o panel dedicado).
### AppHeader
Mostrar avatar / nombre del usuario logueado. Botón de logout.
---
## Stack — cambios
| Agregado | Propósito |
| --- | --- |
| `@supabase/supabase-js` | Cliente Supabase (auth + db) |
| Variables de entorno `VITE_SUPABASE_URL` y `VITE_SUPABASE_ANON_KEY` | Configuración por entorno |
| Eliminado | Motivo |
| --- | --- |
| `dexie` | Reemplazado por Supabase PostgreSQL |
| `src/persistence/db.ts` | Reemplazado por cliente Supabase |
| Migraciones Dexie (v1-v4) | Ya no aplican |
Las variables de entorno deben existir en `.env.local` (ignorado por git) y documentarse en `.env.example`.
---
## Etapas tentativas
El orden importa — cada etapa habilita la siguiente.
| Etapa | Descripción |
| --- | --- |
| 1 | Supabase setup + AuthService + login page + Google OAuth |
| 2 | Schema PostgreSQL + migración de repositorios (procesos + grupos + tags) |
| 3 | Migración de actividades + recursos globales (nuevo modelo) |
| 4 | Migración de simulaciones + limpieza de Dexie |
| 5 | Modelo de privacidad: RLS + UI de visibilidad en biblioteca |
| 6 | Identidad en UI: header con usuario + "actualizado por" en biblioteca y workspace |
| 7 | ResourcesPanel: UX de catálogo global |
| 8 | Tests de integración + polish + validación E2E de auth |
Cada etapa tiene su propio `ETAPA_N_PROMPT.md` antes de arrancar. No improvisar orden ni scope entre etapas.
---
## Criterios de aceptación del sprint
- [ ] Login con Google Workspace (`@inquality.com.py`) funciona en desa y prod
- [ ] Ruta protegida — sin login no se accede a nada de la app
- [ ] Procesos se guardan y recuperan de Supabase (no de IndexedDB)
- [ ] Control de acceso funciona — un proceso sin `process_access` no lo ve otro usuario (salvo `platform_admin`)
- [ ] Recursos son globales — visibles para todos los usuarios autenticados
- [ ] En la biblioteca aparece "Actualizado por [nombre]" en cada proceso
- [ ] `AuthService` es una interface con implementación `SupabaseAuthService`
- [ ] No hay llamadas directas a Supabase fuera de repositorios y `AuthService`
- [ ] Dexie eliminado del proyecto (no solo sin uso — removido del package.json)
- [ ] Build limpio sin errores TypeScript
- [ ] Tests de dominio previos siguen verdes (simulación, ROI, PDF)
- [ ] Validación visual del director en cada etapa con output visual
---
## Riesgos y mitigaciones
**Google Workspace OAuth requiere configuración en Google Cloud Console.**
Acción: crear el proyecto OAuth antes de que Claude Code escriba una línea de código. El `client_id` y `client_secret` tienen que estar disponibles. Se configura también en Supabase Dashboard → Auth → Providers.
**El cambio de recursos globales rompe el UX actual completamente.**
Acción: el prompt de Etapa 7 (ResourcesPanel) debe incluir un mockup del nuevo UX aprobado antes de implementar. Aplicar Patrón C.5 (mockup como contrato visual).
**Múltiples etapas tienen riesgo de regresión en simulación/PDF.**
Acción: correr `npm run test` al final de cada etapa y verificar que los 561 tests previos siguen verdes. Si baja el count, parar y reportar antes de continuar.
---
## Fuera del scope de Sprint 4 — explícito
- Auditoría de cambios (audit log) — sprint dedicado futuro
- Keycloak / migración de auth provider — sprint futuro
- Offline mode / sync — roadmap
- Acceso de clientes externos — Fase 2
- Versionado de procesos — Fase 2
- Export a Excel — BACKLOG sin sprint
- Cualquier feature de PDF o simulación nueva
---
## Referencias
| Recurso | Propósito |
| --- | --- |
| `simulador-web/CLAUDE.md` | Reglas de implementación para Claude Code |
| `simulador-web/src/persistence/` | Código Dexie actual a reemplazar |
| `simulador-web/src/store/` | Stores Zustand que llaman a repositorios |
| `simulador-web/src/domain/types.ts` | Tipos de dominio (base del schema PostgreSQL) |
| `methodology/PATTERNS.md` | C.5 (mockup como contrato), C.7 (git diff preventivo) |
| `methodology/ANTI_PATTERNS.md` | AP.8 (adelanto silencioso) |
| Supabase docs — RLS | https://supabase.com/docs/guides/auth/row-level-security |
| Supabase docs — Google OAuth | https://supabase.com/docs/guides/auth/social-login/auth-google |