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 { SupabaseAuthService } from './SupabaseAuthService'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import type { AuthUser } from './types'
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null
|
||||
loading: boolean
|
||||
signIn: () => Promise<void>
|
||||
signInWithEmailPassword: (email: string, password: string) => Promise<{ error: string | null }>
|
||||
signOut: () => Promise<void>
|
||||
updateUser: (updates: Partial<Pick<AuthUser, 'name' | 'company'>>) => void
|
||||
pendingPasswordReset: boolean
|
||||
clearPasswordReset: () => void
|
||||
}
|
||||
|
||||
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.
|
||||
const [loading, setLoading] = useState(isOAuthRedirect)
|
||||
const [pendingPasswordReset, setPendingPasswordReset] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Verificar y sincronizar con Supabase en background.
|
||||
@@ -72,6 +77,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
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.
|
||||
// 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.
|
||||
@@ -90,12 +106,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
// Upsert idempotente: crea la fila si no existe (primer login),
|
||||
// ignoreDuplicates evita sobreescribir name/company editados posteriormente.
|
||||
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)
|
||||
if (profile) {
|
||||
setUser((prev) =>
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -115,17 +137,34 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const signIn = useCallback(() => authService.signInWithGoogle(), [])
|
||||
|
||||
const signInWithEmailPassword = useCallback(
|
||||
(email: string, password: string) => authService.signInWithEmailPassword(email, password),
|
||||
[]
|
||||
)
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await authService.signOut()
|
||||
setUser(null)
|
||||
setPendingPasswordReset(false)
|
||||
}, [])
|
||||
|
||||
const updateUser = useCallback((updates: Partial<Pick<AuthUser, 'name' | 'company'>>) => {
|
||||
setUser((prev) => prev ? { ...prev, ...updates } : prev)
|
||||
}, [])
|
||||
|
||||
const clearPasswordReset = useCallback(() => setPendingPasswordReset(false), [])
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, signIn, signOut, updateUser }}>
|
||||
<AuthContext.Provider value={{
|
||||
user,
|
||||
loading,
|
||||
signIn,
|
||||
signInWithEmailPassword,
|
||||
signOut,
|
||||
updateUser,
|
||||
pendingPasswordReset,
|
||||
clearPasswordReset,
|
||||
}}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
|
||||
@@ -15,6 +15,11 @@ export class SupabaseAuthService implements AuthService {
|
||||
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> {
|
||||
const { error } = await supabase.auth.signOut()
|
||||
if (error) throw error
|
||||
@@ -34,6 +39,8 @@ export class SupabaseAuthService implements AuthService {
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||
(event, session) => { // NO async — el lock de GoTrueClient está activo durante este callback
|
||||
try {
|
||||
// Token refresh silencioso — no disparar re-sync ni setLoading
|
||||
if (event === 'TOKEN_REFRESHED' || event === 'USER_UPDATED') return
|
||||
if (!session?.user) {
|
||||
// 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.
|
||||
@@ -41,10 +48,12 @@ export class SupabaseAuthService implements AuthService {
|
||||
if (event !== 'INITIAL_SESSION') callback(null)
|
||||
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.
|
||||
// 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))
|
||||
} catch (err) {
|
||||
console.error('Error en onAuthStateChange:', err)
|
||||
@@ -57,16 +66,19 @@ export class SupabaseAuthService implements AuthService {
|
||||
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.
|
||||
// signal permite cancelar si el servidor tarda demasiado (libera el processLock).
|
||||
async syncProfileFromDB(userId: string, signal?: AbortSignal): Promise<Pick<AuthUser, 'name' | 'company'> | null> {
|
||||
const base = supabase.from('users').select('company, name').eq('id', userId)
|
||||
async syncProfileFromDB(userId: string, signal?: AbortSignal): Promise<Pick<AuthUser, 'name' | 'company' | 'platformRole' | 'orgId'> | null> {
|
||||
const base = supabase.from('users').select('name, company, platform_role, org_id').eq('id', userId)
|
||||
const { data } = await (signal ? base.abortSignal(signal) : base).maybeSingle()
|
||||
if (!data) return null
|
||||
return {
|
||||
name: ((data.name as string | null | undefined) || undefined) as unknown as string,
|
||||
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.
|
||||
// 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 {
|
||||
const email = supabaseUser.email ?? ''
|
||||
return {
|
||||
|
||||
@@ -3,12 +3,14 @@ export interface AuthUser {
|
||||
email: string
|
||||
name: string
|
||||
avatarUrl?: string
|
||||
platformRole: 'platform_admin' | 'member' | 'guest'
|
||||
platformRole: 'platform_admin' | 'member' | 'client_editor' | 'client_viewer' | 'guest'
|
||||
company?: string
|
||||
orgId?: string
|
||||
}
|
||||
|
||||
export interface AuthService {
|
||||
signInWithGoogle(): Promise<void>
|
||||
signInWithEmailPassword(email: string, password: string): Promise<{ error: string | null }>
|
||||
signOut(): Promise<void>
|
||||
getCurrentUser(): Promise<AuthUser | null>
|
||||
onAuthStateChange(callback: (user: AuthUser | null) => void): () => void
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
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 {
|
||||
DropdownMenu,
|
||||
@@ -77,6 +77,20 @@ export function AppHeader() {
|
||||
>
|
||||
InQ ROI
|
||||
</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 && (
|
||||
<DropdownMenu>
|
||||
<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 { 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() {
|
||||
return (
|
||||
@@ -12,12 +22,39 @@ function GoogleIcon() {
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||
<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>{`
|
||||
@@ -40,16 +77,63 @@ export function LoginPage() {
|
||||
|
||||
<button
|
||||
onClick={signIn}
|
||||
disabled={loading}
|
||||
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"
|
||||
disabled={loading || isSubmitting}
|
||||
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' }}
|
||||
>
|
||||
<GoogleIcon />
|
||||
Iniciar sesión con Google
|
||||
Continuar con Google
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-slate-400">
|
||||
Solo para usuarios con cuenta @inquality.com.py
|
||||
<div className="w-full flex items-center gap-3">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,11 @@ import { LibraryPage } from '@/features/library/LibraryPage'
|
||||
import { SettingsPage } from '@/features/settings/SettingsPage'
|
||||
import { ResourceCatalogPage } from '@/features/resources/ResourceCatalogPage'
|
||||
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 { Loader2 } from 'lucide-react'
|
||||
|
||||
@@ -26,19 +31,26 @@ function ReportPageWithSuspense() {
|
||||
)
|
||||
}
|
||||
|
||||
const PUBLIC_PATHS = ['/login', '/reset-password']
|
||||
|
||||
function RootComponent() {
|
||||
const { user, loading } = useAuth()
|
||||
const { user, loading, pendingPasswordReset } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
|
||||
useEffect(() => {
|
||||
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' })
|
||||
} else if (user && pathname === '/login') {
|
||||
} else if (user && !pendingPasswordReset && pathname === '/login') {
|
||||
void navigate({ to: '/' })
|
||||
}
|
||||
}, [user, loading, pathname, navigate])
|
||||
}, [user, loading, pathname, navigate, pendingPasswordReset])
|
||||
|
||||
if (loading) {
|
||||
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">
|
||||
<Loader2 className="h-8 w-8 animate-spin" style={{ color: '#F59845' }} />
|
||||
</div>
|
||||
@@ -57,6 +69,13 @@ function RootComponent() {
|
||||
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 loginRoute = createRoute({
|
||||
@@ -65,6 +84,12 @@ const loginRoute = createRoute({
|
||||
component: LoginPage,
|
||||
})
|
||||
|
||||
const resetPasswordRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/reset-password',
|
||||
component: ResetPasswordPage,
|
||||
})
|
||||
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
@@ -101,14 +126,53 @@ const resourcesRoute = createRoute({
|
||||
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([
|
||||
loginRoute,
|
||||
resetPasswordRoute,
|
||||
indexRoute,
|
||||
importRoute,
|
||||
workspaceRoute,
|
||||
reportRoute,
|
||||
settingsRoute,
|
||||
resourcesRoute,
|
||||
adminLayoutRoute.addChildren([
|
||||
adminIndexRoute,
|
||||
adminOrgsRoute,
|
||||
adminOrgDetailRoute,
|
||||
adminUsersRoute,
|
||||
]),
|
||||
])
|
||||
|
||||
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