docs(sprint-6): cierre formal — observaciones Etapas 1-3 + reflexiones estratégicas
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled
This commit is contained in:
555
sprints/sprint-7/BRIEF.md
Normal file
555
sprints/sprint-7/BRIEF.md
Normal file
@@ -0,0 +1,555 @@
|
||||
# Sprint 7 — BRIEF
|
||||
|
||||
> InQ ROI — Multi-usuario y acceso de clientes
|
||||
> Inicio: post-cierre Sprint 6
|
||||
> Foco: primer acceso de equipo cliente con roles diferenciados e InQuality como administrador de plataforma
|
||||
|
||||
---
|
||||
|
||||
## Contexto
|
||||
|
||||
Sprint 6 cerró el producto listo para uso interno y para primera presentación a clientes con datos reales. Sprint 7 abre el producto a usuarios del lado del cliente: pueden editar parámetros de sus procesos, simular, ver reportes y exportar PDFs — con visibilidad estrictamente acotada a su organización. InQuality mantiene el control total de invitaciones, roles y gestión de usuarios.
|
||||
|
||||
El esquema de autenticación replica el patrón de **InQ Sizing** (documentado en `cowork/InQ ROI - Simulador de procesos/autenticacion-y-usuarios.md`) que ya resolvió estos problemas. Este sprint adapta ese esquema al stack de InQ ROI (TanStack Router en lugar de React Router, `public.users` en lugar de `profiles`, etc.).
|
||||
|
||||
---
|
||||
|
||||
## Modelo de roles (definición final)
|
||||
|
||||
| Rol | Quién | Acceso |
|
||||
|---|---|---|
|
||||
| `platform_admin` | InQuality | Todo: crear procesos, gestionar orgs, invitar/suspender/eliminar usuarios |
|
||||
| `member` | InQuality | Crear y gestionar procesos, asignar a orgs — sin gestión de usuarios |
|
||||
| `client_editor` | Cliente, operativo | Workspace completo de los procesos de su org (editar costos/tiempos/recursos, simular, reporte, PDF) — sin importar BPMNs nuevos |
|
||||
| `client_viewer` | Cliente, decisor | Solo reporte + sensibilidad + PDF de los procesos de su org — sin edición |
|
||||
|
||||
> **Nota sobre roles existentes:** `platform_role = 'guest'` existe en la DB pero queda deprecado. Los usuarios `@inquality.com.py` activos son `platform_admin` o `member`. El rol `guest` solo se mantiene en el check constraint por compatibilidad; ningún usuario nuevo recibirá ese rol.
|
||||
|
||||
---
|
||||
|
||||
## Estado actual del schema relevante
|
||||
|
||||
Migraciones ya aplicadas (001–013):
|
||||
- `public.users`: `id`, `name`, `avatar_url`, `platform_role` ('platform_admin' | 'member' | 'guest'), `company`, `created_at`
|
||||
- `public.processes`: tiene campo `client` (text) para nombre del cliente — se mantiene para display (PDF, header); se agrega `org_id` FK para control de acceso
|
||||
- `public.user_groups` + `group_memberships`: agrupaciones internas de InQuality — se conservan sin cambios
|
||||
- `public.process_access`: access grants por usuario o grupo — se conserva sin cambios
|
||||
- `public.is_platform_admin()`: función SECURITY DEFINER existente — se mantiene
|
||||
|
||||
Problema crítico identificado en auditoría (Sprint 6 Etapa 3): `AuthContext.getUserFromStorage()` y `SupabaseAuthService.buildAuthUser()` derivan `platformRole` del dominio del email en tiempo de ejecución (`@inquality.com.py` → `platform_admin`, else → `guest`). Para que los roles de cliente funcionen, `syncProfileFromDB` debe leer también `platform_role` desde la DB. Los nuevos roles (`client_editor`, `client_viewer`) con emails no-InQuality quedarían atrapados en `guest` sin este fix.
|
||||
|
||||
---
|
||||
|
||||
## Decisión de migración: `client` text → `org_id` FK
|
||||
|
||||
Estrategia **Opción A** (decidida por el director):
|
||||
- Script de migración crea una organización por cada valor único de `processes.client`
|
||||
- Vincula los procesos a esa org via `org_id`
|
||||
- El campo `client` (text) **se mantiene** para display (PDF, header del proceso) — los dos campos coexisten
|
||||
- `org_id = null` significa "sin organización asignada" — los procesos existentes sin `client` quedan sin org y solo InQuality los ve
|
||||
|
||||
---
|
||||
|
||||
## Etapas
|
||||
|
||||
### Etapa 1 — Migraciones de base de datos + helpers
|
||||
|
||||
**Archivos a crear:** `supabase/migrations/014_create_organizations.sql`, `supabase/migrations/015_client_roles_and_rls.sql`
|
||||
|
||||
**014 — Organizations + org_id:**
|
||||
|
||||
```sql
|
||||
-- Tabla organizations
|
||||
CREATE TABLE public.organizations (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
is_provider boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE public.organizations ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- InQuality como org proveedora
|
||||
INSERT INTO public.organizations (name, is_provider)
|
||||
VALUES ('InQuality', true);
|
||||
|
||||
-- org_id en users (nullable — usuarios InQuality no necesitan org de cliente)
|
||||
ALTER TABLE public.users ADD COLUMN IF NOT EXISTS org_id uuid
|
||||
REFERENCES public.organizations(id) ON DELETE SET NULL;
|
||||
|
||||
-- org_id en processes (nullable — procesos sin cliente asignado)
|
||||
ALTER TABLE public.processes ADD COLUMN IF NOT EXISTS org_id uuid
|
||||
REFERENCES public.organizations(id) ON DELETE SET NULL;
|
||||
|
||||
-- Migración automática: crear orgs desde valores únicos de processes.client
|
||||
INSERT INTO public.organizations (name, is_provider)
|
||||
SELECT DISTINCT client, false
|
||||
FROM public.processes
|
||||
WHERE client IS NOT NULL AND trim(client) != ''
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Vincular procesos a sus orgs
|
||||
UPDATE public.processes p
|
||||
SET org_id = o.id
|
||||
FROM public.organizations o
|
||||
WHERE trim(o.name) = trim(p.client) AND o.is_provider = false;
|
||||
|
||||
-- Ampliar check constraint de platform_role para incluir roles de cliente
|
||||
ALTER TABLE public.users DROP CONSTRAINT IF EXISTS users_platform_role_check;
|
||||
ALTER TABLE public.users ADD CONSTRAINT users_platform_role_check
|
||||
CHECK (platform_role IN ('platform_admin', 'member', 'client_editor', 'client_viewer', 'guest'));
|
||||
```
|
||||
|
||||
**015 — SECURITY DEFINER helpers + RLS actualizada:**
|
||||
|
||||
```sql
|
||||
-- Helper: org del usuario actual
|
||||
CREATE OR REPLACE FUNCTION public.current_org()
|
||||
RETURNS uuid LANGUAGE sql STABLE SECURITY DEFINER AS $$
|
||||
SELECT org_id FROM public.users WHERE id = auth.uid()
|
||||
$$;
|
||||
|
||||
-- Helper: ¿es usuario InQuality? (tiene org con is_provider=true)
|
||||
CREATE OR REPLACE FUNCTION public.is_inquality()
|
||||
RETURNS boolean LANGUAGE sql STABLE SECURITY DEFINER AS $$
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM public.users u
|
||||
JOIN public.organizations o ON o.id = u.org_id
|
||||
WHERE u.id = auth.uid() AND o.is_provider = true
|
||||
)
|
||||
$$;
|
||||
|
||||
-- Helper: rol del usuario actual
|
||||
CREATE OR REPLACE FUNCTION public.current_platform_role()
|
||||
RETURNS text LANGUAGE sql STABLE SECURITY DEFINER AS $$
|
||||
SELECT platform_role FROM public.users WHERE id = auth.uid()
|
||||
$$;
|
||||
|
||||
-- Trigger handle_new_user: auto-crear fila en public.users para Google OAuth @inquality.com.py
|
||||
-- Usuarios clientes se crean via Edge Function (inviteUserByEmail), no por trigger.
|
||||
CREATE OR REPLACE FUNCTION public.handle_new_user()
|
||||
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER AS $$
|
||||
DECLARE
|
||||
provider_org_id uuid;
|
||||
BEGIN
|
||||
IF new.email LIKE '%@inquality.com.py' THEN
|
||||
SELECT id INTO provider_org_id
|
||||
FROM public.organizations WHERE is_provider = true LIMIT 1;
|
||||
|
||||
INSERT INTO public.users (id, name, avatar_url, platform_role, org_id)
|
||||
VALUES (
|
||||
new.id,
|
||||
COALESCE(new.raw_user_meta_data->>'full_name', new.email),
|
||||
new.raw_user_meta_data->>'avatar_url',
|
||||
'member', -- rol conservador; platform_admin se asigna manualmente
|
||||
provider_org_id
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING; -- no sobrescribir si Edge Function ya creó la fila
|
||||
END IF;
|
||||
RETURN new;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER on_auth_user_created
|
||||
AFTER INSERT ON auth.users
|
||||
FOR EACH ROW EXECUTE PROCEDURE public.handle_new_user();
|
||||
|
||||
-- RLS: organizations
|
||||
CREATE POLICY "orgs_visible" ON public.organizations
|
||||
FOR SELECT USING (
|
||||
public.is_inquality() OR id = public.current_org()
|
||||
);
|
||||
|
||||
CREATE POLICY "orgs_insert" ON public.organizations
|
||||
FOR INSERT WITH CHECK (
|
||||
public.is_platform_admin()
|
||||
);
|
||||
|
||||
CREATE POLICY "orgs_update" ON public.organizations
|
||||
FOR UPDATE USING (public.is_platform_admin());
|
||||
|
||||
-- RLS: users — actualizar para que platform_admin vea todos
|
||||
DROP POLICY IF EXISTS "read_users" ON public.users;
|
||||
|
||||
CREATE POLICY "read_own_user" ON public.users
|
||||
FOR SELECT USING (id = auth.uid());
|
||||
|
||||
CREATE POLICY "admin_read_all_users" ON public.users
|
||||
FOR SELECT USING (public.is_platform_admin());
|
||||
|
||||
-- RLS: processes — reemplazar select y update para incluir acceso por org
|
||||
DROP POLICY IF EXISTS "select_processes" ON public.processes;
|
||||
|
||||
CREATE POLICY "select_processes" ON public.processes
|
||||
FOR SELECT USING (
|
||||
public.is_platform_admin()
|
||||
OR owner_id = auth.uid()
|
||||
OR (public.current_org() IS NOT NULL AND org_id = public.current_org())
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.process_access
|
||||
WHERE process_id = processes.id
|
||||
AND (
|
||||
(grantee_type = 'user' AND grantee_id = auth.uid())
|
||||
OR (grantee_type = 'group' AND grantee_id IN (
|
||||
SELECT group_id FROM public.group_memberships WHERE user_id = auth.uid()
|
||||
))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
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'
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "update_processes" ON public.processes;
|
||||
CREATE POLICY "update_processes" ON public.processes
|
||||
FOR UPDATE USING (
|
||||
public.is_platform_admin()
|
||||
OR owner_id = auth.uid()
|
||||
OR (
|
||||
public.current_platform_role() = 'client_editor'
|
||||
AND org_id = public.current_org()
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM public.process_access
|
||||
WHERE process_id = processes.id AND permission = 'edit'
|
||||
AND (
|
||||
(grantee_type = 'user' AND grantee_id = auth.uid())
|
||||
OR (grantee_type = 'group' AND grantee_id IN (
|
||||
SELECT group_id FROM public.group_memberships WHERE user_id = auth.uid()
|
||||
))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- RLS: activities, resources, simulations, activity_resource_assignments
|
||||
-- Reemplazar política amplia por filtro via proceso padre
|
||||
-- (si el usuario no puede SELECT el proceso, tampoco accede a sus hijos)
|
||||
|
||||
DROP POLICY IF EXISTS "all_activities" ON public.activities;
|
||||
CREATE POLICY "activities_via_process" ON public.activities
|
||||
FOR SELECT USING (
|
||||
EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id)
|
||||
);
|
||||
CREATE POLICY "activities_write" ON public.activities
|
||||
FOR INSERT WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id)
|
||||
AND public.current_platform_role() != 'client_viewer'
|
||||
);
|
||||
CREATE POLICY "activities_update" ON public.activities
|
||||
FOR UPDATE USING (
|
||||
EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id)
|
||||
AND public.current_platform_role() != 'client_viewer'
|
||||
);
|
||||
CREATE POLICY "activities_delete" ON public.activities
|
||||
FOR DELETE USING (
|
||||
EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id)
|
||||
AND public.current_platform_role() IN ('platform_admin', 'member')
|
||||
);
|
||||
|
||||
-- Mismo patrón para resources, simulations, activity_resource_assignments, gateways
|
||||
-- (ver spec completo en el prompt de Etapa 1)
|
||||
```
|
||||
|
||||
**Criterios Etapa 1:**
|
||||
- [ ] Migraciones aplican sin error en Supabase Studio
|
||||
- [ ] Tabla `organizations` existe con la fila de InQuality (`is_provider = true`)
|
||||
- [ ] Procesos con `client` no vacío tienen `org_id` asignado
|
||||
- [ ] Check constraint acepta los 5 roles (incluidos los nuevos)
|
||||
- [ ] Un usuario `client_editor` con `org_id = X` puede SELECT procesos donde `org_id = X` y no otros
|
||||
- [ ] Un usuario `client_viewer` no puede UPDATE activities
|
||||
- [ ] `npm run test` → 552+ tests verdes (los tests de dominio no tocan DB)
|
||||
|
||||
---
|
||||
|
||||
### Etapa 2 — Edge Functions (invitar, suspender, eliminar usuarios)
|
||||
|
||||
Edge Functions corren en Deno (Supabase). La `service_role` key **nunca va al browser**. Crear la estructura:
|
||||
|
||||
```
|
||||
supabase/functions/
|
||||
├── invite-user/
|
||||
│ └── index.ts
|
||||
├── delete-user/
|
||||
│ └── index.ts
|
||||
└── suspend-user/
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
**invite-user** (patrón exacto del doc de InQ Sizing, adaptado):
|
||||
```typescript
|
||||
// Validaciones en orden:
|
||||
// 1. Verificar que el caller es platform_admin (leer profiles en DB, no confiar en JWT)
|
||||
// 2. Verificar que la org destino existe y no es is_provider (evitar auto-elevación)
|
||||
// 3. Invitar con inviteUserByEmail (manda email automático con magic link)
|
||||
// 4. UPSERT del registro en public.users con onConflict: "id"
|
||||
// (UPSERT, no INSERT — race condition con trigger handle_new_user si el email es @inquality.com.py)
|
||||
|
||||
await adminClient.auth.admin.inviteUserByEmail(body.email, {
|
||||
data: { role: body.role, org_id: body.org_id }
|
||||
})
|
||||
|
||||
await adminClient.from('users').upsert({
|
||||
id: invited.user.id,
|
||||
name: body.full_name,
|
||||
platform_role: body.role, // 'client_editor' | 'client_viewer' | 'member'
|
||||
org_id: body.org_id,
|
||||
}, { onConflict: 'id' })
|
||||
```
|
||||
|
||||
**delete-user:**
|
||||
```typescript
|
||||
// Validar caller = platform_admin, no puede eliminarse a sí mismo
|
||||
await adminClient.auth.admin.deleteUser(body.user_id)
|
||||
// ON DELETE CASCADE en public.users elimina la fila automáticamente
|
||||
```
|
||||
|
||||
**suspend-user / unsuspend-user:**
|
||||
```typescript
|
||||
// Supabase ban: 876000h ≈ 100 años (equivalente a suspensión indefinida)
|
||||
await adminClient.auth.admin.updateUserById(body.user_id, {
|
||||
ban_duration: body.suspend ? '876000h' : 'none'
|
||||
})
|
||||
```
|
||||
|
||||
**Criterios Etapa 2:**
|
||||
- [ ] `POST /functions/v1/invite-user` con payload válido crea usuario en Supabase Auth y fila en `public.users`
|
||||
- [ ] `POST /functions/v1/delete-user` elimina el usuario y su fila (cascade)
|
||||
- [ ] `POST /functions/v1/suspend-user` bloquea el login del usuario; `{ suspend: false }` lo restaura
|
||||
- [ ] Un caller no-platform_admin recibe 403 en todas las funciones
|
||||
- [ ] Invitar a una org con `is_provider = true` devuelve 400
|
||||
|
||||
---
|
||||
|
||||
### Etapa 3 — Capa de auth: email/password + fix de role desde DB
|
||||
|
||||
**3a — Fix crítico: leer `platform_role` desde DB (no inferir del email)**
|
||||
|
||||
Problema: `AuthContext.getUserFromStorage()` y `SupabaseAuthService.buildAuthUser()` asignan `platform_admin` si el email es `@inquality.com.py`, `guest` en cualquier otro caso. Un `client_editor` con email `@banco-solar.com` siempre sería `guest`.
|
||||
|
||||
Fix: extender `syncProfileFromDB` para leer también `platform_role`:
|
||||
|
||||
```typescript
|
||||
// En SupabaseAuthService.syncProfileFromDB:
|
||||
const { data } = await supabase
|
||||
.from('users')
|
||||
.select('name, company, platform_role, org_id') // agregar platform_role y org_id
|
||||
.eq('id', userId)
|
||||
.maybeSingle()
|
||||
|
||||
// En AuthContext, actualizar el estado con el rol real:
|
||||
setUser(prev => prev?.id === userId
|
||||
? { ...prev, company: data.company, name: data.name || prev.name,
|
||||
platformRole: data.platform_role, orgId: data.org_id }
|
||||
: prev
|
||||
)
|
||||
```
|
||||
|
||||
Actualizar `AuthUser` en `types.ts`:
|
||||
```typescript
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
avatarUrl?: string
|
||||
platformRole: 'platform_admin' | 'member' | 'client_editor' | 'client_viewer' | 'guest'
|
||||
company?: string
|
||||
orgId?: string // para routing y RLS del frontend
|
||||
}
|
||||
```
|
||||
|
||||
El `getUserFromStorage()` puede seguir usando la heurística del email como valor inicial optimista (evita el flash de login). El `syncProfileFromDB` lo corrige después del primer render.
|
||||
|
||||
**3b — Agregar `signInWithEmailPassword` a la capa de auth**
|
||||
|
||||
```typescript
|
||||
// AuthService interface:
|
||||
signInWithEmailPassword(email: string, password: string): Promise<{ error: string | null }>
|
||||
|
||||
// SupabaseAuthService implementation:
|
||||
async signInWithEmailPassword(email: string, password: string) {
|
||||
const { error } = await supabase.auth.signInWithPassword({ email, password })
|
||||
return { error: error?.message ?? null }
|
||||
}
|
||||
```
|
||||
|
||||
**3c — Agregar guard TOKEN_REFRESHED en onAuthStateChange** (lección de InQ Sizing doc):
|
||||
|
||||
```typescript
|
||||
// Problema: TOKEN_REFRESHED dispara cuando el usuario vuelve a la tab → loading=true brevemente → UI flashea
|
||||
// Fix: solo setLoading en SIGNED_IN e INITIAL_SESSION
|
||||
supabase.auth.onAuthStateChange(async (event, session) => {
|
||||
if (event === 'SIGNED_IN' || event === 'INITIAL_SESSION') {
|
||||
// ... handle login
|
||||
} else if (event === 'SIGNED_OUT') {
|
||||
setUser(null)
|
||||
setLoading(false)
|
||||
}
|
||||
// TOKEN_REFRESHED, USER_UPDATED → ignorar (la sesión ya está activa)
|
||||
})
|
||||
```
|
||||
|
||||
**3d — LoginPage: agregar form email/password**
|
||||
|
||||
La LoginPage actual solo tiene "Continuar con Google". Agregar:
|
||||
- Form email/password debajo del botón Google, separado por "o"
|
||||
- Validación Zod: email válido, contraseña no vacía
|
||||
- Mensaje de error inline si `signInWithEmailPassword` retorna error
|
||||
- Sin registro público — los clientes solo acceden por invitación
|
||||
|
||||
**3e — Página `/reset-password`**
|
||||
|
||||
Cuando el usuario acepta el magic link del invite, Supabase redirige a `/reset-password?access_token=...`. Esta página:
|
||||
- Lee el token del hash/query
|
||||
- Muestra form de nueva contraseña (mínimo 8 caracteres)
|
||||
- Llama `supabase.auth.updateUser({ password: newPassword })`
|
||||
- Redirige al home después del éxito
|
||||
|
||||
**Criterios Etapa 3:**
|
||||
- [ ] Un `client_editor` con email no-InQuality puede iniciar sesión con email/password
|
||||
- [ ] Después del `syncProfileFromDB`, `user.platformRole` es `client_editor` (no `guest`)
|
||||
- [ ] `/reset-password` funciona con el magic link del invite
|
||||
- [ ] Volver a una tab inactiva no causa flash de loading (TOKEN_REFRESHED ignorado)
|
||||
- [ ] `npm run test` → mantener 552+ tests verdes
|
||||
|
||||
---
|
||||
|
||||
### Etapa 4 — Panel de administración de InQuality
|
||||
|
||||
Ruta: `/admin` — solo accesible a `platform_admin`.
|
||||
|
||||
**4a — Organizations page (`/admin/organizations`)**
|
||||
|
||||
Lista de organizaciones de clientes (todas las que no son InQuality):
|
||||
- Nombre, fecha de creación, cantidad de procesos asignados, cantidad de usuarios
|
||||
- Botón "Nueva organización" → modal/sheet con campo nombre
|
||||
- Click en org → detalle: procesos asignados + usuarios de esa org
|
||||
- Desde el detalle: asignar un proceso existente a la org (dropdown de procesos sin org asignada o reasignar)
|
||||
|
||||
**4b — Users page (`/admin/users`)**
|
||||
|
||||
Lista de todos los usuarios, filtrables por org:
|
||||
- Nombre, email, rol, org, estado (activo/suspendido)
|
||||
- Botón "Invitar usuario" → sheet con campos: email, nombre, rol (client_editor | client_viewer | member), org (obligatorio para roles de cliente)
|
||||
- Al invitar: llama Edge Function `invite-user` → si OK, muestra confirmación "Email de invitación enviado"
|
||||
- Menú de acciones por usuario: Cambiar rol, Suspender/Reactivar, Eliminar
|
||||
- Suspender llama Edge Function `suspend-user`; Eliminar llama `delete-user` con confirmación
|
||||
|
||||
**4c — Entrada en AppHeader para admins**
|
||||
|
||||
Un ícono de escudo o ajuste en el AppHeader visible solo para `platform_admin`, que navega a `/admin`.
|
||||
|
||||
**Criterios Etapa 4:**
|
||||
- [ ] `platform_admin` puede crear una organización y ver sus procesos/usuarios
|
||||
- [ ] `platform_admin` puede invitar un usuario con rol `client_editor` a una org
|
||||
- [ ] El usuario invitado recibe email con magic link
|
||||
- [ ] `platform_admin` puede suspender y reactivar un usuario
|
||||
- [ ] `platform_admin` puede eliminar un usuario (con confirmación)
|
||||
- [ ] Un `member` o `client_editor` que navega a `/admin` es redirigido
|
||||
- [ ] `npm run build` limpio
|
||||
|
||||
---
|
||||
|
||||
### Etapa 5 — UX de roles de cliente y routing
|
||||
|
||||
**5a — Routing con roles (TanStack Router)**
|
||||
|
||||
Extender el guard de `beforeLoad` del router para verificar el rol además de la sesión:
|
||||
|
||||
```typescript
|
||||
// Rutas de workspace y biblioteca: platform_admin | member | client_editor
|
||||
// Ruta de reporte: todos los roles autenticados (RLS filtra visibilidad)
|
||||
// Rutas de admin: platform_admin únicamente
|
||||
// Import BPMN: platform_admin | member únicamente
|
||||
|
||||
// En el beforeLoad de workspace/:processId:
|
||||
if (user.platformRole === 'client_viewer') {
|
||||
throw redirect({ to: '/report/$processId', params: { processId } })
|
||||
}
|
||||
```
|
||||
|
||||
**5b — WorkspacePage con `client_editor`**
|
||||
|
||||
`client_editor` accede al workspace completo con estas restricciones de UI:
|
||||
- Ocultar el botón "Importar BPMN" (no puede crear procesos nuevos)
|
||||
- El resto del workspace (ActivityPanel, recursos, simulación) funciona igual
|
||||
|
||||
`client_viewer` en workspace → redirigido automáticamente al reporte.
|
||||
|
||||
**5c — Library page con roles de cliente**
|
||||
|
||||
La biblioteca ya filtra por RLS — los `client_editor` y `client_viewer` ven solo sus procesos. Ajustar la UI:
|
||||
- Ocultar el botón "Nuevo proceso" para roles de cliente
|
||||
- Ocultar grupos/tags de organización interna (process_groups) si no son relevantes para el cliente
|
||||
- El "grupo" de bienvenida puede mostrar el nombre de su organización
|
||||
|
||||
**5d — Proceso visible para `client_viewer` en reporte**
|
||||
|
||||
En `ReportPage`, cuando `user.platformRole === 'client_viewer'`:
|
||||
- Tab activa por defecto: `roi` (no `actual`)
|
||||
- Los demás tabs (Actual, Automatizado) son visibles pero las actividades están en modo solo lectura
|
||||
- El ActivityPanel no se muestra (es del workspace, no del reporte)
|
||||
|
||||
**Criterios Etapa 5:**
|
||||
- [ ] `client_editor` de Banco Solar ve solo los procesos de Banco Solar en la biblioteca
|
||||
- [ ] `client_editor` puede editar parámetros de actividades, simular y exportar PDF
|
||||
- [ ] `client_editor` no ve el botón "Importar BPMN"
|
||||
- [ ] `client_viewer` que navega a `/workspace/:id` es redirigido a `/report/:id`
|
||||
- [ ] `client_viewer` ve el reporte con tab ROI activo por defecto
|
||||
- [ ] Un usuario de Banco Solar no puede ver procesos de Aseguradora Yacyretá (verificar con dos sesiones)
|
||||
- [ ] Validación visual del director antes del OK formal
|
||||
|
||||
---
|
||||
|
||||
## Distribución del sprint
|
||||
|
||||
| Área | Etapas | % estimado |
|
||||
|---|---|---|
|
||||
| DB / infraestructura | 1, 2 | ~30% |
|
||||
| Auth layer | 3 | ~20% |
|
||||
| Admin UI | 4 | ~30% |
|
||||
| UX cliente / routing | 5 | ~20% |
|
||||
|
||||
Sprint más grande hasta la fecha — comparable a Sprint 4 (refactorización arquitectural). Planear 6-8 sesiones de trabajo.
|
||||
|
||||
---
|
||||
|
||||
## Referencia de patrones de InQ Sizing aplicados
|
||||
|
||||
| Patrón | Dónde en InQ Sizing | Adaptación en InQ ROI |
|
||||
|---|---|---|
|
||||
| SECURITY DEFINER helpers | `0001_init.sql` | Migraciones 014-015, misma lógica |
|
||||
| Trigger handle_new_user + ON CONFLICT DO NOTHING | `0003_google_oauth_trigger.sql` | Migración 015, mismo patrón |
|
||||
| UPSERT en Edge Function (race condition fix) | `invite-user/index.ts` | Edge Function invite-user |
|
||||
| Caller validado en DB, no en JWT | `invite-user/index.ts` | Todas las Edge Functions |
|
||||
| guard TOKEN_REFRESHED en onAuthStateChange | `AuthContext.tsx` | Etapa 3c |
|
||||
| hasOAuthCallback() en estado inicial | `AuthContext.tsx` | Ya implementado en InQ ROI |
|
||||
| ProtectedRoute devuelve null mientras loading | `ProtectedRoute.tsx` | Adaptar a TanStack Router beforeLoad |
|
||||
| GerenteGuard: redirigir antes de montar | `App.tsx` | beforeLoad de workspace route para client_viewer |
|
||||
|
||||
---
|
||||
|
||||
## Qué NO entra en Sprint 7
|
||||
|
||||
- Compartir procesos individuales con usuarios específicos (ya existe via `process_access` — no ampliar en este sprint)
|
||||
- Autenticación centralizada entre InQ Sizing e InQ ROI (decisión Sprint 8+)
|
||||
- Gestión de `process_groups` por clientes (solo InQuality usa grupos)
|
||||
- i18n, modo offline, PWA
|
||||
- Export Excel
|
||||
- Historial de simulaciones
|
||||
|
||||
---
|
||||
|
||||
## Definition of Done del sprint
|
||||
|
||||
- [ ] Un usuario cliente puede aceptar el invite, setear contraseña y acceder al workspace de su org
|
||||
- [ ] RLS verificada con dos sesiones simultáneas de orgs distintas — ningún dato cross-org visible
|
||||
- [ ] `platform_admin` puede invitar, suspender y eliminar usuarios desde el panel admin
|
||||
- [ ] `npm run build` limpio
|
||||
- [ ] `npm run lint` sin warnings nuevos
|
||||
- [ ] Tests Vitest: mantener o superar 552
|
||||
- [ ] OBSERVATIONS.md con hallazgos del sprint
|
||||
- [ ] STRATEGIC_DIRECTION.md actualizado al cerrar el sprint
|
||||
Reference in New Issue
Block a user