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:
4
.env.example
Normal file
4
.env.example
Normal 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
99
package-lock.json
generated
@@ -19,6 +19,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@supabase/supabase-js": "^2.106.2",
|
||||
"@tanstack/react-router": "^1.169.2",
|
||||
"@tanstack/router-devtools": "^1.166.13",
|
||||
"@types/html2canvas": "^0.5.35",
|
||||
@@ -3038,6 +3039,90 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.161.6",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.161.6.tgz",
|
||||
@@ -3702,7 +3787,7 @@
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -3712,7 +3797,7 @@
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
@@ -4651,7 +4736,6 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3": {
|
||||
@@ -5846,6 +5930,15 @@
|
||||
"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": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@supabase/supabase-js": "^2.106.2",
|
||||
"@tanstack/react-router": "^1.169.2",
|
||||
"@tanstack/router-devtools": "^1.166.13",
|
||||
"@types/html2canvas": "^0.5.35",
|
||||
|
||||
475
sprints/sprint-4/BRIEF.md
Normal file
475
sprints/sprint-4/BRIEF.md
Normal 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 |
|
||||
240
sprints/sprint-4/ETAPA_1_PROMPT.md
Normal file
240
sprints/sprint-4/ETAPA_1_PROMPT.md
Normal 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.
|
||||
@@ -1,13 +1,14 @@
|
||||
import { RouterProvider } from '@tanstack/react-router'
|
||||
import { router } from './router'
|
||||
import { Toaster } from '@/components/ui/toaster'
|
||||
import { AuthProvider } from '@/auth/AuthContext'
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<>
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster />
|
||||
</>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
46
src/auth/AuthContext.tsx
Normal file
46
src/auth/AuthContext.tsx
Normal 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
|
||||
}
|
||||
70
src/auth/SupabaseAuthService.ts
Normal file
70
src/auth/SupabaseAuthService.ts
Normal 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
14
src/auth/types.ts
Normal 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
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
export function AppHeader() {
|
||||
return (
|
||||
<header
|
||||
@@ -14,15 +12,15 @@ export function AppHeader() {
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Link to="/" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<a href="/" style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<img
|
||||
src="/inquality-logo-white.png"
|
||||
alt="InQuality"
|
||||
style={{ height: 24, objectFit: 'contain', cursor: 'pointer' }}
|
||||
/>
|
||||
</Link>
|
||||
<Link
|
||||
to="/"
|
||||
</a>
|
||||
<a
|
||||
href="/"
|
||||
style={{
|
||||
color: 'white',
|
||||
fontSize: 13,
|
||||
@@ -33,7 +31,7 @@ export function AppHeader() {
|
||||
}}
|
||||
>
|
||||
InQ ROI
|
||||
</Link>
|
||||
</a>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
57
src/features/login/LoginPage.tsx
Normal file
57
src/features/login/LoginPage.tsx
Normal 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
10
src/lib/supabase.ts
Normal 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)
|
||||
@@ -1,13 +1,13 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { createRouter, createRootRoute, createRoute, Outlet } from '@tanstack/react-router'
|
||||
import { lazy, Suspense, useEffect } from 'react'
|
||||
import { createRouter, createRootRoute, createRoute, Outlet, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||
import { WorkspacePage } from '@/features/workspace/WorkspacePage'
|
||||
import { ImportPage } from '@/features/import/ImportPage'
|
||||
import { LibraryPage } from '@/features/library/LibraryPage'
|
||||
import { SettingsPage } from '@/features/settings/SettingsPage'
|
||||
import { LoginPage } from '@/features/login/LoginPage'
|
||||
import { useAuth } from '@/auth/AuthContext'
|
||||
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(() =>
|
||||
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({
|
||||
getParentRoute: () => rootRoute,
|
||||
@@ -57,7 +90,15 @@ const settingsRoute = createRoute({
|
||||
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 })
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
|
||||
21
supabase/migrations/001_create_users.sql
Normal file
21
supabase/migrations/001_create_users.sql
Normal 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());
|
||||
Reference in New Issue
Block a user