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

4
.env.example Normal file
View File

@@ -0,0 +1,4 @@
# Supabase — InQ ROI
# Obtener desde Supabase Dashboard → Project Settings → API
VITE_SUPABASE_URL=https://<project-ref>.supabase.co
VITE_SUPABASE_ANON_KEY=<anon-public-key>

99
package-lock.json generated
View File

@@ -19,6 +19,7 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@supabase/supabase-js": "^2.106.2",
"@tanstack/react-router": "^1.169.2", "@tanstack/react-router": "^1.169.2",
"@tanstack/router-devtools": "^1.166.13", "@tanstack/router-devtools": "^1.166.13",
"@types/html2canvas": "^0.5.35", "@types/html2canvas": "^0.5.35",
@@ -3038,6 +3039,90 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@supabase/auth-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.106.2.tgz",
"integrity": "sha512-VcAjUErkHkhC5Jaf+g/G1qbkQrFh8edaCdHa7pxJmHUjkWKjT7UnYCtPA89XV0N0GIYRkEqJZw5V62CtOxTmBQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.106.2.tgz",
"integrity": "sha512-oRnr0QrL8H+zTO1YyQ1QjiHZU/957jvubbxSJTUm2XLAgzoGGV9Tahfyd+uvLsBLRVmXLtpU3oyCjdQIvkGMOA==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/phoenix": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz",
"integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==",
"license": "MIT"
},
"node_modules/@supabase/postgrest-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.106.2.tgz",
"integrity": "sha512-tDOzyPgp9pIRMR2x6C9+uDSJrnXSzxLtt3d7nC+Lrsy3jnJDHYfdQC/xcRyhJE/TOBJ0heSqRKR3UmejDjZxsw==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.106.2.tgz",
"integrity": "sha512-LdRGT7DNhyZkPjubUv5bSdAZ0jSEX8wTHvx7htj7+K59TOZRvz4TuQK7tL2RWxyIZVeFMRluL04SzWS61rKnUA==",
"license": "MIT",
"dependencies": {
"@supabase/phoenix": "^0.4.2",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.106.2.tgz",
"integrity": "sha512-xgKCSYuev1YarV+iVqr+zlfgSyremnJtn8T0NCT8L4XmMv1CLtESc0Q6kNp8+mKWdX/8ND0nzm7OMKx08kwNAw==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.106.2",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.106.2.tgz",
"integrity": "sha512-2/RZ/1fmJx/MRSEDG2Xk8+J4JVk5clM9V0uSI6kUTrcS32KA89DtqI5RUOC9r6mzY3WBC9qexLjssIHjbLyVJA==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.106.2",
"@supabase/functions-js": "2.106.2",
"@supabase/postgrest-js": "2.106.2",
"@supabase/realtime-js": "2.106.2",
"@supabase/storage-js": "2.106.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@tanstack/history": { "node_modules/@tanstack/history": {
"version": "1.161.6", "version": "1.161.6",
"resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.161.6.tgz", "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.161.6.tgz",
@@ -3702,7 +3787,7 @@
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -3712,7 +3797,7 @@
"version": "19.2.3", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
@@ -4651,7 +4736,6 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3": { "node_modules/d3": {
@@ -5846,6 +5930,15 @@
"node": ">=8.0.0" "node": ">=8.0.0"
} }
}, },
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",

View File

@@ -24,6 +24,7 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@supabase/supabase-js": "^2.106.2",
"@tanstack/react-router": "^1.169.2", "@tanstack/react-router": "^1.169.2",
"@tanstack/router-devtools": "^1.166.13", "@tanstack/router-devtools": "^1.166.13",
"@types/html2canvas": "^0.5.35", "@types/html2canvas": "^0.5.35",

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 |

View File

@@ -0,0 +1,240 @@
# Sprint 4 — Etapa 1: Supabase setup + AuthService + Login page + Google OAuth
## Contexto
InQ ROI es una herramienta web de análisis de procesos BPMN y cálculo de ROI de automatización.
Está construida con React 18 + Vite + TypeScript + TanStack Router + Zustand + shadcn/ui + Tailwind.
La persistencia actual es IndexedDB via Dexie — este sprint la reemplaza por Supabase PostgreSQL.
Esta etapa no toca la persistencia de datos de negocio (procesos, actividades, etc.).
Su único objetivo es: **la app tiene login con Google, las rutas están protegidas, y existe la abstracción `AuthService`**.
---
## Prerequisito — verificar antes de empezar
Las siguientes variables de entorno deben estar disponibles en `.env.local`:
```
VITE_SUPABASE_URL=https://<project-ref>.supabase.co
VITE_SUPABASE_ANON_KEY=<anon-key>
```
Si no existen, **parar y reportar**. No avanzar sin estas credenciales.
---
## Alcance estricto — qué entra en esta etapa
1. **Instalar `@supabase/supabase-js`** y crear el cliente singleton en `src/lib/supabase.ts`
2. **Definir la interfaz `AuthService`** y el tipo `AuthUser` en `src/auth/types.ts`
3. **Implementar `SupabaseAuthService`** en `src/auth/SupabaseAuthService.ts`
4. **Crear la tabla `users`** en Supabase con script SQL documentado
5. **Crear `LoginPage`** (`/login`) con botón "Iniciar sesión con Google"
6. **Proteger rutas**: cualquier ruta excepto `/login` redirige a `/login` si no hay sesión
7. **Documentar `.env.example`** con las variables requeridas
## Qué NO entra en esta etapa
- Migración de repositorios Dexie → Supabase (Etapa 2)
- Migración de datos de negocio (Etapa 2-4)
- Avatar/nombre en AppHeader (Etapa 6)
- UI de "Actualizado por" (Etapa 6)
- Tablas de `user_groups`, `group_memberships`, `process_access` (Etapa 2)
- Lógica de `platform_role` en la UI (la tabla `users` se crea, pero la UI no lo usa aún)
- Cualquier cambio en el motor de simulación, ROI, PDF o tests existentes
---
## Interfaces — contratos obligatorios
### `src/auth/types.ts`
```typescript
export interface AuthUser {
id: string
email: string
name: string
avatarUrl?: string
platformRole: 'platform_admin' | 'member' | 'guest'
}
export interface AuthService {
signInWithGoogle(): Promise<void>
signOut(): Promise<void>
getCurrentUser(): Promise<AuthUser | null>
onAuthStateChange(callback: (user: AuthUser | null) => void): () => void
}
```
`platformRole` se lee de la tabla `users` de la DB de la app, no de los metadatos de Supabase Auth.
### `src/auth/SupabaseAuthService.ts`
Implementa `AuthService`. Es la **única clase** que importa `@supabase/supabase-js` o `src/lib/supabase.ts`.
Comportamiento de `signInWithGoogle()`:
- Llama a `supabase.auth.signInWithOAuth({ provider: 'google', options: { redirectTo: window.location.origin } })`
- El callback de redirect completa el flujo
Comportamiento de `onAuthStateChange()`:
- Cuando el usuario se autentica por primera vez, hace upsert en la tabla `users` de la DB:
- Si el email es `@inquality.com.py``platform_role = 'platform_admin'`
- Cualquier otro dominio → `platform_role = 'guest'`
- Retorna la función de cleanup para desuscribirse
### `src/lib/supabase.ts`
```typescript
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Variables de entorno VITE_SUPABASE_URL y VITE_SUPABASE_ANON_KEY son requeridas')
}
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
```
---
## Schema SQL — tabla `users`
Crear este script en `supabase/migrations/001_create_users.sql`:
```sql
-- Tabla users: extiende auth.users con atributos de la plataforma
CREATE TABLE IF NOT EXISTS public.users (
id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
email text NOT NULL,
name text,
avatar_url text,
platform_role text NOT NULL DEFAULT 'guest'
CHECK (platform_role IN ('platform_admin', 'member', 'guest')),
created_at timestamptz NOT NULL DEFAULT now()
);
-- RLS
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
-- Cualquier usuario autenticado puede leer (para mostrar nombres)
CREATE POLICY "read_users" ON public.users
FOR SELECT USING (auth.uid() IS NOT NULL);
-- Solo el propio usuario puede actualizar su perfil
CREATE POLICY "update_own_user" ON public.users
FOR UPDATE USING (id = auth.uid());
-- El upsert inicial lo hace el trigger (SECURITY DEFINER)
CREATE POLICY "insert_users" ON public.users
FOR INSERT WITH CHECK (id = auth.uid());
```
Documentar en `README.md` o en el archivo de la migración que este script debe correrse en el dashboard de Supabase SQL Editor o via CLI.
---
## Rutas protegidas — comportamiento requerido
El router de TanStack Router debe:
- Detectar si hay sesión activa al cargar la app (llamando a `authService.getCurrentUser()`)
- Si no hay sesión → redirigir a `/login`
- Si hay sesión y el usuario va a `/login` → redirigir a `/` (workspace)
- Durante la carga inicial (verificando sesión) → mostrar un spinner o pantalla en blanco, no el contenido
Implementar un `AuthProvider` (React Context) que:
- Expone `user: AuthUser | null` y `loading: boolean`
- Llama a `authService.onAuthStateChange()` al montar
- Es el único lugar que llama a `AuthService` — los componentes consumen el contexto
No pasar `authService` como prop a ningún componente. Usar el contexto.
---
## LoginPage — especificación de UI
Ruta: `/login`
Layout:
- Pantalla completa, centrada vertical y horizontalmente
- Fondo: `#0F0F0F` (negro InQ) o blanco — usar blanco para este sprint, es más neutro
- Logo de InQuality centrado (usar `assets/MARCA FINAL 2023 AREA DE SEGURIDAD 1 COLOR.png`)
- Título: "InQ ROI" en tipografía grande, debajo del logo
- Subtítulo: "Análisis de procesos y ROI de automatización"
- Botón primario: "Iniciar sesión con Google" — usar ícono de Google (SVG inline o Lucide)
- Color del botón: naranja InQ `#F59845` o botón con borde (sin color relleno) — elegir el que quede más limpio
- Debajo del botón: texto pequeño en gris — "Solo para usuarios con cuenta @inquality.com.py"
**Versión wow**: agregar animación de entrada suave (fade-in) del contenido al cargar la página.
No mostrar campos de email/password ni opción de magic link — eso es Sprint futuro.
---
## .env.example
Crear o actualizar `.env.example` en la raíz del proyecto:
```
# Supabase — InQ ROI
# Obtener desde Supabase Dashboard → Project Settings → API
VITE_SUPABASE_URL=https://<project-ref>.supabase.co
VITE_SUPABASE_ANON_KEY=<anon-public-key>
```
`.env.local` debe estar en `.gitignore` (verificar que ya está).
---
## Criterios de validación — esta etapa está completa cuando
- [ ] `npm run build` pasa sin errores TypeScript
- [ ] `npm run test` — los 561 tests existentes siguen verdes (no se rompe nada)
- [ ] Navegar a `http://localhost:5173` sin sesión → redirige a `/login`
- [ ] La página `/login` muestra logo InQuality + botón de Google
- [ ] Al hacer click en "Iniciar sesión con Google" → redirige a Google OAuth
- [ ] Después de autenticarse con cuenta `@inquality.com.py` → redirige al workspace
- [ ] En la tabla `users` de Supabase aparece el registro del usuario con `platform_role = 'platform_admin'`
- [ ] No hay importaciones de `@supabase/supabase-js` fuera de `src/lib/supabase.ts` y `src/auth/SupabaseAuthService.ts`
- [ ] Validación visual del director: captura de pantalla de LoginPage + workspace post-login
---
## Reporte esperado al completar
Claude Code debe reportar:
1. Lista de archivos creados/modificados
2. Resultado de `npm run test` (número de tests, pass/fail)
3. Resultado de `npm run build` (sin errores)
4. Captura de pantalla o descripción de la LoginPage
5. Confirmación de que el upsert en la tabla `users` funciona (puede ser manual: "corrí el flujo y aparece el registro")
6. Cualquier decisión de iniciativa tomada fuera del scope especificado (clasificar por nivel 1/2/3)
---
## Referencia de archivos existentes
| Archivo | Relevancia |
| --- | --- |
| `src/router.tsx` | Router actual de TanStack — agregar protección de rutas aquí |
| `src/App.tsx` | Entry point de la app — agregar `AuthProvider` aquí |
| `src/domain/types.ts` | Tipos de dominio actuales (NO tocar en esta etapa) |
| `src/store/` | Stores Zustand (NO tocar en esta etapa) |
| `src/persistence/` | Código Dexie (NO tocar en esta etapa — se migra en Etapas 2-4) |
| `assets/` | Logos PNG para la LoginPage |
| `.gitignore` | Verificar que `.env.local` está ignorado |
---
## Nota de arquitectura — convivencia Dexie + Supabase
En esta etapa Dexie **sigue funcionando**. La app autentica con Supabase pero persiste datos con Dexie. Esto es intencional — la migración de datos es trabajo de Etapas 2-4. No tocar los stores ni los repositorios Dexie en esta etapa.
Al terminar esta etapa, la app tiene:
- Auth funcional con Google
- Rutas protegidas
- Datos en Dexie (sin cambios)
Las etapas siguientes migran los datos a Supabase manteniendo la app funcional en cada paso.

View File

@@ -1,13 +1,14 @@
import { RouterProvider } from '@tanstack/react-router' import { RouterProvider } from '@tanstack/react-router'
import { router } from './router' import { router } from './router'
import { Toaster } from '@/components/ui/toaster' import { Toaster } from '@/components/ui/toaster'
import { AuthProvider } from '@/auth/AuthContext'
export function App() { export function App() {
return ( return (
<> <AuthProvider>
<RouterProvider router={router} /> <RouterProvider router={router} />
<Toaster /> <Toaster />
</> </AuthProvider>
) )
} }

46
src/auth/AuthContext.tsx Normal file
View File

@@ -0,0 +1,46 @@
import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react'
import { SupabaseAuthService } from './SupabaseAuthService'
import type { AuthUser } from './types'
interface AuthContextValue {
user: AuthUser | null
loading: boolean
signIn: () => Promise<void>
signOut: () => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
const authService = new SupabaseAuthService()
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const unsubscribe = authService.onAuthStateChange((u) => {
setUser(u)
setLoading(false)
})
return unsubscribe
}, [])
const signIn = useCallback(() => authService.signInWithGoogle(), [])
const signOut = useCallback(async () => {
await authService.signOut()
setUser(null)
}, [])
return (
<AuthContext.Provider value={{ user, loading, signIn, signOut }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth debe usarse dentro de AuthProvider')
return ctx
}

View File

@@ -0,0 +1,70 @@
import { supabase } from '@/lib/supabase'
import type { AuthService, AuthUser } from './types'
export class SupabaseAuthService implements AuthService {
async signInWithGoogle(): Promise<void> {
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: { redirectTo: window.location.origin },
})
if (error) throw error
}
async signOut(): Promise<void> {
const { error } = await supabase.auth.signOut()
if (error) throw error
}
async getCurrentUser(): Promise<AuthUser | null> {
const { data: { session } } = await supabase.auth.getSession()
if (!session?.user) return null
return this.buildAuthUser(session.user)
}
onAuthStateChange(callback: (user: AuthUser | null) => void): () => void {
const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => {
if (!session?.user) {
callback(null)
return
}
if (event === 'SIGNED_IN') {
const email = session.user.email ?? ''
const role = email.endsWith('@inquality.com.py') ? 'platform_admin' : 'guest'
await supabase.from('users').upsert(
{
id: session.user.id,
name: session.user.user_metadata?.full_name ?? email,
avatar_url: session.user.user_metadata?.avatar_url ?? null,
platform_role: role,
},
{ onConflict: 'id', ignoreDuplicates: true }
)
}
const user = await this.buildAuthUser(session.user)
callback(user)
}
)
return () => subscription.unsubscribe()
}
private async buildAuthUser(supabaseUser: { id: string; email?: string; user_metadata?: Record<string, unknown> }): Promise<AuthUser> {
const email = supabaseUser.email ?? ''
const { data: row } = await supabase
.from('users')
.select('name, avatar_url, platform_role')
.eq('id', supabaseUser.id)
.single()
return {
id: supabaseUser.id,
email,
name: row?.name ?? supabaseUser.user_metadata?.full_name as string ?? email,
avatarUrl: row?.avatar_url ?? supabaseUser.user_metadata?.avatar_url as string ?? undefined,
platformRole: (row?.platform_role ?? 'guest') as AuthUser['platformRole'],
}
}
}

14
src/auth/types.ts Normal file
View File

@@ -0,0 +1,14 @@
export interface AuthUser {
id: string
email: string
name: string
avatarUrl?: string
platformRole: 'platform_admin' | 'member' | 'guest'
}
export interface AuthService {
signInWithGoogle(): Promise<void>
signOut(): Promise<void>
getCurrentUser(): Promise<AuthUser | null>
onAuthStateChange(callback: (user: AuthUser | null) => void): () => void
}

View File

@@ -1,5 +1,3 @@
import { Link } from '@tanstack/react-router'
export function AppHeader() { export function AppHeader() {
return ( return (
<header <header
@@ -14,15 +12,15 @@ export function AppHeader() {
flexShrink: 0, flexShrink: 0,
}} }}
> >
<Link to="/" style={{ display: 'flex', alignItems: 'center' }}> <a href="/" style={{ display: 'flex', alignItems: 'center' }}>
<img <img
src="/inquality-logo-white.png" src="/inquality-logo-white.png"
alt="InQuality" alt="InQuality"
style={{ height: 24, objectFit: 'contain', cursor: 'pointer' }} style={{ height: 24, objectFit: 'contain', cursor: 'pointer' }}
/> />
</Link> </a>
<Link <a
to="/" href="/"
style={{ style={{
color: 'white', color: 'white',
fontSize: 13, fontSize: 13,
@@ -33,7 +31,7 @@ export function AppHeader() {
}} }}
> >
InQ ROI InQ ROI
</Link> </a>
</header> </header>
) )
} }

View File

@@ -0,0 +1,57 @@
import { useAuth } from '@/auth/AuthContext'
function GoogleIcon() {
return (
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg">
<path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908C16.658 14.013 17.64 11.79 17.64 9.2z" fill="white"/>
<path d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.258c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332C2.438 15.983 5.482 18 9 18z" fill="white"/>
<path d="M3.964 10.707A5.41 5.41 0 0 1 3.682 9c0-.59.102-1.167.282-1.707V4.961H.957C.347 6.175 0 7.55 0 9s.348 2.826.957 4.039l3.007-2.332z" fill="white"/>
<path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0 5.482 0 2.438 2.017.957 4.961L3.964 6.293C4.672 4.169 6.656 3.58 9 3.58z" fill="white"/>
</svg>
)
}
export function LoginPage() {
const { signIn, loading } = useAuth()
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div
className="flex flex-col items-center gap-6 px-8"
style={{ animation: 'loginFadeIn 0.5s ease-out both' }}
>
<style>{`
@keyframes loginFadeIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
<img
src="/inquality-logo-color.png"
alt="InQuality"
className="h-12 object-contain"
/>
<div className="text-center">
<h1 className="text-3xl font-bold text-slate-900 tracking-tight">InQ ROI</h1>
<p className="text-slate-500 mt-1 text-sm">Análisis de procesos y ROI de automatización</p>
</div>
<button
onClick={signIn}
disabled={loading}
className="flex items-center gap-3 px-6 py-3 rounded-xl font-semibold text-white text-sm shadow-sm transition-opacity hover:opacity-90 disabled:opacity-60 disabled:cursor-not-allowed"
style={{ backgroundColor: '#F59845' }}
>
<GoogleIcon />
Iniciar sesión con Google
</button>
<p className="text-xs text-slate-400">
Solo para usuarios con cuenta @inquality.com.py
</p>
</div>
</div>
)
}

10
src/lib/supabase.ts Normal file
View File

@@ -0,0 +1,10 @@
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Variables de entorno VITE_SUPABASE_URL y VITE_SUPABASE_ANON_KEY son requeridas')
}
export const supabase = createClient(supabaseUrl, supabaseAnonKey)

View File

@@ -1,13 +1,13 @@
import { lazy, Suspense } from 'react' import { lazy, Suspense, useEffect } from 'react'
import { createRouter, createRootRoute, createRoute, Outlet } from '@tanstack/react-router' import { createRouter, createRootRoute, createRoute, Outlet, useNavigate, useRouterState } from '@tanstack/react-router'
import { WorkspacePage } from '@/features/workspace/WorkspacePage' import { WorkspacePage } from '@/features/workspace/WorkspacePage'
import { ImportPage } from '@/features/import/ImportPage' import { ImportPage } from '@/features/import/ImportPage'
import { LibraryPage } from '@/features/library/LibraryPage' import { LibraryPage } from '@/features/library/LibraryPage'
import { SettingsPage } from '@/features/settings/SettingsPage' import { SettingsPage } from '@/features/settings/SettingsPage'
import { LoginPage } from '@/features/login/LoginPage'
import { useAuth } from '@/auth/AuthContext'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
// ECharts es grande (373KB gzip). Lazy-load del ReportPage para que el
// bundle inicial no lo incluya — se carga solo cuando el usuario llega al reporte.
const ReportPage = lazy(() => const ReportPage = lazy(() =>
import('@/features/report/ReportPage').then((m) => ({ default: m.ReportPage })) import('@/features/report/ReportPage').then((m) => ({ default: m.ReportPage }))
) )
@@ -25,7 +25,40 @@ function ReportPageWithSuspense() {
) )
} }
const rootRoute = createRootRoute({ component: () => <Outlet /> }) function RootComponent() {
const { user, loading } = useAuth()
const navigate = useNavigate()
const pathname = useRouterState({ select: (s) => s.location.pathname })
useEffect(() => {
if (loading) return
if (!user && pathname !== '/login') {
void navigate({ to: '/login' })
} else if (user && pathname === '/login') {
void navigate({ to: '/' })
}
}, [user, loading, pathname, navigate])
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin" style={{ color: '#F59845' }} />
</div>
)
}
if (!user && pathname !== '/login') return null
return <Outlet />
}
const rootRoute = createRootRoute({ component: RootComponent })
const loginRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/login',
component: LoginPage,
})
const indexRoute = createRoute({ const indexRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
@@ -57,7 +90,15 @@ const settingsRoute = createRoute({
component: SettingsPage, component: SettingsPage,
}) })
const routeTree = rootRoute.addChildren([indexRoute, importRoute, workspaceRoute, reportRoute, settingsRoute]) const routeTree = rootRoute.addChildren([
loginRoute,
indexRoute,
importRoute,
workspaceRoute,
reportRoute,
settingsRoute,
])
export const router = createRouter({ routeTree }) export const router = createRouter({ routeTree })
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {

View File

@@ -0,0 +1,21 @@
-- Tabla users: extiende auth.users con atributos de la plataforma
CREATE TABLE IF NOT EXISTS public.users (
id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
name text,
avatar_url text,
platform_role text NOT NULL DEFAULT 'guest'
CHECK (platform_role IN ('platform_admin', 'member', 'guest')),
created_at timestamptz NOT NULL DEFAULT now()
);
-- RLS
ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
CREATE POLICY "read_users" ON public.users
FOR SELECT USING (auth.uid() IS NOT NULL);
CREATE POLICY "update_own_user" ON public.users
FOR UPDATE USING (id = auth.uid());
CREATE POLICY "insert_users" ON public.users
FOR INSERT WITH CHECK (id = auth.uid());