This commit is contained in:
197
sprints/sprint-7/ETAPA_5_PROMPT.md
Normal file
197
sprints/sprint-7/ETAPA_5_PROMPT.md
Normal 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
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
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 { UploadCloud, FileText, Loader2, FlaskConical } from 'lucide-react'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
@@ -43,6 +43,15 @@ export function ImportPage() {
|
|||||||
const [isProcessing, setIsProcessing] = useState(false)
|
const [isProcessing, setIsProcessing] = useState(false)
|
||||||
const [loadingSampleId, setLoadingSampleId] = useState<string | null>(null)
|
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
|
// groupId viene de la URL cuando el usuario importa desde dentro de un grupo
|
||||||
const search = useSearch({ from: '/import' })
|
const search = useSearch({ from: '/import' })
|
||||||
const targetGroupId = (search as { groupId?: string }).groupId ?? null
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex flex-col">
|
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex flex-col">
|
||||||
<AppHeader />
|
<AppHeader />
|
||||||
|
|||||||
@@ -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)
|
// Empty state: sin grupos ni procesos (ya terminó de cargar)
|
||||||
if (groups.length === 0 && processes.length === 0) {
|
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 (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-24 gap-4">
|
<div className="flex flex-col items-center justify-center py-24 gap-4">
|
||||||
<Building2 className="h-16 w-16 text-muted-foreground/30" />
|
<Building2 className="h-16 w-16 text-muted-foreground/30" />
|
||||||
@@ -527,8 +538,8 @@ function HomeView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Vista normal — grupos */}
|
{/* Vista normal — grupos (solo roles internos) */}
|
||||||
{!isSearching && (
|
{!isSearching && !isClientRole && (
|
||||||
<>
|
<>
|
||||||
{/* Tag cloud (wow): visible cuando hay >= 3 tags distintos */}
|
{/* Tag cloud (wow): visible cuando hay >= 3 tags distintos */}
|
||||||
{cloud.length >= 3 && (
|
{cloud.length >= 3 && (
|
||||||
@@ -564,7 +575,7 @@ function HomeView({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Header sección grupos */}
|
{/* 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">
|
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
{settings.groupLabel}s
|
{settings.groupLabel}s
|
||||||
</span>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -788,6 +814,7 @@ export function LibraryPage() {
|
|||||||
const currentUserId = user!.id
|
const currentUserId = user!.id
|
||||||
const currentUserName = user!.name
|
const currentUserName = user!.name
|
||||||
const totalGroups = groups.length
|
const totalGroups = groups.length
|
||||||
|
const isClientRole = user?.platformRole === 'client_editor' || user?.platformRole === 'client_viewer'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
@@ -833,14 +860,16 @@ export function LibraryPage() {
|
|||||||
>
|
>
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
{!isClientRole && (
|
||||||
onClick={() => handleImport(view === 'group' && activeGroupId ? activeGroupId : undefined)}
|
<button
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-white transition-opacity hover:opacity-90"
|
onClick={() => handleImport(view === 'group' && activeGroupId ? activeGroupId : undefined)}
|
||||||
style={{ background: '#F59845' }}
|
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
|
<Upload className="h-4 w-4" />
|
||||||
</button>
|
Importar BPMN
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -29,11 +29,13 @@ import { AutomationScenarioNote } from './AutomationScenarioNote'
|
|||||||
import { MethodologyFooter } from './MethodologyFooter'
|
import { MethodologyFooter } from './MethodologyFooter'
|
||||||
import { useHeatmapMode } from '@/lib/hooks/useHeatmapMode'
|
import { useHeatmapMode } from '@/lib/hooks/useHeatmapMode'
|
||||||
import { useSimulationStore } from '@/store/simulation-store'
|
import { useSimulationStore } from '@/store/simulation-store'
|
||||||
|
import { useAuth } from '@/auth/AuthContext'
|
||||||
import { AppHeader } from '@/components/AppHeader'
|
import { AppHeader } from '@/components/AppHeader'
|
||||||
|
|
||||||
export function ReportPage() {
|
export function ReportPage() {
|
||||||
const { processId } = useParams({ from: '/report/$processId' })
|
const { processId } = useParams({ from: '/report/$processId' })
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const { user } = useAuth()
|
||||||
const { process, simulation, activities, resources, isLoading, error } = useReportData(processId)
|
const { process, simulation, activities, resources, isLoading, error } = useReportData(processId)
|
||||||
|
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
@@ -108,7 +110,8 @@ export function ReportPage() {
|
|||||||
|
|
||||||
const [isExportingPdf, setIsExportingPdf] = useState(false)
|
const [isExportingPdf, setIsExportingPdf] = useState(false)
|
||||||
const [isExportingCsv, setIsExportingCsv] = 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
|
// Tab "Actual" — cross-linking heatmap ↔ tabla
|
||||||
const handleHeatmapClickActual = useCallback((bpmnElementId: string) => {
|
const handleHeatmapClickActual = useCallback((bpmnElementId: string) => {
|
||||||
@@ -294,7 +297,7 @@ export function ReportPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── Tabs ─────────────────────────────────────────────────────── */}
|
{/* ── 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">
|
<TabsList className="mb-6">
|
||||||
<TabsTrigger value="actual">Actual</TabsTrigger>
|
<TabsTrigger value="actual">Actual</TabsTrigger>
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ export function WorkspacePage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [currentProcess?.id, loadLatestForProcess])
|
}, [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
|
// Validación XOR: todos los gateways exclusivos deben sumar 1.0
|
||||||
const xorValidation = useMemo(() => {
|
const xorValidation = useMemo(() => {
|
||||||
const invalidIds = gateways
|
const invalidIds = gateways
|
||||||
@@ -153,7 +160,7 @@ export function WorkspacePage() {
|
|||||||
|
|
||||||
// isLoading manda siempre, incluso si currentProcess todavía tiene el proceso
|
// isLoading manda siempre, incluso si currentProcess todavía tiene el proceso
|
||||||
// anterior en el store (navegación directa entre dos workspaces distintos).
|
// anterior en el store (navegación directa entre dos workspaces distintos).
|
||||||
if (isLoading) {
|
if (isLoading || user?.platformRole === 'client_viewer') {
|
||||||
return <WorkspaceSkeleton />
|
return <WorkspaceSkeleton />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
141
tests/features/library/library-ui.test.tsx
Normal file
141
tests/features/library/library-ui.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)', () => {
|
describe('ReportPage — empty state (sin simulación)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(useReportData).mockReturnValue({
|
vi.mocked(useReportData).mockReturnValue({
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ vi.mock('@tanstack/react-router', async (importActual) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
vi.mock('@/auth/AuthContext', () => ({
|
||||||
|
useAuth: () => ({ user: { id: 'test-user', platformRole: 'platform_admin' } }),
|
||||||
|
}))
|
||||||
|
|
||||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
import type { ActivitySimResult, Activity, SimulationResult } from '@/domain/types'
|
import type { ActivitySimResult, Activity, SimulationResult } from '@/domain/types'
|
||||||
|
|||||||
99
tests/features/routing/role-guards.test.ts
Normal file
99
tests/features/routing/role-guards.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user