From 78e776df6b855c9a53d0e3cba4053659fdf7b531 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcos=20Ben=C3=ADtez?= Date: Wed, 27 May 2026 23:53:12 -0300 Subject: [PATCH] feat(sprint-4/etapa-1): Supabase auth + Google OAuth + LoginPage + rutas protegidas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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: para evitar dependencia de RouterContext en tests - 561/561 tests verdes, build sin errores TS Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 4 + package-lock.json | 99 ++++- package.json | 1 + sprints/sprint-4/BRIEF.md | 475 +++++++++++++++++++++++ sprints/sprint-4/ETAPA_1_PROMPT.md | 240 ++++++++++++ src/App.tsx | 5 +- src/auth/AuthContext.tsx | 46 +++ src/auth/SupabaseAuthService.ts | 70 ++++ src/auth/types.ts | 14 + src/components/AppHeader.tsx | 12 +- src/features/login/LoginPage.tsx | 57 +++ src/lib/supabase.ts | 10 + src/router.tsx | 53 ++- supabase/migrations/001_create_users.sql | 21 + 14 files changed, 1089 insertions(+), 18 deletions(-) create mode 100644 .env.example create mode 100644 sprints/sprint-4/BRIEF.md create mode 100644 sprints/sprint-4/ETAPA_1_PROMPT.md create mode 100644 src/auth/AuthContext.tsx create mode 100644 src/auth/SupabaseAuthService.ts create mode 100644 src/auth/types.ts create mode 100644 src/features/login/LoginPage.tsx create mode 100644 src/lib/supabase.ts create mode 100644 supabase/migrations/001_create_users.sql diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d96582b --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# Supabase — InQ ROI +# Obtener desde Supabase Dashboard → Project Settings → API +VITE_SUPABASE_URL=https://.supabase.co +VITE_SUPABASE_ANON_KEY= diff --git a/package-lock.json b/package-lock.json index d83e481..ac88566 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 39cb160..6787528 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/sprints/sprint-4/BRIEF.md b/sprints/sprint-4/BRIEF.md new file mode 100644 index 0000000..bdf5400 --- /dev/null +++ b/sprints/sprint-4/BRIEF.md @@ -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 + signOut(): Promise + getCurrentUser(): Promise + 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 + getById(id: string): Promise + save(process: Process): Promise + delete(id: string): Promise + // ... +} +``` + +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 | diff --git a/sprints/sprint-4/ETAPA_1_PROMPT.md b/sprints/sprint-4/ETAPA_1_PROMPT.md new file mode 100644 index 0000000..6c9cf8e --- /dev/null +++ b/sprints/sprint-4/ETAPA_1_PROMPT.md @@ -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://.supabase.co +VITE_SUPABASE_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 + signOut(): Promise + getCurrentUser(): Promise + 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://.supabase.co +VITE_SUPABASE_ANON_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. diff --git a/src/App.tsx b/src/App.tsx index 6e7961c..6350ef2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( - <> + - + ) } diff --git a/src/auth/AuthContext.tsx b/src/auth/AuthContext.tsx new file mode 100644 index 0000000..32864e7 --- /dev/null +++ b/src/auth/AuthContext.tsx @@ -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 + signOut: () => Promise +} + +const AuthContext = createContext(null) + +const authService = new SupabaseAuthService() + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(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 ( + + {children} + + ) +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext) + if (!ctx) throw new Error('useAuth debe usarse dentro de AuthProvider') + return ctx +} diff --git a/src/auth/SupabaseAuthService.ts b/src/auth/SupabaseAuthService.ts new file mode 100644 index 0000000..5180f91 --- /dev/null +++ b/src/auth/SupabaseAuthService.ts @@ -0,0 +1,70 @@ +import { supabase } from '@/lib/supabase' +import type { AuthService, AuthUser } from './types' + +export class SupabaseAuthService implements AuthService { + async signInWithGoogle(): Promise { + const { error } = await supabase.auth.signInWithOAuth({ + provider: 'google', + options: { redirectTo: window.location.origin }, + }) + if (error) throw error + } + + async signOut(): Promise { + const { error } = await supabase.auth.signOut() + if (error) throw error + } + + async getCurrentUser(): Promise { + 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 }): Promise { + 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'], + } + } +} diff --git a/src/auth/types.ts b/src/auth/types.ts new file mode 100644 index 0000000..78a67d2 --- /dev/null +++ b/src/auth/types.ts @@ -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 + signOut(): Promise + getCurrentUser(): Promise + onAuthStateChange(callback: (user: AuthUser | null) => void): () => void +} diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index f773e5d..d6bd2ee 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -1,5 +1,3 @@ -import { Link } from '@tanstack/react-router' - export function AppHeader() { return (
- + InQuality - - + InQ ROI - +
) } diff --git a/src/features/login/LoginPage.tsx b/src/features/login/LoginPage.tsx new file mode 100644 index 0000000..30d5921 --- /dev/null +++ b/src/features/login/LoginPage.tsx @@ -0,0 +1,57 @@ +import { useAuth } from '@/auth/AuthContext' + +function GoogleIcon() { + return ( + + + + + + + ) +} + +export function LoginPage() { + const { signIn, loading } = useAuth() + + return ( +
+
+ + + InQuality + +
+

InQ ROI

+

Análisis de procesos y ROI de automatización

+
+ + + +

+ Solo para usuarios con cuenta @inquality.com.py +

+
+
+ ) +} diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts new file mode 100644 index 0000000..57b2357 --- /dev/null +++ b/src/lib/supabase.ts @@ -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) diff --git a/src/router.tsx b/src/router.tsx index 7ca7038..3430a6c 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -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: () => }) +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 ( +
+ +
+ ) + } + + if (!user && pathname !== '/login') return null + + return +} + +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' { diff --git a/supabase/migrations/001_create_users.sql b/supabase/migrations/001_create_users.sql new file mode 100644 index 0000000..46e7d72 --- /dev/null +++ b/supabase/migrations/001_create_users.sql @@ -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());