Compare commits

...

15 Commits

Author SHA1 Message Date
8b839baf77 refactor(router): guards AdminLayout + ImportPage → RequireRole wrapper (Etapa 6A)
Some checks are pending
/ Deploy to Cloudflare Pages (push) Waiting to run
- Crea RequireRole component (Approach B — router sin auth context)
- AdminLayout: elimina useEffect + spinner + null check, usa <RequireRole roles={['platform_admin']}>
- ImportPage: elimina useEffect guard + return null client_viewer, usa <RequireRole roles={['platform_admin','member','client_editor']}>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 13:18:14 -03:00
1032944f8b fix import para client_editor
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-07-09 12:10:36 -03:00
0257dd95ec Otro fix para el import con client_editor
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-07-08 11:54:18 -03:00
fdbdc42470 El bug está en toRow() en process-repo.ts, no en el ImportPage:
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
typescript// toRow() en process-repo.ts — líneas 4-24
// NUNCA incluye org_id en el payload del upsert
const row = {
  id: process.id,
  name: process.name,
  // ... otros campos
  // ← org_id ausente completamente
}
La cadena completa del fallo:

ImportPage.tsx sí tiene orgId: user!.orgId ?? null → el Process object tiene el valor correcto
process-repo.ts llama toRow() para construir el payload de Supabase
toRow() nunca pone org_id en el payload → Supabase recibe org_id = null (default)
La RLS chequea org_id = current_org() → null ≠ '2a956076-...' → 403
El error propaga y sale como toast genérico de archivo dañado

Esto pasó desapercibido hasta ahora porque platform_admin tiene is_platform_admin() que bypasea la verificación de org_id. La inserción con org_id = null le funciona a admins, nunca a client_editor.
El fix es quirúrgico: agregar una sola línea en toRow():
typescriptorg_id: process.orgId ?? null,
Eso es todo. Una línea en process-repo.ts. La restricción "no tocar process-repo.ts" de Etapa 5 era por razón equivocada (asumíamos que el repo no necesitaba cambios) — este es un bug que impide que Etapa 5 funcione en absoluto.
2026-07-08 11:46:29 -03:00
3044342ac1 etapa 5
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-07-07 23:38:38 -03:00
85190aef0c docs(changelog): etapa 5 sprint 8
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 22:18:41 -03:00
789e9a34ea feat(library): botón Importar BPMN visible para client_editor; empty state diferenciado
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 22:18:25 -03:00
bff71f0f29 feat(import): client_editor puede importar BPMN; orgId asignado automáticamente
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 22:18:16 -03:00
70d35dad8b feat(db): migración 018 — INSERT policy para client_editor en processes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 22:18:11 -03:00
54306be1c2 Etapa 4.5 entregada.
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
2026-07-07 22:02:19 -03:00
d776af5f24 docs(changelog): etapa 4.5 sprint 8
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 21:57:40 -03:00
cc5563974a fix(library): scroll a sección de org al volver del workspace con ?org param
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 21:57:29 -03:00
c6c88501d1 fix(workspace): handleBack pasa ?org=orgId para scroll contextual en biblioteca
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 21:57:25 -03:00
1813a9396a fix(admin): InQuality visible en OrganizationsPage con badge — quitar filtro is_provider
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 21:57:20 -03:00
b149e1cd0c fix(workspace): sentinel '__none__' en selector área — Radix UI prohíbe value='' en SelectItem
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-07 21:18:58 -03:00
18 changed files with 1142 additions and 92 deletions

View File

@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### Added
- `RequireRole` componente wrapper para guards de rol declarativos (Approach B — sin router context) (Sprint 8)
- Edge Function `delete-user`: hard delete permanente de usuario en auth.users + public.users (Sprint 8)
- Edge Function `list-users`: lista usuarios con email desde auth.users vía service_role (Sprint 8)
- UsersPage: columna "Email" entre Nombre y Rol (via Edge Function list-users) (Sprint 8)
- Migración 018: policy INSERT en `processes` extendida para `client_editor` en su org (Sprint 8)
- ImportPage: `client_editor` puede importar BPMN; proceso se crea con `orgId` de su cuenta (Sprint 8)
- LibraryPage: botón "Importar BPMN" visible para `client_editor`; empty state con call-to-action de importar (Sprint 8)
### Changed
- AdminLayout: guard useEffect + spinner → RequireRole wrapper (sin flash de contenido protegido) (Sprint 8)
- ImportPage: guard useEffect → RequireRole wrapper (Sprint 8)
- UsersPage: "Eliminar" (soft delete / revoke-user) → "Eliminar permanentemente" (hard delete / delete-user) (Sprint 8)
### Fixed
- ImportPage: texto de marca corregido — "Process Cost Platform · MVP Fase 0" → "InQ ROI" (Sprint 8)
- ImportPage: párrafo "Sin backend · Sin registro · Datos guardados localmente" eliminado (obsoleto desde Sprint 4) (Sprint 8)
- OrganizationsPage: InQuality visible en lista con badge naranja — quitar filtro is_provider (Sprint 8)
- WorkspacePage: back button pasa ?org=orgId para scroll contextual al volver a biblioteca (Sprint 8)
- LibraryPage: scroll automático a sección de org al recibir ?org param desde workspace (Sprint 8)
- router.tsx: redirect de settingsRoute pasa search: { org: undefined } para compatibilidad con validateSearch (Sprint 8)
### Added (Etapas 1-4)
- GlobalSettingsPanel: selector de área reemplaza campo libre "Cliente" (Sprint 8) - GlobalSettingsPanel: selector de área reemplaza campo libre "Cliente" (Sprint 8)
- WorkspacePage topbar: display de `areaName` de solo lectura reemplaza edición inline de cliente (Sprint 8) - WorkspacePage topbar: display de `areaName` de solo lectura reemplaza edición inline de cliente (Sprint 8)
- OrganizationDetailPage: tercer tab "Áreas" con CRUD completo — crear, renombrar, eliminar con confirmación (Sprint 8) - OrganizationDetailPage: tercer tab "Áreas" con CRUD completo — crear, renombrar, eliminar con confirmación (Sprint 8)

View File

@@ -0,0 +1,266 @@
# Sprint 8 — Etapa 4.5: Fixes de navegación e InQuality
> **Tipo:** UI — fixes puntuales
> **Rama:** main
> **Estimación:** 1 hora
> **Dependencias:** Etapa 4 completada ✅
---
## Objetivo
Dos fixes independientes detectados en validación de Etapa 4:
1. **InQuality invisible en admin:** `OrganizationsPage` filtra con `.eq('is_provider', false)`, dejando fuera a InQuality. El equipo no puede gestionar sus propias áreas ni procesos desde el panel. Fix: mostrar todas las orgs, con un badge visual para InQuality.
2. **Back button sin contexto de org:** En el workspace, el botón "← Solar Banco" (o cualquier org) navega a `/library` sin parámetros — el usuario llega al home genérico en lugar de la sección de esa org. Fix: pasar `?org=<orgId>` al navegar y hacer scroll al bloque correcto en LibraryPage.
---
## Fix 1: `OrganizationsPage.tsx`
### Problema actual
Línea en `fetchOrgs()`:
```typescript
.eq('is_provider', false)
```
Esta condición excluye a InQuality (que tiene `is_provider = true`) de la lista. El equipo de InQuality no puede entrar a `/admin/organizations/:id` de su propia org para gestionar áreas y procesos.
### Cambios en `OrganizationsPage.tsx`
**1A. Extender la interface `OrgRow`:**
```typescript
interface OrgRow {
id: string
name: string
is_provider: boolean // 🆕
created_at: string
processes: [{ count: number }]
users: [{ count: number }]
}
```
**1B. Actualizar `fetchOrgs()` — quitar el filtro y agregar `is_provider` al select:**
```typescript
const fetchOrgs = async () => {
const { data } = await supabase
.from('organizations')
.select('id, name, is_provider, created_at, processes:processes(count), users:users(count)')
// ← eliminar el .eq('is_provider', false)
.order('name')
// Ordenar: proveedor primero, luego clientes alfabético
const sorted = ((data as OrgRow[] | null) ?? []).sort((a, b) => {
if (a.is_provider && !b.is_provider) return -1
if (!a.is_provider && b.is_provider) return 1
return a.name.localeCompare(b.name)
})
setOrgs(sorted)
setLoading(false)
}
```
**1C. Actualizar el título:**
```tsx
// Antes:
<h2 className="text-lg font-semibold text-slate-900">Organizaciones clientes</h2>
// Después:
<h2 className="text-lg font-semibold text-slate-900">Organizaciones</h2>
```
**1D. Agregar badge "Proveedor" en la columna de nombre:**
En la fila de la tabla, en la celda de nombre:
```tsx
<td className="py-3 px-4 font-medium text-slate-900">
<div className="flex items-center gap-2">
{org.name}
{org.is_provider && (
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full border"
style={{ color: '#F59845', background: '#fff8f0', borderColor: '#fcd9aa' }}>
InQuality
</span>
)}
</div>
</td>
```
No cambiar el botón "Ver detalle →" — funciona igual para InQuality que para cualquier otra org.
---
## Fix 2: Back button con contexto de org
Son tres cambios coordinados: `router.tsx`, `WorkspacePage.tsx`, y `LibraryPage.tsx`.
### Fix 2A: `router.tsx` — declarar `org` como search param de `/library`
TanStack Router requiere que los search params estén declarados en la ruta para que `navigate({ search: ... })` sea type-safe.
```typescript
// Antes:
const libraryRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/library',
component: LibraryPage,
})
// Después:
const libraryRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/library',
component: LibraryPage,
validateSearch: (search: Record<string, unknown>) => ({
org: typeof search.org === 'string' ? search.org : undefined,
}),
})
```
**NO tocar** `indexRoute` (`/`) — no necesita el param porque nunca se navega a `/` desde el workspace.
### Fix 2B: `WorkspacePage.tsx` — pasar org en `handleBack`
```typescript
// Antes (línea ~194-196):
function handleBack() {
void navigate({ to: '/library' })
}
// Después:
function handleBack() {
void navigate({
to: '/library',
search: currentProcess.orgId ? { org: currentProcess.orgId } : {},
})
}
```
### Fix 2C: `LibraryPage.tsx` — leer param y hacer scroll
**Imports:** agregar `useLocation` al import existente de `@tanstack/react-router`:
```typescript
// Antes:
import { useNavigate } from '@tanstack/react-router'
// Después:
import { useNavigate, useLocation } from '@tanstack/react-router'
```
**Leer el param** (agregar dentro del cuerpo de `LibraryPage`, junto a los otros hooks):
```typescript
const location = useLocation()
const orgParam = useMemo(
() => new URLSearchParams(location.search).get('org') ?? undefined,
[location.search]
)
```
`useMemo` ya está importado en el archivo.
**Efecto de scroll** (agregar después de los efectos existentes):
```typescript
// Scroll a la sección del org si venimos del workspace
useEffect(() => {
if (!orgParam || isLoading || processes.length === 0) return
requestAnimationFrame(() => {
const el = document.getElementById(`org-section-${orgParam}`)
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}, [orgParam, isLoading, processes.length])
```
**Agregar `id` al div de cada org en el bloque admin** (en el `orgGroups.map(...)`):
```tsx
// Antes:
<div key={orgId} className="border border-border rounded-xl overflow-hidden">
// Después:
<div key={orgId} id={`org-section-${orgId}`} className="border border-border rounded-xl overflow-hidden">
```
Solo en el div raíz de cada org (el que tiene el `key={orgId}`). No tocar divs internos.
---
## Lo que NO cambia en esta etapa
- **`src/features/admin/OrganizationDetailPage.tsx`** — sin cambios; el detalle de InQuality ya funciona una vez que aparece en la lista.
- **`src/store/library-store.ts`** — sin cambios.
- **Cualquier otro archivo** — sin cambios.
---
## Criterios de validación
### Fix 1 — InQuality visible
- [ ] La página `/admin/organizations` muestra a InQuality en la lista
- [ ] InQuality aparece primero (antes que las orgs clientes)
- [ ] InQuality tiene el badge naranja "InQuality"
- [ ] Al hacer click en "Ver detalle →" de InQuality, lleva a su detalle con procesos, usuarios y áreas
- [ ] Las otras orgs clientes siguen apareciendo igual que antes
### Fix 2 — Back button con scroll
- [ ] Abrir un proceso de "Solar Banco" → workspace → botón "← Solar Banco" → navega a `/library?org=<orgId>`
- [ ] Al llegar a `/library`, la página hace scroll automático al bloque de "Solar Banco"
- [ ] El bloque de "Solar Banco" está expandido (todas las orgs empiezan expandidas por default)
- [ ] Si el proceso no tiene org (edge case), el back button va a `/library` sin param y no hay scroll
### Técnicos
- [ ] `npm run build` sin errores TypeScript
- [ ] `npm run test` sin regresiones (596 tests)
---
## Restricciones explícitas
- **NO agregar** `validateSearch` a `indexRoute` (`/`) — solo a `libraryRoute`
- **NO modificar** `OrganizationDetailPage.tsx` en esta etapa
- **NO cambiar** el comportamiento del botón "Nueva organización" — InQuality se crea con `is_provider: false`, InQuality nunca se crea desde la UI (es dato de seeding)
- **NO agregar** tests en esta etapa
---
## Archivos autorizados a modificar
```
src/
├── router.tsx ← validateSearch en libraryRoute
├── features/
│ ├── workspace/
│ │ └── WorkspacePage.tsx ← handleBack con search: { org }
│ ├── library/
│ │ └── LibraryPage.tsx ← useLocation + scroll effect + id en org divs
│ └── admin/
│ └── OrganizationsPage.tsx ← quitar filtro is_provider + badge + título
```
---
## Commits esperados
```
fix(admin): InQuality visible en OrganizationsPage con badge — quitar filtro is_provider
fix(workspace): handleBack pasa ?org=orgId para scroll contextual en biblioteca
fix(library): scroll a sección de org al volver del workspace con ?org param
```
---
## Reporte esperado
1. Confirmar que InQuality aparece en la lista de organizaciones con el badge
2. Confirmar que el back button del workspace pasa `?org=` en la URL
3. Confirmar que la biblioteca hace scroll al bloque correcto al recibir el param
4. `npm run build` y `npm run test` (con conteo)
5. **Solicitar validación visual del director** en los dos fixes antes del OK formal

View File

@@ -0,0 +1,343 @@
# Sprint 8 — Etapa 5: client_editor puede importar BPMN
> **Tipo:** DB + UI — habilitar flujo de importación para rol cliente editor
> **Rama:** main
> **Estimación:** 1.5 horas
> **Dependencias:** Etapas 14 completadas ✅
> (tabla `areas` y columna `org_id` en processes ya existen, `isClientRole` ya implementado en workspace)
---
## Objetivo
Habilitar que un usuario con rol `client_editor` pueda importar archivos BPMN propios. Actualmente la `ImportPage` redirige a todos los roles cliente a `/`. Con esta etapa, `client_editor` pasa a ser un ciudadano de primera clase del flujo de importación: puede subir un BPMN, el proceso se asigna automáticamente a su org, y puede trabajar desde el workspace como lo hace InQuality.
`client_viewer` **no** obtiene acceso de importación — eso no cambia.
---
## Contexto del código actual
Leer estos archivos antes de modificar:
- `src/features/import/ImportPage.tsx` — tiene dos guards que bloquean a client_editor:
1. `useEffect` con `allowed = ['platform_admin', 'member']` → redirige a `/`
2. `if (isClientRole) return null` antes del return final → nunca llega al JSX
Además, el proceso se crea con `orgId: null`, lo que rompe las RLS.
- `src/features/library/LibraryPage.tsx` — el botón "Importar BPMN" del header está envuelto en `{!isClientRole && ...}` (líneas ~841-860). El empty state de la vista cliente muestra "Contactá a tu administrador" sin botón de importar para nadie.
- `supabase/migrations/015_client_roles_and_rls.sql` — la política `insert_processes` permite solo `platform_admin` y `member`. La línea de comentario lo dice explícitamente: `-- client_editor y client_viewer NO pueden crear procesos nuevos`.
---
## Parte 1: Migration 018
Crear `supabase/migrations/018_client_editor_insert.sql`.
El único cambio de esta migración: extender la política `insert_processes` para incluir a `client_editor` cuando inserta en su propia org.
```sql
-- Permite que client_editor inserte procesos en su propia org
-- Los demás roles mantienen sus permisos actuales
DROP POLICY IF EXISTS "insert_processes" ON public.processes;
CREATE POLICY "insert_processes" ON public.processes
FOR INSERT WITH CHECK (
public.is_platform_admin()
OR public.current_platform_role() = 'member'
OR (
public.current_platform_role() = 'client_editor'
AND org_id = public.current_org()
)
-- client_viewer y guest siguen sin poder crear procesos
);
```
**Verificar que las políticas de actividades y gateways ya permiten a client_editor:**
Buscar en `015_client_roles_and_rls.sql`:
- `activities_insert`: usa `NOT IN ('client_viewer', 'guest')` → client_editor puede ✅
- `gateways_write`: usa `NOT IN ('client_viewer', 'guest')` → client_editor puede ✅
Si alguna de esas dos NO incluye a client_editor, corregir en esta misma migración. Verificar antes de cerrar el archivo.
### Aplicar la migración
Aplicar en Supabase y verificar:
```sql
-- Verificar que la nueva política existe
SELECT policyname, cmd, qual
FROM pg_policies
WHERE schemaname = 'public' AND tablename = 'processes';
-- Esperado: ver "insert_processes" con la nueva definición que incluye client_editor
```
---
## Parte 2: `ImportPage.tsx`
### 2A — Eliminar los dos guards que bloquean a client_editor
**Guard 1 — cambiar la lista de roles permitidos en el `useEffect`:**
```typescript
// Antes:
const allowed = ['platform_admin', 'member']
if (!allowed.includes(user.platformRole)) {
void navigate({ to: '/' })
}
// Después:
const allowed = ['platform_admin', 'member', 'client_editor']
if (!allowed.includes(user.platformRole)) {
void navigate({ to: '/' })
}
```
**Guard 2 — reemplazar el bloqueo total por uno solo para client_viewer:**
```typescript
// Antes (bloquea a todos los client roles):
const isClientRole = user?.platformRole === 'client_editor' || user?.platformRole === 'client_viewer'
if (isClientRole) return null
// Después (solo bloquea client_viewer):
if (user?.platformRole === 'client_viewer') return null
```
### 2B — Asignar `orgId` del usuario en el proceso creado
Al construir el objeto `process` dentro de `processFile`, el campo `orgId` actualmente es `null`. Para que la RLS de `insert_processes` valide `org_id = current_org()`, el proceso insertado debe tener el `org_id` del usuario.
```typescript
// Antes:
const process: Process = {
// ...
orgId: null,
// ...
}
// Después:
const process: Process = {
// ...
orgId: user!.orgId, // ← org_id del usuario autenticado; null solo si no tiene org (no debería pasar para client_editor)
// ...
}
```
Verificar que `user.orgId` está disponible en el tipo `User` del AuthContext (debería estar — se pobló en Sprint 7).
### 2C — Ocultar los procesos de ejemplo para client_editor
Los "procesos de ejemplo" (`SAMPLE_PROCESSES`) son demostraciones internas de InQuality. Un client_editor no debería importar datos de prueba de InQuality en su propio espacio.
En el JSX, la sección de ejemplos está bajo el drop zone. Envolverla en una condición:
```tsx
// Antes: siempre visible
<div className="mt-8 w-full max-w-lg">
<p ...>O cargá un proceso de ejemplo con un click</p>
<div className="grid grid-cols-3 gap-3">
{SAMPLE_PROCESSES.map(...)}
</div>
</div>
// Después: solo visible para InQuality (platform_admin y member)
{user?.platformRole !== 'client_editor' && (
<div className="mt-8 w-full max-w-lg">
<p ...>O cargá un proceso de ejemplo con un click</p>
<div className="grid grid-cols-3 gap-3">
{SAMPLE_PROCESSES.map(...)}
</div>
</div>
)}
```
### 2D — Corregir texto de marca en ImportPage
El header tiene texto desactualizado que viola las reglas de marca del producto:
```tsx
// Antes (incorrecto — nombre viejo del producto):
<div className="inline-flex items-center gap-2 ...">
<FlaskConical className="h-3.5 w-3.5" />
Process Cost Platform · MVP Fase 0
</div>
// Después:
<div className="inline-flex items-center gap-2 ...">
<FlaskConical className="h-3.5 w-3.5" />
InQ ROI
</div>
```
El tagline del final de la página también está desactualizado:
```tsx
// Antes:
<p className="mt-8 text-xs text-slate-300">
100% en tu navegador · Sin backend · Sin registro · Datos guardados localmente
</p>
// Después: eliminar este párrafo (el producto ya tiene backend y registro)
```
---
## Parte 3: `LibraryPage.tsx`
### 3A — Botón "Importar BPMN" en el header visible para client_editor
El botón en el header de LibraryPage (líneas ~841-860) está condicionado con `!isClientRole`, lo que lo oculta para todos los roles cliente. Cambiar para mostrarlo a `client_editor`:
```tsx
// Antes:
{!isClientRole && (
<button
onClick={handleImport}
className="..."
style={{ background: '#F59845' }}
>
<Upload className="h-4 w-4" />
Importar BPMN
</button>
)}
// Después:
{user?.platformRole !== 'client_viewer' && (
<button
onClick={handleImport}
className="..."
style={{ background: '#F59845' }}
>
<Upload className="h-4 w-4" />
Importar BPMN
</button>
)}
```
### 3B — Empty state diferenciado para client_editor vs client_viewer
En `HomeView`, el empty state para roles cliente (cuando `groups.length === 0 && processes.length === 0`) actualmente muestra "Contactá a tu administrador" para todos. Diferenciarlo:
```tsx
// Antes (igual para todos los client roles):
if (isClientRole) {
return (
<div ...>
<Building2 ... />
<p>No tenés procesos asignados aún</p>
<p>Contactá a tu administrador para que te asigne acceso</p>
</div>
)
}
// Después (diferenciado):
if (isClientRole) {
if (user?.platformRole === 'client_editor') {
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">Tu biblioteca está vacía</p>
<p className="text-[13px] text-muted-foreground">
Importá tu primer BPMN para empezar a cuantificar costos
</p>
<button
onClick={() => onImport()}
className="flex items-center gap-2 px-4 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>
)
}
// client_viewer: mantener mensaje existente
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>
)
}
```
`user` está disponible en el scope de `HomeView` como prop (o via `useAuth()` — verificar cuál aplica).
---
## Lo que NO cambia en esta etapa
- **`process-repo.ts`** — no tocar. `updateEditable()` no necesita `bpmn_xml` para este flujo.
- **`process-store.ts`** — no tocar.
- **`WorkspacePage.tsx`** — sin cambios. client_editor ya tiene acceso al workspace desde Sprint 7 (la RLS de `update_processes` ya lo cubre).
- **`GlobalSettingsPanel.tsx`** — sin cambios. El selector de área ya funciona para client_editor desde Etapa 4.
- **Rol `client_viewer`** — sigue sin acceso a importación en todo momento.
---
## Criterios de validación
### Técnicos
- [ ] Migración 018 creada y aplicada en Supabase
- [ ] `insert_processes` en la DB incluye la cláusula de client_editor (verificar con `pg_policies`)
- [ ] `npm run build` sin errores TypeScript
- [ ] `npm run test` sin regresiones (596 tests)
### Funcionales (validación visual mandatoria del director)
- [ ] Loguear como `client_editor``/library` → botón "Importar BPMN" visible en el header
- [ ] Hacer click en "Importar BPMN" → lleva a `/import` (no redirige a `/`)
- [ ] `/import` muestra el drop zone; la sección de "procesos de ejemplo" NO aparece
- [ ] Subir un archivo BPMN real → proceso creado → workspace abre correctamente
- [ ] En Supabase: verificar que el proceso creado tiene `org_id` = org del client_editor (no null)
- [ ] Loguear como `client_viewer``/library` → botón "Importar BPMN" NO visible
- [ ] Si client_viewer navega manualmente a `/import` → redirige a `/`
- [ ] La biblioteca vacía de client_editor muestra botón de importar; la de client_viewer muestra "Contactá a tu administrador"
---
## Restricciones explícitas
- **NO** tocar políticas RLS de tablas distintas a `processes` (activities y gateways ya permiten client_editor)
- **NO** crear procesos de muestra en la DB para client_editor — el flujo es importar BPMNs reales
- **NO** modificar el workspace ni el reporte — client_editor ya puede usarlos
- **NO** agregar tests en esta etapa
---
## Archivos autorizados a modificar/crear
```
supabase/migrations/
└── 018_client_editor_insert.sql ← crear y aplicar
src/features/
├── import/
│ └── ImportPage.tsx ← guards + orgId + ejemplos + texto marca
└── library/
└── LibraryPage.tsx ← botón header + empty state diferenciado
```
---
## Commits esperados
```
feat(db): migración 018 — INSERT policy para client_editor en processes
feat(import): client_editor puede importar BPMN; orgId asignado automáticamente
feat(library): botón Importar BPMN visible para client_editor; empty state diferenciado
fix(import): corregir texto de marca — "Process Cost Platform" → "InQ ROI"
```
---
## Reporte esperado
1. Contenido exacto de `018_client_editor_insert.sql`
2. Resultado de la query de verificación en `pg_policies` para `insert_processes`
3. Lista de cambios en `ImportPage.tsx` (resumen compacto)
4. Lista de cambios en `LibraryPage.tsx` (resumen compacto)
5. `npm run build` y `npm run test` (con conteo)
6. **Solicitar validación visual del director** — describir el estado de la UI para client_editor antes del OK formal

View File

@@ -0,0 +1,30 @@
import { useEffect } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useAuth } from '@/auth/AuthContext'
export function RequireRole({
roles,
redirectTo = '/',
children,
}: {
roles: string[]
redirectTo?: '/' | '/library'
children: React.ReactNode
}) {
const { user, isProfileLoaded } = useAuth()
const navigate = useNavigate()
useEffect(() => {
if (!isProfileLoaded) return
if (!user || !roles.includes(user.platformRole)) {
void navigate({ to: redirectTo })
}
// roles y redirectTo son constantes en cada call site — excluidos intencionalmente
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.platformRole, isProfileLoaded, navigate])
if (!isProfileLoaded || !user) return null
if (!roles.includes(user.platformRole)) return null
return <>{children}</>
}

View File

@@ -1,33 +1,13 @@
import { useEffect } from 'react' import { Outlet, Link, useRouterState } from '@tanstack/react-router'
import { Outlet, Link, useNavigate, useRouterState } from '@tanstack/react-router'
import { Loader2 } from 'lucide-react'
import { useAuth } from '@/auth/AuthContext'
import { AppHeader } from '@/components/AppHeader' import { AppHeader } from '@/components/AppHeader'
import { RequireRole } from '@/components/RequireRole'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
export function AdminLayout() { export function AdminLayout() {
const { user, loading } = useAuth()
const navigate = useNavigate()
const pathname = useRouterState({ select: (s) => s.location.pathname }) const pathname = useRouterState({ select: (s) => s.location.pathname })
useEffect(() => {
if (loading) return
if (!user || user.platformRole !== 'platform_admin') {
void navigate({ to: '/' })
}
}, [user, loading, navigate])
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin" style={{ color: '#F59845' }} />
</div>
)
}
if (!user || user.platformRole !== 'platform_admin') return null
return ( return (
<RequireRole roles={['platform_admin']}>
<div className="min-h-screen flex flex-col bg-slate-50"> <div className="min-h-screen flex flex-col bg-slate-50">
<AppHeader /> <AppHeader />
<div className="flex-1 max-w-6xl mx-auto w-full px-6 py-8"> <div className="flex-1 max-w-6xl mx-auto w-full px-6 py-8">
@@ -42,6 +22,7 @@ export function AdminLayout() {
<Outlet /> <Outlet />
</div> </div>
</div> </div>
</RequireRole>
) )
} }

View File

@@ -17,6 +17,7 @@ import {
interface OrgRow { interface OrgRow {
id: string id: string
name: string name: string
is_provider: boolean
created_at: string created_at: string
processes: [{ count: number }] processes: [{ count: number }]
users: [{ count: number }] users: [{ count: number }]
@@ -41,10 +42,16 @@ export function OrganizationsPage() {
const fetchOrgs = async () => { const fetchOrgs = async () => {
const { data } = await supabase const { data } = await supabase
.from('organizations') .from('organizations')
.select('id, name, created_at, processes:processes(count), users:users(count)') .select('id, name, is_provider, created_at, processes:processes(count), users:users(count)')
.eq('is_provider', false)
.order('name') .order('name')
setOrgs((data as OrgRow[] | null) ?? [])
const sorted = ((data as OrgRow[] | null) ?? []).sort((a, b) => {
if (a.is_provider && !b.is_provider) return -1
if (!a.is_provider && b.is_provider) return 1
return a.name.localeCompare(b.name)
})
setOrgs(sorted)
setLoading(false) setLoading(false)
} }
@@ -84,7 +91,7 @@ export function OrganizationsPage() {
return ( return (
<> <>
<div className="flex items-center justify-between mb-5"> <div className="flex items-center justify-between mb-5">
<h2 className="text-lg font-semibold text-slate-900">Organizaciones clientes</h2> <h2 className="text-lg font-semibold text-slate-900">Organizaciones</h2>
<Button size="sm" onClick={openCreate}> <Button size="sm" onClick={openCreate}>
<Plus className="h-4 w-4 mr-1.5" /> <Plus className="h-4 w-4 mr-1.5" />
Nueva organización Nueva organización
@@ -113,7 +120,17 @@ export function OrganizationsPage() {
<tbody> <tbody>
{orgs.map((org) => ( {orgs.map((org) => (
<tr key={org.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/50 transition-colors"> <tr key={org.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/50 transition-colors">
<td className="py-3 px-4 font-medium text-slate-900">{org.name}</td> <td className="py-3 px-4 font-medium text-slate-900">
<div className="flex items-center gap-2">
{org.name}
{org.is_provider && (
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full border"
style={{ color: '#F59845', background: '#fff8f0', borderColor: '#fcd9aa' }}>
InQuality
</span>
)}
</div>
</td>
<td className="py-3 px-4 text-right text-slate-600 tabular-nums"> <td className="py-3 px-4 text-right text-slate-600 tabular-nums">
{org.processes[0]?.count ?? 0} {org.processes[0]?.count ?? 0}
</td> </td>

View File

@@ -1,12 +1,10 @@
/** /**
* Limitaciones conocidas documentadas: * Limitaciones conocidas:
* 1. email no está en public.users (vive en auth.users, requiere service_role) → solo se muestra name. * - "Reactivar" restaura siempre a 'client_editor' — no se guarda el rol previo a la suspensión.
* 2. "Eliminar" llama revoke-user que baja platform_role a 'guest'. Eliminación real requiere Edge Function futura.
* 3. "Reactivar" restaura siempre a 'client_editor' — no se guarda el rol previo a la suspensión.
*/ */
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog' import * as DialogPrimitive from '@radix-ui/react-dialog'
import { UserX, UserPlus, MoreVertical } from 'lucide-react' import { UserX, UserPlus, MoreVertical, Trash2 } from 'lucide-react'
import { supabase } from '@/lib/supabase' import { supabase } from '@/lib/supabase'
import { useAuth } from '@/auth/AuthContext' import { useAuth } from '@/auth/AuthContext'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -34,6 +32,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { useToast } from '@/components/ui/use-toast'
import { RoleBadge } from './RoleBadge' import { RoleBadge } from './RoleBadge'
interface AdminUser { interface AdminUser {
@@ -44,6 +43,7 @@ interface AdminUser {
org_id: string | null org_id: string | null
avatar_url: string | null avatar_url: string | null
organization: { name: string; is_provider: boolean } | null organization: { name: string; is_provider: boolean } | null
email?: string | null
} }
interface OrgOption { interface OrgOption {
@@ -116,6 +116,7 @@ const CHANGE_ROLES = ['platform_admin', 'member', 'client_editor', 'client_viewe
export function UsersPage() { export function UsersPage() {
const { user: adminUser } = useAuth() const { user: adminUser } = useAuth()
const { toast } = useToast()
const [users, setUsers] = useState<AdminUser[]>([]) const [users, setUsers] = useState<AdminUser[]>([])
const [orgs, setOrgs] = useState<OrgOption[]>([]) const [orgs, setOrgs] = useState<OrgOption[]>([])
@@ -155,8 +156,25 @@ export function UsersPage() {
const finalQuery = filterOrgId !== 'all' const finalQuery = filterOrgId !== 'all'
? query.eq('org_id', filterOrgId) ? query.eq('org_id', filterOrgId)
: query : query
const { data } = await finalQuery
setUsers((data as AdminUser[] | null) ?? []) const [{ data }, { data: emailData }] = await Promise.all([
finalQuery,
supabase.functions.invoke('list-users'),
])
const emailMap = new Map<string, string | null>()
if (emailData) {
for (const u of (emailData as Array<{ id: string; email: string | null }>) ?? []) {
emailMap.set(u.id, u.email ?? null)
}
}
const usersWithEmail = ((data as AdminUser[] | null) ?? []).map((u) => ({
...u,
email: emailMap.get(u.id) ?? null,
}))
setUsers(usersWithEmail)
setLoadingUsers(false) setLoadingUsers(false)
} }
@@ -260,16 +278,24 @@ export function UsersPage() {
void fetchUsers() void fetchUsers()
} }
// ── Delete (también usa revoke-user — baja a guest, eliminación real futura) // ── Hard delete vía Edge Function delete-user ────────────────────────────
const handleDelete = async () => { const handleDelete = async () => {
if (!deleteTarget) return if (!deleteTarget) return
setDeleting(true) setDeleting(true)
const { error } = await supabase.functions.invoke('revoke-user', { const { error } = await supabase.functions.invoke('delete-user', {
body: { userId: deleteTarget.id }, body: { userId: deleteTarget.id },
}) })
if (error) { if (error) {
console.error('Error al eliminar usuario:', error) toast({
title: 'Error al eliminar usuario',
description: typeof error === 'object' && 'message' in error
? String(error.message)
: 'Ocurrió un error inesperado.',
variant: 'destructive',
})
} else {
toast({ title: 'Usuario eliminado permanentemente' })
} }
setDeleteTarget(null) setDeleteTarget(null)
setDeleting(false) setDeleting(false)
@@ -334,6 +360,7 @@ export function UsersPage() {
<thead className="bg-slate-50 border-b border-slate-200"> <thead className="bg-slate-50 border-b border-slate-200">
<tr> <tr>
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Nombre</th> <th className="text-left py-2.5 px-4 font-medium text-slate-600">Nombre</th>
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Email</th>
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Rol</th> <th className="text-left py-2.5 px-4 font-medium text-slate-600">Rol</th>
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Organización</th> <th className="text-left py-2.5 px-4 font-medium text-slate-600">Organización</th>
<th className="py-2.5 px-4" /> <th className="py-2.5 px-4" />
@@ -363,6 +390,9 @@ export function UsersPage() {
)} )}
</div> </div>
</td> </td>
<td className="py-3 px-4 text-slate-500 text-xs font-mono">
{u.email ?? '—'}
</td>
<td className="py-3 px-4"> <td className="py-3 px-4">
<RoleBadge role={u.platform_role} /> <RoleBadge role={u.platform_role} />
</td> </td>
@@ -403,7 +433,8 @@ export function UsersPage() {
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
onClick={() => setDeleteTarget(u)} onClick={() => setDeleteTarget(u)}
> >
Eliminar <Trash2 className="h-3.5 w-3.5 mr-2" />
Eliminar permanentemente
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -537,13 +568,13 @@ export function UsersPage() {
destructive destructive
/> />
{/* ─── ConfirmDialog: Eliminar ─────────────────────────────────────── */} {/* ─── ConfirmDialog: Eliminar permanentemente ────────────────────── */}
<ConfirmDialog <ConfirmDialog
open={!!deleteTarget} open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(null)} onOpenChange={(open) => !open && setDeleteTarget(null)}
title={`¿Eliminar a ${deleteTarget?.name ?? 'este usuario'}?`} title={`¿Eliminar permanentemente a ${deleteTarget?.name ?? 'este usuario'}?`}
description="El usuario quedará suspendido permanentemente. (Nota: la eliminación real de la cuenta estará disponible en una versión futura.)" description="Esta acción no puede deshacerse. El usuario perderá acceso inmediatamente y sus datos de autenticación serán eliminados de forma permanente."
confirmLabel="Eliminar" confirmLabel="Eliminar permanentemente"
onConfirm={handleDelete} onConfirm={handleDelete}
loading={deleting} loading={deleting}
destructive destructive
@@ -580,6 +611,7 @@ function TableSkeleton({ rows }: { rows: number }) {
{Array.from({ length: rows }).map((_, i) => ( {Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex gap-4 px-4 py-3 bg-white items-center"> <div key={i} className="flex gap-4 px-4 py-3 bg-white items-center">
<Skeleton className="h-4 flex-1" /> <Skeleton className="h-4 flex-1" />
<Skeleton className="h-4 w-40" />
<Skeleton className="h-5 w-20 rounded" /> <Skeleton className="h-5 w-20 rounded" />
<Skeleton className="h-4 w-28" /> <Skeleton className="h-4 w-28" />
<Skeleton className="h-6 w-6 rounded" /> <Skeleton className="h-6 w-6 rounded" />

View File

@@ -1,5 +1,5 @@
import { useNavigate, useSearch } from '@tanstack/react-router' import { useNavigate, useSearch } from '@tanstack/react-router'
import { useCallback, useEffect, useState } from 'react' import { useCallback, 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'
@@ -13,6 +13,7 @@ import { useToast } from '@/components/ui/use-toast'
import { useAuth } from '@/auth/AuthContext' import { useAuth } from '@/auth/AuthContext'
import { AppFooter } from '@/components/AppFooter' import { AppFooter } from '@/components/AppFooter'
import { AppHeader } from '@/components/AppHeader' import { AppHeader } from '@/components/AppHeader'
import { RequireRole } from '@/components/RequireRole'
const SAMPLE_PROCESSES = [ const SAMPLE_PROCESSES = [
{ {
@@ -43,15 +44,6 @@ 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
@@ -91,7 +83,7 @@ export function ImportPage() {
tags: [], tags: [],
ownerId: user!.id, ownerId: user!.id,
updatedBy: user!.id, updatedBy: user!.id,
orgId: null, orgId: user!.orgId ?? null,
areaId: null, areaId: null,
} }
@@ -167,7 +159,7 @@ export function ImportPage() {
console.error(err) console.error(err)
} }
} }
}, [navigate, toast]) }, [navigate, toast, user])
const onDrop = useCallback((e: React.DragEvent) => { const onDrop = useCallback((e: React.DragEvent) => {
e.preventDefault() e.preventDefault()
@@ -201,10 +193,8 @@ export function ImportPage() {
} }
} }
const isClientRole = user?.platformRole === 'client_editor' || user?.platformRole === 'client_viewer'
if (isClientRole) return null
return ( return (
<RequireRole roles={['platform_admin', 'member', 'client_editor']}>
<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 />
<div className="flex-1 flex flex-col items-center justify-center p-6"> <div className="flex-1 flex flex-col items-center justify-center p-6">
@@ -212,7 +202,7 @@ export function ImportPage() {
<div className="text-center mb-10"> <div className="text-center mb-10">
<div className="inline-flex items-center gap-2 text-primary text-sm font-medium mb-4 bg-primary/10 px-3 py-1 rounded-full"> <div className="inline-flex items-center gap-2 text-primary text-sm font-medium mb-4 bg-primary/10 px-3 py-1 rounded-full">
<FlaskConical className="h-3.5 w-3.5" /> <FlaskConical className="h-3.5 w-3.5" />
Process Cost Platform · MVP Fase 0 InQ ROI
</div> </div>
<h1 className="text-4xl font-bold text-slate-900 mb-3"> <h1 className="text-4xl font-bold text-slate-900 mb-3">
Cuantificá el costo operativo<br />de tus procesos Cuantificá el costo operativo<br />de tus procesos
@@ -276,8 +266,8 @@ export function ImportPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Procesos de ejemplo — visibles directamente */} {/* Procesos de ejemplo — solo para InQuality */}
<div className="mt-8 w-full max-w-lg"> {user?.platformRole !== 'client_editor' && <div className="mt-8 w-full max-w-lg">
<p className="text-xs text-slate-400 uppercase tracking-wide font-medium text-center mb-3"> <p className="text-xs text-slate-400 uppercase tracking-wide font-medium text-center mb-3">
O cargá un proceso de ejemplo con un click O cargá un proceso de ejemplo con un click
</p> </p>
@@ -311,14 +301,11 @@ export function ImportPage() {
) )
})} })}
</div> </div>
</div> </div>}
<p className="mt-8 text-xs text-slate-300">
100% en tu navegador · Sin backend · Sin registro · Datos guardados localmente
</p>
<AppFooter /> <AppFooter />
</div> </div>
</div> </div>
</RequireRole>
) )
} }

View File

@@ -1,5 +1,5 @@
import { useEffect, useState, useMemo } from 'react' import { useEffect, useState, useMemo } from 'react'
import { useNavigate } from '@tanstack/react-router' import { useNavigate, useLocation } from '@tanstack/react-router'
import { import {
Upload, Search, Building2, GitBranch, Upload, Search, Building2, GitBranch,
Clock, TrendingUp, BarChart2, FileX2, X, Settings, Clock, TrendingUp, BarChart2, FileX2, X, Settings,
@@ -383,6 +383,25 @@ function HomeView({
// 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) { if (isClientRole) {
if (user?.platformRole === 'client_editor') {
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">Tu biblioteca está vacía</p>
<p className="text-[13px] text-muted-foreground">
Importá tu primer BPMN para empezar a cuantificar costos
</p>
<button
onClick={() => onImport()}
className="flex items-center gap-2 px-4 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>
)
}
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" />
@@ -508,7 +527,7 @@ function HomeView({
const areaGroups = getAreaGroups(orgProcesses) const areaGroups = getAreaGroups(orgProcesses)
return ( return (
<div key={orgId} className="border border-border rounded-xl overflow-hidden"> <div key={orgId} id={`org-section-${orgId}`} className="border border-border rounded-xl overflow-hidden">
{/* Header de org — colapsable */} {/* Header de org — colapsable */}
<button <button
onClick={() => toggleOrg(orgId)} onClick={() => toggleOrg(orgId)}
@@ -731,7 +750,7 @@ function GroupView({
export function LibraryPage() { export function LibraryPage() {
const navigate = useNavigate() const navigate = useNavigate()
const { load, processes } = useLibraryStore() const { load, processes, isLoading } = useLibraryStore()
const { user, isProfileLoaded } = useAuth() const { user, isProfileLoaded } = useAuth()
const [view, setView] = useState<'home' | 'group'>('home') const [view, setView] = useState<'home' | 'group'>('home')
const [activeGroupId, setActiveGroupId] = useState<string | null>(null) const [activeGroupId, setActiveGroupId] = useState<string | null>(null)
@@ -739,11 +758,26 @@ export function LibraryPage() {
const [ownerFilter, setOwnerFilter] = useState<'all' | 'mine'>('all') const [ownerFilter, setOwnerFilter] = useState<'all' | 'mine'>('all')
const { message: toastMessage, show: showToast } = useToast() const { message: toastMessage, show: showToast } = useToast()
const location = useLocation()
const orgParam = useMemo(
() => new URLSearchParams(location.search).get('org') ?? undefined,
[location.search]
)
useEffect(() => { useEffect(() => {
if (!user) return // no fetchear hasta tener usuario confirmado (evita race condition con sesión Supabase) if (!user) return // no fetchear hasta tener usuario confirmado (evita race condition con sesión Supabase)
load() load()
}, [load, user?.id]) }, [load, user?.id])
// Scroll a la sección del org si venimos del workspace
useEffect(() => {
if (!orgParam || isLoading || processes.length === 0) return
requestAnimationFrame(() => {
const el = document.getElementById(`org-section-${orgParam}`)
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}, [orgParam, isLoading, processes.length])
function navigateHome() { function navigateHome() {
setTransitioning(true) setTransitioning(true)
setTimeout(() => { setTimeout(() => {
@@ -832,7 +866,7 @@ export function LibraryPage() {
<Settings className="h-4 w-4" /> <Settings className="h-4 w-4" />
</button> </button>
)} )}
{!isClientRole && ( {user.platformRole !== 'client_viewer' && (
<button <button
onClick={handleImport} onClick={handleImport}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-white transition-opacity hover:opacity-90" className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-white transition-opacity hover:opacity-90"

View File

@@ -30,7 +30,7 @@ export function GlobalSettingsPanel() {
const { user } = useAuth() const { user } = useAuth()
const isClientRole = user?.platformRole === 'client_editor' || user?.platformRole === 'client_viewer' const isClientRole = user?.platformRole === 'client_editor' || user?.platformRole === 'client_viewer'
const [localName, setLocalName] = useState('') const [localName, setLocalName] = useState('')
const [localAreaId, setLocalAreaId] = useState<string>('') const [localAreaId, setLocalAreaId] = useState<string>('__none__')
const [areas, setAreas] = useState<Area[]>([]) const [areas, setAreas] = useState<Area[]>([])
const [areasLoading, setAreasLoading] = useState(false) const [areasLoading, setAreasLoading] = useState(false)
const [localCurrency, setLocalCurrency] = useState('USD') const [localCurrency, setLocalCurrency] = useState('USD')
@@ -43,7 +43,7 @@ export function GlobalSettingsPanel() {
useEffect(() => { useEffect(() => {
if (!currentProcess) return if (!currentProcess) return
setLocalName(currentProcess.name) setLocalName(currentProcess.name)
setLocalAreaId(currentProcess.areaId ?? '') setLocalAreaId(currentProcess.areaId ?? '__none__')
// Cargar áreas de la org del proceso // Cargar áreas de la org del proceso
if (currentProcess.orgId) { if (currentProcess.orgId) {
@@ -69,7 +69,7 @@ export function GlobalSettingsPanel() {
const clampedHorizon = Math.min(10, Math.max(1, localHorizon)) const clampedHorizon = Math.min(10, Math.max(1, localHorizon))
await updateProcess({ await updateProcess({
name: localName.trim() || 'Proceso sin nombre', name: localName.trim() || 'Proceso sin nombre',
areaId: localAreaId === '' ? null : localAreaId, areaId: localAreaId === '__none__' ? null : localAreaId,
currency: localCurrency, currency: localCurrency,
overheadPercentage: localOverhead / 100, overheadPercentage: localOverhead / 100,
annualFrequency: Math.max(1, localFrequency), annualFrequency: Math.max(1, localFrequency),
@@ -115,7 +115,7 @@ export function GlobalSettingsPanel() {
<SelectValue placeholder="Sin área" /> <SelectValue placeholder="Sin área" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="" className="text-xs text-slate-400 italic">Sin área</SelectItem> <SelectItem value="__none__" className="text-xs text-slate-400 italic">Sin área</SelectItem>
{areas.map((a) => ( {areas.map((a) => (
<SelectItem key={a.id} value={a.id} className="text-xs">{a.name}</SelectItem> <SelectItem key={a.id} value={a.id} className="text-xs">{a.name}</SelectItem>
))} ))}

View File

@@ -192,8 +192,10 @@ export function WorkspacePage() {
: currentProcess.orgName ?? 'Biblioteca' : currentProcess.orgName ?? 'Biblioteca'
function handleBack() { function handleBack() {
// Navega siempre a /library (sin ?org=) hasta que exista una vista filtrada por org void navigate({
void navigate({ to: '/library' }) to: '/library',
search: { org: currentProcess?.orgId ?? undefined },
})
} }
// Label del tab "elemento" según lo seleccionado // Label del tab "elemento" según lo seleccionado

View File

@@ -14,6 +14,7 @@ function toRow(process: Process, userId: string) {
automation_investment: process.automationInvestment, automation_investment: process.automationInvestment,
group_id: process.groupId, group_id: process.groupId,
area_id: process.areaId, area_id: process.areaId,
org_id: process.orgId ?? null,
tags: process.tags, tags: process.tags,
// owner_id se preserva del valor existente en el proceso; solo se inicializa con userId en creates // owner_id se preserva del valor existente en el proceso; solo se inicializa con userId en creates
owner_id: process.ownerId || userId, owner_id: process.ownerId || userId,

View File

@@ -107,6 +107,9 @@ const libraryRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/library', path: '/library',
component: LibraryPage, component: LibraryPage,
validateSearch: (search: Record<string, unknown>) => ({
org: typeof search.org === 'string' ? search.org : undefined,
}),
}) })
const importRoute = createRoute({ const importRoute = createRoute({
@@ -134,7 +137,7 @@ const settingsRoute = createRoute({
beforeLoad: ({ context }) => { beforeLoad: ({ context }) => {
const role = (context as { auth?: { user?: { platformRole?: string } } }).auth?.user?.platformRole const role = (context as { auth?: { user?: { platformRole?: string } } }).auth?.user?.platformRole
if (role === 'client_editor' || role === 'client_viewer') { if (role === 'client_editor' || role === 'client_viewer') {
throw redirect({ to: '/library' }) throw redirect({ to: '/library', search: { org: undefined } })
} }
}, },
}) })

View File

@@ -0,0 +1,99 @@
/**
* Edge Function: delete-user
* Propósito: eliminación permanente de un usuario (hard delete de auth.users + public.users).
* Solo accesible por platform_admin. No reversible.
* Variables de entorno: SUPABASE_URL y SUPABASE_SERVICE_ROLE_KEY (auto-disponibles en Edge Functions).
* Deploy: supabase functions deploy delete-user
*/
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
interface DeleteUserRequest {
userId: string
}
Deno.serve(async (req: Request) => {
const headers = {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}
if (req.method === 'OPTIONS') {
return new Response('ok', { headers })
}
if (req.method !== 'POST') {
return new Response(JSON.stringify({ error: 'Método no permitido' }), { status: 405, headers })
}
const adminClient = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
)
// ── 1. Autenticar al llamador ──────────────────────────────────────────────
const callerToken = req.headers.get('Authorization')?.replace('Bearer ', '')
if (!callerToken) {
return new Response(JSON.stringify({ error: 'Authorization header requerido' }), { status: 401, headers })
}
const { data: { user: caller }, error: authError } = await adminClient.auth.getUser(callerToken)
if (authError || !caller) {
return new Response(JSON.stringify({ error: 'Token inválido o expirado' }), { status: 401, headers })
}
const { data: callerProfile, error: profileError } = await adminClient
.from('users')
.select('platform_role')
.eq('id', caller.id)
.single()
if (profileError || callerProfile?.platform_role !== 'platform_admin') {
return new Response(
JSON.stringify({ error: 'Forbidden: solo platform_admin puede eliminar usuarios' }),
{ status: 403, headers },
)
}
// ── 2. Parsear body ────────────────────────────────────────────────────────
let body: DeleteUserRequest
try {
body = await req.json() as DeleteUserRequest
} catch {
return new Response(JSON.stringify({ error: 'Body JSON inválido' }), { status: 400, headers })
}
const { userId } = body
if (!userId?.trim()) {
return new Response(JSON.stringify({ error: 'El campo userId es requerido' }), { status: 400, headers })
}
// ── 3. Prevenir auto-eliminación ──────────────────────────────────────────
if (userId === caller.id) {
return new Response(
JSON.stringify({ error: 'No puedes eliminar tu propia cuenta' }),
{ status: 400, headers },
)
}
// ── 4. Eliminar de public.users primero (CASCADE, pero por seguridad explícita) ─
await adminClient.from('users').delete().eq('id', userId)
// ── 5. Hard delete de auth.users ─────────────────────────────────────────
const { error: deleteError } = await adminClient.auth.admin.deleteUser(userId)
if (deleteError) {
return new Response(
JSON.stringify({ error: `Error al eliminar usuario: ${deleteError.message}` }),
{ status: 500, headers },
)
}
return new Response(JSON.stringify({ success: true }), { status: 200, headers })
})

View File

@@ -0,0 +1,74 @@
/**
* Edge Function: list-users
* Propósito: devuelve usuarios de public.users con email de auth.users (requiere service_role).
* Solo accesible por platform_admin.
* Deploy: supabase functions deploy list-users
*/
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
Deno.serve(async (req: Request) => {
const headers = {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}
if (req.method === 'OPTIONS') {
return new Response('ok', { headers })
}
const adminClient = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
)
// ── 1. Autenticar al llamador ──────────────────────────────────────────────
const callerToken = req.headers.get('Authorization')?.replace('Bearer ', '')
if (!callerToken) {
return new Response(JSON.stringify({ error: 'Authorization header requerido' }), { status: 401, headers })
}
const { data: { user: caller }, error: authError } = await adminClient.auth.getUser(callerToken)
if (authError || !caller) {
return new Response(JSON.stringify({ error: 'Token inválido o expirado' }), { status: 401, headers })
}
const { data: callerProfile, error: profileError } = await adminClient
.from('users')
.select('platform_role')
.eq('id', caller.id)
.single()
if (profileError || callerProfile?.platform_role !== 'platform_admin') {
return new Response(
JSON.stringify({ error: 'Forbidden: solo platform_admin puede listar usuarios con email' }),
{ status: 403, headers },
)
}
// ── 2. Obtener emails de auth.users ───────────────────────────────────────
const { data: authData, error: listError } = await adminClient.auth.admin.listUsers({
page: 1,
perPage: 1000,
})
if (listError) {
return new Response(
JSON.stringify({ error: `Error al listar usuarios: ${listError.message}` }),
{ status: 500, headers },
)
}
// ── 3. Devolver mapa id → email ───────────────────────────────────────────
const result = (authData?.users ?? []).map((u) => ({
id: u.id,
email: u.email ?? null,
}))
return new Response(JSON.stringify(result), { status: 200, headers })
})

View File

@@ -0,0 +1,15 @@
-- Permite que client_editor inserte procesos en su propia org
-- Los demás roles mantienen sus permisos actuales
DROP POLICY IF EXISTS "insert_processes" ON public.processes;
CREATE POLICY "insert_processes" ON public.processes
FOR INSERT WITH CHECK (
public.is_platform_admin()
OR public.current_platform_role() = 'member'
OR (
public.current_platform_role() = 'client_editor'
AND org_id = public.current_org()
)
-- client_viewer y guest siguen sin poder crear procesos
);

View File

@@ -0,0 +1,143 @@
-- seed_client_processes.sql
-- Distribuye procesos de InQuality a los demás clientes para testing.
-- Duplica entre 5 y 10 procesos aleatorios por org (deep copy: proceso + actividades + gateways + resource assignments).
-- Los procesos de InQuality NO se modifican ni eliminan.
-- Ejecutar desde el SQL Editor de Supabase (o psql con service_role).
DO $$
DECLARE
inquality_org_id uuid;
client_org record;
source_proc record;
new_proc_id uuid;
source_act record;
new_act_id uuid;
proc_count int := 0;
BEGIN
-- Obtener InQuality (is_provider = true)
SELECT id INTO inquality_org_id
FROM organizations
WHERE is_provider = true
LIMIT 1;
IF inquality_org_id IS NULL THEN
RAISE EXCEPTION 'No se encontró una organización con is_provider = true';
END IF;
RAISE NOTICE 'InQuality org_id: %', inquality_org_id;
-- Para cada org cliente
FOR client_org IN
SELECT id, name
FROM organizations
WHERE is_provider = false
ORDER BY name
LOOP
proc_count := 0;
RAISE NOTICE '→ Distribuyendo a: %', client_org.name;
-- Seleccionar entre 5 y 10 procesos aleatorios de InQuality
FOR source_proc IN
SELECT *
FROM processes
WHERE org_id = inquality_org_id
ORDER BY RANDOM()
LIMIT 5 + FLOOR(RANDOM() * 6)::int -- 5..10 inclusive
LOOP
new_proc_id := gen_random_uuid();
-- 1. Duplicar proceso
INSERT INTO processes (
id, name, client, bpmn_xml, currency,
overhead_percentage, annual_frequency, analysis_horizon_years,
automation_investment, group_id, area_id, org_id, tags,
owner_id, created_by, updated_by, updated_at
) VALUES (
new_proc_id,
source_proc.name,
source_proc.client,
source_proc.bpmn_xml,
source_proc.currency,
source_proc.overhead_percentage,
source_proc.annual_frequency,
source_proc.analysis_horizon_years,
source_proc.automation_investment,
NULL, -- group_id: columna legacy, no asignar
NULL, -- area_id: sin área; asignar manualmente si aplica
client_org.id, -- org destino
source_proc.tags,
source_proc.owner_id,
source_proc.created_by,
source_proc.updated_by,
NOW()
);
-- 2. Duplicar actividades + sus resource assignments
FOR source_act IN
SELECT * FROM activities WHERE process_id = source_proc.id
LOOP
new_act_id := gen_random_uuid();
INSERT INTO activities (
id, process_id, bpmn_element_id, name, type,
direct_cost_fixed, execution_time_minutes,
automatable, automated_cost_fixed, automated_time_minutes,
created_by, updated_by, created_at, updated_at
) VALUES (
new_act_id,
new_proc_id,
source_act.bpmn_element_id,
source_act.name,
source_act.type,
source_act.direct_cost_fixed,
source_act.execution_time_minutes,
source_act.automatable,
source_act.automated_cost_fixed,
source_act.automated_time_minutes,
source_act.created_by,
source_act.updated_by,
NOW(),
NOW()
);
-- Copiar resource assignments para esta actividad (si los hay)
INSERT INTO activity_resource_assignments (
id, activity_id, resource_id, utilization_minutes, units, scenario
)
SELECT
gen_random_uuid(),
new_act_id,
resource_id,
utilization_minutes,
units,
scenario
FROM activity_resource_assignments
WHERE activity_id = source_act.id;
END LOOP;
-- 3. Duplicar gateways
INSERT INTO gateways (
id, process_id, bpmn_element_id, gateway_type, branches,
created_at, updated_at
)
SELECT
gen_random_uuid(),
new_proc_id,
bpmn_element_id,
gateway_type,
branches,
NOW(),
NOW()
FROM gateways
WHERE process_id = source_proc.id;
proc_count := proc_count + 1;
RAISE NOTICE ' ✓ %', source_proc.name;
END LOOP;
RAISE NOTICE ' → % procesos duplicados para %', proc_count, client_org.name;
END LOOP;
RAISE NOTICE 'Script completado.';
END $$;

View File

@@ -74,6 +74,7 @@ vi.mock('@/components/AppHeader', () => ({ AppHeader: () => null }))
vi.mock('@tanstack/react-router', () => ({ vi.mock('@tanstack/react-router', () => ({
useNavigate: () => vi.fn(), useNavigate: () => vi.fn(),
useRouterState: () => ({ location: { search: '' } }), useRouterState: () => ({ location: { search: '' } }),
useLocation: () => ({ search: '' }),
Link: ({ children }: { children: React.ReactNode }) => <>{children}</>, Link: ({ children }: { children: React.ReactNode }) => <>{children}</>,
})) }))
@@ -133,9 +134,9 @@ describe('LibraryPage — visibilidad de controles según rol', () => {
authUserRef.current = { ...authUserRef.current, platformRole: 'client_editor' } authUserRef.current = { ...authUserRef.current, platformRole: 'client_editor' }
}) })
it('NO muestra el botón "Importar BPMN"', () => { it('muestra el botón "Importar BPMN"', () => {
render(<LibraryPage />) render(<LibraryPage />)
expect(screen.queryByText('Importar BPMN')).not.toBeInTheDocument() expect(screen.getByText('Importar BPMN')).toBeInTheDocument()
}) })
it('NO muestra el panel de grupos', () => { it('NO muestra el panel de grupos', () => {