diff --git a/sprints/sprint-7/ETAPA_7_PROMPT.md b/sprints/sprint-7/ETAPA_7_PROMPT.md new file mode 100644 index 0000000..a5107e8 --- /dev/null +++ b/sprints/sprint-7/ETAPA_7_PROMPT.md @@ -0,0 +1,176 @@ +# Etapa 7 — Fix: race condition de roles en LibraryPage + simulations UPDATE policy + +**Sprint:** 7 +**Fecha:** 2026-07-07 +**Alcance:** AuthContext (loading state), LibraryPage (guard), nueva migración SQL (`simulations_update`), campo client read-only para client_editor. + +--- + +## Contexto del bug + +`msebem@gmail.com` tiene `platform_role = 'client_editor'` confirmado en DB. +Sin embargo al cargar LibraryPage muestra elementos que solo debería ver InQuality: +- Botón "Importar BPMN" +- Panel de grupos ("Clientes") +- Ícono de configuración + +Al navegar a otra ruta y volver, el comportamiento es correcto (elementos ocultos). + +--- + +## Causa raíz confirmada — DOS problemas independientes + +### Problema A: race condition de roles en LibraryPage + +**Flujo actual:** +1. Login → `onAuthStateChange` SIGNED_IN → `buildAuthUser(supabaseUser)` → `platformRole: 'guest'` (heurística email; `msebem@gmail.com` no es `@inquality.com.py`) +2. `guest` → `isClientRole = false` → LibraryPage renderiza con todos los elementos visibles +3. `syncProfileFromDB` completa → `platformRole: 'client_editor'` → store actualizado +4. Navegar y volver → re-render correcto + +La UI "miente" durante la ventana entre login y sync de DB. El fix es no renderizar contenido rol-dependiente hasta que `syncProfileFromDB` haya completado al menos una vez en la sesión. + +### Problema B: política `simulations_update` inexistente + +En `migration 015_client_roles_and_rls.sql` existe `simulations_insert` pero NO existe `simulations_update`. + +Cuando "Guardar configuración" intenta hacer UPDATE sobre una simulación ya existente → PostgreSQL no encuentra política → operación bloqueada silenciosamente. El usuario no recibe error explícito pero los cambios no se persisten. + +--- + +## Cambios requeridos + +**ANTES de empezar:** leer `src/auth/AuthContext.tsx` (o el archivo que gestiona `onAuthStateChange` y llama a `syncProfileFromDB`) y `src/features/library/LibraryPage.tsx` para entender el estado actual. Leer también `src/features/workspace/WorkspacePage.tsx` para detectar si tiene el mismo problema. + +--- + +### Fix A1: agregar `isProfileLoaded` al AuthContext + +En el contexto de autenticación (el que expone `user`, maneja `onAuthStateChange`, llama a `syncProfileFromDB`): + +Agregar un estado `isProfileLoaded: boolean` (inicialmente `false`). + +Lógica: +- Se pone en `false` cuando empieza el proceso de login (SIGNED_IN event) +- Se pone en `true` después de que `syncProfileFromDB` completa (tanto si retorna datos como si retorna null) +- Cuando el evento es `SIGNED_OUT`, volver a `false` +- Cuando el token se refresca (`TOKEN_REFRESHED`) o el usuario ya estaba logueado al cargar la app, también completar el sync antes de poner `true` + +Exponer `isProfileLoaded` en el contexto junto a `user`. + +**Importante:** `isProfileLoaded: true` con `user: null` es un estado válido (usuario no logueado, pero ya se verificó). `isProfileLoaded: false` con `user: null` es "todavía verificando". + +--- + +### Fix A2: guard en LibraryPage + +**ANTES de empezar este fix:** leer el archivo completo de LibraryPage para entender dónde se usa `isClientRole` y qué elementos condicionan. + +En LibraryPage, antes de renderizar contenido rol-dependiente (import button, groups panel, settings gear, y cualquier sección condicionada por `isClientRole`), verificar `isProfileLoaded`. + +**Si `!isProfileLoaded`:** mostrar un estado de carga mínimo. No es necesario un skeleton complejo — basta con el mismo spinner o skeleton que ya usa LibraryPage en su estado de carga (`isLoading && groups.length === 0`). Reutilizar ese patrón si existe. + +**Si `isProfileLoaded`:** renderizar normalmente, el `isClientRole` ahora tiene el valor correcto. + +La idea es que el usuario vea un spinner breve (< 200ms típicamente) en lugar de una UI incompleta que luego cambia. Preferible UX. + +--- + +### Fix A3: campo "cliente" read-only para client_editor (en WorkspacePage / GlobalPanel) + +**ANTES de empezar:** leer el componente que renderiza el campo "cliente" (texto libre) en el sidebar o panel global del workspace. Puede estar en `WorkspacePage.tsx`, `GlobalPanel.tsx`, o similar. + +Para `isClientRole = true`: mostrar el nombre del cliente como texto estático (no como input editable). El campo sigue visible pero no editable. No es necesario guardarlo o enviar cambios al hacer click en él. + +Para InQuality (`!isClientRole`): comportamiento actual sin cambios. + +--- + +### Fix B: nueva migración — `simulations_update` policy + +Crear el archivo de migración con el número siguiente a 015 (verificar qué existe en `supabase/migrations/` y usar el siguiente). + +Nombre: `016_add_simulations_update_policy.sql` + +```sql +-- Fix: faltaba política UPDATE en simulations. +-- simulations_insert existe en 015 pero simulations_update no fue creada. +-- Sin esta policy, client_editor no puede guardar configuración sobre simulaciones existentes. + +CREATE POLICY "simulations_update" ON public.simulations + FOR UPDATE USING ( + EXISTS (SELECT 1 FROM public.processes WHERE id = simulations.process_id) + AND public.current_platform_role() NOT IN ('client_viewer', 'guest') + ); +``` + +**Verificar antes de crear:** que no exista ya una `simulations_update` policy en la DB (puede haberse creado manualmente). Si ya existe, no crearla (o usar `DROP POLICY IF EXISTS` + recrear para idempotencia). + +--- + +### Fix B2: verificar y corregir rename de proceso para client_editor + +**ANTES de empezar:** en WorkspacePage (o donde se maneja el rename del proceso), encontrar la lógica que guarda el nombre cuando el usuario lo edita. + +La política `update_processes` en migration 015 **ya permite** `client_editor AND org_id = current_org()`. Si el rename no funciona, el problema es frontend (el input no llama al save, o el save usa el método incorrecto). + +Verificar: +1. ¿El input de nombre del proceso llama a la función de save en `onBlur` o `onEnter`? +2. ¿Esa función usa `supabase.from('processes').update()` con el id correcto? +3. Si ambos están bien pero sigue fallando → verificar en Supabase SQL Editor que `UPDATE public.processes SET name = 'test' WHERE id = ''` funcione como el usuario client_editor (hint: no se puede hacer directamente en SQL Editor como el usuario — verificar la condición de la policy manualmente) + +Si es un bug frontend (el save no se dispara), arreglarlo. + +--- + +## Tests nuevos + +```typescript +// Mínimo requerido: + +// 1. LibraryPage no renderiza elements rol-dependientes hasta isProfileLoaded = true +// (simular: isProfileLoaded: false → assert import button no visible, groups panel no visible) + +// 2. LibraryPage renderiza correctamente para client_editor una vez isProfileLoaded = true +// (simular: isProfileLoaded: true, platformRole: 'client_editor' → assert import button no visible, groups panel no visible) + +// 3. LibraryPage renderiza correctamente para platform_admin una vez isProfileLoaded = true +// (simular: isProfileLoaded: true, platformRole: 'platform_admin' → assert import button visible) +``` + +--- + +## Criterios de validación + +- [ ] Loguear como `msebem@gmail.com` → LibraryPage NO muestra Import BPMN ni groups panel en ningún momento (ni durante la carga inicial) +- [ ] "Guardar configuración" en workspace funciona y persiste los cambios (no silent fail) +- [ ] El campo "cliente" en el sidebar del workspace es read-only para `msebem@gmail.com` +- [ ] Un usuario InQuality (platform_admin/member) sigue viendo todos los elementos sin regresión +- [ ] `npm run test` → tests verdes (incluidos los 3 nuevos mínimos) +- [ ] `npm run build` limpio +- [ ] **Validación visual del director antes del OK formal** + +--- + +## NO modificar + +- Edge Functions (`supabase/functions/`) +- Migraciones 001-015 (no editar, solo crear 016 nueva) +- Panel de admin (`/admin/*`) +- Motor de simulación, dominio, ROI, PDF, CSV +- `ConfirmInvitePage` (Etapa 6) +- `ReportPage` +- Lógica de cálculo en `domain/` + +--- + +## Flujo esperado post-fix + +1. `msebem@gmail.com` loguea +2. LibraryPage muestra spinner breve (~200ms) +3. `syncProfileFromDB` completa → `client_editor` +4. LibraryPage renderiza: lista flat de procesos de Solar Banco, sin import button, sin groups panel, sin settings gear +5. Entra al workspace de su proceso +6. Edita parámetros de actividades y configuración global +7. Hace click en "Guardar configuración" → persiste correctamente +8. Puede ver reporte y exportar PDF diff --git a/src/auth/AuthContext.tsx b/src/auth/AuthContext.tsx index 599457d..32824d6 100644 --- a/src/auth/AuthContext.tsx +++ b/src/auth/AuthContext.tsx @@ -6,6 +6,7 @@ import type { AuthUser } from './types' interface AuthContextValue { user: AuthUser | null loading: boolean + isProfileLoaded: boolean signIn: () => Promise signInWithEmailPassword: (email: string, password: string) => Promise<{ error: string | null }> signOut: () => Promise @@ -65,6 +66,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Si es un OAuth redirect, mantener loading=true hasta que Supabase procese el token. const [loading, setLoading] = useState(isOAuthRedirect) const [pendingPasswordReset, setPendingPasswordReset] = useState(false) + const [isProfileLoaded, setIsProfileLoaded] = useState(false) useEffect(() => { // Verificar y sincronizar con Supabase en background. @@ -73,6 +75,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { const unsubscribe = authService.onAuthStateChange((u) => { setUser(u) setLoading(false) + // Si no hay usuario, no hay nada que sincronizar → marcar como cargado. + // Si hay usuario, la sincronización con DB reseteará a false → true al completar. + if (!u) setIsProfileLoaded(true) + else setIsProfileLoaded(false) }) return () => unsubscribe() }, []) @@ -126,6 +132,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { console.error('Error al sincronizar perfil:', err) } finally { clearTimeout(timeoutId) + setIsProfileLoaded(true) } })() @@ -158,6 +165,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { ('home') const [activeGroupId, setActiveGroupId] = useState(null) const [transitioning, setTransitioning] = useState(false) @@ -811,10 +811,31 @@ export function LibraryPage() { } } - const currentUserId = user!.id - const currentUserName = user!.name + // Esperar a que syncProfileFromDB complete antes de renderizar UI rol-dependiente. + // Evita el flash donde 'guest' se muestra como platform_admin durante la ventana de sync. + if (!user || !isProfileLoaded) { + return ( +
+ +
+
+
+
+ {[1, 2, 3].map((i) =>
)} +
+
+ {[1, 2].map((i) =>
)} +
+
+
+
+ ) + } + + const currentUserId = user.id + const currentUserName = user.name const totalGroups = groups.length - const isClientRole = user?.platformRole === 'client_editor' || user?.platformRole === 'client_viewer' + const isClientRole = user.platformRole === 'client_editor' || user.platformRole === 'client_viewer' return (
diff --git a/src/features/workspace/GlobalSettingsPanel.tsx b/src/features/workspace/GlobalSettingsPanel.tsx index 786dc0d..9f74faf 100644 --- a/src/features/workspace/GlobalSettingsPanel.tsx +++ b/src/features/workspace/GlobalSettingsPanel.tsx @@ -26,6 +26,7 @@ const CURRENCIES = [ export function GlobalSettingsPanel() { const { currentProcess, updateProcess } = useProcessStore() const { user } = useAuth() + const isClientRole = user?.platformRole === 'client_editor' || user?.platformRole === 'client_viewer' const [localName, setLocalName] = useState('') const [localClient, setLocalClient] = useState('') const [localCurrency, setLocalCurrency] = useState('USD') @@ -83,14 +84,18 @@ export function GlobalSettingsPanel() { {/* Cliente */}
- - { setLocalClient(e.target.value); markDirty() }} - placeholder="ej. Empresa ABC" - className="h-8 text-sm" - /> + + {isClientRole ? ( +

{localClient || '—'}

+ ) : ( + { setLocalClient(e.target.value); markDirty() }} + placeholder="ej. Empresa ABC" + className="h-8 text-sm" + /> + )}
{/* Moneda */} diff --git a/src/features/workspace/WorkspacePage.tsx b/src/features/workspace/WorkspacePage.tsx index 2e928ad..f98f6ed 100644 --- a/src/features/workspace/WorkspacePage.tsx +++ b/src/features/workspace/WorkspacePage.tsx @@ -235,8 +235,12 @@ export function WorkspacePage() {
)} - {/* Cliente — editable inline */} - {editingField === 'client' ? ( + {/* Cliente — editable inline solo para roles internos */} + {user?.platformRole === 'client_editor' ? ( + currentProcess.clientName && ( +

{currentProcess.clientName}

+ ) + ) : editingField === 'client' ? ( { name: 'Test User', platformRole: 'platform_admin' as string, }, + isProfileLoaded: true, } const sampleProcess = { id: 'proc-1', @@ -59,7 +60,7 @@ const { authUserRef, libStoreRef } = vi.hoisted(() => { }) vi.mock('@/auth/AuthContext', () => ({ - useAuth: () => ({ user: authUserRef.current }), + useAuth: () => ({ user: authUserRef.current, isProfileLoaded: authUserRef.isProfileLoaded }), })) vi.mock('@/store/library-store', () => ({ @@ -89,6 +90,24 @@ describe('LibraryPage — visibilidad de controles según rol', () => { libStoreRef.load = vi.fn() libStoreRef.getAllTags = vi.fn().mockReturnValue([]) libStoreRef.getLatestSimulation = vi.fn().mockResolvedValue(null) + authUserRef.isProfileLoaded = true + }) + + describe('isProfileLoaded = false (sync en curso)', () => { + beforeEach(() => { + authUserRef.current = { ...authUserRef.current, platformRole: 'platform_admin' } + authUserRef.isProfileLoaded = false + }) + + it('NO muestra el botón "Importar BPMN" mientras el perfil no está cargado', () => { + render() + expect(screen.queryByText('Importar BPMN')).not.toBeInTheDocument() + }) + + it('NO muestra el panel de grupos mientras el perfil no está cargado', () => { + render() + expect(screen.queryByTestId('groups-header')).not.toBeInTheDocument() + }) }) describe('platform_admin', () => {