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.
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react'
|
import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react'
|
||||||
import { SupabaseAuthService } from './SupabaseAuthService'
|
import { SupabaseAuthService } from './SupabaseAuthService'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
import type { AuthUser } from './types'
|
import type { AuthUser } from './types'
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
user: AuthUser | null
|
user: AuthUser | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
signIn: () => Promise<void>
|
signIn: () => Promise<void>
|
||||||
|
signInWithEmailPassword: (email: string, password: string) => Promise<{ error: string | null }>
|
||||||
signOut: () => Promise<void>
|
signOut: () => Promise<void>
|
||||||
updateUser: (updates: Partial<Pick<AuthUser, 'name' | 'company'>>) => void
|
updateUser: (updates: Partial<Pick<AuthUser, 'name' | 'company'>>) => void
|
||||||
|
pendingPasswordReset: boolean
|
||||||
|
clearPasswordReset: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | null>(null)
|
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
@@ -60,6 +64,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
)
|
)
|
||||||
// Si es un OAuth redirect, mantener loading=true hasta que Supabase procese el token.
|
// Si es un OAuth redirect, mantener loading=true hasta que Supabase procese el token.
|
||||||
const [loading, setLoading] = useState(isOAuthRedirect)
|
const [loading, setLoading] = useState(isOAuthRedirect)
|
||||||
|
const [pendingPasswordReset, setPendingPasswordReset] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Verificar y sincronizar con Supabase en background.
|
// Verificar y sincronizar con Supabase en background.
|
||||||
@@ -72,6 +77,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => unsubscribe()
|
return () => unsubscribe()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Suscripción directa para PASSWORD_RECOVERY (no pasa por authService para evitar
|
||||||
|
// mezclar el flujo normal con el de reset de contraseña).
|
||||||
|
useEffect(() => {
|
||||||
|
const { data: { subscription } } = supabase.auth.onAuthStateChange((event) => {
|
||||||
|
if (event === 'PASSWORD_RECOVERY') {
|
||||||
|
setPendingPasswordReset(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => subscription.unsubscribe()
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Sincronización con tabla users FUERA del lock de auth.
|
// Sincronización con tabla users FUERA del lock de auth.
|
||||||
// Se ejecuta al cambiar de usuario (login/logout), NO en cada updateUser().
|
// Se ejecuta al cambiar de usuario (login/logout), NO en cada updateUser().
|
||||||
// user?.id es primitivo estable: no cambia cuando se edita nombre o empresa.
|
// user?.id es primitivo estable: no cambia cuando se edita nombre o empresa.
|
||||||
@@ -90,12 +106,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
// Upsert idempotente: crea la fila si no existe (primer login),
|
// Upsert idempotente: crea la fila si no existe (primer login),
|
||||||
// ignoreDuplicates evita sobreescribir name/company editados posteriormente.
|
// ignoreDuplicates evita sobreescribir name/company editados posteriormente.
|
||||||
await authService.upsertUserOnLogin(capturedUser, controller.signal)
|
await authService.upsertUserOnLogin(capturedUser, controller.signal)
|
||||||
// Leer company y name editado desde la tabla (pueden diferir del user_metadata de Google).
|
// Leer platform_role, org_id, name y company desde la tabla (sobrescriben la heurística de email).
|
||||||
const profile = await authService.syncProfileFromDB(userId, controller.signal)
|
const profile = await authService.syncProfileFromDB(userId, controller.signal)
|
||||||
if (profile) {
|
if (profile) {
|
||||||
setUser((prev) =>
|
setUser((prev) =>
|
||||||
prev?.id === userId
|
prev?.id === userId
|
||||||
? { ...prev, company: profile.company, name: profile.name || prev.name }
|
? {
|
||||||
|
...prev,
|
||||||
|
name: profile.name || prev.name,
|
||||||
|
company: profile.company,
|
||||||
|
platformRole: profile.platformRole,
|
||||||
|
orgId: profile.orgId,
|
||||||
|
}
|
||||||
: prev
|
: prev
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -115,17 +137,34 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const signIn = useCallback(() => authService.signInWithGoogle(), [])
|
const signIn = useCallback(() => authService.signInWithGoogle(), [])
|
||||||
|
|
||||||
|
const signInWithEmailPassword = useCallback(
|
||||||
|
(email: string, password: string) => authService.signInWithEmailPassword(email, password),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
const signOut = useCallback(async () => {
|
const signOut = useCallback(async () => {
|
||||||
await authService.signOut()
|
await authService.signOut()
|
||||||
setUser(null)
|
setUser(null)
|
||||||
|
setPendingPasswordReset(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const updateUser = useCallback((updates: Partial<Pick<AuthUser, 'name' | 'company'>>) => {
|
const updateUser = useCallback((updates: Partial<Pick<AuthUser, 'name' | 'company'>>) => {
|
||||||
setUser((prev) => prev ? { ...prev, ...updates } : prev)
|
setUser((prev) => prev ? { ...prev, ...updates } : prev)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const clearPasswordReset = useCallback(() => setPendingPasswordReset(false), [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, signIn, signOut, updateUser }}>
|
<AuthContext.Provider value={{
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
signIn,
|
||||||
|
signInWithEmailPassword,
|
||||||
|
signOut,
|
||||||
|
updateUser,
|
||||||
|
pendingPasswordReset,
|
||||||
|
clearPasswordReset,
|
||||||
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ export class SupabaseAuthService implements AuthService {
|
|||||||
if (error) throw error
|
if (error) throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async signInWithEmailPassword(email: string, password: string): Promise<{ error: string | null }> {
|
||||||
|
const { error } = await supabase.auth.signInWithPassword({ email, password })
|
||||||
|
return { error: error?.message ?? null }
|
||||||
|
}
|
||||||
|
|
||||||
async signOut(): Promise<void> {
|
async signOut(): Promise<void> {
|
||||||
const { error } = await supabase.auth.signOut()
|
const { error } = await supabase.auth.signOut()
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
@@ -34,6 +39,8 @@ export class SupabaseAuthService implements AuthService {
|
|||||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||||
(event, session) => { // NO async — el lock de GoTrueClient está activo durante este callback
|
(event, session) => { // NO async — el lock de GoTrueClient está activo durante este callback
|
||||||
try {
|
try {
|
||||||
|
// Token refresh silencioso — no disparar re-sync ni setLoading
|
||||||
|
if (event === 'TOKEN_REFRESHED' || event === 'USER_UPDATED') return
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
// INITIAL_SESSION null es transitorio: Supabase no pudo verificar la sesión
|
// INITIAL_SESSION null es transitorio: Supabase no pudo verificar la sesión
|
||||||
// (red lenta, cold start, timeout). Si hay sesión en localStorage, no forzar logout.
|
// (red lenta, cold start, timeout). Si hay sesión en localStorage, no forzar logout.
|
||||||
@@ -41,10 +48,12 @@ export class SupabaseAuthService implements AuthService {
|
|||||||
if (event !== 'INITIAL_SESSION') callback(null)
|
if (event !== 'INITIAL_SESSION') callback(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event !== 'INITIAL_SESSION' && event !== 'SIGNED_IN') return
|
// PASSWORD_RECOVERY tiene sesión activa — incluir para que AuthContext establezca el usuario
|
||||||
|
// antes de redirigir a /reset-password. La señal de redirect se maneja en AuthContext.
|
||||||
|
if (event !== 'INITIAL_SESSION' && event !== 'SIGNED_IN' && event !== 'PASSWORD_RECOVERY') return
|
||||||
// Llamar callback inmediatamente con datos básicos del token OAuth.
|
// Llamar callback inmediatamente con datos básicos del token OAuth.
|
||||||
// NO hacer queries a Supabase desde aquí — el lock de auth está activo (GoTrueClient.js L3213).
|
// NO hacer queries a Supabase desde aquí — el lock de auth está activo (GoTrueClient.js L3213).
|
||||||
// El upsert y la lectura de company/name ocurren en AuthContext fuera del lock.
|
// El upsert y la lectura de platform_role/org_id ocurren en AuthContext fuera del lock.
|
||||||
callback(this.buildAuthUser(session.user))
|
callback(this.buildAuthUser(session.user))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error en onAuthStateChange:', err)
|
console.error('Error en onAuthStateChange:', err)
|
||||||
@@ -57,16 +66,19 @@ export class SupabaseAuthService implements AuthService {
|
|||||||
return () => subscription.unsubscribe()
|
return () => subscription.unsubscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Leer company y name editados desde la tabla users (pueden diferir del user_metadata de Google).
|
// Leer platform_role, org_id, company y name desde la tabla users.
|
||||||
|
// Sobrescribe la heurística de email de buildAuthUser con el valor real de DB.
|
||||||
// Se llama desde AuthContext FUERA del lock de auth, en un useEffect normal.
|
// Se llama desde AuthContext FUERA del lock de auth, en un useEffect normal.
|
||||||
// signal permite cancelar si el servidor tarda demasiado (libera el processLock).
|
// signal permite cancelar si el servidor tarda demasiado (libera el processLock).
|
||||||
async syncProfileFromDB(userId: string, signal?: AbortSignal): Promise<Pick<AuthUser, 'name' | 'company'> | null> {
|
async syncProfileFromDB(userId: string, signal?: AbortSignal): Promise<Pick<AuthUser, 'name' | 'company' | 'platformRole' | 'orgId'> | null> {
|
||||||
const base = supabase.from('users').select('company, name').eq('id', userId)
|
const base = supabase.from('users').select('name, company, platform_role, org_id').eq('id', userId)
|
||||||
const { data } = await (signal ? base.abortSignal(signal) : base).maybeSingle()
|
const { data } = await (signal ? base.abortSignal(signal) : base).maybeSingle()
|
||||||
if (!data) return null
|
if (!data) return null
|
||||||
return {
|
return {
|
||||||
name: ((data.name as string | null | undefined) || undefined) as unknown as string,
|
name: ((data.name as string | null | undefined) || undefined) as unknown as string,
|
||||||
company: (data.company as string | null) ?? undefined,
|
company: (data.company as string | null) ?? undefined,
|
||||||
|
platformRole: (data.platform_role as AuthUser['platformRole']) ?? 'guest',
|
||||||
|
orgId: (data.org_id as string | null) ?? undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +100,7 @@ export class SupabaseAuthService implements AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sincrónico: deriva el usuario de los metadatos de sesión sin consultar la DB.
|
// Sincrónico: deriva el usuario de los metadatos de sesión sin consultar la DB.
|
||||||
// El rol se deriva del dominio del email (misma lógica que el upsert en SIGNED_IN).
|
// El rol se deriva del dominio del email (valor optimista, syncProfileFromDB lo corrige después).
|
||||||
private buildAuthUser(supabaseUser: { id: string; email?: string; user_metadata?: Record<string, unknown> }): AuthUser {
|
private buildAuthUser(supabaseUser: { id: string; email?: string; user_metadata?: Record<string, unknown> }): AuthUser {
|
||||||
const email = supabaseUser.email ?? ''
|
const email = supabaseUser.email ?? ''
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ export interface AuthUser {
|
|||||||
email: string
|
email: string
|
||||||
name: string
|
name: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
platformRole: 'platform_admin' | 'member' | 'guest'
|
platformRole: 'platform_admin' | 'member' | 'client_editor' | 'client_viewer' | 'guest'
|
||||||
company?: string
|
company?: string
|
||||||
|
orgId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthService {
|
export interface AuthService {
|
||||||
signInWithGoogle(): Promise<void>
|
signInWithGoogle(): Promise<void>
|
||||||
|
signInWithEmailPassword(email: string, password: string): Promise<{ error: string | null }>
|
||||||
signOut(): Promise<void>
|
signOut(): Promise<void>
|
||||||
getCurrentUser(): Promise<AuthUser | null>
|
getCurrentUser(): Promise<AuthUser | null>
|
||||||
onAuthStateChange(callback: (user: AuthUser | null) => void): () => void
|
onAuthStateChange(callback: (user: AuthUser | null) => void): () => void
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link } from '@tanstack/react-router'
|
import { Link } from '@tanstack/react-router'
|
||||||
import { LogOut, UserPen } from 'lucide-react'
|
import { LogOut, UserPen, Shield } from 'lucide-react'
|
||||||
import { useAuth } from '@/auth/AuthContext'
|
import { useAuth } from '@/auth/AuthContext'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -77,6 +77,20 @@ export function AppHeader() {
|
|||||||
>
|
>
|
||||||
InQ ROI
|
InQ ROI
|
||||||
</Link>
|
</Link>
|
||||||
|
{user?.platformRole === 'platform_admin' && (
|
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
title="Panel de administración"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
color: 'white',
|
||||||
|
opacity: 0.85,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Shield style={{ width: 18, height: 18 }} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
{user && (
|
{user && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
|
|||||||
13
src/components/ui/skeleton.tsx
Normal file
13
src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('animate-pulse rounded-md bg-slate-200', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
70
src/features/admin/AdminLayout.tsx
Normal file
70
src/features/admin/AdminLayout.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
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 { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function AdminLayout() {
|
||||||
|
const { user, loading } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen flex flex-col bg-slate-50">
|
||||||
|
<AppHeader />
|
||||||
|
<div className="flex-1 max-w-6xl mx-auto w-full px-6 py-8">
|
||||||
|
<nav className="flex gap-1 border-b border-slate-200 mb-6">
|
||||||
|
<AdminNavTab to="/admin/organizations" active={pathname.startsWith('/admin/organizations')}>
|
||||||
|
Organizaciones
|
||||||
|
</AdminNavTab>
|
||||||
|
<AdminNavTab to="/admin/users" active={pathname.startsWith('/admin/users')}>
|
||||||
|
Usuarios
|
||||||
|
</AdminNavTab>
|
||||||
|
</nav>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminNavTab({
|
||||||
|
to,
|
||||||
|
active,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
to: string
|
||||||
|
active: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||||
|
active
|
||||||
|
? 'border-[#F59845] text-[#F59845]'
|
||||||
|
: 'border-transparent text-slate-600 hover:text-slate-900'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
291
src/features/admin/OrganizationDetailPage.tsx
Normal file
291
src/features/admin/OrganizationDetailPage.tsx
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useParams, useNavigate, Link } from '@tanstack/react-router'
|
||||||
|
import { ArrowLeft, FileText, Users, FolderX, UserX, GitBranch } from 'lucide-react'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetFooter,
|
||||||
|
} from '@/components/ui/sheet'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import { RoleBadge } from './RoleBadge'
|
||||||
|
|
||||||
|
interface OrgProcess {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
client: string | null
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrgUser {
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
company: string | null
|
||||||
|
platform_role: string
|
||||||
|
avatar_url?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UnassignedProcess {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString('es-PY', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrganizationDetailPage() {
|
||||||
|
const { orgId } = useParams({ from: '/admin/organizations/$orgId' })
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [orgName, setOrgName] = useState<string>('')
|
||||||
|
const [processes, setProcesses] = useState<OrgProcess[]>([])
|
||||||
|
const [users, setUsers] = useState<OrgUser[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
// Asignar proceso
|
||||||
|
const [assignOpen, setAssignOpen] = useState(false)
|
||||||
|
const [unassigned, setUnassigned] = useState<UnassignedProcess[]>([])
|
||||||
|
const [loadingUnassigned, setLoadingUnassigned] = useState(false)
|
||||||
|
const [selectedProcessId, setSelectedProcessId] = useState<string>('')
|
||||||
|
const [assigning, setAssigning] = useState(false)
|
||||||
|
const [assignError, setAssignError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const fetchDetail = async () => {
|
||||||
|
const [orgRes, procRes, userRes] = await Promise.all([
|
||||||
|
supabase.from('organizations').select('name').eq('id', orgId).single(),
|
||||||
|
supabase.from('processes').select('id, name, client, updated_at').eq('org_id', orgId).order('updated_at', { ascending: false }),
|
||||||
|
supabase.from('users').select('id, name, company, platform_role, avatar_url').eq('org_id', orgId).order('name'),
|
||||||
|
])
|
||||||
|
if (orgRes.data) setOrgName((orgRes.data as { name: string }).name)
|
||||||
|
setProcesses((procRes.data as OrgProcess[] | null) ?? [])
|
||||||
|
setUsers((userRes.data as OrgUser[] | null) ?? [])
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { void fetchDetail() }, [orgId])
|
||||||
|
|
||||||
|
const openAssign = async () => {
|
||||||
|
setSelectedProcessId('')
|
||||||
|
setAssignError(null)
|
||||||
|
setAssignOpen(true)
|
||||||
|
setLoadingUnassigned(true)
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('processes')
|
||||||
|
.select('id, name')
|
||||||
|
.is('org_id', null)
|
||||||
|
.order('name')
|
||||||
|
setUnassigned((data as UnassignedProcess[] | null) ?? [])
|
||||||
|
setLoadingUnassigned(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAssign = async () => {
|
||||||
|
if (!selectedProcessId) {
|
||||||
|
setAssignError('Seleccioná un proceso')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAssigning(true)
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('processes')
|
||||||
|
.update({ org_id: orgId })
|
||||||
|
.eq('id', selectedProcessId)
|
||||||
|
if (error) {
|
||||||
|
setAssignError(error.message)
|
||||||
|
setAssigning(false)
|
||||||
|
} else {
|
||||||
|
setAssignOpen(false)
|
||||||
|
setAssigning(false)
|
||||||
|
void fetchDetail()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Skeleton className="h-6 w-48" />
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate({ to: '/admin/organizations' })}
|
||||||
|
className="text-slate-500 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||||
|
Volver
|
||||||
|
</Button>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">{orgName}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="processes">
|
||||||
|
<TabsList className="mb-4">
|
||||||
|
<TabsTrigger value="processes">
|
||||||
|
<FileText className="h-4 w-4 mr-1.5" />
|
||||||
|
Procesos ({processes.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="users">
|
||||||
|
<Users className="h-4 w-4 mr-1.5" />
|
||||||
|
Usuarios ({users.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* ─── Tab: Procesos ─────────────────────────────────────────────── */}
|
||||||
|
<TabsContent value="processes">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{processes.length === 0
|
||||||
|
? 'No hay procesos asignados a esta organización.'
|
||||||
|
: `${processes.length} proceso${processes.length !== 1 ? 's' : ''} asignado${processes.length !== 1 ? 's' : ''}.`}
|
||||||
|
</p>
|
||||||
|
<Button size="sm" variant="outline" onClick={openAssign}>
|
||||||
|
<GitBranch className="h-4 w-4 mr-1.5" />
|
||||||
|
Asignar proceso existente
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{processes.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<FolderX className="h-10 w-10 text-slate-300" />}
|
||||||
|
message="Esta organización no tiene procesos asignados. Podés asignar uno existente o importar uno nuevo."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-slate-200 overflow-hidden bg-white">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-50 border-b border-slate-200">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Proceso</th>
|
||||||
|
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Última actualización</th>
|
||||||
|
<th className="py-2.5 px-4" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{processes.map((p) => (
|
||||||
|
<tr key={p.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/50">
|
||||||
|
<td className="py-3 px-4 font-medium text-slate-900">{p.name}</td>
|
||||||
|
<td className="py-3 px-4 text-slate-500">{formatDate(p.updated_at)}</td>
|
||||||
|
<td className="py-3 px-4 text-right">
|
||||||
|
<Link
|
||||||
|
to="/workspace/$processId"
|
||||||
|
params={{ processId: p.id }}
|
||||||
|
className="text-xs font-semibold text-[#F59845] hover:text-[#e0872e]"
|
||||||
|
>
|
||||||
|
Abrir workspace →
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ─── Tab: Usuarios ─────────────────────────────────────────────── */}
|
||||||
|
<TabsContent value="users">
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<UserX className="h-10 w-10 text-slate-300" />}
|
||||||
|
message="No hay usuarios en esta organización. Invitá usuarios desde la sección Usuarios."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-slate-200 overflow-hidden bg-white">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-50 border-b border-slate-200">
|
||||||
|
<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">Rol</th>
|
||||||
|
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Empresa</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((u) => (
|
||||||
|
<tr key={u.id} className="border-b border-slate-100 last:border-0">
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<span className="font-medium text-slate-900">{u.name || '—'}</span>
|
||||||
|
{!u.name && !u.avatar_url && (
|
||||||
|
<span className="ml-2 text-[10px] font-medium px-1.5 py-0.5 rounded bg-amber-50 text-amber-700 border border-amber-200">
|
||||||
|
Invitación pendiente
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<RoleBadge role={u.platform_role} />
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-slate-500">{u.company ?? '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* ─── Sheet: Asignar proceso ─────────────────────────────────────── */}
|
||||||
|
<Sheet open={assignOpen} onOpenChange={setAssignOpen}>
|
||||||
|
<SheetContent>
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Asignar proceso a {orgName}</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="mt-5 flex flex-col gap-4">
|
||||||
|
{loadingUnassigned ? (
|
||||||
|
<Skeleton className="h-9 w-full" />
|
||||||
|
) : unassigned.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">No hay procesos sin organización asignada.</p>
|
||||||
|
) : (
|
||||||
|
<Select value={selectedProcessId} onValueChange={setSelectedProcessId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Seleccioná un proceso" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{unassigned.map((p) => (
|
||||||
|
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
{assignError && <p className="text-xs text-red-600">{assignError}</p>}
|
||||||
|
<SheetFooter>
|
||||||
|
<Button
|
||||||
|
onClick={handleAssign}
|
||||||
|
disabled={assigning || loadingUnassigned || unassigned.length === 0}
|
||||||
|
>
|
||||||
|
{assigning ? 'Asignando…' : 'Asignar proceso'}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ icon, message }: { icon: React.ReactNode; message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
||||||
|
{icon}
|
||||||
|
<p className="text-sm text-slate-500 max-w-sm">{message}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
196
src/features/admin/OrganizationsPage.tsx
Normal file
196
src/features/admin/OrganizationsPage.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { Building2, Plus } from 'lucide-react'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetFooter,
|
||||||
|
} from '@/components/ui/sheet'
|
||||||
|
|
||||||
|
interface OrgRow {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
created_at: string
|
||||||
|
processes: [{ count: number }]
|
||||||
|
users: [{ count: number }]
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString('es-PY', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrganizationsPage() {
|
||||||
|
const [orgs, setOrgs] = useState<OrgRow[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [orgName, setOrgName] = useState('')
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
||||||
|
const fetchOrgs = async () => {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('organizations')
|
||||||
|
.select('id, name, created_at, processes:processes(count), users:users(count)')
|
||||||
|
.eq('is_provider', false)
|
||||||
|
.order('name')
|
||||||
|
setOrgs((data as OrgRow[] | null) ?? [])
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { void fetchOrgs() }, [])
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setOrgName('')
|
||||||
|
setCreateError(null)
|
||||||
|
setCreateOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const trimmed = orgName.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
setCreateError('El nombre es requerido')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCreating(true)
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('organizations')
|
||||||
|
.insert({ name: trimmed, is_provider: false })
|
||||||
|
if (error) {
|
||||||
|
setCreateError(
|
||||||
|
error.message.toLowerCase().includes('duplicate') || error.message.toLowerCase().includes('unique')
|
||||||
|
? 'Ya existe una organización con ese nombre'
|
||||||
|
: error.message
|
||||||
|
)
|
||||||
|
setCreating(false)
|
||||||
|
} else {
|
||||||
|
setCreateOpen(false)
|
||||||
|
setCreating(false)
|
||||||
|
void fetchOrgs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">Organizaciones clientes</h2>
|
||||||
|
<Button size="sm" onClick={openCreate}>
|
||||||
|
<Plus className="h-4 w-4 mr-1.5" />
|
||||||
|
Nueva organización
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<TableSkeleton rows={5} />
|
||||||
|
) : orgs.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Building2 className="h-10 w-10 text-slate-300" />}
|
||||||
|
message="No hay organizaciones clientes aún. Creá la primera con el botón de arriba."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-slate-200 overflow-hidden bg-white">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-50 border-b border-slate-200">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Organización</th>
|
||||||
|
<th className="text-right py-2.5 px-4 font-medium text-slate-600">Procesos</th>
|
||||||
|
<th className="text-right py-2.5 px-4 font-medium text-slate-600">Usuarios</th>
|
||||||
|
<th className="text-left py-2.5 px-4 font-medium text-slate-600">Creada</th>
|
||||||
|
<th className="py-2.5 px-4" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{orgs.map((org) => (
|
||||||
|
<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 text-right text-slate-600 tabular-nums">
|
||||||
|
{org.processes[0]?.count ?? 0}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-right text-slate-600 tabular-nums">
|
||||||
|
{org.users[0]?.count ?? 0}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-slate-500">{formatDate(org.created_at)}</td>
|
||||||
|
<td className="py-3 px-4 text-right">
|
||||||
|
<Link
|
||||||
|
to="/admin/organizations/$orgId"
|
||||||
|
params={{ orgId: org.id }}
|
||||||
|
className="text-xs font-semibold text-[#F59845] hover:text-[#e0872e] transition-colors"
|
||||||
|
>
|
||||||
|
Ver detalle →
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Sheet open={createOpen} onOpenChange={setCreateOpen}>
|
||||||
|
<SheetContent>
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Nueva organización</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<form onSubmit={handleCreate} className="mt-5 flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="create-org-name">Nombre</Label>
|
||||||
|
<Input
|
||||||
|
id="create-org-name"
|
||||||
|
value={orgName}
|
||||||
|
onChange={(e) => setOrgName(e.target.value)}
|
||||||
|
placeholder="ej. Banco Solar SA"
|
||||||
|
disabled={creating}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{createError && <p className="text-xs text-red-600">{createError}</p>}
|
||||||
|
</div>
|
||||||
|
<SheetFooter className="mt-2">
|
||||||
|
<Button type="submit" disabled={creating}>
|
||||||
|
{creating ? 'Creando…' : 'Crear organización'}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</form>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableSkeleton({ rows }: { rows: number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-slate-200 overflow-hidden">
|
||||||
|
<div className="bg-slate-50 border-b border-slate-200 h-10" />
|
||||||
|
<div className="flex flex-col divide-y divide-slate-100">
|
||||||
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
|
<div key={i} className="flex gap-4 px-4 py-3 bg-white">
|
||||||
|
<Skeleton className="h-4 flex-1" />
|
||||||
|
<Skeleton className="h-4 w-12" />
|
||||||
|
<Skeleton className="h-4 w-12" />
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ icon, message }: { icon: React.ReactNode; message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 gap-3 text-center">
|
||||||
|
{icon}
|
||||||
|
<p className="text-sm text-slate-500 max-w-xs">{message}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
38
src/features/admin/RoleBadge.tsx
Normal file
38
src/features/admin/RoleBadge.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// Badges de rol con colores por spec: platform_admin naranja, member slate,
|
||||||
|
// client_editor azul, client_viewer verde, guest gris/suspendido
|
||||||
|
const ROLE_STYLES: Record<string, { label: string; className: string }> = {
|
||||||
|
platform_admin: {
|
||||||
|
label: 'Platform Admin',
|
||||||
|
className: 'bg-orange-100 text-orange-700 border-orange-200',
|
||||||
|
},
|
||||||
|
member: {
|
||||||
|
label: 'Member',
|
||||||
|
className: 'bg-slate-100 text-slate-700 border-slate-200',
|
||||||
|
},
|
||||||
|
client_editor: {
|
||||||
|
label: 'Client Editor',
|
||||||
|
className: 'bg-blue-100 text-blue-700 border-blue-200',
|
||||||
|
},
|
||||||
|
client_viewer: {
|
||||||
|
label: 'Client Viewer',
|
||||||
|
className: 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||||
|
},
|
||||||
|
guest: {
|
||||||
|
label: 'Suspendido',
|
||||||
|
className: 'bg-gray-100 text-gray-500 border-gray-200',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoleBadge({ role }: { role: string }) {
|
||||||
|
const style = ROLE_STYLES[role] ?? {
|
||||||
|
label: role,
|
||||||
|
className: 'bg-gray-100 text-gray-500 border-gray-200',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2 py-0.5 rounded text-[11px] font-medium border ${style.className}`}
|
||||||
|
>
|
||||||
|
{style.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
600
src/features/admin/UsersPage.tsx
Normal file
600
src/features/admin/UsersPage.tsx
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
/**
|
||||||
|
* Limitaciones conocidas documentadas:
|
||||||
|
* 1. email no está en public.users (vive en auth.users, requiere service_role) → solo se muestra name.
|
||||||
|
* 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 * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||||
|
import { UserX, UserPlus, MoreVertical } from 'lucide-react'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import { useAuth } from '@/auth/AuthContext'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetFooter,
|
||||||
|
} from '@/components/ui/sheet'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { RoleBadge } from './RoleBadge'
|
||||||
|
|
||||||
|
interface AdminUser {
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
company: string | null
|
||||||
|
platform_role: string
|
||||||
|
org_id: string | null
|
||||||
|
avatar_url: string | null
|
||||||
|
organization: { name: string; is_provider: boolean } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrgOption {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ConfirmDialog usando @radix-ui/react-dialog (sin @radix-ui/react-alert-dialog) ──
|
||||||
|
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
confirmLabel?: string
|
||||||
|
onConfirm: () => void
|
||||||
|
loading?: boolean
|
||||||
|
destructive?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfirmDialog({
|
||||||
|
open, onOpenChange, title, description,
|
||||||
|
confirmLabel = 'Confirmar',
|
||||||
|
onConfirm, loading = false, destructive = false,
|
||||||
|
}: ConfirmDialogProps) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogPrimitive.Portal>
|
||||||
|
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
className="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white rounded-xl shadow-xl p-6 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
|
||||||
|
onInteractOutside={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<DialogPrimitive.Title className="text-base font-semibold text-slate-900">
|
||||||
|
{title}
|
||||||
|
</DialogPrimitive.Title>
|
||||||
|
<DialogPrimitive.Description className="mt-2 text-sm text-slate-600">
|
||||||
|
{description}
|
||||||
|
</DialogPrimitive.Description>
|
||||||
|
<div className="flex justify-end gap-2 mt-6">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={destructive ? 'destructive' : 'default'}
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Procesando…' : confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPrimitive.Portal>
|
||||||
|
</DialogPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Página principal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const INVITE_ROLES = ['client_editor', 'client_viewer', 'member'] as const
|
||||||
|
type InviteRole = typeof INVITE_ROLES[number]
|
||||||
|
|
||||||
|
const CHANGE_ROLES = ['platform_admin', 'member', 'client_editor', 'client_viewer', 'guest'] as const
|
||||||
|
|
||||||
|
export function UsersPage() {
|
||||||
|
const { user: adminUser } = useAuth()
|
||||||
|
|
||||||
|
const [users, setUsers] = useState<AdminUser[]>([])
|
||||||
|
const [orgs, setOrgs] = useState<OrgOption[]>([])
|
||||||
|
const [loadingUsers, setLoadingUsers] = useState(true)
|
||||||
|
const [filterOrgId, setFilterOrgId] = useState<string>('all')
|
||||||
|
|
||||||
|
// Invite sheet
|
||||||
|
const [inviteOpen, setInviteOpen] = useState(false)
|
||||||
|
const [inviteName, setInviteName] = useState('')
|
||||||
|
const [inviteEmail, setInviteEmail] = useState('')
|
||||||
|
const [inviteRole, setInviteRole] = useState<InviteRole>('client_editor')
|
||||||
|
const [inviteOrgName, setInviteOrgName] = useState('')
|
||||||
|
const [inviteError, setInviteError] = useState<string | null>(null)
|
||||||
|
const [inviteSuccess, setInviteSuccess] = useState<string | null>(null)
|
||||||
|
const [inviting, setInviting] = useState(false)
|
||||||
|
|
||||||
|
// Change role sheet
|
||||||
|
const [changeRoleUser, setChangeRoleUser] = useState<AdminUser | null>(null)
|
||||||
|
const [changeRoleOpen, setChangeRoleOpen] = useState(false)
|
||||||
|
const [newRole, setNewRole] = useState<string>('')
|
||||||
|
const [changingRole, setChangingRole] = useState(false)
|
||||||
|
const [changeRoleError, setChangeRoleError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Confirmations
|
||||||
|
const [suspendTarget, setSuspendTarget] = useState<AdminUser | null>(null)
|
||||||
|
const [suspending, setSuspending] = useState(false)
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [reactivateTarget, setReactivateTarget] = useState<AdminUser | null>(null)
|
||||||
|
const [reactivating, setReactivating] = useState(false)
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
const query = supabase
|
||||||
|
.from('users')
|
||||||
|
.select('id, name, company, platform_role, org_id, avatar_url, organization:organizations(name, is_provider)')
|
||||||
|
.order('name')
|
||||||
|
const finalQuery = filterOrgId !== 'all'
|
||||||
|
? query.eq('org_id', filterOrgId)
|
||||||
|
: query
|
||||||
|
const { data } = await finalQuery
|
||||||
|
setUsers((data as AdminUser[] | null) ?? [])
|
||||||
|
setLoadingUsers(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchOrgs = async () => {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('organizations')
|
||||||
|
.select('id, name')
|
||||||
|
.order('name')
|
||||||
|
setOrgs((data as OrgOption[] | null) ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchOrgs()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoadingUsers(true)
|
||||||
|
void fetchUsers()
|
||||||
|
}, [filterOrgId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// ── Invite user ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const openInvite = () => {
|
||||||
|
setInviteName('')
|
||||||
|
setInviteEmail('')
|
||||||
|
setInviteRole('client_editor')
|
||||||
|
setInviteOrgName('')
|
||||||
|
setInviteError(null)
|
||||||
|
setInviteSuccess(null)
|
||||||
|
setInviteOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInvite = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setInviteError(null)
|
||||||
|
setInviteSuccess(null)
|
||||||
|
if (!inviteName.trim()) { setInviteError('El nombre es requerido'); return }
|
||||||
|
if (!inviteEmail.trim() || !inviteEmail.includes('@')) { setInviteError('Email inválido'); return }
|
||||||
|
if ((inviteRole === 'client_editor' || inviteRole === 'client_viewer') && !inviteOrgName.trim()) {
|
||||||
|
setInviteError('La organización es requerida para este rol')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setInviting(true)
|
||||||
|
const { error } = await supabase.functions.invoke('invite-user', {
|
||||||
|
body: {
|
||||||
|
email: inviteEmail.trim(),
|
||||||
|
name: inviteName.trim(),
|
||||||
|
platform_role: inviteRole,
|
||||||
|
org_name: inviteRole === 'member' ? 'InQuality' : inviteOrgName.trim(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (error) {
|
||||||
|
setInviteError(typeof error === 'object' && 'message' in error ? String(error.message) : String(error))
|
||||||
|
setInviting(false)
|
||||||
|
} else {
|
||||||
|
setInviteSuccess(`Invitación enviada a ${inviteEmail.trim()}`)
|
||||||
|
setInviting(false)
|
||||||
|
void fetchUsers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Change role ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const openChangeRole = (u: AdminUser) => {
|
||||||
|
setChangeRoleUser(u)
|
||||||
|
setNewRole(u.platform_role)
|
||||||
|
setChangeRoleError(null)
|
||||||
|
setChangeRoleOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChangeRole = async () => {
|
||||||
|
if (!changeRoleUser || !newRole) return
|
||||||
|
setChangingRole(true)
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('users')
|
||||||
|
.update({ platform_role: newRole })
|
||||||
|
.eq('id', changeRoleUser.id)
|
||||||
|
if (error) {
|
||||||
|
setChangeRoleError(error.message)
|
||||||
|
setChangingRole(false)
|
||||||
|
} else {
|
||||||
|
setChangeRoleOpen(false)
|
||||||
|
setChangingRole(false)
|
||||||
|
void fetchUsers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Suspend (revoke-user vía Edge Function) ───────────────────────────────
|
||||||
|
|
||||||
|
const handleSuspend = async () => {
|
||||||
|
if (!suspendTarget) return
|
||||||
|
setSuspending(true)
|
||||||
|
const { error } = await supabase.functions.invoke('revoke-user', {
|
||||||
|
body: { userId: suspendTarget.id },
|
||||||
|
})
|
||||||
|
if (error) {
|
||||||
|
console.error('Error al suspender usuario:', error)
|
||||||
|
}
|
||||||
|
setSuspendTarget(null)
|
||||||
|
setSuspending(false)
|
||||||
|
void fetchUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete (también usa revoke-user — baja a guest, eliminación real futura) ─
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteTarget) return
|
||||||
|
setDeleting(true)
|
||||||
|
const { error } = await supabase.functions.invoke('revoke-user', {
|
||||||
|
body: { userId: deleteTarget.id },
|
||||||
|
})
|
||||||
|
if (error) {
|
||||||
|
console.error('Error al eliminar usuario:', error)
|
||||||
|
}
|
||||||
|
setDeleteTarget(null)
|
||||||
|
setDeleting(false)
|
||||||
|
void fetchUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reactivate (restore to client_editor — rol previo no se guarda) ───────
|
||||||
|
|
||||||
|
const handleReactivate = async () => {
|
||||||
|
if (!reactivateTarget) return
|
||||||
|
setReactivating(true)
|
||||||
|
await supabase
|
||||||
|
.from('users')
|
||||||
|
.update({ platform_role: 'client_editor' })
|
||||||
|
.eq('id', reactivateTarget.id)
|
||||||
|
setReactivateTarget(null)
|
||||||
|
setReactivating(false)
|
||||||
|
void fetchUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const displayedUsers = users
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">Usuarios</h2>
|
||||||
|
<Select value={filterOrgId} onValueChange={setFilterOrgId}>
|
||||||
|
<SelectTrigger className="w-48 h-8 text-xs">
|
||||||
|
<SelectValue placeholder="Filtrar por org" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Todas las organizaciones</SelectItem>
|
||||||
|
{orgs.map((o) => (
|
||||||
|
<SelectItem key={o.id} value={o.id}>{o.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={openInvite}>
|
||||||
|
<UserPlus className="h-4 w-4 mr-1.5" />
|
||||||
|
Invitar usuario
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingUsers ? (
|
||||||
|
<TableSkeleton rows={6} />
|
||||||
|
) : displayedUsers.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<UserX className="h-10 w-10 text-slate-300" />}
|
||||||
|
message={
|
||||||
|
filterOrgId !== 'all'
|
||||||
|
? 'No hay usuarios en esta organización.'
|
||||||
|
: 'No hay usuarios registrados aún.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-slate-200 overflow-hidden bg-white">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-50 border-b border-slate-200">
|
||||||
|
<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">Rol</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" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{displayedUsers.map((u) => {
|
||||||
|
const isSelf = adminUser?.id === u.id
|
||||||
|
const isPending = !u.name && !u.avatar_url
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={u.id} className="border-b border-slate-100 last:border-0 hover:bg-slate-50/50">
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`font-medium ${u.platform_role === 'guest' ? 'text-slate-400' : 'text-slate-900'}`}>
|
||||||
|
{u.name || <span className="italic text-slate-400">Sin nombre</span>}
|
||||||
|
</span>
|
||||||
|
{isPending && (
|
||||||
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-amber-50 text-amber-700 border border-amber-200">
|
||||||
|
Invitación pendiente
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isSelf && (
|
||||||
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded bg-slate-100 text-slate-500">
|
||||||
|
Tú
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<RoleBadge role={u.platform_role} />
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-slate-500">
|
||||||
|
{u.organization?.name ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 text-right">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
disabled={isSelf}
|
||||||
|
title={isSelf ? 'No puedes modificar tu propio usuario' : undefined}
|
||||||
|
>
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => openChangeRole(u)}>
|
||||||
|
Cambiar rol
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{u.platform_role === 'guest' ? (
|
||||||
|
<DropdownMenuItem onClick={() => setReactivateTarget(u)}>
|
||||||
|
Reactivar usuario
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-amber-600 focus:text-amber-600"
|
||||||
|
onClick={() => setSuspendTarget(u)}
|
||||||
|
>
|
||||||
|
Suspender
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => setDeleteTarget(u)}
|
||||||
|
>
|
||||||
|
Eliminar
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── Sheet: Invitar usuario ──────────────────────────────────────── */}
|
||||||
|
<Sheet open={inviteOpen} onOpenChange={setInviteOpen}>
|
||||||
|
<SheetContent>
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Invitar usuario</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<form onSubmit={handleInvite} className="mt-5 flex flex-col gap-4">
|
||||||
|
<Field label="Nombre completo" id="invite-name">
|
||||||
|
<Input
|
||||||
|
id="invite-name"
|
||||||
|
value={inviteName}
|
||||||
|
onChange={(e) => setInviteName(e.target.value)}
|
||||||
|
placeholder="Ej. Ana Martínez"
|
||||||
|
disabled={inviting}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Email" id="invite-email">
|
||||||
|
<Input
|
||||||
|
id="invite-email"
|
||||||
|
type="email"
|
||||||
|
value={inviteEmail}
|
||||||
|
onChange={(e) => setInviteEmail(e.target.value)}
|
||||||
|
placeholder="ana@empresa.com"
|
||||||
|
disabled={inviting}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Rol" id="invite-role">
|
||||||
|
<Select
|
||||||
|
value={inviteRole}
|
||||||
|
onValueChange={(v) => setInviteRole(v as InviteRole)}
|
||||||
|
disabled={inviting}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="invite-role">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="client_editor">Client Editor</SelectItem>
|
||||||
|
<SelectItem value="client_viewer">Client Viewer</SelectItem>
|
||||||
|
<SelectItem value="member">Member (InQuality)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{inviteRole === 'member' ? (
|
||||||
|
<Field label="Organización" id="invite-org-member">
|
||||||
|
<Input id="invite-org-member" value="InQuality" disabled />
|
||||||
|
</Field>
|
||||||
|
) : (
|
||||||
|
<Field label="Organización" id="invite-org">
|
||||||
|
<Input
|
||||||
|
id="invite-org"
|
||||||
|
list="orgs-datalist"
|
||||||
|
value={inviteOrgName}
|
||||||
|
onChange={(e) => setInviteOrgName(e.target.value)}
|
||||||
|
placeholder="Nombre de la organización"
|
||||||
|
disabled={inviting}
|
||||||
|
/>
|
||||||
|
<datalist id="orgs-datalist">
|
||||||
|
{orgs.map((o) => (
|
||||||
|
<option key={o.id} value={o.name} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{inviteError && <p className="text-xs text-red-600">{inviteError}</p>}
|
||||||
|
{inviteSuccess && <p className="text-xs text-emerald-600 font-medium">{inviteSuccess}</p>}
|
||||||
|
|
||||||
|
<SheetFooter className="mt-2">
|
||||||
|
<Button type="submit" disabled={inviting}>
|
||||||
|
{inviting ? 'Enviando invitación…' : 'Enviar invitación'}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</form>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
{/* ─── Sheet: Cambiar rol ──────────────────────────────────────────── */}
|
||||||
|
<Sheet open={changeRoleOpen} onOpenChange={setChangeRoleOpen}>
|
||||||
|
<SheetContent>
|
||||||
|
<SheetHeader>
|
||||||
|
<SheetTitle>Cambiar rol — {changeRoleUser?.name}</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="mt-5 flex flex-col gap-4">
|
||||||
|
<Field label="Nuevo rol" id="change-role-select">
|
||||||
|
<Select value={newRole} onValueChange={setNewRole} disabled={changingRole}>
|
||||||
|
<SelectTrigger id="change-role-select">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CHANGE_ROLES.map((r) => (
|
||||||
|
<SelectItem key={r} value={r}>{r}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
{changeRoleError && <p className="text-xs text-red-600">{changeRoleError}</p>}
|
||||||
|
<SheetFooter className="mt-2">
|
||||||
|
<Button onClick={handleChangeRole} disabled={changingRole}>
|
||||||
|
{changingRole ? 'Guardando…' : 'Guardar rol'}
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
{/* ─── ConfirmDialog: Suspender ────────────────────────────────────── */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!suspendTarget}
|
||||||
|
onOpenChange={(open) => !open && setSuspendTarget(null)}
|
||||||
|
title={`¿Suspender a ${suspendTarget?.name ?? 'este usuario'}?`}
|
||||||
|
description="No podrá acceder a la plataforma mientras esté suspendido. Podés reactivarlo después."
|
||||||
|
confirmLabel="Suspender"
|
||||||
|
onConfirm={handleSuspend}
|
||||||
|
loading={suspending}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ─── ConfirmDialog: Eliminar ─────────────────────────────────────── */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||||
|
title={`¿Eliminar 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.)"
|
||||||
|
confirmLabel="Eliminar"
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
loading={deleting}
|
||||||
|
destructive
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ─── ConfirmDialog: Reactivar ────────────────────────────────────── */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!reactivateTarget}
|
||||||
|
onOpenChange={(open) => !open && setReactivateTarget(null)}
|
||||||
|
title={`¿Reactivar a ${reactivateTarget?.name ?? 'este usuario'}?`}
|
||||||
|
description="Se le asignará el rol Client Editor. (Nota: el rol original no se conservó al momento de la suspensión.)"
|
||||||
|
confirmLabel="Reactivar"
|
||||||
|
onConfirm={handleReactivate}
|
||||||
|
loading={reactivating}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, id, children }: { label: string; id: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor={id}>{label}</Label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableSkeleton({ rows }: { rows: number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-slate-200 overflow-hidden">
|
||||||
|
<div className="bg-slate-50 border-b border-slate-200 h-10" />
|
||||||
|
<div className="flex flex-col divide-y divide-slate-100">
|
||||||
|
{Array.from({ length: rows }).map((_, i) => (
|
||||||
|
<div key={i} className="flex gap-4 px-4 py-3 bg-white items-center">
|
||||||
|
<Skeleton className="h-4 flex-1" />
|
||||||
|
<Skeleton className="h-5 w-20 rounded" />
|
||||||
|
<Skeleton className="h-4 w-28" />
|
||||||
|
<Skeleton className="h-6 w-6 rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ icon, message }: { icon: React.ReactNode; message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 gap-3 text-center">
|
||||||
|
{icon}
|
||||||
|
<p className="text-sm text-slate-500 max-w-xs">{message}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
src/features/auth/ResetPasswordPage.tsx
Normal file
103
src/features/auth/ResetPasswordPage.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import { useAuth } from '@/auth/AuthContext'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
export function ResetPasswordPage() {
|
||||||
|
const { clearPasswordReset } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirm, setConfirm] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError('La contraseña debe tener al menos 8 caracteres')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password !== confirm) {
|
||||||
|
setError('Las contraseñas no coinciden')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
const { error: updateError } = await supabase.auth.updateUser({ password })
|
||||||
|
if (updateError) {
|
||||||
|
setError(updateError.message)
|
||||||
|
setLoading(false)
|
||||||
|
} else {
|
||||||
|
clearPasswordReset()
|
||||||
|
void navigate({ to: '/' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center gap-6 px-8 w-full max-w-sm"
|
||||||
|
style={{ animation: 'fadeIn 0.4s ease-out both' }}
|
||||||
|
>
|
||||||
|
<style>{`
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<img
|
||||||
|
src="/inquality-logo-color.png"
|
||||||
|
alt="InQuality"
|
||||||
|
className="h-12 object-contain"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">Establecer contraseña</h1>
|
||||||
|
<p className="text-slate-500 mt-1 text-sm">Creá una contraseña para tu cuenta InQ ROI</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="w-full flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="new-password" className="text-sm text-slate-700">Nueva contraseña</Label>
|
||||||
|
<Input
|
||||||
|
id="new-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="Mínimo 8 caracteres"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="confirm-password" className="text-sm text-slate-700">Confirmar contraseña</Label>
|
||||||
|
<Input
|
||||||
|
id="confirm-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
placeholder="Repetí la contraseña"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-red-600">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" disabled={loading} className="w-full">
|
||||||
|
{loading ? 'Guardando…' : 'Establecer contraseña'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,14 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { z } from 'zod'
|
||||||
import { useAuth } from '@/auth/AuthContext'
|
import { useAuth } from '@/auth/AuthContext'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email('Email inválido'),
|
||||||
|
password: z.string().min(1, 'La contraseña es requerida'),
|
||||||
|
})
|
||||||
|
|
||||||
function GoogleIcon() {
|
function GoogleIcon() {
|
||||||
return (
|
return (
|
||||||
@@ -12,12 +22,39 @@ function GoogleIcon() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { signIn, loading } = useAuth()
|
const { signIn, signInWithEmailPassword, loading } = useAuth()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [formError, setFormError] = useState<string | null>(null)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const handleEmailLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setFormError(null)
|
||||||
|
|
||||||
|
const result = loginSchema.safeParse({ email, password })
|
||||||
|
if (!result.success) {
|
||||||
|
setFormError(result.error.issues[0].message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
const { error } = await signInWithEmailPassword(email, password)
|
||||||
|
if (error) {
|
||||||
|
setFormError(
|
||||||
|
error === 'Invalid login credentials'
|
||||||
|
? 'Email o contraseña incorrectos'
|
||||||
|
: error
|
||||||
|
)
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
// En login exitoso no resetear isSubmitting — el router redirige y desmonta el componente
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||||
<div
|
<div
|
||||||
className="flex flex-col items-center gap-6 px-8"
|
className="flex flex-col items-center gap-6 px-8 w-full max-w-sm"
|
||||||
style={{ animation: 'loginFadeIn 0.5s ease-out both' }}
|
style={{ animation: 'loginFadeIn 0.5s ease-out both' }}
|
||||||
>
|
>
|
||||||
<style>{`
|
<style>{`
|
||||||
@@ -40,16 +77,63 @@ export function LoginPage() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={signIn}
|
onClick={signIn}
|
||||||
disabled={loading}
|
disabled={loading || isSubmitting}
|
||||||
className="flex items-center gap-3 px-6 py-3 rounded-xl font-semibold text-white text-sm shadow-sm transition-opacity hover:opacity-90 disabled:opacity-60 disabled:cursor-not-allowed"
|
className="w-full flex items-center justify-center gap-3 px-6 py-3 rounded-xl font-semibold text-white text-sm shadow-sm transition-opacity hover:opacity-90 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
style={{ backgroundColor: '#F59845' }}
|
style={{ backgroundColor: '#F59845' }}
|
||||||
>
|
>
|
||||||
<GoogleIcon />
|
<GoogleIcon />
|
||||||
Iniciar sesión con Google
|
Continuar con Google
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="text-xs text-slate-400">
|
<div className="w-full flex items-center gap-3">
|
||||||
Solo para usuarios con cuenta @inquality.com.py
|
<div className="flex-1 h-px bg-slate-200" />
|
||||||
|
<span className="text-xs text-slate-400">o</span>
|
||||||
|
<div className="flex-1 h-px bg-slate-200" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleEmailLogin} className="w-full flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="email" className="text-sm text-slate-700">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="usuario@empresa.com"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="password" className="text-sm text-slate-700">Contraseña</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formError && (
|
||||||
|
<p className="text-xs text-red-600">{formError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || loading}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Ingresando…' : 'Ingresar'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-400 text-center">
|
||||||
|
Los usuarios externos acceden por invitación.<br />
|
||||||
|
Si no recibiste una, contactá a tu consultor InQuality.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import { LibraryPage } from '@/features/library/LibraryPage'
|
|||||||
import { SettingsPage } from '@/features/settings/SettingsPage'
|
import { SettingsPage } from '@/features/settings/SettingsPage'
|
||||||
import { ResourceCatalogPage } from '@/features/resources/ResourceCatalogPage'
|
import { ResourceCatalogPage } from '@/features/resources/ResourceCatalogPage'
|
||||||
import { LoginPage } from '@/features/login/LoginPage'
|
import { LoginPage } from '@/features/login/LoginPage'
|
||||||
|
import { ResetPasswordPage } from '@/features/auth/ResetPasswordPage'
|
||||||
|
import { AdminLayout } from '@/features/admin/AdminLayout'
|
||||||
|
import { OrganizationsPage } from '@/features/admin/OrganizationsPage'
|
||||||
|
import { OrganizationDetailPage } from '@/features/admin/OrganizationDetailPage'
|
||||||
|
import { UsersPage } from '@/features/admin/UsersPage'
|
||||||
import { useAuth } from '@/auth/AuthContext'
|
import { useAuth } from '@/auth/AuthContext'
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
@@ -26,19 +31,26 @@ function ReportPageWithSuspense() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PUBLIC_PATHS = ['/login', '/reset-password']
|
||||||
|
|
||||||
function RootComponent() {
|
function RootComponent() {
|
||||||
const { user, loading } = useAuth()
|
const { user, loading, pendingPasswordReset } = useAuth()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading) return
|
if (loading) return
|
||||||
if (!user && pathname !== '/login') {
|
// Usuario con PASSWORD_RECOVERY activo → forzar /reset-password
|
||||||
|
if (user && pendingPasswordReset && pathname !== '/reset-password') {
|
||||||
|
void navigate({ to: '/reset-password' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!user && !PUBLIC_PATHS.includes(pathname)) {
|
||||||
void navigate({ to: '/login' })
|
void navigate({ to: '/login' })
|
||||||
} else if (user && pathname === '/login') {
|
} else if (user && !pendingPasswordReset && pathname === '/login') {
|
||||||
void navigate({ to: '/' })
|
void navigate({ to: '/' })
|
||||||
}
|
}
|
||||||
}, [user, loading, pathname, navigate])
|
}, [user, loading, pathname, navigate, pendingPasswordReset])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -48,7 +60,7 @@ function RootComponent() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user && pathname !== '/login') return (
|
if (!user && !PUBLIC_PATHS.includes(pathname)) return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<Loader2 className="h-8 w-8 animate-spin" style={{ color: '#F59845' }} />
|
<Loader2 className="h-8 w-8 animate-spin" style={{ color: '#F59845' }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -57,6 +69,13 @@ function RootComponent() {
|
|||||||
return <Outlet />
|
return <Outlet />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Redirect /admin → /admin/organizations
|
||||||
|
function AdminIndexRedirect() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
useEffect(() => { void navigate({ to: '/admin/organizations' }) }, [navigate])
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const rootRoute = createRootRoute({ component: RootComponent })
|
const rootRoute = createRootRoute({ component: RootComponent })
|
||||||
|
|
||||||
const loginRoute = createRoute({
|
const loginRoute = createRoute({
|
||||||
@@ -65,6 +84,12 @@ const loginRoute = createRoute({
|
|||||||
component: LoginPage,
|
component: LoginPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const resetPasswordRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/reset-password',
|
||||||
|
component: ResetPasswordPage,
|
||||||
|
})
|
||||||
|
|
||||||
const indexRoute = createRoute({
|
const indexRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -101,14 +126,53 @@ const resourcesRoute = createRoute({
|
|||||||
component: ResourceCatalogPage,
|
component: ResourceCatalogPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── Admin routes (guard de platform_admin en AdminLayout) ────────────────────
|
||||||
|
|
||||||
|
const adminLayoutRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/admin',
|
||||||
|
component: AdminLayout,
|
||||||
|
})
|
||||||
|
|
||||||
|
const adminIndexRoute = createRoute({
|
||||||
|
getParentRoute: () => adminLayoutRoute,
|
||||||
|
path: '/',
|
||||||
|
component: AdminIndexRedirect,
|
||||||
|
})
|
||||||
|
|
||||||
|
const adminOrgsRoute = createRoute({
|
||||||
|
getParentRoute: () => adminLayoutRoute,
|
||||||
|
path: '/organizations',
|
||||||
|
component: OrganizationsPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
const adminOrgDetailRoute = createRoute({
|
||||||
|
getParentRoute: () => adminLayoutRoute,
|
||||||
|
path: '/organizations/$orgId',
|
||||||
|
component: OrganizationDetailPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
const adminUsersRoute = createRoute({
|
||||||
|
getParentRoute: () => adminLayoutRoute,
|
||||||
|
path: '/users',
|
||||||
|
component: UsersPage,
|
||||||
|
})
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
loginRoute,
|
loginRoute,
|
||||||
|
resetPasswordRoute,
|
||||||
indexRoute,
|
indexRoute,
|
||||||
importRoute,
|
importRoute,
|
||||||
workspaceRoute,
|
workspaceRoute,
|
||||||
reportRoute,
|
reportRoute,
|
||||||
settingsRoute,
|
settingsRoute,
|
||||||
resourcesRoute,
|
resourcesRoute,
|
||||||
|
adminLayoutRoute.addChildren([
|
||||||
|
adminIndexRoute,
|
||||||
|
adminOrgsRoute,
|
||||||
|
adminOrgDetailRoute,
|
||||||
|
adminUsersRoute,
|
||||||
|
]),
|
||||||
])
|
])
|
||||||
|
|
||||||
export const router = createRouter({ routeTree })
|
export const router = createRouter({ routeTree })
|
||||||
|
|||||||
1
supabase/.temp/cli-latest
Normal file
1
supabase/.temp/cli-latest
Normal file
@@ -0,0 +1 @@
|
|||||||
|
v2.109.0
|
||||||
1
supabase/.temp/linked-project.json
Normal file
1
supabase/.temp/linked-project.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"ref":"xgqbkxcliyjzisoccsur","name":"inq-roi-desa","organization_id":"ekmgshaupdqaebwypwwj","organization_slug":"ekmgshaupdqaebwypwwj"}
|
||||||
424
supabase/docker-compose.yml
Normal file
424
supabase/docker-compose.yml
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
# Usage
|
||||||
|
# Start: docker compose up -d
|
||||||
|
# Stop: docker compose down
|
||||||
|
# Reset everything: sh reset.sh
|
||||||
|
#
|
||||||
|
# Variables de entorno específicas de InQ ROI (agregar a .env además de las de InQ Sizing):
|
||||||
|
# APP_CODE_PATH — path absoluto al directorio code/ del app en Dokploy.
|
||||||
|
# Se obtiene después de crear la aplicación InQ ROI en Dokploy.
|
||||||
|
# Ejemplo: /etc/dokploy/applications/inq-roi-app-abc123/code
|
||||||
|
# GOTRUE_URI_ALLOW_LIST — URLs permitidas para redirect OAuth.
|
||||||
|
# Ejemplo: https://inq-roi.inqualityhq.com/**,http://localhost:5173/**
|
||||||
|
|
||||||
|
name: supabase
|
||||||
|
|
||||||
|
services:
|
||||||
|
|
||||||
|
studio:
|
||||||
|
image: supabase/studio:2026.06.03-sha-0bca601
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"node -e \"fetch('http://localhost:3000/api/platform/profile').then((r) => {if (r.status !== 200) throw new Error(r.status)})\""
|
||||||
|
]
|
||||||
|
timeout: 10s
|
||||||
|
interval: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
environment:
|
||||||
|
HOSTNAME: "0.0.0.0"
|
||||||
|
|
||||||
|
STUDIO_PG_META_URL: http://meta:8080
|
||||||
|
POSTGRES_PORT: ${POSTGRES_PORT}
|
||||||
|
POSTGRES_HOST: ${POSTGRES_HOST}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
|
||||||
|
PG_META_CRYPTO_KEY: ${PG_META_CRYPTO_KEY}
|
||||||
|
PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}
|
||||||
|
PGRST_DB_MAX_ROWS: ${PGRST_DB_MAX_ROWS:-1000}
|
||||||
|
PGRST_DB_EXTRA_SEARCH_PATH: ${PGRST_DB_EXTRA_SEARCH_PATH:-public}
|
||||||
|
|
||||||
|
DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION}
|
||||||
|
DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT}
|
||||||
|
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||||
|
|
||||||
|
SUPABASE_URL: http://kong:8000
|
||||||
|
SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
|
||||||
|
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||||
|
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
||||||
|
AUTH_JWT_SECRET: ${JWT_SECRET}
|
||||||
|
SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY}
|
||||||
|
SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY}
|
||||||
|
|
||||||
|
ENABLED_FEATURES_LOGS_ALL: "false"
|
||||||
|
|
||||||
|
SNIPPETS_MANAGEMENT_FOLDER: /app/snippets
|
||||||
|
EDGE_FUNCTIONS_MANAGEMENT_FOLDER: /app/edge-functions
|
||||||
|
volumes:
|
||||||
|
- ./volumes/snippets:/app/snippets:z
|
||||||
|
# [InQ ROI] path al repo del app en Dokploy — configurar APP_CODE_PATH en .env
|
||||||
|
- ${APP_CODE_PATH}/supabase/functions:/app/edge-functions:ro,z
|
||||||
|
|
||||||
|
kong:
|
||||||
|
image: kong/kong:3.9.1
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
aliases:
|
||||||
|
- api-gw
|
||||||
|
dokploy-network: {}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "kong", "health"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
depends_on:
|
||||||
|
studio:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- ${KONG_HTTP_PORT}:8000/tcp
|
||||||
|
- ${KONG_HTTPS_PORT}:8443/tcp
|
||||||
|
volumes:
|
||||||
|
- ./volumes/api/kong.yml:/home/kong/temp.yml:ro,z
|
||||||
|
- ./volumes/api/kong-entrypoint.sh:/home/kong/kong-entrypoint.sh:ro,z
|
||||||
|
environment:
|
||||||
|
KONG_DATABASE: "off"
|
||||||
|
KONG_DECLARATIVE_CONFIG: /usr/local/kong/kong.yml
|
||||||
|
KONG_DNS_ORDER: LAST,A,CNAME
|
||||||
|
KONG_DNS_NOT_FOUND_TTL: 1
|
||||||
|
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction,post-function
|
||||||
|
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
|
||||||
|
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
|
||||||
|
KONG_PROXY_ACCESS_LOG: /dev/stdout combined
|
||||||
|
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||||
|
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
||||||
|
SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY:-}
|
||||||
|
SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY:-}
|
||||||
|
ANON_KEY_ASYMMETRIC: ${ANON_KEY_ASYMMETRIC:-}
|
||||||
|
SERVICE_ROLE_KEY_ASYMMETRIC: ${SERVICE_ROLE_KEY_ASYMMETRIC:-}
|
||||||
|
DASHBOARD_USERNAME: ${DASHBOARD_USERNAME}
|
||||||
|
DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD}
|
||||||
|
entrypoint: /home/kong/kong-entrypoint.sh
|
||||||
|
|
||||||
|
auth:
|
||||||
|
image: supabase/gotrue:v2.189.0
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"wget",
|
||||||
|
"--no-verbose",
|
||||||
|
"--tries=1",
|
||||||
|
"--spider",
|
||||||
|
"http://localhost:9999/health"
|
||||||
|
]
|
||||||
|
timeout: 5s
|
||||||
|
interval: 5s
|
||||||
|
retries: 3
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
GOTRUE_API_HOST: 0.0.0.0
|
||||||
|
GOTRUE_API_PORT: 9999
|
||||||
|
API_EXTERNAL_URL: ${API_EXTERNAL_URL}
|
||||||
|
|
||||||
|
GOTRUE_DB_DRIVER: postgres
|
||||||
|
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
||||||
|
|
||||||
|
GOTRUE_SITE_URL: ${SITE_URL}
|
||||||
|
# [InQ ROI] configurar en .env — ej: https://inq-roi.inqualityhq.com/**,http://localhost:5173/**
|
||||||
|
GOTRUE_URI_ALLOW_LIST: ${GOTRUE_URI_ALLOW_LIST}
|
||||||
|
GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP}
|
||||||
|
|
||||||
|
GOTRUE_JWT_ADMIN_ROLES: service_role
|
||||||
|
GOTRUE_JWT_AUD: authenticated
|
||||||
|
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
|
||||||
|
GOTRUE_JWT_EXP: ${JWT_EXPIRY}
|
||||||
|
GOTRUE_JWT_SECRET: ${JWT_SECRET}
|
||||||
|
GOTRUE_JWT_ISSUER: ${API_EXTERNAL_URL}/auth/v1
|
||||||
|
|
||||||
|
GOTRUE_EXTERNAL_EMAIL_ENABLED: true
|
||||||
|
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: ${ENABLE_ANONYMOUS_USERS}
|
||||||
|
GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM}
|
||||||
|
|
||||||
|
GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL}
|
||||||
|
GOTRUE_SMTP_HOST: ${SMTP_HOST}
|
||||||
|
GOTRUE_SMTP_PORT: ${SMTP_PORT}
|
||||||
|
GOTRUE_SMTP_USER: ${SMTP_USER}
|
||||||
|
GOTRUE_SMTP_PASS: ${SMTP_PASS}
|
||||||
|
GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME}
|
||||||
|
GOTRUE_MAILER_URLPATHS_INVITE: ${MAILER_URLPATHS_INVITE}
|
||||||
|
GOTRUE_MAILER_URLPATHS_CONFIRMATION: ${MAILER_URLPATHS_CONFIRMATION}
|
||||||
|
GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/v1/verify
|
||||||
|
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: ${MAILER_URLPATHS_EMAIL_CHANGE}
|
||||||
|
|
||||||
|
GOTRUE_EXTERNAL_PHONE_ENABLED: ${ENABLE_PHONE_SIGNUP}
|
||||||
|
GOTRUE_SMS_AUTOCONFIRM: ${ENABLE_PHONE_AUTOCONFIRM}
|
||||||
|
|
||||||
|
# Google OAuth — credenciales en .env (misma Google Cloud Console app o una nueva)
|
||||||
|
GOTRUE_EXTERNAL_GOOGLE_ENABLED: "true"
|
||||||
|
GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID: "${GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID}"
|
||||||
|
GOTRUE_EXTERNAL_GOOGLE_SECRET: "${GOTRUE_EXTERNAL_GOOGLE_SECRET}"
|
||||||
|
# [InQ ROI] redirect URI usa API_EXTERNAL_URL — solo cambiar ese valor en .env
|
||||||
|
GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: "${API_EXTERNAL_URL}/auth/v1/callback"
|
||||||
|
|
||||||
|
rest:
|
||||||
|
image: postgrest/postgrest:v14.12
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
||||||
|
PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}
|
||||||
|
PGRST_DB_MAX_ROWS: ${PGRST_DB_MAX_ROWS:-1000}
|
||||||
|
PGRST_DB_EXTRA_SEARCH_PATH: ${PGRST_DB_EXTRA_SEARCH_PATH:-public}
|
||||||
|
PGRST_DB_ANON_ROLE: anon
|
||||||
|
PGRST_JWT_SECRET: ${JWT_JWKS:-${JWT_SECRET}}
|
||||||
|
PGRST_DB_USE_LEGACY_GUCS: "false"
|
||||||
|
PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
|
||||||
|
PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY}
|
||||||
|
command: ["postgrest"]
|
||||||
|
|
||||||
|
realtime:
|
||||||
|
image: supabase/realtime:v2.102.3
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"curl -sSfL --head -o /dev/null -H \"Authorization: Bearer ${ANON_KEY}\" http://localhost:4000/api/tenants/realtime-dev/health"
|
||||||
|
]
|
||||||
|
timeout: 5s
|
||||||
|
interval: 30s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
environment:
|
||||||
|
PORT: 4000
|
||||||
|
DB_HOST: ${POSTGRES_HOST}
|
||||||
|
DB_PORT: ${POSTGRES_PORT}
|
||||||
|
DB_USER: supabase_admin
|
||||||
|
DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
DB_NAME: ${POSTGRES_DB}
|
||||||
|
DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
|
||||||
|
DB_ENC_KEY: supabaserealtime
|
||||||
|
API_JWT_SECRET: ${JWT_SECRET}
|
||||||
|
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
|
||||||
|
METRICS_JWT_SECRET: ${JWT_SECRET}
|
||||||
|
ERL_AFLAGS: -proto_dist inet_tcp
|
||||||
|
DNS_NODES: "''"
|
||||||
|
RLIMIT_NOFILE: "10000"
|
||||||
|
APP_NAME: realtime
|
||||||
|
SEED_SELF_HOST: "true"
|
||||||
|
RUN_JANITOR: "true"
|
||||||
|
DISABLE_HEALTHCHECK_LOGGING: "true"
|
||||||
|
|
||||||
|
storage:
|
||||||
|
image: supabase/storage-api:v1.60.4
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
rest:
|
||||||
|
condition: service_started
|
||||||
|
imgproxy:
|
||||||
|
condition: service_started
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"wget",
|
||||||
|
"--no-verbose",
|
||||||
|
"--tries=1",
|
||||||
|
"--spider",
|
||||||
|
"http://storage:5000/status"
|
||||||
|
]
|
||||||
|
timeout: 5s
|
||||||
|
interval: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
environment:
|
||||||
|
ANON_KEY: ${ANON_KEY}
|
||||||
|
SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
||||||
|
POSTGREST_URL: http://rest:3000
|
||||||
|
AUTH_JWT_SECRET: ${JWT_SECRET}
|
||||||
|
DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
||||||
|
STORAGE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
|
||||||
|
REQUEST_ALLOW_X_FORWARDED_PATH: "true"
|
||||||
|
FILE_SIZE_LIMIT: 52428800
|
||||||
|
STORAGE_BACKEND: file
|
||||||
|
GLOBAL_S3_BUCKET: ${GLOBAL_S3_BUCKET}
|
||||||
|
FILE_STORAGE_BACKEND_PATH: /var/lib/storage
|
||||||
|
TENANT_ID: ${STORAGE_TENANT_ID}
|
||||||
|
REGION: ${REGION}
|
||||||
|
ENABLE_IMAGE_TRANSFORMATION: "true"
|
||||||
|
IMGPROXY_URL: http://imgproxy:5001
|
||||||
|
S3_PROTOCOL_ACCESS_KEY_ID: ${S3_PROTOCOL_ACCESS_KEY_ID}
|
||||||
|
S3_PROTOCOL_ACCESS_KEY_SECRET: ${S3_PROTOCOL_ACCESS_KEY_SECRET}
|
||||||
|
volumes:
|
||||||
|
- ./volumes/storage:/var/lib/storage:z
|
||||||
|
|
||||||
|
imgproxy:
|
||||||
|
image: darthsim/imgproxy:v3.30.1
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./volumes/storage:/var/lib/storage:z
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "imgproxy", "health"]
|
||||||
|
timeout: 5s
|
||||||
|
interval: 5s
|
||||||
|
retries: 3
|
||||||
|
environment:
|
||||||
|
IMGPROXY_BIND: ":5001"
|
||||||
|
IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
|
||||||
|
IMGPROXY_USE_ETAG: "true"
|
||||||
|
IMGPROXY_AUTO_WEBP: ${IMGPROXY_AUTO_WEBP}
|
||||||
|
IMGPROXY_MAX_SRC_RESOLUTION: 16.8
|
||||||
|
|
||||||
|
meta:
|
||||||
|
image: supabase/postgres-meta:v0.96.6
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
PG_META_PORT: 8080
|
||||||
|
PG_META_DB_HOST: ${POSTGRES_HOST}
|
||||||
|
PG_META_DB_PORT: ${POSTGRES_PORT}
|
||||||
|
PG_META_DB_NAME: ${POSTGRES_DB}
|
||||||
|
PG_META_DB_USER: supabase_admin
|
||||||
|
PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
CRYPTO_KEY: ${PG_META_CRYPTO_KEY}
|
||||||
|
|
||||||
|
functions:
|
||||||
|
image: supabase/edge-runtime:v1.74.0
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
# [InQ ROI] path al repo del app en Dokploy — configurar APP_CODE_PATH en .env
|
||||||
|
- ${APP_CODE_PATH}/supabase/functions:/home/deno/functions:z
|
||||||
|
- deno-cache:/root/.cache/deno
|
||||||
|
depends_on:
|
||||||
|
kong:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "timeout 1 bash -c '</dev/tcp/127.0.0.1/9000'"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
environment:
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
SUPABASE_URL: http://kong:8000
|
||||||
|
SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
|
||||||
|
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
|
||||||
|
SUPABASE_PUBLISHABLE_KEYS: "{\"default\":\"${SUPABASE_PUBLISHABLE_KEY:-}\"}"
|
||||||
|
SUPABASE_SECRET_KEYS: "{\"default\":\"${SUPABASE_SECRET_KEY:-}\"}"
|
||||||
|
SUPABASE_DB_URL: postgresql://postgres:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
|
||||||
|
# [InQ ROI] SITE_URL se inyecta en las Edge Functions como variable de entorno
|
||||||
|
SITE_URL: ${SITE_URL}
|
||||||
|
VERIFY_JWT: "${FUNCTIONS_VERIFY_JWT}"
|
||||||
|
command:
|
||||||
|
[
|
||||||
|
"start",
|
||||||
|
"--main-service",
|
||||||
|
"/home/deno/functions/main"
|
||||||
|
]
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: supabase/postgres:15.8.1.085
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z
|
||||||
|
- ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z
|
||||||
|
- ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z
|
||||||
|
- ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z
|
||||||
|
- db-data:/var/lib/postgresql/data:Z
|
||||||
|
- ./volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:Z
|
||||||
|
- ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z
|
||||||
|
- ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:Z
|
||||||
|
- db-config:/etc/postgresql-custom
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "pg_isready", "-U", "postgres", "-h", "localhost"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
environment:
|
||||||
|
POSTGRES_HOST: /var/run/postgresql
|
||||||
|
PGPORT: ${POSTGRES_PORT}
|
||||||
|
POSTGRES_PORT: ${POSTGRES_PORT}
|
||||||
|
PGPASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
PGDATABASE: ${POSTGRES_DB}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
JWT_EXP: ${JWT_EXPIRY}
|
||||||
|
command:
|
||||||
|
[
|
||||||
|
"postgres",
|
||||||
|
"-c", "config_file=/etc/postgresql/postgresql.conf",
|
||||||
|
"-c", "log_min_messages=fatal"
|
||||||
|
]
|
||||||
|
|
||||||
|
supavisor:
|
||||||
|
image: supabase/supavisor:2.9.5
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- ${POSTGRES_PORT}:5432
|
||||||
|
- ${POOLER_PROXY_PORT_TRANSACTION}:6543
|
||||||
|
volumes:
|
||||||
|
- ./volumes/pooler/pooler.exs:/etc/pooler/pooler.exs:ro,z
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD", "curl", "-sSfL", "--head", "-o", "/dev/null",
|
||||||
|
"http://127.0.0.1:4000/api/health"
|
||||||
|
]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
PORT: 4000
|
||||||
|
POSTGRES_PORT: ${POSTGRES_PORT}
|
||||||
|
POSTGRES_HOST: ${POSTGRES_HOST}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
DATABASE_URL: ecto://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase
|
||||||
|
CLUSTER_POSTGRES: "true"
|
||||||
|
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
|
||||||
|
VAULT_ENC_KEY: ${VAULT_ENC_KEY}
|
||||||
|
API_JWT_SECRET: ${JWT_SECRET}
|
||||||
|
METRICS_JWT_SECRET: ${JWT_SECRET}
|
||||||
|
REGION: local
|
||||||
|
ERL_AFLAGS: -proto_dist inet_tcp
|
||||||
|
POOLER_TENANT_ID: ${POOLER_TENANT_ID}
|
||||||
|
POOLER_DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE}
|
||||||
|
POOLER_MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN}
|
||||||
|
POOLER_POOL_MODE: transaction
|
||||||
|
DB_POOL_SIZE: ${POOLER_DB_POOL_SIZE}
|
||||||
|
command:
|
||||||
|
[
|
||||||
|
"/bin/sh", "-c",
|
||||||
|
"/app/bin/migrate && /app/bin/supavisor eval \"$$(cat /etc/pooler/pooler.exs)\" && /app/bin/server"
|
||||||
|
]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db-config:
|
||||||
|
deno-cache:
|
||||||
|
db-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default: {}
|
||||||
|
dokploy-network:
|
||||||
|
external: true
|
||||||
195
supabase/functions/invite-user/index.ts
Normal file
195
supabase/functions/invite-user/index.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* - SUPABASE_URL y SUPABASE_SERVICE_ROLE_KEY: disponibles automáticamente en Edge Functions
|
||||||
|
* - SITE_URL: agregar manualmente en Supabase Dashboard → Edge Functions → Secrets
|
||||||
|
* Valor producción: https://inq-roi.inqualityhq.com
|
||||||
|
* Deploy: supabase functions deploy invite-user
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||||
|
|
||||||
|
interface InviteUserRequest {
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
platform_role: 'client_editor' | 'client_viewer' | 'member'
|
||||||
|
org_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOWED_ROLES = ['client_editor', 'client_viewer', 'member'] as const
|
||||||
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
|
||||||
|
Deno.serve(async (req: Request) => {
|
||||||
|
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 CORS preflight
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar platform_role desde DB — el JWT no es fuente confiable para el rol
|
||||||
|
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 invitar usuarios' }), { status: 403, headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Parsear y validar el body ──────────────────────────────────────────
|
||||||
|
|
||||||
|
let body: InviteUserRequest
|
||||||
|
try {
|
||||||
|
body = await req.json() as InviteUserRequest
|
||||||
|
} catch {
|
||||||
|
return new Response(JSON.stringify({ error: 'Body JSON inválido' }), { status: 400, headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email, name, platform_role, org_name } = body
|
||||||
|
|
||||||
|
if (!email || !EMAIL_REGEX.test(email)) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Email inválido o no proporcionado' }), { status: 400, headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name?.trim()) {
|
||||||
|
return new Response(JSON.stringify({ error: 'El campo name es requerido' }), { status: 400, headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!platform_role || !ALLOWED_ROLES.includes(platform_role)) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: `platform_role debe ser uno de: ${ALLOWED_ROLES.join(', ')}` }),
|
||||||
|
{ status: 400, headers },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((platform_role === 'client_editor' || platform_role === 'client_viewer') && !org_name?.trim()) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'org_name es requerido para roles client_editor y client_viewer' }),
|
||||||
|
{ status: 400, headers },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Resolver org_id ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let orgId: string
|
||||||
|
|
||||||
|
if (platform_role === 'member') {
|
||||||
|
// Nuevo miembro InQuality → la org es siempre InQuality (is_provider = true)
|
||||||
|
const { data: inqOrg, error: inqOrgError } = await adminClient
|
||||||
|
.from('organizations')
|
||||||
|
.select('id')
|
||||||
|
.eq('is_provider', true)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (inqOrgError || !inqOrg) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'No se encontró la organización InQuality en la base de datos' }),
|
||||||
|
{ status: 500, headers },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
orgId = inqOrg.id
|
||||||
|
} else {
|
||||||
|
// Usuario cliente → buscar org por nombre exacto (trim), crear si no existe
|
||||||
|
const trimmedOrgName = org_name!.trim()
|
||||||
|
|
||||||
|
const { data: existingOrg } = await adminClient
|
||||||
|
.from('organizations')
|
||||||
|
.select('id')
|
||||||
|
.eq('name', trimmedOrgName)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (existingOrg) {
|
||||||
|
orgId = existingOrg.id
|
||||||
|
} else {
|
||||||
|
const { data: newOrg, error: createOrgError } = await adminClient
|
||||||
|
.from('organizations')
|
||||||
|
.insert({ name: trimmedOrgName, is_provider: false })
|
||||||
|
.select('id')
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (createOrgError || !newOrg) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: `Error al crear organización: ${createOrgError?.message ?? 'desconocido'}` }),
|
||||||
|
{ status: 500, headers },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
orgId = newOrg.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Invitar usuario vía Auth Admin API ─────────────────────────────────
|
||||||
|
// inviteUserByEmail crea el usuario en auth.users y envía el email de bienvenida.
|
||||||
|
// Si el email ya existe en auth.users, retorna un error descriptivo.
|
||||||
|
|
||||||
|
const { data: inviteData, error: inviteError } = await adminClient.auth.admin.inviteUserByEmail(
|
||||||
|
email,
|
||||||
|
{
|
||||||
|
data: { full_name: name.trim() }, // se almacena en raw_user_meta_data
|
||||||
|
redirectTo: `${Deno.env.get('SITE_URL') ?? ''}/auth/callback`,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (inviteError) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: inviteError.message }),
|
||||||
|
{ status: 400, headers },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Crear fila en public.users ─────────────────────────────────────────
|
||||||
|
// UPSERT con ignoreDuplicates: false para manejar la race condition con handle_new_user:
|
||||||
|
// si el trigger ya creó la fila (emails @inquality.com.py), actualizar platform_role y org_id.
|
||||||
|
|
||||||
|
const { error: upsertError } = await adminClient
|
||||||
|
.from('users')
|
||||||
|
.upsert(
|
||||||
|
{
|
||||||
|
id: inviteData.user.id,
|
||||||
|
name: name.trim(),
|
||||||
|
platform_role,
|
||||||
|
org_id: orgId,
|
||||||
|
},
|
||||||
|
{ onConflict: 'id', ignoreDuplicates: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (upsertError) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: `Error al crear perfil de usuario: ${upsertError.message}` }),
|
||||||
|
{ status: 500, headers },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: true, userId: inviteData.user.id }),
|
||||||
|
{ status: 200, headers },
|
||||||
|
)
|
||||||
|
})
|
||||||
37
supabase/functions/main/index.ts
Normal file
37
supabase/functions/main/index.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Edge Function: main (relay)
|
||||||
|
* Enruta todas las requests al worker de la función correspondiente.
|
||||||
|
* Requerido por supabase/edge-runtime en self-hosted (--main-service).
|
||||||
|
* No expuesto directamente — solo opera como router interno.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FUNCTIONS = ['invite-user', 'revoke-user']
|
||||||
|
|
||||||
|
Deno.serve(async (req: Request) => {
|
||||||
|
const url = new URL(req.url)
|
||||||
|
|
||||||
|
// Extraer nombre de función del path: /functions/v1/invite-user → invite-user
|
||||||
|
const segments = url.pathname.split('/').filter(Boolean)
|
||||||
|
const functionName = segments[segments.length - 1]
|
||||||
|
|
||||||
|
if (!functionName || !FUNCTIONS.includes(functionName)) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: `Función no encontrada: ${functionName}` }),
|
||||||
|
{ status: 404, headers: { 'Content-Type': 'application/json' } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const servicePath = `/home/deno/functions/${functionName}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
// EdgeRuntime.userWorkers es el API del supabase/edge-runtime para crear workers por función
|
||||||
|
const worker = await EdgeRuntime.userWorkers.create({ servicePath })
|
||||||
|
return await worker.fetch(req)
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: message }),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
118
supabase/functions/revoke-user/index.ts
Normal file
118
supabase/functions/revoke-user/index.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* Edge Function: revoke-user
|
||||||
|
* Propósito: revocar acceso de un usuario (suspensión suave — cambia platform_role a 'guest')
|
||||||
|
* Solo accesible por platform_admin. No elimina datos ni el usuario de auth.users.
|
||||||
|
* Variables de entorno requeridas: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SITE_URL
|
||||||
|
* - SUPABASE_URL y SUPABASE_SERVICE_ROLE_KEY: disponibles automáticamente en Edge Functions
|
||||||
|
* - SITE_URL: agregar manualmente en Supabase Dashboard → Edge Functions → Secrets
|
||||||
|
* Valor producción: https://inq-roi.inqualityhq.com
|
||||||
|
* Deploy: supabase functions deploy revoke-user
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||||
|
|
||||||
|
interface RevokeUserRequest {
|
||||||
|
userId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve(async (req: Request) => {
|
||||||
|
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 CORS preflight
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar platform_role desde DB — el JWT no es fuente confiable para el rol
|
||||||
|
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 revocar accesos' }),
|
||||||
|
{ status: 403, headers },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Parsear y validar el body ──────────────────────────────────────────
|
||||||
|
|
||||||
|
let body: RevokeUserRequest
|
||||||
|
try {
|
||||||
|
body = await req.json() as RevokeUserRequest
|
||||||
|
} 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-revocación ───────────────────────────────────────────
|
||||||
|
|
||||||
|
if (userId === caller.id) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'No puedes revocar tu propio acceso' }),
|
||||||
|
{ status: 400, headers },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. Cambiar platform_role a 'guest' ────────────────────────────────────
|
||||||
|
// Suspensión suave: el usuario queda en auth.users pero RLS bloquea acceso a datos.
|
||||||
|
|
||||||
|
const { error: updateError } = await adminClient
|
||||||
|
.from('users')
|
||||||
|
.update({ platform_role: 'guest' })
|
||||||
|
.eq('id', userId)
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: `Error al revocar acceso: ${updateError.message}` }),
|
||||||
|
{ status: 500, headers },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. Revocar sesiones activas (opcional) ────────────────────────────────
|
||||||
|
// Intenta cerrar las sesiones activas del usuario. Si el método no está disponible
|
||||||
|
// en esta versión del cliente, el rol 'guest' ya impide el acceso via RLS.
|
||||||
|
|
||||||
|
try {
|
||||||
|
await adminClient.auth.admin.signOut(userId)
|
||||||
|
} catch {
|
||||||
|
// No crítico: el rol ya fue cambiado. Las sesiones expirarán naturalmente.
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: true }),
|
||||||
|
{ status: 200, headers },
|
||||||
|
)
|
||||||
|
})
|
||||||
58
supabase/migrations/014_create_organizations.sql
Normal file
58
supabase/migrations/014_create_organizations.sql
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
-- 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;
|
||||||
|
|
||||||
|
-- Constraint único en name — requerido para ON CONFLICT (name) DO NOTHING en los INSERTs
|
||||||
|
-- siguientes. Debe estar ANTES de cualquier INSERT (orden crítico).
|
||||||
|
ALTER TABLE public.organizations ADD CONSTRAINT organizations_name_unique UNIQUE (name);
|
||||||
|
|
||||||
|
-- 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 automática)
|
||||||
|
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 (name) DO NOTHING requiere el constraint organizations_name_unique definido arriba.
|
||||||
|
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 platform_role = 'platform_admin' o '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');
|
||||||
293
supabase/migrations/015_client_roles_and_rls.sql
Normal file
293
supabase/migrations/015_client_roles_and_rls.sql
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
-- ── 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 "read_users" (migration 001) con políticas granulares
|
||||||
|
|
||||||
|
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 las tres políticas de migration 012 con versiones que incluyen 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'
|
||||||
|
-- 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 "all_activities" (migration 006) con políticas granulares por rol
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "all_activities" ON public.activities;
|
||||||
|
|
||||||
|
-- SELECT: acceso transitivo — si el usuario puede ver el proceso padre (via processes SELECT policy),
|
||||||
|
-- puede ver sus actividades. El EXISTS aplica RLS de processes automáticamente.
|
||||||
|
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 pueden escribir; client_viewer y guest no
|
||||||
|
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 (platform_admin o member)
|
||||||
|
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 ────────────────────────────────────────────────────────────
|
||||||
|
-- Reemplazar "all_resources" (migration 005) con políticas granulares por rol.
|
||||||
|
-- NOTA: la tabla resources no tiene columna process_id en este schema — los recursos
|
||||||
|
-- son catálogo global. La policy SELECT mantiene acceso a todos los usuarios autenticados
|
||||||
|
-- (equivalente a la política original) y se alinea con supabaseResourceRepo.getAll().
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "all_resources" ON public.resources;
|
||||||
|
|
||||||
|
CREATE POLICY "resources_select" ON public.resources
|
||||||
|
FOR SELECT USING (auth.uid() IS NOT NULL);
|
||||||
|
|
||||||
|
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 ─────────────────────────────────────────────────────────
|
||||||
|
-- Reemplazar "all_simulations" (migration 009) con políticas granulares por rol
|
||||||
|
|
||||||
|
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 ───────────────────────────────────────
|
||||||
|
-- Reemplazar "all_assignments" (migration 007 — nombre real, no "all_activity_resource_assignments")
|
||||||
|
-- con políticas granulares. Heredan acceso del proceso via actividad padre.
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "all_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 ────────────────────────────────────────────────────────────
|
||||||
|
-- Reemplazar "all_gateways" (migration 008) con políticas granulares por rol
|
||||||
|
|
||||||
|
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')
|
||||||
|
);
|
||||||
142
tests/features/auth/auth-role-sync.test.ts
Normal file
142
tests/features/auth/auth-role-sync.test.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* Tests de sincronización de rol desde DB.
|
||||||
|
* Verifica que buildAuthUser retorna el valor optimista correcto y que
|
||||||
|
* syncProfileFromDB sobrescribe la heurística de email con el rol real de DB.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { SupabaseAuthService } from '@/auth/SupabaseAuthService'
|
||||||
|
|
||||||
|
// ─── Mocks con vi.hoisted para evitar el TDZ al ser elevados al tope del archivo ──
|
||||||
|
|
||||||
|
const { mockGetSession, mockMaybeSingle } = vi.hoisted(() => ({
|
||||||
|
mockGetSession: vi.fn(),
|
||||||
|
mockMaybeSingle: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/supabase', () => {
|
||||||
|
const buildChain = () => {
|
||||||
|
const chain: Record<string, unknown> = {}
|
||||||
|
chain.select = vi.fn().mockReturnValue(chain)
|
||||||
|
chain.eq = vi.fn().mockReturnValue(chain)
|
||||||
|
chain.abortSignal = vi.fn().mockReturnValue(chain)
|
||||||
|
chain.maybeSingle = mockMaybeSingle
|
||||||
|
return chain
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
supabase: {
|
||||||
|
auth: {
|
||||||
|
getSession: mockGetSession,
|
||||||
|
onAuthStateChange: vi.fn(() => ({ data: { subscription: { unsubscribe: vi.fn() } } })),
|
||||||
|
signInWithPassword: vi.fn(),
|
||||||
|
},
|
||||||
|
from: vi.fn().mockImplementation(() => buildChain()),
|
||||||
|
},
|
||||||
|
SUPABASE_URL: 'https://test.supabase.co',
|
||||||
|
SUPABASE_ANON_KEY: 'test-key',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('SupabaseAuthService', () => {
|
||||||
|
const service = new SupabaseAuthService()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockMaybeSingle.mockResolvedValue({ data: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildAuthUser (vía getCurrentUser)', () => {
|
||||||
|
it('devuelve platform_admin optimista para email @inquality.com.py', async () => {
|
||||||
|
mockGetSession.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
session: {
|
||||||
|
user: {
|
||||||
|
id: 'user-1',
|
||||||
|
email: 'test@inquality.com.py',
|
||||||
|
user_metadata: { full_name: 'Test InQ' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const user = await service.getCurrentUser()
|
||||||
|
expect(user?.platformRole).toBe('platform_admin')
|
||||||
|
expect(user?.email).toBe('test@inquality.com.py')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('devuelve guest optimista para email externo', async () => {
|
||||||
|
mockGetSession.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
session: {
|
||||||
|
user: {
|
||||||
|
id: 'user-2',
|
||||||
|
email: 'cliente@empresa.com',
|
||||||
|
user_metadata: { full_name: 'Cliente Externo' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const user = await service.getCurrentUser()
|
||||||
|
expect(user?.platformRole).toBe('guest')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('syncProfileFromDB', () => {
|
||||||
|
it('retorna client_editor desde DB (sobrescribe heurística guest para email externo)', async () => {
|
||||||
|
mockMaybeSingle.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
name: 'Lucia García',
|
||||||
|
company: 'Banco Solar',
|
||||||
|
platform_role: 'client_editor',
|
||||||
|
org_id: 'org-abc-123',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const profile = await service.syncProfileFromDB('user-2')
|
||||||
|
expect(profile?.platformRole).toBe('client_editor')
|
||||||
|
expect(profile?.orgId).toBe('org-abc-123')
|
||||||
|
expect(profile?.company).toBe('Banco Solar')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retorna platform_admin desde DB incluso para email sin dominio @inquality', async () => {
|
||||||
|
mockMaybeSingle.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
name: 'Admin Externo',
|
||||||
|
company: null,
|
||||||
|
platform_role: 'platform_admin',
|
||||||
|
org_id: 'org-inq-456',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const profile = await service.syncProfileFromDB('user-3')
|
||||||
|
expect(profile?.platformRole).toBe('platform_admin')
|
||||||
|
expect(profile?.orgId).toBe('org-inq-456')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retiene orgId correctamente tras sync con client_viewer', async () => {
|
||||||
|
const orgId = 'b1c2d3e4-f5a6-7890-abcd-ef1234567890'
|
||||||
|
mockMaybeSingle.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
name: 'Viewer Test',
|
||||||
|
company: 'Empresa SA',
|
||||||
|
platform_role: 'client_viewer',
|
||||||
|
org_id: orgId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const profile = await service.syncProfileFromDB('user-4')
|
||||||
|
expect(profile?.orgId).toBe(orgId)
|
||||||
|
expect(profile?.platformRole).toBe('client_viewer')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retorna null cuando el usuario no existe en DB', async () => {
|
||||||
|
mockMaybeSingle.mockResolvedValue({ data: null })
|
||||||
|
|
||||||
|
const profile = await service.syncProfileFromDB('nonexistent-user')
|
||||||
|
expect(profile).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user