Etapa 5 completa.
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled

This commit is contained in:
2026-07-07 10:32:02 -03:00
parent bc9f70419c
commit ef9ec472d5
9 changed files with 511 additions and 15 deletions

View File

@@ -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 && <ImportBpmnButton />} // 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 && <CreateProcessButton />}
```
### 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 && <ProcessGroupsPanel />}
```
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 && (
<p className="text-sm text-muted-foreground mb-4">
Procesos de <span className="font-medium">{orgName}</span>
</p>
)}
```
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 = '<tu-id>';
-- Después de probar:
UPDATE public.users SET platform_role = 'platform_admin' WHERE id = '<tu-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

View File

@@ -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<string | null>(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 (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex flex-col">
<AppHeader />

View File

@@ -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 (
<div className="flex flex-col items-center justify-center py-24 gap-4">
<Building2 className="h-16 w-16 text-muted-foreground/30" />
<p className="text-base font-medium">No tenés procesos asignados aún</p>
<p className="text-[13px] text-muted-foreground">Contactá a tu administrador para que te asigne acceso</p>
</div>
)
}
return (
<div className="flex flex-col items-center justify-center py-24 gap-4">
<Building2 className="h-16 w-16 text-muted-foreground/30" />
@@ -527,8 +538,8 @@ function HomeView({
</div>
)}
{/* 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 */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-2" data-testid="groups-header">
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{settings.groupLabel}s
</span>
@@ -616,6 +627,21 @@ function HomeView({
)}
</>
)}
{/* Vista cliente: lista plana de procesos (sin grupos, RLS garantiza que solo ven los suyos) */}
{!isSearching && isClientRole && (
<div className="space-y-2">
{visibleProcesses.map((p) => (
<ProcessCardWithSim
key={p.id}
process={p}
currentUserId={currentUserId}
currentUserName={currentUserName}
onShared={onShared}
/>
))}
</div>
)}
</div>
)
}
@@ -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 (
<div className="min-h-screen bg-background">
@@ -833,14 +860,16 @@ export function LibraryPage() {
>
<Settings className="h-4 w-4" />
</button>
<button
onClick={() => handleImport(view === 'group' && activeGroupId ? activeGroupId : undefined)}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-white transition-opacity hover:opacity-90"
style={{ background: '#F59845' }}
>
<Upload className="h-4 w-4" />
Importar BPMN
</button>
{!isClientRole && (
<button
onClick={() => handleImport(view === 'group' && activeGroupId ? activeGroupId : undefined)}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-white transition-opacity hover:opacity-90"
style={{ background: '#F59845' }}
>
<Upload className="h-4 w-4" />
Importar BPMN
</button>
)}
</div>
</div>

View File

@@ -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 ─────────────────────────────────────────────────────── */}
<Tabs defaultValue="actual" className="w-full" onValueChange={(v) => setActiveTab(v as typeof activeTab)}>
<Tabs defaultValue={defaultTab} className="w-full" onValueChange={(v) => setActiveTab(v as typeof activeTab)}>
<TabsList className="mb-6">
<TabsTrigger value="actual">Actual</TabsTrigger>

View File

@@ -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 <WorkspaceSkeleton />
}

View File

@@ -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(<LibraryPage />)
expect(screen.getByText('Importar BPMN')).toBeInTheDocument()
})
it('muestra el panel de grupos (header de sección)', () => {
render(<LibraryPage />)
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(<LibraryPage />)
expect(screen.queryByText('Importar BPMN')).not.toBeInTheDocument()
})
it('NO muestra el panel de grupos', () => {
render(<LibraryPage />)
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(<LibraryPage />)
expect(screen.queryByText('Importar BPMN')).not.toBeInTheDocument()
})
it('NO muestra el panel de grupos', () => {
render(<LibraryPage />)
expect(screen.queryByTestId('groups-header')).not.toBeInTheDocument()
})
})
})

View File

@@ -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({

View File

@@ -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'

View File

@@ -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')
})
})