diff --git a/sprints/sprint-7/ETAPA_5_PROMPT.md b/sprints/sprint-7/ETAPA_5_PROMPT.md new file mode 100644 index 0000000..ad0bc78 --- /dev/null +++ b/sprints/sprint-7/ETAPA_5_PROMPT.md @@ -0,0 +1,197 @@ +# Etapa 5 — UX de roles de cliente y routing + +**Sprint:** 7 +**Fecha:** 2026-07-06 +**Alcance:** guards de rol en router + ajustes de UI en LibraryPage, WorkspacePage y ReportPage. Sin cambios en migraciones, Edge Functions, o panel admin. + +--- + +## Contexto + +Las Etapas 1-4 construyeron la infraestructura. Esta etapa cierra la experiencia de usuario para `client_editor` y `client_viewer`: +- El router impide que `client_viewer` entre al workspace +- La Library oculta elementos internos de InQuality (crear proceso, grupos) +- El workspace oculta "Importar BPMN" para `client_editor` +- El reporte arranca en la tab ROI para `client_viewer` + +La RLS ya filtra los datos (un `client_editor` de Banco Solar solo ve procesos de Banco Solar). Esta etapa solo ajusta la UI para que el comportamiento sea coherente con esa realidad de datos. + +**ANTES de empezar:** leer el código actual de `src/router.tsx`, `LibraryPage.tsx`, `WorkspacePage.tsx` y `ReportPage.tsx`. Los nombres exactos de componentes y props pueden diferir del BRIEF. + +--- + +## 5a — Guards de rol en el router + +### Workspace route (`/workspace/$processId`) + +`client_viewer` no puede editar → redirigir al reporte: + +```typescript +beforeLoad: ({ context, params }) => { + const user = context.auth?.user + if (!user) throw redirect({ to: '/login' }) + if (user.platformRole === 'client_viewer') { + throw redirect({ to: '/report/$processId', params: { processId: params.processId } }) + } +} +``` + +### Import BPMN route (si existe como ruta separada) + +Si hay una ruta `/import` o similar: solo `platform_admin` y `member` pueden acceder. `client_editor` y `client_viewer` → redirect a `/`. + +```typescript +beforeLoad: ({ context }) => { + const user = context.auth?.user + const allowed = ['platform_admin', 'member'] + if (!user || !allowed.includes(user.platformRole)) { + throw redirect({ to: '/' }) + } +} +``` + +### Rutas de admin (verificar que ya están protegidas) + +Las rutas `/admin/*` ya tienen guard de Etapa 4. Solo verificar que siguen funcionando después de los cambios de esta etapa. + +--- + +## 5b — WorkspacePage: ocultar "Importar BPMN" para roles de cliente + +El botón o trigger de importación de BPMN solo debe aparecer para `platform_admin` y `member`. + +```typescript +const { user } = useAuth() +const canImport = user?.platformRole === 'platform_admin' || user?.platformRole === 'member' + +// En el JSX: +{canImport && } // o el componente/botón equivalente +``` + +No deshabilitar el botón — ocultarlo completamente. Un `client_editor` no necesita saber que esa función existe. + +--- + +## 5c — LibraryPage: UI ajustada para roles de cliente + +### Ocultar "Nuevo proceso" para client_editor y client_viewer + +```typescript +const canCreateProcess = user?.platformRole === 'platform_admin' || user?.platformRole === 'member' + +{canCreateProcess && } +``` + +### Ocultar grupos/tags internos de InQuality para roles de cliente + +Los `process_groups` y el sistema de grupos/tags son navegación interna de InQuality. Para `client_editor` y `client_viewer`, la Library debe mostrar directamente la lista de procesos sin el panel lateral de grupos. + +```typescript +const isClientRole = user?.platformRole === 'client_editor' || user?.platformRole === 'client_viewer' + +// Ocultar completamente el panel de grupos si es rol cliente +{!isClientRole && } +``` + +Si el layout actual tiene los grupos integrados de forma que no se pueden ocultar sin romper la estructura, simplificar: mostrar todos los procesos sin agrupación para roles de cliente (la RLS ya garantiza que solo ven los suyos). + +### Encabezado de bienvenida para roles de cliente + +Opcional (versión wow): si `isClientRole`, mostrar el nombre de la organización como contexto: + +```tsx +{isClientRole && user?.orgId && ( +

+ Procesos de {orgName} +

+)} +``` + +Para obtener `orgName`: hacer una query a `organizations` con `user.orgId`. Si ya hay un hook que lo cargue, usarlo. Si no, una query simple al cargar el componente. + +--- + +## 5d — ReportPage: tab ROI por defecto para client_viewer + +En `ReportPage`, la tab activa inicial debe ser `roi` cuando el usuario es `client_viewer`: + +```typescript +const { user } = useAuth() +const defaultTab = user?.platformRole === 'client_viewer' ? 'roi' : 'actual' +const [activeTab, setActiveTab] = useState(defaultTab) +``` + +Las otras tabs (Actual, Automatizado) permanecen visibles — `client_viewer` puede verlas pero las actividades son de solo lectura (esto ya está garantizado por RLS: no puede hacer UPDATE/INSERT). No es necesario bloquear la UI explícitamente en esta etapa; la RLS rechazará cualquier intento de escritura. + +Si el componente de tab activa ya es controlado desde el router (query param o similar), adaptar según el código real. + +--- + +## Tests nuevos + +```typescript +// tests/features/routing/role-guards.test.ts +// Con vitest + mocks del AuthContext: + +// 1. client_viewer en /workspace/$id → redirect a /report/$id +// 2. client_editor en /workspace/$id → NO redirige (permanece) +// 3. client_editor en /import → redirect a / +// 4. platform_admin en /import → NO redirige + +// tests/features/library/library-ui.test.ts +// 5. client_editor: "Nuevo proceso" no renderiza +// 6. client_editor: grupos panel no renderiza +// 7. platform_admin: "Nuevo proceso" renderiza +// 8. platform_admin: grupos panel renderiza + +// tests/features/report/report-default-tab.test.ts +// 9. client_viewer: tab inicial = 'roi' +// 10. platform_admin: tab inicial = 'actual' (o cualquier otro default actual) +``` + +--- + +## Criterios de validación + +- [ ] `client_viewer` que navega a `/workspace/:id` → redirige a `/report/:id` +- [ ] `client_editor` puede entrar al workspace normalmente +- [ ] Botón "Importar BPMN" no visible para `client_editor` ni `client_viewer` +- [ ] Botón "Nuevo proceso" no visible para `client_editor` ni `client_viewer` +- [ ] Panel de grupos no visible para `client_editor` ni `client_viewer` +- [ ] `ReportPage` con `client_viewer` abre en tab ROI +- [ ] `ReportPage` con `platform_admin` abre en la tab que ya abre actualmente (no cambiar comportamiento existente) +- [ ] `npm run test` → 558+ tests verdes (más los nuevos ~10) +- [ ] `npm run build` limpio +- [ ] **Validación visual del director antes del OK formal** (ver checklist abajo) + +--- + +## Checklist de validación visual (para el director) + +Para validar esta etapa necesitás idealmente dos sesiones: una como `platform_admin` y una como `client_editor`. Si aún no hay usuario cliente invitado, la validación puede hacerse manualmente editando el `platform_role` en Supabase Studio: + +```sql +-- Cambiar temporalmente para prueba (revertir después) +UPDATE public.users SET platform_role = 'client_editor' WHERE id = ''; +-- Después de probar: +UPDATE public.users SET platform_role = 'platform_admin' WHERE id = ''; +``` + +Con rol `client_editor`: +- [ ] Library: no aparece "Nuevo proceso", no aparece el panel de grupos +- [ ] Workspace: no aparece el botón/opción de importar BPMN +- [ ] Workspace: todo lo demás funciona (editar actividades, simular, ver reporte) + +Con rol `client_viewer` (mismo mecanismo de UPDATE temporal): +- [ ] Intentar navegar a `/workspace/$id` → redirige al reporte +- [ ] Reporte abre directo en tab ROI + +--- + +## NO modificar + +- Panel de admin (`/admin/*`) +- Migraciones (001-015) +- Edge Functions +- Lógica de simulación, dominio, cálculo de ROI, PDF, CSV +- `AppHeader` más allá de lo estrictamente necesario diff --git a/src/features/import/ImportPage.tsx b/src/features/import/ImportPage.tsx index f7fc15b..f5a0a4d 100644 --- a/src/features/import/ImportPage.tsx +++ b/src/features/import/ImportPage.tsx @@ -1,5 +1,5 @@ import { useNavigate, useSearch } from '@tanstack/react-router' -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { UploadCloud, FileText, Loader2, FlaskConical } from 'lucide-react' import { v4 as uuidv4 } from 'uuid' import { Card, CardContent } from '@/components/ui/card' @@ -43,6 +43,15 @@ export function ImportPage() { const [isProcessing, setIsProcessing] = useState(false) const [loadingSampleId, setLoadingSampleId] = useState(null) + // Solo platform_admin y member pueden importar BPMN + useEffect(() => { + if (!user) return + const allowed = ['platform_admin', 'member'] + if (!allowed.includes(user.platformRole)) { + void navigate({ to: '/' }) + } + }, [user, navigate]) + // groupId viene de la URL cuando el usuario importa desde dentro de un grupo const search = useSearch({ from: '/import' }) const targetGroupId = (search as { groupId?: string }).groupId ?? null @@ -190,6 +199,9 @@ export function ImportPage() { } } + const isClientRole = user?.platformRole === 'client_editor' || user?.platformRole === 'client_viewer' + if (isClientRole) return null + return (
diff --git a/src/features/library/LibraryPage.tsx b/src/features/library/LibraryPage.tsx index 35b1346..ec460ee 100644 --- a/src/features/library/LibraryPage.tsx +++ b/src/features/library/LibraryPage.tsx @@ -447,8 +447,19 @@ function HomeView({ ) } + const isClientRole = user?.platformRole === 'client_editor' || user?.platformRole === 'client_viewer' + // Empty state: sin grupos ni procesos (ya terminó de cargar) if (groups.length === 0 && processes.length === 0) { + if (isClientRole) { + return ( +
+ +

No tenés procesos asignados aún

+

Contactá a tu administrador para que te asigne acceso

+
+ ) + } return (
@@ -527,8 +538,8 @@ function HomeView({
)} - {/* Vista normal — grupos */} - {!isSearching && ( + {/* Vista normal — grupos (solo roles internos) */} + {!isSearching && !isClientRole && ( <> {/* Tag cloud (wow): visible cuando hay >= 3 tags distintos */} {cloud.length >= 3 && ( @@ -564,7 +575,7 @@ function HomeView({ )} {/* Header sección grupos */} -
+
{settings.groupLabel}s @@ -616,6 +627,21 @@ function HomeView({ )} )} + + {/* Vista cliente: lista plana de procesos (sin grupos, RLS garantiza que solo ven los suyos) */} + {!isSearching && isClientRole && ( +
+ {visibleProcesses.map((p) => ( + + ))} +
+ )}
) } @@ -788,6 +814,7 @@ export function LibraryPage() { const currentUserId = user!.id const currentUserName = user!.name const totalGroups = groups.length + const isClientRole = user?.platformRole === 'client_editor' || user?.platformRole === 'client_viewer' return (
@@ -833,14 +860,16 @@ export function LibraryPage() { > - + {!isClientRole && ( + + )}
diff --git a/src/features/report/ReportPage.tsx b/src/features/report/ReportPage.tsx index 1883371..f6a9877 100644 --- a/src/features/report/ReportPage.tsx +++ b/src/features/report/ReportPage.tsx @@ -29,11 +29,13 @@ import { AutomationScenarioNote } from './AutomationScenarioNote' import { MethodologyFooter } from './MethodologyFooter' import { useHeatmapMode } from '@/lib/hooks/useHeatmapMode' import { useSimulationStore } from '@/store/simulation-store' +import { useAuth } from '@/auth/AuthContext' import { AppHeader } from '@/components/AppHeader' export function ReportPage() { const { processId } = useParams({ from: '/report/$processId' }) const navigate = useNavigate() + const { user } = useAuth() const { process, simulation, activities, resources, isLoading, error } = useReportData(processId) const { toast } = useToast() @@ -108,7 +110,8 @@ export function ReportPage() { const [isExportingPdf, setIsExportingPdf] = useState(false) const [isExportingCsv, setIsExportingCsv] = useState(false) - const [activeTab, setActiveTab] = useState<'actual' | 'automatizado' | 'roi'>('actual') + const defaultTab: 'actual' | 'automatizado' | 'roi' = user?.platformRole === 'client_viewer' ? 'roi' : 'actual' + const [activeTab, setActiveTab] = useState<'actual' | 'automatizado' | 'roi'>(defaultTab) // Tab "Actual" — cross-linking heatmap ↔ tabla const handleHeatmapClickActual = useCallback((bpmnElementId: string) => { @@ -294,7 +297,7 @@ export function ReportPage() { /> {/* ── Tabs ─────────────────────────────────────────────────────── */} - setActiveTab(v as typeof activeTab)}> + setActiveTab(v as typeof activeTab)}> Actual diff --git a/src/features/workspace/WorkspacePage.tsx b/src/features/workspace/WorkspacePage.tsx index 31e00cb..2e928ad 100644 --- a/src/features/workspace/WorkspacePage.tsx +++ b/src/features/workspace/WorkspacePage.tsx @@ -60,6 +60,13 @@ export function WorkspacePage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentProcess?.id, loadLatestForProcess]) + // client_viewer no puede editar — redirigir al reporte + useEffect(() => { + if (user?.platformRole === 'client_viewer') { + void navigate({ to: '/report/$processId', params: { processId } }) + } + }, [user, processId, navigate]) + // Validación XOR: todos los gateways exclusivos deben sumar 1.0 const xorValidation = useMemo(() => { const invalidIds = gateways @@ -153,7 +160,7 @@ export function WorkspacePage() { // isLoading manda siempre, incluso si currentProcess todavía tiene el proceso // anterior en el store (navegación directa entre dos workspaces distintos). - if (isLoading) { + if (isLoading || user?.platformRole === 'client_viewer') { return } diff --git a/tests/features/library/library-ui.test.tsx b/tests/features/library/library-ui.test.tsx new file mode 100644 index 0000000..19e98a0 --- /dev/null +++ b/tests/features/library/library-ui.test.tsx @@ -0,0 +1,141 @@ +/** + * Tests de renderizado condicional en LibraryPage según el rol del usuario. + * Verifica que el botón "Importar BPMN" y el panel de grupos se muestran + * solo para roles internos (platform_admin / member). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import React from 'react' + +// ─── Referencias mutables para controlar el rol durante los tests ───────────── + +const { authUserRef, libStoreRef } = vi.hoisted(() => { + const authUserRef = { + current: { + id: 'user-1', + email: 'test@test.com', + name: 'Test User', + platformRole: 'platform_admin' as string, + }, + } + const sampleProcess = { + id: 'proc-1', + name: 'Proceso de prueba', + ownerId: 'user-1', + ownerName: 'Test User', + updaterName: 'Test User', + groupId: null, + tags: [], + updatedAt: Date.now(), + bpmnXml: '', + annualFrequency: 1, + analysisHorizonYears: 3, + automationInvestment: 0, + currency: 'USD', + clientName: null, + overhead: 0, + isShared: false, + } + const sampleGroup = { + id: 'grp-1', + processId: 'proc-1', + name: 'Banca', + createdAt: Date.now(), + ownerId: 'user-1', + } + const libStoreRef = { + groups: [sampleGroup], + processes: [sampleProcess], + settings: { groupLabel: 'Área', groupLabelPlural: 'Áreas', currency: 'USD' }, + isLoading: false, + error: null, + load: vi.fn(), + createGroup: vi.fn(), + getAllTags: vi.fn().mockReturnValue([]), + getLatestSimulation: vi.fn().mockResolvedValue(null), + deleteProcess: vi.fn(), + } + return { authUserRef, libStoreRef } +}) + +vi.mock('@/auth/AuthContext', () => ({ + useAuth: () => ({ user: authUserRef.current }), +})) + +vi.mock('@/store/library-store', () => ({ + useLibraryStore: (selector?: (s: typeof libStoreRef) => unknown) => + selector ? selector(libStoreRef) : libStoreRef, +})) + +vi.mock('@/components/AppHeader', () => ({ AppHeader: () => null })) + +vi.mock('@tanstack/react-router', () => ({ + useNavigate: () => vi.fn(), + Link: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +vi.mock('@/components/ui/use-toast', () => ({ + useToast: () => ({ message: null, show: vi.fn() }), +})) + +// Importar después de los mocks +import { LibraryPage } from '@/features/library/LibraryPage' + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('LibraryPage — visibilidad de controles según rol', () => { + beforeEach(() => { + vi.clearAllMocks() + libStoreRef.load = vi.fn() + libStoreRef.getAllTags = vi.fn().mockReturnValue([]) + libStoreRef.getLatestSimulation = vi.fn().mockResolvedValue(null) + }) + + describe('platform_admin', () => { + beforeEach(() => { + authUserRef.current = { ...authUserRef.current, platformRole: 'platform_admin' } + }) + + it('muestra el botón "Importar BPMN"', () => { + render() + expect(screen.getByText('Importar BPMN')).toBeInTheDocument() + }) + + it('muestra el panel de grupos (header de sección)', () => { + render() + expect(screen.getByTestId('groups-header')).toBeInTheDocument() + }) + }) + + describe('client_editor', () => { + beforeEach(() => { + authUserRef.current = { ...authUserRef.current, platformRole: 'client_editor' } + }) + + it('NO muestra el botón "Importar BPMN"', () => { + render() + expect(screen.queryByText('Importar BPMN')).not.toBeInTheDocument() + }) + + it('NO muestra el panel de grupos', () => { + render() + expect(screen.queryByTestId('groups-header')).not.toBeInTheDocument() + }) + }) + + describe('client_viewer', () => { + beforeEach(() => { + authUserRef.current = { ...authUserRef.current, platformRole: 'client_viewer' } + }) + + it('NO muestra el botón "Importar BPMN"', () => { + render() + expect(screen.queryByText('Importar BPMN')).not.toBeInTheDocument() + }) + + it('NO muestra el panel de grupos', () => { + render() + expect(screen.queryByTestId('groups-header')).not.toBeInTheDocument() + }) + }) +}) diff --git a/tests/features/report/components.test.tsx b/tests/features/report/components.test.tsx index 47d3527..7f80dae 100644 --- a/tests/features/report/components.test.tsx +++ b/tests/features/report/components.test.tsx @@ -279,6 +279,10 @@ vi.mock('@tanstack/react-router', async (importActual) => { } }) +vi.mock('@/auth/AuthContext', () => ({ + useAuth: () => ({ user: { id: 'test-user', platformRole: 'platform_admin' } }), +})) + describe('ReportPage — empty state (sin simulación)', () => { beforeEach(() => { vi.mocked(useReportData).mockReturnValue({ diff --git a/tests/features/report/roi-components.test.tsx b/tests/features/report/roi-components.test.tsx index 23adf32..db58132 100644 --- a/tests/features/report/roi-components.test.tsx +++ b/tests/features/report/roi-components.test.tsx @@ -47,6 +47,10 @@ vi.mock('@tanstack/react-router', async (importActual) => { } }) +vi.mock('@/auth/AuthContext', () => ({ + useAuth: () => ({ user: { id: 'test-user', platformRole: 'platform_admin' } }), +})) + // ─── Fixtures ───────────────────────────────────────────────────────────────── import type { ActivitySimResult, Activity, SimulationResult } from '@/domain/types' diff --git a/tests/features/routing/role-guards.test.ts b/tests/features/routing/role-guards.test.ts new file mode 100644 index 0000000..8dae7cf --- /dev/null +++ b/tests/features/routing/role-guards.test.ts @@ -0,0 +1,99 @@ +/** + * Tests de guards de rol para rutas protegidas. + * Verifica las condiciones de redirección para client_viewer e client_editor. + */ +import { describe, it, expect } from 'vitest' +import type { AuthUser } from '@/auth/types' + +// ─── Helper: construir un usuario de prueba con un rol específico ────────────── + +function makeUser(platformRole: AuthUser['platformRole']): AuthUser { + return { + id: 'user-test', + email: 'test@test.com', + name: 'Test User', + platformRole, + } +} + +// ─── Guard de workspace: client_viewer → redirige a /report/$processId ──────── + +function workspaceGuardShouldRedirect(user: AuthUser | null): boolean { + return user?.platformRole === 'client_viewer' +} + +describe('workspace route guard', () => { + it('client_viewer debe redirigir al reporte', () => { + expect(workspaceGuardShouldRedirect(makeUser('client_viewer'))).toBe(true) + }) + + it('client_editor NO debe redirigir', () => { + expect(workspaceGuardShouldRedirect(makeUser('client_editor'))).toBe(false) + }) + + it('platform_admin NO debe redirigir', () => { + expect(workspaceGuardShouldRedirect(makeUser('platform_admin'))).toBe(false) + }) + + it('member NO debe redirigir', () => { + expect(workspaceGuardShouldRedirect(makeUser('member'))).toBe(false) + }) + + it('usuario null NO debe redirigir (no autenticado)', () => { + expect(workspaceGuardShouldRedirect(null)).toBe(false) + }) +}) + +// ─── Guard de importación: solo platform_admin y member pueden importar ──────── + +function importGuardShouldRedirect(user: AuthUser | null): boolean { + if (!user) return false // RootComponent maneja el redirect a /login + const allowed: AuthUser['platformRole'][] = ['platform_admin', 'member'] + return !allowed.includes(user.platformRole) +} + +describe('import route guard', () => { + it('client_editor debe redirigir a /', () => { + expect(importGuardShouldRedirect(makeUser('client_editor'))).toBe(true) + }) + + it('client_viewer debe redirigir a /', () => { + expect(importGuardShouldRedirect(makeUser('client_viewer'))).toBe(true) + }) + + it('guest debe redirigir a /', () => { + expect(importGuardShouldRedirect(makeUser('guest'))).toBe(true) + }) + + it('platform_admin NO debe redirigir', () => { + expect(importGuardShouldRedirect(makeUser('platform_admin'))).toBe(false) + }) + + it('member NO debe redirigir', () => { + expect(importGuardShouldRedirect(makeUser('member'))).toBe(false) + }) +}) + +// ─── Cálculo de tab por defecto en ReportPage ───────────────────────────────── + +function computeDefaultTab(user: AuthUser | null): 'actual' | 'roi' { + return user?.platformRole === 'client_viewer' ? 'roi' : 'actual' +} + +describe('ReportPage defaultTab', () => { + it('client_viewer arranca en tab ROI', () => { + expect(computeDefaultTab(makeUser('client_viewer'))).toBe('roi') + }) + + it('platform_admin arranca en tab actual', () => { + expect(computeDefaultTab(makeUser('platform_admin'))).toBe('actual') + }) + + it('client_editor arranca en tab actual', () => { + expect(computeDefaultTab(makeUser('client_editor'))).toBe('actual') + }) + + it('member arranca en tab actual', () => { + expect(computeDefaultTab(makeUser('member'))).toBe('actual') + }) +})