This commit is contained in:
430
sprints/sprint-7/ETAPA_1_PROMPT.md
Normal file
430
sprints/sprint-7/ETAPA_1_PROMPT.md
Normal file
@@ -0,0 +1,430 @@
|
||||
# Etapa 1 — Migraciones de base de datos + SECURITY DEFINER helpers
|
||||
|
||||
**Sprint:** 7
|
||||
**Fecha:** 2026-06-24
|
||||
**Alcance:** solo archivos SQL en `supabase/migrations/`. Cero cambios en frontend.
|
||||
|
||||
---
|
||||
|
||||
## Contexto
|
||||
|
||||
Esta etapa crea la fundación de datos sobre la que se construye todo el multi-usuario de Sprint 7. El esquema replica el patrón de InQ Sizing documentado en `cowork/InQ ROI - Simulador de procesos/autenticacion-y-usuarios.md` — leerlo antes de empezar.
|
||||
|
||||
**Schema actual relevante (no tocar lo que ya existe):**
|
||||
- `public.users`: `id`, `name`, `avatar_url`, `platform_role` ('platform_admin'|'member'|'guest'), `company`, `org_id` (no existe aún), `created_at`
|
||||
- `public.processes`: `client` (text — campo de display, se mantiene), `owner_id`, más campos de config. Sin `org_id` aún.
|
||||
- `public.user_groups` + `group_memberships` + `public.process_access`: sistema de acceso interno de InQuality — NO tocar
|
||||
- `public.is_platform_admin()`: función SECURITY DEFINER existente — NO tocar
|
||||
|
||||
---
|
||||
|
||||
## Archivo 1: `supabase/migrations/014_create_organizations.sql`
|
||||
|
||||
```sql
|
||||
-- Tabla de organizaciones clientes
|
||||
-- is_provider = true identifica a InQuality (org administradora de la plataforma)
|
||||
CREATE TABLE IF NOT EXISTS 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;
|
||||
|
||||
-- Insertar la organización de InQuality como proveedor
|
||||
-- ON CONFLICT DO NOTHING: idempotente si ya existe
|
||||
INSERT INTO public.organizations (name, is_provider)
|
||||
VALUES ('InQuality', true)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Agregar org_id a users (nullable — usuarios InQuality existentes no tienen org de cliente)
|
||||
ALTER TABLE public.users
|
||||
ADD COLUMN IF NOT EXISTS org_id uuid REFERENCES public.organizations(id) ON DELETE SET NULL;
|
||||
|
||||
-- Agregar org_id a processes (nullable — procesos existentes se vinculan por migración)
|
||||
ALTER TABLE public.processes
|
||||
ADD COLUMN IF NOT EXISTS org_id uuid REFERENCES public.organizations(id) ON DELETE SET NULL;
|
||||
|
||||
-- Ampliar check constraint de platform_role para incluir roles de cliente
|
||||
-- Primero eliminar la restricción existente, luego recrear con los valores nuevos
|
||||
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'));
|
||||
|
||||
-- ── Migración automática: crear orgs desde valores únicos de processes.client ──
|
||||
-- Crea una organización por cada client name único y no vacío
|
||||
-- ON CONFLICT DO NOTHING: si ya existe una org con ese nombre, no duplicar
|
||||
INSERT INTO public.organizations (name, is_provider)
|
||||
SELECT DISTINCT trim(client), false
|
||||
FROM public.processes
|
||||
WHERE client IS NOT NULL AND trim(client) != ''
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Vincular procesos a sus orgs recién creadas
|
||||
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
|
||||
AND p.org_id IS NULL;
|
||||
|
||||
-- Asignar org_id de InQuality a usuarios InQuality existentes
|
||||
-- (los que tienen email @inquality.com.py o platform_role = 'platform_admin'/'member')
|
||||
UPDATE public.users u
|
||||
SET org_id = (SELECT id FROM public.organizations WHERE is_provider = true LIMIT 1)
|
||||
WHERE u.org_id IS NULL
|
||||
AND u.platform_role IN ('platform_admin', 'member');
|
||||
```
|
||||
|
||||
**Nota sobre el ON CONFLICT en organizations:** la tabla no tiene un UNIQUE constraint en `name` aún. Agregar uno:
|
||||
|
||||
```sql
|
||||
-- Agregar constraint UNIQUE en name para que ON CONFLICT funcione
|
||||
ALTER TABLE public.organizations ADD CONSTRAINT organizations_name_unique UNIQUE (name);
|
||||
```
|
||||
|
||||
Este constraint va ANTES del INSERT de InQuality y de la migración automática.
|
||||
|
||||
---
|
||||
|
||||
## Archivo 2: `supabase/migrations/015_client_roles_and_rls.sql`
|
||||
|
||||
```sql
|
||||
-- ── SECURITY DEFINER helpers ──────────────────────────────────────────────────
|
||||
|
||||
-- Retorna el org_id del usuario actual (null para usuarios sin org asignada)
|
||||
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()
|
||||
$$;
|
||||
|
||||
-- Retorna true si el usuario pertenece a la organización proveedora (InQuality)
|
||||
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
|
||||
)
|
||||
$$;
|
||||
|
||||
-- Retorna el platform_role 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-crea fila en public.users para usuarios @inquality.com.py que se loguean
|
||||
-- con Google OAuth. Los usuarios clientes se crean via Edge Function (invite),
|
||||
-- no por este trigger.
|
||||
-- ON CONFLICT (id) DO NOTHING: no sobrescribir si la Edge Function ya creó la fila.
|
||||
|
||||
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',
|
||||
provider_org_id
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
END IF;
|
||||
RETURN new;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Crear trigger (DROP IF EXISTS para idempotencia)
|
||||
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
|
||||
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_select" 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());
|
||||
|
||||
CREATE POLICY "orgs_delete" ON public.organizations
|
||||
FOR DELETE USING (public.is_platform_admin());
|
||||
|
||||
-- ── RLS: users ───────────────────────────────────────────────────────────────
|
||||
-- Reemplazar la política permisiva existente
|
||||
|
||||
DROP POLICY IF EXISTS "read_users" ON public.users;
|
||||
|
||||
CREATE POLICY "users_select_own" ON public.users
|
||||
FOR SELECT USING (id = auth.uid());
|
||||
|
||||
CREATE POLICY "users_select_admin" ON public.users
|
||||
FOR SELECT USING (public.is_platform_admin());
|
||||
|
||||
-- ── RLS: processes ────────────────────────────────────────────────────────────
|
||||
-- Reemplazar select y update para incluir acceso por org de cliente
|
||||
|
||||
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'
|
||||
-- client_editor y client_viewer NO pueden crear procesos nuevos
|
||||
);
|
||||
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
-- Reemplazar política permisiva "all_activities" por filtro via proceso padre
|
||||
|
||||
DROP POLICY IF EXISTS "all_activities" ON public.activities;
|
||||
|
||||
-- SELECT: si el usuario puede ver el proceso padre, puede ver sus actividades
|
||||
CREATE POLICY "activities_select" ON public.activities
|
||||
FOR SELECT USING (
|
||||
EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id)
|
||||
);
|
||||
|
||||
-- INSERT / UPDATE: InQuality + client_editor (no client_viewer)
|
||||
CREATE POLICY "activities_insert" ON public.activities
|
||||
FOR INSERT WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.processes WHERE id = activities.process_id)
|
||||
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
|
||||
);
|
||||
|
||||
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() NOT IN ('client_viewer', 'guest')
|
||||
);
|
||||
|
||||
-- DELETE: solo InQuality
|
||||
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')
|
||||
);
|
||||
|
||||
-- ── RLS: resources ────────────────────────────────────────────────────────────
|
||||
|
||||
DROP POLICY IF EXISTS "all_resources" ON public.resources;
|
||||
|
||||
CREATE POLICY "resources_select" ON public.resources
|
||||
FOR SELECT USING (
|
||||
public.is_inquality()
|
||||
OR (
|
||||
process_id IS NULL -- recursos del catálogo global: visibles a todos autenticados
|
||||
AND auth.uid() IS NOT NULL
|
||||
)
|
||||
OR EXISTS (SELECT 1 FROM public.processes WHERE id = resources.process_id)
|
||||
);
|
||||
|
||||
CREATE POLICY "resources_insert" ON public.resources
|
||||
FOR INSERT WITH CHECK (
|
||||
auth.uid() IS NOT NULL
|
||||
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
|
||||
);
|
||||
|
||||
CREATE POLICY "resources_update" ON public.resources
|
||||
FOR UPDATE USING (
|
||||
auth.uid() IS NOT NULL
|
||||
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
|
||||
);
|
||||
|
||||
CREATE POLICY "resources_delete" ON public.resources
|
||||
FOR DELETE USING (
|
||||
public.current_platform_role() IN ('platform_admin', 'member')
|
||||
);
|
||||
|
||||
-- ── RLS: simulations ─────────────────────────────────────────────────────────
|
||||
|
||||
DROP POLICY IF EXISTS "all_simulations" ON public.simulations;
|
||||
|
||||
CREATE POLICY "simulations_select" ON public.simulations
|
||||
FOR SELECT USING (
|
||||
EXISTS (SELECT 1 FROM public.processes WHERE id = simulations.process_id)
|
||||
);
|
||||
|
||||
CREATE POLICY "simulations_insert" ON public.simulations
|
||||
FOR INSERT WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.processes WHERE id = simulations.process_id)
|
||||
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
|
||||
);
|
||||
|
||||
CREATE POLICY "simulations_delete" ON public.simulations
|
||||
FOR DELETE USING (
|
||||
EXISTS (SELECT 1 FROM public.processes WHERE id = simulations.process_id)
|
||||
AND public.current_platform_role() IN ('platform_admin', 'member')
|
||||
);
|
||||
|
||||
-- ── RLS: activity_resource_assignments ───────────────────────────────────────
|
||||
-- Heredan acceso del proceso via actividad
|
||||
|
||||
DROP POLICY IF EXISTS "all_activity_resource_assignments" ON public.activity_resource_assignments;
|
||||
|
||||
CREATE POLICY "ara_select" ON public.activity_resource_assignments
|
||||
FOR SELECT USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.activities a
|
||||
JOIN public.processes p ON p.id = a.process_id
|
||||
WHERE a.id = activity_resource_assignments.activity_id
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "ara_write" ON public.activity_resource_assignments
|
||||
FOR INSERT WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.activities a
|
||||
JOIN public.processes p ON p.id = a.process_id
|
||||
WHERE a.id = activity_resource_assignments.activity_id
|
||||
)
|
||||
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
|
||||
);
|
||||
|
||||
CREATE POLICY "ara_update" ON public.activity_resource_assignments
|
||||
FOR UPDATE USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.activities a
|
||||
JOIN public.processes p ON p.id = a.process_id
|
||||
WHERE a.id = activity_resource_assignments.activity_id
|
||||
)
|
||||
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
|
||||
);
|
||||
|
||||
CREATE POLICY "ara_delete" ON public.activity_resource_assignments
|
||||
FOR DELETE USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.activities a
|
||||
JOIN public.processes p ON p.id = a.process_id
|
||||
WHERE a.id = activity_resource_assignments.activity_id
|
||||
)
|
||||
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
|
||||
);
|
||||
|
||||
-- ── RLS: gateways ────────────────────────────────────────────────────────────
|
||||
|
||||
DROP POLICY IF EXISTS "all_gateways" ON public.gateways;
|
||||
|
||||
CREATE POLICY "gateways_select" ON public.gateways
|
||||
FOR SELECT USING (
|
||||
EXISTS (SELECT 1 FROM public.processes WHERE id = gateways.process_id)
|
||||
);
|
||||
|
||||
CREATE POLICY "gateways_write" ON public.gateways
|
||||
FOR INSERT WITH CHECK (
|
||||
EXISTS (SELECT 1 FROM public.processes WHERE id = gateways.process_id)
|
||||
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
|
||||
);
|
||||
|
||||
CREATE POLICY "gateways_update" ON public.gateways
|
||||
FOR UPDATE USING (
|
||||
EXISTS (SELECT 1 FROM public.processes WHERE id = gateways.process_id)
|
||||
AND public.current_platform_role() NOT IN ('client_viewer', 'guest')
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Qué verificar antes de aplicar en producción
|
||||
|
||||
1. **En Supabase Studio (SQL Editor):** ejecutar primero en una rama o en el proyecto de staging si existe. Las migraciones son destructivas en las policies RLS existentes (DROP POLICY).
|
||||
|
||||
2. **Verificar nombres exactos de las policies a eliminar:** los `DROP POLICY IF EXISTS` son seguros (no fallan si no existen), pero verificar que los nombres coincidan con los de las migraciones anteriores.
|
||||
|
||||
3. **Verificar tabla `activity_resource_assignments`:** confirmar que existe con ese nombre exacto (puede ser `activity_resource_assignments` o similar según migration 007).
|
||||
|
||||
4. **Verificar tabla `gateways`:** confirmar que existe y tiene política existente que eliminar.
|
||||
|
||||
---
|
||||
|
||||
## Criterios de validación
|
||||
|
||||
- [ ] Migration 014 aplica sin error en Supabase Studio
|
||||
- [ ] Migration 015 aplica sin error
|
||||
- [ ] `public.organizations` existe con fila InQuality (`is_provider = true`)
|
||||
- [ ] Procesos con `client` no vacío tienen `org_id` asignado (verificar en tabla)
|
||||
- [ ] Usuarios existentes de InQuality tienen `org_id` apuntando a la org de InQuality
|
||||
- [ ] Check constraint acepta 'client_editor' y 'client_viewer' (test: UPDATE manual de un usuario)
|
||||
- [ ] `SELECT public.current_org()` retorna el `org_id` del usuario autenticado
|
||||
- [ ] `SELECT public.is_inquality()` retorna true para usuario InQuality, false para usuario sin org
|
||||
- [ ] `SELECT public.current_platform_role()` retorna el rol correcto
|
||||
- [ ] Trigger `on_auth_user_created` existe en `auth.users`
|
||||
- [ ] Un usuario con `platform_role = 'client_viewer'` NO puede INSERT en activities (verificar con RLS test en Studio)
|
||||
- [ ] `npm run test` → 552+ tests verdes (los tests de dominio/UI no tocan DB directamente)
|
||||
- [ ] `npm run build` limpio (ningún cambio de TypeScript en esta etapa)
|
||||
|
||||
---
|
||||
|
||||
## Archivos a crear
|
||||
|
||||
- `supabase/migrations/014_create_organizations.sql`
|
||||
- `supabase/migrations/015_client_roles_and_rls.sql`
|
||||
|
||||
## NO modificar
|
||||
|
||||
- Ningún archivo TypeScript / React
|
||||
- Ningún archivo de tests existente
|
||||
- `supabase/migrations/001` a `013` — son historia inmutable
|
||||
- `public.user_groups`, `public.group_memberships`, `public.process_access` — sistema interno de InQuality, no tocar sus políticas
|
||||
273
sprints/sprint-7/ETAPA_2_PROMPT.md
Normal file
273
sprints/sprint-7/ETAPA_2_PROMPT.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# Etapa 2 — Edge Function: invitación de usuarios cliente
|
||||
|
||||
**Sprint:** 7
|
||||
**Fecha:** 2026-07-06
|
||||
**Alcance:** Edge Function de Supabase + endpoint de revocación. Cero cambios en frontend. Cero cambios en migraciones existentes (001-015 son historia inmutable).
|
||||
|
||||
---
|
||||
|
||||
## Contexto
|
||||
|
||||
Las migraciones 014-015 ya están aplicadas en producción:
|
||||
- `public.organizations` existe con InQuality (is_provider=true)
|
||||
- `public.users.org_id` + `public.users.platform_role` con 4 roles: platform_admin, member, client_editor, client_viewer
|
||||
- SECURITY DEFINER helpers: `current_org()`, `is_inquality()`, `current_platform_role()`
|
||||
- RLS activo en todas las tablas
|
||||
|
||||
Esta etapa crea el flujo de invitación: un `platform_admin` llama a una Edge Function que:
|
||||
1. Crea el usuario en `auth.users` de Supabase (vía Admin API)
|
||||
2. Crea o reutiliza la organización cliente
|
||||
3. Inserta la fila en `public.users` con el rol correcto y org_id
|
||||
4. Envía el email de bienvenida vía Supabase Auth inviteUserByEmail
|
||||
|
||||
El patrón es el mismo que InQ Sizing (`autenticacion-y-usuarios.md`), adaptado al schema de InQ ROI.
|
||||
|
||||
---
|
||||
|
||||
## Estructura de archivos a crear
|
||||
|
||||
```
|
||||
supabase/
|
||||
└── functions/
|
||||
├── invite-user/
|
||||
│ └── index.ts ← Edge Function principal
|
||||
└── revoke-user/
|
||||
└── index.ts ← Edge Function de revocación/suspensión
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Edge Function: `invite-user`
|
||||
|
||||
### Request
|
||||
|
||||
```typescript
|
||||
// POST /functions/v1/invite-user
|
||||
// Authorization: Bearer <access_token del platform_admin>
|
||||
{
|
||||
email: string, // email del usuario a invitar
|
||||
name: string, // nombre completo
|
||||
platform_role: 'client_editor' | 'client_viewer' | 'member',
|
||||
org_name: string, // nombre de la organización (se crea si no existe)
|
||||
// Si platform_role = 'member', org_name se ignora → org será InQuality
|
||||
}
|
||||
```
|
||||
|
||||
### Validaciones
|
||||
|
||||
- Solo `platform_admin` puede llamar esta función. Verificar con `adminClient.auth.getUser(token)` + consulta a `public.users` para confirmar `platform_role = 'platform_admin'`.
|
||||
- `email` requerido, formato válido.
|
||||
- `platform_role` debe ser uno de los tres valores permitidos.
|
||||
- `org_name` requerido si `platform_role` es `client_editor` o `client_viewer`.
|
||||
- No invitar emails que ya existen en `auth.users` (verificar antes).
|
||||
|
||||
### Lógica
|
||||
|
||||
```typescript
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||
|
||||
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 ', '')
|
||||
const { data: { user: caller } } = await adminClient.auth.getUser(callerToken)
|
||||
const { data: callerProfile } = await adminClient
|
||||
.from('users')
|
||||
.select('platform_role')
|
||||
.eq('id', caller.id)
|
||||
.single()
|
||||
|
||||
if (callerProfile?.platform_role !== 'platform_admin') {
|
||||
return new Response(JSON.stringify({ error: 'Forbidden' }), { status: 403 })
|
||||
}
|
||||
|
||||
// 2. Resolver org_id
|
||||
let orgId: string
|
||||
|
||||
if (platform_role === 'member') {
|
||||
// Nuevo miembro InQuality → org es InQuality
|
||||
const { data: inqOrg } = await adminClient
|
||||
.from('organizations')
|
||||
.select('id')
|
||||
.eq('is_provider', true)
|
||||
.single()
|
||||
orgId = inqOrg.id
|
||||
} else {
|
||||
// Usuario cliente → buscar o crear org por nombre
|
||||
const { data: existingOrg } = await adminClient
|
||||
.from('organizations')
|
||||
.select('id')
|
||||
.eq('name', org_name.trim())
|
||||
.single()
|
||||
|
||||
if (existingOrg) {
|
||||
orgId = existingOrg.id
|
||||
} else {
|
||||
const { data: newOrg } = await adminClient
|
||||
.from('organizations')
|
||||
.insert({ name: org_name.trim(), is_provider: false })
|
||||
.select('id')
|
||||
.single()
|
||||
orgId = newOrg.id
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Invitar usuario vía Auth Admin API (envía email automáticamente)
|
||||
const { data: inviteData, error: inviteError } = await adminClient.auth.admin.inviteUserByEmail(
|
||||
email,
|
||||
{
|
||||
data: { full_name: name }, // raw_user_meta_data
|
||||
redirectTo: `${Deno.env.get('SITE_URL')}/auth/callback`
|
||||
}
|
||||
)
|
||||
|
||||
if (inviteError) {
|
||||
return new Response(JSON.stringify({ error: inviteError.message }), { status: 400 })
|
||||
}
|
||||
|
||||
// 4. Crear fila en public.users
|
||||
// UPSERT (no INSERT) para manejar race condition: si el trigger handle_new_user
|
||||
// ya corrió (emails @inquality.com.py), no duplicar.
|
||||
const { error: upsertError } = await adminClient
|
||||
.from('users')
|
||||
.upsert({
|
||||
id: inviteData.user.id,
|
||||
name,
|
||||
platform_role,
|
||||
org_id: orgId,
|
||||
}, { onConflict: 'id', ignoreDuplicates: false })
|
||||
|
||||
if (upsertError) {
|
||||
return new Response(JSON.stringify({ error: upsertError.message }), { status: 500 })
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ success: true, userId: inviteData.user.id }), { status: 200 })
|
||||
```
|
||||
|
||||
### Headers de respuesta
|
||||
|
||||
Siempre incluir:
|
||||
```typescript
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': Deno.env.get('SITE_URL') ?? '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
}
|
||||
```
|
||||
|
||||
Manejar OPTIONS (CORS preflight):
|
||||
```typescript
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers })
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Edge Function: `revoke-user`
|
||||
|
||||
### Request
|
||||
|
||||
```typescript
|
||||
// POST /functions/v1/revoke-user
|
||||
// Authorization: Bearer <access_token del platform_admin>
|
||||
{
|
||||
userId: string // UUID del usuario a revocar
|
||||
}
|
||||
```
|
||||
|
||||
### Lógica
|
||||
|
||||
```typescript
|
||||
// 1. Verificar que el llamador es platform_admin (igual que invite-user)
|
||||
|
||||
// 2. No permitir auto-revocación
|
||||
if (userId === caller.id) {
|
||||
return new Response(JSON.stringify({ error: 'No puedes revocar tu propio acceso' }), { status: 400 })
|
||||
}
|
||||
|
||||
// 3. Cambiar platform_role a 'guest' (suspensión suave, no elimina datos)
|
||||
const { error } = await adminClient
|
||||
.from('users')
|
||||
.update({ platform_role: 'guest' })
|
||||
.eq('id', userId)
|
||||
|
||||
if (error) {
|
||||
return new Response(JSON.stringify({ error: error.message }), { status: 500 })
|
||||
}
|
||||
|
||||
// 4. Opcionalmente: revocar sesiones activas
|
||||
await adminClient.auth.admin.signOut(userId)
|
||||
|
||||
return new Response(JSON.stringify({ success: true }), { status: 200 })
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Variables de entorno requeridas
|
||||
|
||||
Las Edge Functions de Supabase tienen acceso automático a:
|
||||
- `SUPABASE_URL`
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||
- `SUPABASE_ANON_KEY`
|
||||
|
||||
Agregar manualmente en Supabase Dashboard → Edge Functions → Secrets:
|
||||
- `SITE_URL` = `https://inq-roi.inqualityhq.com`
|
||||
|
||||
Documentar esto en un comentario al tope de cada función.
|
||||
|
||||
---
|
||||
|
||||
## TypeScript — tipado estricto
|
||||
|
||||
```typescript
|
||||
interface InviteUserRequest {
|
||||
email: string
|
||||
name: string
|
||||
platform_role: 'client_editor' | 'client_viewer' | 'member'
|
||||
org_name?: string
|
||||
}
|
||||
|
||||
interface RevokeUserRequest {
|
||||
userId: string
|
||||
}
|
||||
```
|
||||
|
||||
Validar con guardas de tipo antes de proceder. Retornar errores descriptivos en español para consumo interno.
|
||||
|
||||
---
|
||||
|
||||
## Criterios de validación
|
||||
|
||||
- [ ] `supabase/functions/invite-user/index.ts` existe y compila (Deno/TypeScript)
|
||||
- [ ] `supabase/functions/revoke-user/index.ts` existe y compila
|
||||
- [ ] Llamada sin Authorization header → 401
|
||||
- [ ] Llamada con token de usuario no-admin → 403
|
||||
- [ ] Llamada con email ya existente → error descriptivo (no crash)
|
||||
- [ ] Llamada con platform_role inválido → error descriptivo
|
||||
- [ ] `npm run test` → 552+ tests verdes (ningún test nuevo requerido en esta etapa — el E2E de invite requiere despliegue real)
|
||||
- [ ] `npm run build` limpio
|
||||
|
||||
## Documentar en comentario al tope de cada función
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Edge Function: invite-user
|
||||
* Propósito: invitar usuario externo (client_editor/client_viewer) o interno (member)
|
||||
* Solo accesible por platform_admin.
|
||||
* Variables de entorno requeridas: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SITE_URL
|
||||
* Deploy: supabase functions deploy invite-user
|
||||
*/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## NO modificar
|
||||
|
||||
- Ningún archivo de migración (001-015 son historia inmutable)
|
||||
- Ningún archivo TypeScript/React del frontend
|
||||
- Ningún test existente
|
||||
- Las tablas `user_groups`, `group_memberships`, `process_access` y sus policies
|
||||
293
sprints/sprint-7/ETAPA_3_PROMPT.md
Normal file
293
sprints/sprint-7/ETAPA_3_PROMPT.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# Etapa 3 — Capa de auth: fix de rol desde DB + email/password + reset-password
|
||||
|
||||
**Sprint:** 7
|
||||
**Fecha:** 2026-07-06
|
||||
**Alcance:** capa de auth (frontend + router). Sin cambios en Edge Functions ni migraciones.
|
||||
|
||||
---
|
||||
|
||||
## Contexto y problema a resolver
|
||||
|
||||
`SupabaseAuthService.buildAuthUser()` infiere el rol del email: `@inquality.com.py` → `platform_admin`, cualquier otro → `guest`. Esto rompe los nuevos roles: un `client_editor` con email `@banco-solar.com` siempre sería `guest` y no podría acceder a nada.
|
||||
|
||||
El fix: `syncProfileFromDB` (o equivalente) debe leer `platform_role` y `org_id` desde `public.users` y actualizar el estado del usuario. La heurística del email puede sobrevivir como valor inicial optimista, pero el rol real viene de la DB.
|
||||
|
||||
**ANTES de modificar cualquier archivo:** leer el código actual de:
|
||||
- `src/features/auth/SupabaseAuthService.ts` (o donde viva `buildAuthUser`, `syncProfileFromDB`, `signInWithGoogle`)
|
||||
- `src/features/auth/AuthContext.tsx` (o `AuthStore.tsx`)
|
||||
- `src/domain/types.ts` (para ver la interface `AuthUser` actual)
|
||||
- `src/router.tsx` (para entender el patrón de rutas existente)
|
||||
|
||||
Adaptar el fix según lo que realmente existe — los nombres de métodos y archivos pueden diferir del BRIEF.
|
||||
|
||||
---
|
||||
|
||||
## 3a — Fix crítico: leer `platform_role` y `org_id` desde DB
|
||||
|
||||
### Cambios en `src/domain/types.ts`
|
||||
|
||||
Extender `AuthUser` para incluir `orgId`:
|
||||
|
||||
```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 // uuid de la organización del usuario — null para usuarios sin org
|
||||
}
|
||||
```
|
||||
|
||||
Si `AuthUser` ya existe con campos distintos, no eliminar campos existentes — solo agregar `orgId`.
|
||||
|
||||
### Cambios en `SupabaseAuthService` (o donde viva `syncProfileFromDB`)
|
||||
|
||||
Extender el SELECT para incluir `platform_role` y `org_id`:
|
||||
|
||||
```typescript
|
||||
const { data } = await supabase
|
||||
.from('users')
|
||||
.select('name, company, platform_role, org_id')
|
||||
.eq('id', userId)
|
||||
.maybeSingle()
|
||||
|
||||
if (data) {
|
||||
// Retornar o despachar con los nuevos campos
|
||||
return {
|
||||
name: data.name,
|
||||
company: data.company ?? undefined,
|
||||
platformRole: data.platform_role as AuthUser['platformRole'],
|
||||
orgId: data.org_id ?? undefined,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cambios en `AuthContext` (o donde se llame `syncProfileFromDB`)
|
||||
|
||||
Cuando se recibe el resultado de `syncProfileFromDB`, actualizar TODOS los campos incluyendo `platformRole` y `orgId`:
|
||||
|
||||
```typescript
|
||||
setUser(prev => prev
|
||||
? {
|
||||
...prev,
|
||||
name: profile.name || prev.name,
|
||||
company: profile.company,
|
||||
platformRole: profile.platformRole, // ← reemplaza la heurística de email
|
||||
orgId: profile.orgId,
|
||||
}
|
||||
: prev
|
||||
)
|
||||
```
|
||||
|
||||
### Mantener heurística de email como valor inicial (optimista)
|
||||
|
||||
`buildAuthUser()` puede seguir usando el email para un valor inicial mientras se carga el perfil de DB. El `syncProfileFromDB` lo corrige después. Esto evita el flash de "sin acceso" durante la carga inicial.
|
||||
|
||||
---
|
||||
|
||||
## 3b — Agregar `signInWithEmailPassword` a la capa de auth
|
||||
|
||||
### Interface `AuthService` (o equivalente)
|
||||
|
||||
Agregar método:
|
||||
|
||||
```typescript
|
||||
signInWithEmailPassword(email: string, password: string): Promise<{ error: string | null }>
|
||||
```
|
||||
|
||||
### Implementación en `SupabaseAuthService`
|
||||
|
||||
```typescript
|
||||
async signInWithEmailPassword(email: string, password: string): Promise<{ error: string | null }> {
|
||||
const { error } = await supabase.auth.signInWithPassword({ email, password })
|
||||
return { error: error?.message ?? null }
|
||||
}
|
||||
```
|
||||
|
||||
### Exponer en el contexto de auth
|
||||
|
||||
Si hay un hook `useAuth()` o similar, agregar `signInWithEmailPassword` al objeto retornado. Si no, exponerlo directamente desde el contexto.
|
||||
|
||||
---
|
||||
|
||||
## 3c — Guard TOKEN_REFRESHED en `onAuthStateChange`
|
||||
|
||||
Modificar el listener de `onAuthStateChange` para no disparar loading innecesariamente en TOKEN_REFRESHED:
|
||||
|
||||
```typescript
|
||||
supabase.auth.onAuthStateChange(async (event, session) => {
|
||||
if (event === 'TOKEN_REFRESHED' || event === 'USER_UPDATED') {
|
||||
// No cambiar loading ni disparar sync — la sesión ya está activa
|
||||
return
|
||||
}
|
||||
|
||||
if (event === 'SIGNED_IN' || event === 'INITIAL_SESSION') {
|
||||
// ... lógica de login existente
|
||||
} else if (event === 'SIGNED_OUT') {
|
||||
setUser(null)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Nota:** este fix puede resolver el bug de "workspace loading — se queda cargando sin refresh" reportado en Sprint 5 si ese bug se manifestaba al volver a la tab. Verificar si es el caso después de aplicar.
|
||||
|
||||
---
|
||||
|
||||
## 3d — LoginPage: agregar form email/password
|
||||
|
||||
La `LoginPage` actual solo tiene el botón "Continuar con Google". Agregar un formulario debajo:
|
||||
|
||||
### Layout
|
||||
|
||||
```
|
||||
[ Continuar con Google ]
|
||||
|
||||
─────── o ─────────────────
|
||||
|
||||
[ Email ]
|
||||
[ Contraseña ]
|
||||
[ Ingresar ]
|
||||
|
||||
Hint: Los usuarios externos acceden por invitación.
|
||||
Si no recibiste uno, contactá a tu consultor InQuality.
|
||||
```
|
||||
|
||||
### Validación (Zod)
|
||||
|
||||
```typescript
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('Email inválido'),
|
||||
password: z.string().min(1, 'La contraseña es requerida'),
|
||||
})
|
||||
```
|
||||
|
||||
### Comportamiento
|
||||
|
||||
- `type="submit"` en el form, no `type="button"` — para que Enter funcione
|
||||
- Mostrar error inline debajo del form si `signInWithEmailPassword` retorna error (no toast, no alert)
|
||||
- Spinner o estado disabled en el botón mientras se procesa
|
||||
- En caso de error: el mensaje de Supabase en inglés es aceptable, o traducir si es simple ("Invalid login credentials" → "Email o contraseña incorrectos")
|
||||
- No registrar usuarios nuevos — solo login. Sin link "Crear cuenta".
|
||||
|
||||
### Estilo
|
||||
|
||||
Usar los componentes shadcn/ui existentes: `Input`, `Button`, `Label`. El separador "o" puede ser un `<Separator />` con texto superpuesto o simplemente un `<p>` centrado con estilo. No introducir librerías nuevas.
|
||||
|
||||
---
|
||||
|
||||
## 3e — Página `/reset-password` (set-password post-invite)
|
||||
|
||||
Cuando un usuario acepta el magic link de invitación, Supabase redirige a `{SITE_URL}/auth/callback` y luego a una URL con `access_token` en el hash. La app debe capturar esto y llevarlo a una página donde el usuario setea su contraseña.
|
||||
|
||||
### Nueva ruta
|
||||
|
||||
Agregar `/reset-password` en `src/router.tsx`. Esta ruta es pública (no requiere sesión activa).
|
||||
|
||||
### Nuevo componente `src/features/auth/ResetPasswordPage.tsx`
|
||||
|
||||
```typescript
|
||||
// Leer el token del hash de la URL
|
||||
// Supabase lo procesa automáticamente vía onAuthStateChange con event = PASSWORD_RECOVERY
|
||||
|
||||
// El flujo:
|
||||
// 1. El usuario hace click en el link del email
|
||||
// 2. Supabase procesa el token → onAuthStateChange dispara PASSWORD_RECOVERY con sesión
|
||||
// 3. La app detecta PASSWORD_RECOVERY y redirige a /reset-password
|
||||
// 4. ResetPasswordPage muestra el form de nueva contraseña
|
||||
|
||||
// En onAuthStateChange (en AuthContext):
|
||||
if (event === 'PASSWORD_RECOVERY') {
|
||||
navigate({ to: '/reset-password' })
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ResetPasswordPage.tsx
|
||||
const ResetPasswordPage = () => {
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (password !== confirm) {
|
||||
setError('Las contraseñas no coinciden')
|
||||
return
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setError('La contraseña debe tener al menos 8 caracteres')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
const { error } = await supabase.auth.updateUser({ password })
|
||||
if (error) {
|
||||
setError(error.message)
|
||||
setLoading(false)
|
||||
} else {
|
||||
navigate({ to: '/' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
// Form con dos campos: nueva contraseña + confirmar, botón "Establecer contraseña"
|
||||
// Misma estructura visual que LoginPage
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Agregar `PASSWORD_RECOVERY` al guard de `onAuthStateChange`
|
||||
|
||||
```typescript
|
||||
if (event === 'PASSWORD_RECOVERY') {
|
||||
// Sesión activa pero el usuario necesita setear contraseña
|
||||
// Redirigir a /reset-password sin setear el user en estado
|
||||
navigate({ to: '/reset-password' })
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests nuevos (mínimo requerido)
|
||||
|
||||
```typescript
|
||||
// tests/features/auth/auth-role-sync.test.ts
|
||||
// - buildAuthUser con email @inquality.com.py retorna platformRole optimista
|
||||
// - syncProfileFromDB con { platform_role: 'client_editor' } actualiza el estado
|
||||
// - syncProfileFromDB con { platform_role: 'platform_admin' } (DB) sobrescribe heurística de email
|
||||
// - AuthUser con orgId seteado retiene el campo tras sync
|
||||
|
||||
// NO requiere tests E2E en esta etapa — el login real se prueba manualmente
|
||||
// con un usuario invitado en producción
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Criterios de validación
|
||||
|
||||
- [ ] `AuthUser` tiene el campo `orgId?: string`
|
||||
- [ ] `syncProfileFromDB` hace SELECT con `platform_role, org_id`
|
||||
- [ ] Después del sync, `user.platformRole` refleja el valor de DB (no la heurística de email)
|
||||
- [ ] `signInWithEmailPassword` existe en la capa de auth y funciona con Supabase
|
||||
- [ ] `LoginPage` muestra el form email/password separado del botón Google
|
||||
- [ ] `/reset-password` existe como ruta pública, muestra el form de nueva contraseña
|
||||
- [ ] `TOKEN_REFRESHED` en `onAuthStateChange` no dispara `setLoading(true)`
|
||||
- [ ] `PASSWORD_RECOVERY` en `onAuthStateChange` redirige a `/reset-password`
|
||||
- [ ] `npm run test` → 552+ tests verdes (más los nuevos de auth-role-sync)
|
||||
- [ ] `npm run build` limpio
|
||||
|
||||
---
|
||||
|
||||
## NO modificar
|
||||
|
||||
- Edge Functions (supabase/functions/)
|
||||
- Archivos de migración (001-015)
|
||||
- WorkspacePage, ReportPage, LibraryPage — los cambios de UI de roles van en Etapa 5
|
||||
- Panel de admin — va en Etapa 4
|
||||
- Lógica de simulación, dominio, y cálculo de ROI
|
||||
288
sprints/sprint-7/ETAPA_4_PROMPT.md
Normal file
288
sprints/sprint-7/ETAPA_4_PROMPT.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# Etapa 4 — Panel de administración de InQuality
|
||||
|
||||
**Sprint:** 7
|
||||
**Fecha:** 2026-07-06
|
||||
**Alcance:** nuevas rutas `/admin/*` con guard de rol + componentes de admin. Sin cambios en migraciones, Edge Functions, WorkspacePage, ReportPage, o LibraryPage.
|
||||
|
||||
---
|
||||
|
||||
## Contexto
|
||||
|
||||
Las Edge Functions `invite-user` y `revoke-user` ya están desplegadas (Etapa 2). Esta etapa construye la UI que las consume. El acceso es exclusivo para `platform_admin`.
|
||||
|
||||
**ANTES de empezar:** leer `src/router.tsx` para entender el patrón de rutas y guards existentes (`beforeLoad`). La estructura de archivos debe seguir el patrón de `features/` ya establecido.
|
||||
|
||||
---
|
||||
|
||||
## Nuevas rutas
|
||||
|
||||
```
|
||||
/admin → redirect a /admin/organizations
|
||||
/admin/organizations → lista de orgs clientes
|
||||
/admin/organizations/$orgId → detalle de org (procesos + usuarios)
|
||||
/admin/users → lista de todos los usuarios
|
||||
```
|
||||
|
||||
Todas requieren `platform_role === 'platform_admin'` — si no, redirect a `/`.
|
||||
|
||||
### Guard en TanStack Router (patrón `beforeLoad`)
|
||||
|
||||
```typescript
|
||||
// Crear layout route para /admin que incluya el guard:
|
||||
beforeLoad: ({ context }) => {
|
||||
const user = context.auth?.user
|
||||
if (!user || user.platformRole !== 'platform_admin') {
|
||||
throw redirect({ to: '/' })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Layout de admin
|
||||
|
||||
**No crear un layout nuevo de toda la app.** Usar el `AppHeader` existente. Las páginas de admin son pages normales con su propia estructura interna.
|
||||
|
||||
Estructura interna sugerida para cada page de admin:
|
||||
|
||||
```
|
||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||
{/* Nav tabs entre Organizations y Users */}
|
||||
<div className="flex gap-4 border-b mb-6">
|
||||
<NavLink to="/admin/organizations">Organizaciones</NavLink>
|
||||
<NavLink to="/admin/users">Usuarios</NavLink>
|
||||
</div>
|
||||
{/* Contenido de la page */}
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ícono admin en AppHeader
|
||||
|
||||
Agregar un ícono `Shield` (Lucide) visible solo cuando `user.platformRole === 'platform_admin'`. Posición: junto al avatar/dropdown, a la izquierda.
|
||||
|
||||
```tsx
|
||||
{user.platformRole === 'platform_admin' && (
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link to="/admin">
|
||||
<Shield className="h-5 w-5 text-muted-foreground" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Página Organizations (`/admin/organizations`)
|
||||
|
||||
### Lista de organizaciones (clientes únicamente — `is_provider = false`)
|
||||
|
||||
Query:
|
||||
```typescript
|
||||
const { data: orgs } = await supabase
|
||||
.from('organizations')
|
||||
.select(`
|
||||
id, name, created_at,
|
||||
processes:processes(count),
|
||||
users:users(count)
|
||||
`)
|
||||
.eq('is_provider', false)
|
||||
.order('name')
|
||||
```
|
||||
|
||||
Columnas de la tabla:
|
||||
| Organización | Procesos | Usuarios | Creada | Acciones |
|
||||
|---|---|---|---|---|
|
||||
| Banco Solar | 3 | 2 | 12 jun 2026 | Ver detalle |
|
||||
|
||||
### Crear organización
|
||||
|
||||
Botón "Nueva organización" → Sheet (no modal) con:
|
||||
- Campo `Nombre` (requerido, trim, no vacío)
|
||||
- Botón "Crear" → `supabase.from('organizations').insert({ name, is_provider: false })`
|
||||
- Al crear: cerrar sheet + refetch lista
|
||||
|
||||
### Detalle de organización (`/admin/organizations/$orgId`)
|
||||
|
||||
Dos secciones en tabs o acordeón:
|
||||
|
||||
**Procesos de esta org:**
|
||||
```typescript
|
||||
const { data: processes } = await supabase
|
||||
.from('processes')
|
||||
.select('id, name, client, updated_at')
|
||||
.eq('org_id', orgId)
|
||||
.order('updated_at', { ascending: false })
|
||||
```
|
||||
|
||||
Mostrar como lista simple: nombre del proceso, fecha de última actualización, link al workspace.
|
||||
|
||||
Botón "Asignar proceso existente" → Sheet con dropdown de procesos sin org asignada (`org_id IS NULL`) + botón asignar:
|
||||
```typescript
|
||||
await supabase
|
||||
.from('processes')
|
||||
.update({ org_id: orgId })
|
||||
.eq('id', selectedProcessId)
|
||||
```
|
||||
|
||||
**Usuarios de esta org:**
|
||||
```typescript
|
||||
const { data: users } = await supabase
|
||||
.from('users')
|
||||
.select('id, name, company, platform_role')
|
||||
.eq('org_id', orgId)
|
||||
.order('name')
|
||||
```
|
||||
|
||||
Mostrar como lista: nombre, rol, acciones (ver Usuarios page para acciones).
|
||||
|
||||
---
|
||||
|
||||
## Página Users (`/admin/users`)
|
||||
|
||||
### Lista de usuarios
|
||||
|
||||
```typescript
|
||||
const { data: users } = await supabase
|
||||
.from('users')
|
||||
.select(`
|
||||
id, name, company, platform_role, org_id,
|
||||
organization:organizations(name, is_provider)
|
||||
`)
|
||||
.order('name')
|
||||
```
|
||||
|
||||
Columnas:
|
||||
| Nombre | Email* | Rol | Organización | Estado | Acciones |
|
||||
|---|---|---|---|---|---|
|
||||
|
||||
*Note: `email` está en `auth.users`, no en `public.users`. Para esta etapa, omitir el email en la lista o usar `name` únicamente. El email se puede agregar en sprint futuro via Edge Function o join con `auth.users` (solo disponible con service_role).
|
||||
|
||||
**Badges de rol con color:**
|
||||
- `platform_admin` → badge naranja (`#F59845`)
|
||||
- `member` → badge slate
|
||||
- `client_editor` → badge azul
|
||||
- `client_viewer` → badge verde claro
|
||||
- `guest` → badge gris, con indicador visual de "suspendido"
|
||||
|
||||
### Filtro por organización
|
||||
|
||||
Dropdown "Filtrar por org" encima de la tabla. Opción "Todas". Al seleccionar una org, filtra con `.eq('org_id', selectedOrgId)`.
|
||||
|
||||
### Invitar usuario
|
||||
|
||||
Botón "Invitar usuario" → Sheet con campos:
|
||||
|
||||
```
|
||||
Nombre completo: [__________________]
|
||||
Email: [__________________]
|
||||
Rol: [client_editor ▾] (opciones: client_editor, client_viewer, member)
|
||||
Organización: [Banco Solar ▾] (solo si rol es client_editor o client_viewer)
|
||||
```
|
||||
|
||||
Al submit → llamar Edge Function:
|
||||
|
||||
```typescript
|
||||
const { data, error } = await supabase.functions.invoke('invite-user', {
|
||||
body: {
|
||||
email,
|
||||
name,
|
||||
platform_role: rol,
|
||||
org_name: selectedOrg?.name ?? '',
|
||||
}
|
||||
})
|
||||
|
||||
if (error) {
|
||||
// mostrar error inline
|
||||
} else {
|
||||
// "Invitación enviada a {email}" — toast o inline
|
||||
// cerrar sheet + refetch lista
|
||||
}
|
||||
```
|
||||
|
||||
**Nota:** la Edge Function `invite-user` recibe `org_name` (string), no `org_id`. Busca o crea la org internamente.
|
||||
|
||||
### Acciones por usuario (menú de 3 puntos o dropdown)
|
||||
|
||||
```
|
||||
[ Cambiar rol ] → Sheet con dropdown de roles + botón guardar
|
||||
→ supabase.from('users').update({ platform_role: nuevoRol }).eq('id', userId)
|
||||
|
||||
[ Suspender ] → cambia platform_role a 'guest' vía Edge Function revoke-user
|
||||
Texto de confirmación: "¿Suspender a {nombre}? No podrá acceder a la plataforma."
|
||||
|
||||
[ Reactivar ] → visible solo si platform_role === 'guest'
|
||||
→ supabase.from('users').update({ platform_role: 'client_editor' }).eq('id', userId)
|
||||
IMPORTANTE: al reactivar, restaurar al rol anterior. Como no lo tenemos guardado,
|
||||
restaurar a 'client_editor' como default. Documentar esto como limitación.
|
||||
|
||||
[ Eliminar ] → confirmación con texto del nombre del usuario
|
||||
→ supabase.functions.invoke('revoke-user', { body: { userId } })
|
||||
Nota: la función actual baja el rol a 'guest'. Eliminar real queda para sprint futuro.
|
||||
Mostrar al admin: "Usuario suspendido permanentemente (eliminación real en versión futura)".
|
||||
```
|
||||
|
||||
**No permitir acciones sobre el usuario propio del admin** — deshabilitar las acciones si `user.id === item.id`.
|
||||
|
||||
---
|
||||
|
||||
## Versión wow (requerida si costo < 40%)
|
||||
|
||||
- Loading skeletons en la tabla mientras carga (usar `Skeleton` de shadcn)
|
||||
- Empty state ilustrado: cuando no hay usuarios en la org, mostrar texto + icono, no tabla vacía
|
||||
- Confirmación de acciones destructivas con `AlertDialog` de shadcn (no `window.confirm`)
|
||||
- Badge de estado en la fila del usuario invitado recientemente: "Invitación pendiente" si el usuario no tiene `name` o `avatar_url` (indicador de que no aceptó el invite aún)
|
||||
- En el sheet de invite: deshabilitar el campo "Organización" cuando el rol es `member`, mostrar "InQuality" como valor fijo
|
||||
|
||||
---
|
||||
|
||||
## Archivos a crear
|
||||
|
||||
```
|
||||
src/features/admin/
|
||||
├── AdminLayout.tsx ← nav tabs, guard visual
|
||||
├── OrganizationsPage.tsx ← lista + crear
|
||||
├── OrganizationDetailPage.tsx ← procesos + usuarios de la org
|
||||
└── UsersPage.tsx ← lista + invite + acciones
|
||||
```
|
||||
|
||||
Agregar en `src/router.tsx` las 4 rutas con `beforeLoad` de guard.
|
||||
|
||||
---
|
||||
|
||||
## Criterios de validación
|
||||
|
||||
- [ ] Navegar a `/admin` sin ser `platform_admin` redirige a `/`
|
||||
- [ ] `platform_admin` ve el ícono Shield en AppHeader → navega a `/admin`
|
||||
- [ ] Lista de organizaciones muestra orgs clientes (no InQuality)
|
||||
- [ ] Crear organización → aparece en la lista sin recargar la página
|
||||
- [ ] Detalle de org muestra procesos y usuarios de esa org
|
||||
- [ ] Asignar proceso a org → proceso aparece en el detalle
|
||||
- [ ] Lista de usuarios muestra todos con badge de rol correcto
|
||||
- [ ] Filtro por org funciona
|
||||
- [ ] Invitar usuario llama la Edge Function y muestra confirmación
|
||||
- [ ] Suspender usuario: confirmación con AlertDialog, cambio a 'guest' efectivo
|
||||
- [ ] No se puede ejecutar ninguna acción sobre el propio usuario admin
|
||||
- [ ] Loading skeletons visibles durante fetch inicial
|
||||
- [ ] `npm run test` → 558+ tests verdes
|
||||
- [ ] `npm run build` limpio
|
||||
- [ ] **Validación visual del director antes del OK formal**
|
||||
|
||||
---
|
||||
|
||||
## NO modificar
|
||||
|
||||
- `WorkspacePage`, `ReportPage`, `LibraryPage`
|
||||
- Migraciones (001-015)
|
||||
- Edge Functions
|
||||
- Lógica de dominio, simulación, ROI
|
||||
- Componentes de PDF/CSV
|
||||
|
||||
---
|
||||
|
||||
## Limitaciones conocidas a documentar en comentarios
|
||||
|
||||
1. `email` no disponible en `public.users` — la columna vive en `auth.users` que requiere service_role. Mostrar solo `name` por ahora.
|
||||
2. "Eliminar" usuario usa `revoke-user` (baja a 'guest'). La eliminación real requiere una Edge Function `delete-user` futura.
|
||||
3. "Reactivar" restaura siempre a `client_editor` — no se guarda el rol previo a la suspensión.
|
||||
Reference in New Issue
Block a user