Resumen de lo entregado
Some checks failed
/ Deploy to Cloudflare Pages (push) Has been cancelled

This commit is contained in:
2026-07-07 09:47:31 -03:00
parent 2922d4c2f3
commit bc9f70419c
26 changed files with 4102 additions and 23 deletions

View 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

View 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

View 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

View 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.

View File

@@ -1,13 +1,17 @@
import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react' import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react'
import { SupabaseAuthService } from './SupabaseAuthService' import { SupabaseAuthService } from './SupabaseAuthService'
import { supabase } from '@/lib/supabase'
import type { AuthUser } from './types' import type { AuthUser } from './types'
interface AuthContextValue { interface AuthContextValue {
user: AuthUser | null user: AuthUser | null
loading: boolean loading: boolean
signIn: () => Promise<void> signIn: () => Promise<void>
signInWithEmailPassword: (email: string, password: string) => Promise<{ error: string | null }>
signOut: () => Promise<void> signOut: () => Promise<void>
updateUser: (updates: Partial<Pick<AuthUser, 'name' | 'company'>>) => void updateUser: (updates: Partial<Pick<AuthUser, 'name' | 'company'>>) => void
pendingPasswordReset: boolean
clearPasswordReset: () => void
} }
const AuthContext = createContext<AuthContextValue | null>(null) const AuthContext = createContext<AuthContextValue | null>(null)
@@ -60,6 +64,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
) )
// Si es un OAuth redirect, mantener loading=true hasta que Supabase procese el token. // Si es un OAuth redirect, mantener loading=true hasta que Supabase procese el token.
const [loading, setLoading] = useState(isOAuthRedirect) const [loading, setLoading] = useState(isOAuthRedirect)
const [pendingPasswordReset, setPendingPasswordReset] = useState(false)
useEffect(() => { useEffect(() => {
// Verificar y sincronizar con Supabase en background. // Verificar y sincronizar con Supabase en background.
@@ -72,6 +77,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return () => unsubscribe() return () => unsubscribe()
}, []) }, [])
// Suscripción directa para PASSWORD_RECOVERY (no pasa por authService para evitar
// mezclar el flujo normal con el de reset de contraseña).
useEffect(() => {
const { data: { subscription } } = supabase.auth.onAuthStateChange((event) => {
if (event === 'PASSWORD_RECOVERY') {
setPendingPasswordReset(true)
}
})
return () => subscription.unsubscribe()
}, [])
// Sincronización con tabla users FUERA del lock de auth. // Sincronización con tabla users FUERA del lock de auth.
// Se ejecuta al cambiar de usuario (login/logout), NO en cada updateUser(). // Se ejecuta al cambiar de usuario (login/logout), NO en cada updateUser().
// user?.id es primitivo estable: no cambia cuando se edita nombre o empresa. // user?.id es primitivo estable: no cambia cuando se edita nombre o empresa.
@@ -90,12 +106,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
// Upsert idempotente: crea la fila si no existe (primer login), // Upsert idempotente: crea la fila si no existe (primer login),
// ignoreDuplicates evita sobreescribir name/company editados posteriormente. // ignoreDuplicates evita sobreescribir name/company editados posteriormente.
await authService.upsertUserOnLogin(capturedUser, controller.signal) await authService.upsertUserOnLogin(capturedUser, controller.signal)
// Leer company y name editado desde la tabla (pueden diferir del user_metadata de Google). // Leer platform_role, org_id, name y company desde la tabla (sobrescriben la heurística de email).
const profile = await authService.syncProfileFromDB(userId, controller.signal) const profile = await authService.syncProfileFromDB(userId, controller.signal)
if (profile) { if (profile) {
setUser((prev) => setUser((prev) =>
prev?.id === userId prev?.id === userId
? { ...prev, company: profile.company, name: profile.name || prev.name } ? {
...prev,
name: profile.name || prev.name,
company: profile.company,
platformRole: profile.platformRole,
orgId: profile.orgId,
}
: prev : prev
) )
} }
@@ -115,17 +137,34 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const signIn = useCallback(() => authService.signInWithGoogle(), []) const signIn = useCallback(() => authService.signInWithGoogle(), [])
const signInWithEmailPassword = useCallback(
(email: string, password: string) => authService.signInWithEmailPassword(email, password),
[]
)
const signOut = useCallback(async () => { const signOut = useCallback(async () => {
await authService.signOut() await authService.signOut()
setUser(null) setUser(null)
setPendingPasswordReset(false)
}, []) }, [])
const updateUser = useCallback((updates: Partial<Pick<AuthUser, 'name' | 'company'>>) => { const updateUser = useCallback((updates: Partial<Pick<AuthUser, 'name' | 'company'>>) => {
setUser((prev) => prev ? { ...prev, ...updates } : prev) setUser((prev) => prev ? { ...prev, ...updates } : prev)
}, []) }, [])
const clearPasswordReset = useCallback(() => setPendingPasswordReset(false), [])
return ( return (
<AuthContext.Provider value={{ user, loading, signIn, signOut, updateUser }}> <AuthContext.Provider value={{
user,
loading,
signIn,
signInWithEmailPassword,
signOut,
updateUser,
pendingPasswordReset,
clearPasswordReset,
}}>
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
) )

View File

@@ -15,6 +15,11 @@ export class SupabaseAuthService implements AuthService {
if (error) throw error if (error) throw error
} }
async signInWithEmailPassword(email: string, password: string): Promise<{ error: string | null }> {
const { error } = await supabase.auth.signInWithPassword({ email, password })
return { error: error?.message ?? null }
}
async signOut(): Promise<void> { async signOut(): Promise<void> {
const { error } = await supabase.auth.signOut() const { error } = await supabase.auth.signOut()
if (error) throw error if (error) throw error
@@ -34,6 +39,8 @@ export class SupabaseAuthService implements AuthService {
const { data: { subscription } } = supabase.auth.onAuthStateChange( const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event, session) => { // NO async — el lock de GoTrueClient está activo durante este callback (event, session) => { // NO async — el lock de GoTrueClient está activo durante este callback
try { try {
// Token refresh silencioso — no disparar re-sync ni setLoading
if (event === 'TOKEN_REFRESHED' || event === 'USER_UPDATED') return
if (!session?.user) { if (!session?.user) {
// INITIAL_SESSION null es transitorio: Supabase no pudo verificar la sesión // INITIAL_SESSION null es transitorio: Supabase no pudo verificar la sesión
// (red lenta, cold start, timeout). Si hay sesión en localStorage, no forzar logout. // (red lenta, cold start, timeout). Si hay sesión en localStorage, no forzar logout.
@@ -41,10 +48,12 @@ export class SupabaseAuthService implements AuthService {
if (event !== 'INITIAL_SESSION') callback(null) if (event !== 'INITIAL_SESSION') callback(null)
return return
} }
if (event !== 'INITIAL_SESSION' && event !== 'SIGNED_IN') return // PASSWORD_RECOVERY tiene sesión activa — incluir para que AuthContext establezca el usuario
// antes de redirigir a /reset-password. La señal de redirect se maneja en AuthContext.
if (event !== 'INITIAL_SESSION' && event !== 'SIGNED_IN' && event !== 'PASSWORD_RECOVERY') return
// Llamar callback inmediatamente con datos básicos del token OAuth. // Llamar callback inmediatamente con datos básicos del token OAuth.
// NO hacer queries a Supabase desde aquí — el lock de auth está activo (GoTrueClient.js L3213). // NO hacer queries a Supabase desde aquí — el lock de auth está activo (GoTrueClient.js L3213).
// El upsert y la lectura de company/name ocurren en AuthContext fuera del lock. // El upsert y la lectura de platform_role/org_id ocurren en AuthContext fuera del lock.
callback(this.buildAuthUser(session.user)) callback(this.buildAuthUser(session.user))
} catch (err) { } catch (err) {
console.error('Error en onAuthStateChange:', err) console.error('Error en onAuthStateChange:', err)
@@ -57,16 +66,19 @@ export class SupabaseAuthService implements AuthService {
return () => subscription.unsubscribe() return () => subscription.unsubscribe()
} }
// Leer company y name editados desde la tabla users (pueden diferir del user_metadata de Google). // Leer platform_role, org_id, company y name desde la tabla users.
// Sobrescribe la heurística de email de buildAuthUser con el valor real de DB.
// Se llama desde AuthContext FUERA del lock de auth, en un useEffect normal. // Se llama desde AuthContext FUERA del lock de auth, en un useEffect normal.
// signal permite cancelar si el servidor tarda demasiado (libera el processLock). // signal permite cancelar si el servidor tarda demasiado (libera el processLock).
async syncProfileFromDB(userId: string, signal?: AbortSignal): Promise<Pick<AuthUser, 'name' | 'company'> | null> { async syncProfileFromDB(userId: string, signal?: AbortSignal): Promise<Pick<AuthUser, 'name' | 'company' | 'platformRole' | 'orgId'> | null> {
const base = supabase.from('users').select('company, name').eq('id', userId) const base = supabase.from('users').select('name, company, platform_role, org_id').eq('id', userId)
const { data } = await (signal ? base.abortSignal(signal) : base).maybeSingle() const { data } = await (signal ? base.abortSignal(signal) : base).maybeSingle()
if (!data) return null if (!data) return null
return { return {
name: ((data.name as string | null | undefined) || undefined) as unknown as string, name: ((data.name as string | null | undefined) || undefined) as unknown as string,
company: (data.company as string | null) ?? undefined, company: (data.company as string | null) ?? undefined,
platformRole: (data.platform_role as AuthUser['platformRole']) ?? 'guest',
orgId: (data.org_id as string | null) ?? undefined,
} }
} }
@@ -88,7 +100,7 @@ export class SupabaseAuthService implements AuthService {
} }
// Sincrónico: deriva el usuario de los metadatos de sesión sin consultar la DB. // Sincrónico: deriva el usuario de los metadatos de sesión sin consultar la DB.
// El rol se deriva del dominio del email (misma lógica que el upsert en SIGNED_IN). // El rol se deriva del dominio del email (valor optimista, syncProfileFromDB lo corrige después).
private buildAuthUser(supabaseUser: { id: string; email?: string; user_metadata?: Record<string, unknown> }): AuthUser { private buildAuthUser(supabaseUser: { id: string; email?: string; user_metadata?: Record<string, unknown> }): AuthUser {
const email = supabaseUser.email ?? '' const email = supabaseUser.email ?? ''
return { return {

View File

@@ -3,12 +3,14 @@ export interface AuthUser {
email: string email: string
name: string name: string
avatarUrl?: string avatarUrl?: string
platformRole: 'platform_admin' | 'member' | 'guest' platformRole: 'platform_admin' | 'member' | 'client_editor' | 'client_viewer' | 'guest'
company?: string company?: string
orgId?: string
} }
export interface AuthService { export interface AuthService {
signInWithGoogle(): Promise<void> signInWithGoogle(): Promise<void>
signInWithEmailPassword(email: string, password: string): Promise<{ error: string | null }>
signOut(): Promise<void> signOut(): Promise<void>
getCurrentUser(): Promise<AuthUser | null> getCurrentUser(): Promise<AuthUser | null>
onAuthStateChange(callback: (user: AuthUser | null) => void): () => void onAuthStateChange(callback: (user: AuthUser | null) => void): () => void

View File

@@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { LogOut, UserPen } from 'lucide-react' import { LogOut, UserPen, Shield } from 'lucide-react'
import { useAuth } from '@/auth/AuthContext' import { useAuth } from '@/auth/AuthContext'
import { import {
DropdownMenu, DropdownMenu,
@@ -77,6 +77,20 @@ export function AppHeader() {
> >
InQ ROI InQ ROI
</Link> </Link>
{user?.platformRole === 'platform_admin' && (
<Link
to="/admin"
title="Panel de administración"
style={{
display: 'flex',
alignItems: 'center',
color: 'white',
opacity: 0.85,
}}
>
<Shield style={{ width: 18, height: 18 }} />
</Link>
)}
{user && ( {user && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>

View 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 }

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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">
</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>
)
}

View 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>
)
}

View File

@@ -1,4 +1,14 @@
import { useState } from 'react'
import { z } from 'zod'
import { useAuth } from '@/auth/AuthContext' import { useAuth } from '@/auth/AuthContext'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
const loginSchema = z.object({
email: z.string().email('Email inválido'),
password: z.string().min(1, 'La contraseña es requerida'),
})
function GoogleIcon() { function GoogleIcon() {
return ( return (
@@ -12,12 +22,39 @@ function GoogleIcon() {
} }
export function LoginPage() { export function LoginPage() {
const { signIn, loading } = useAuth() const { signIn, signInWithEmailPassword, loading } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [formError, setFormError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const handleEmailLogin = async (e: React.FormEvent) => {
e.preventDefault()
setFormError(null)
const result = loginSchema.safeParse({ email, password })
if (!result.success) {
setFormError(result.error.issues[0].message)
return
}
setIsSubmitting(true)
const { error } = await signInWithEmailPassword(email, password)
if (error) {
setFormError(
error === 'Invalid login credentials'
? 'Email o contraseña incorrectos'
: error
)
setIsSubmitting(false)
}
// En login exitoso no resetear isSubmitting — el router redirige y desmonta el componente
}
return ( return (
<div className="min-h-screen bg-white flex items-center justify-center"> <div className="min-h-screen bg-white flex items-center justify-center">
<div <div
className="flex flex-col items-center gap-6 px-8" className="flex flex-col items-center gap-6 px-8 w-full max-w-sm"
style={{ animation: 'loginFadeIn 0.5s ease-out both' }} style={{ animation: 'loginFadeIn 0.5s ease-out both' }}
> >
<style>{` <style>{`
@@ -40,16 +77,63 @@ export function LoginPage() {
<button <button
onClick={signIn} onClick={signIn}
disabled={loading} disabled={loading || isSubmitting}
className="flex items-center gap-3 px-6 py-3 rounded-xl font-semibold text-white text-sm shadow-sm transition-opacity hover:opacity-90 disabled:opacity-60 disabled:cursor-not-allowed" className="w-full flex items-center justify-center gap-3 px-6 py-3 rounded-xl font-semibold text-white text-sm shadow-sm transition-opacity hover:opacity-90 disabled:opacity-60 disabled:cursor-not-allowed"
style={{ backgroundColor: '#F59845' }} style={{ backgroundColor: '#F59845' }}
> >
<GoogleIcon /> <GoogleIcon />
Iniciar sesión con Google Continuar con Google
</button> </button>
<p className="text-xs text-slate-400"> <div className="w-full flex items-center gap-3">
Solo para usuarios con cuenta @inquality.com.py <div className="flex-1 h-px bg-slate-200" />
<span className="text-xs text-slate-400">o</span>
<div className="flex-1 h-px bg-slate-200" />
</div>
<form onSubmit={handleEmailLogin} className="w-full flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="email" className="text-sm text-slate-700">Email</Label>
<Input
id="email"
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="usuario@empresa.com"
disabled={isSubmitting}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="password" className="text-sm text-slate-700">Contraseña</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
disabled={isSubmitting}
/>
</div>
{formError && (
<p className="text-xs text-red-600">{formError}</p>
)}
<Button
type="submit"
disabled={isSubmitting || loading}
className="w-full"
>
{isSubmitting ? 'Ingresando…' : 'Ingresar'}
</Button>
</form>
<p className="text-xs text-slate-400 text-center">
Los usuarios externos acceden por invitación.<br />
Si no recibiste una, contactá a tu consultor InQuality.
</p> </p>
</div> </div>
</div> </div>

View File

@@ -6,6 +6,11 @@ import { LibraryPage } from '@/features/library/LibraryPage'
import { SettingsPage } from '@/features/settings/SettingsPage' import { SettingsPage } from '@/features/settings/SettingsPage'
import { ResourceCatalogPage } from '@/features/resources/ResourceCatalogPage' import { ResourceCatalogPage } from '@/features/resources/ResourceCatalogPage'
import { LoginPage } from '@/features/login/LoginPage' import { LoginPage } from '@/features/login/LoginPage'
import { ResetPasswordPage } from '@/features/auth/ResetPasswordPage'
import { AdminLayout } from '@/features/admin/AdminLayout'
import { OrganizationsPage } from '@/features/admin/OrganizationsPage'
import { OrganizationDetailPage } from '@/features/admin/OrganizationDetailPage'
import { UsersPage } from '@/features/admin/UsersPage'
import { useAuth } from '@/auth/AuthContext' import { useAuth } from '@/auth/AuthContext'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
@@ -26,19 +31,26 @@ function ReportPageWithSuspense() {
) )
} }
const PUBLIC_PATHS = ['/login', '/reset-password']
function RootComponent() { function RootComponent() {
const { user, loading } = useAuth() const { user, loading, pendingPasswordReset } = useAuth()
const navigate = useNavigate() const navigate = useNavigate()
const pathname = useRouterState({ select: (s) => s.location.pathname }) const pathname = useRouterState({ select: (s) => s.location.pathname })
useEffect(() => { useEffect(() => {
if (loading) return if (loading) return
if (!user && pathname !== '/login') { // Usuario con PASSWORD_RECOVERY activo → forzar /reset-password
if (user && pendingPasswordReset && pathname !== '/reset-password') {
void navigate({ to: '/reset-password' })
return
}
if (!user && !PUBLIC_PATHS.includes(pathname)) {
void navigate({ to: '/login' }) void navigate({ to: '/login' })
} else if (user && pathname === '/login') { } else if (user && !pendingPasswordReset && pathname === '/login') {
void navigate({ to: '/' }) void navigate({ to: '/' })
} }
}, [user, loading, pathname, navigate]) }, [user, loading, pathname, navigate, pendingPasswordReset])
if (loading) { if (loading) {
return ( return (
@@ -48,7 +60,7 @@ function RootComponent() {
) )
} }
if (!user && pathname !== '/login') return ( if (!user && !PUBLIC_PATHS.includes(pathname)) return (
<div className="min-h-screen flex items-center justify-center"> <div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin" style={{ color: '#F59845' }} /> <Loader2 className="h-8 w-8 animate-spin" style={{ color: '#F59845' }} />
</div> </div>
@@ -57,6 +69,13 @@ function RootComponent() {
return <Outlet /> return <Outlet />
} }
// Redirect /admin → /admin/organizations
function AdminIndexRedirect() {
const navigate = useNavigate()
useEffect(() => { void navigate({ to: '/admin/organizations' }) }, [navigate])
return null
}
const rootRoute = createRootRoute({ component: RootComponent }) const rootRoute = createRootRoute({ component: RootComponent })
const loginRoute = createRoute({ const loginRoute = createRoute({
@@ -65,6 +84,12 @@ const loginRoute = createRoute({
component: LoginPage, component: LoginPage,
}) })
const resetPasswordRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/reset-password',
component: ResetPasswordPage,
})
const indexRoute = createRoute({ const indexRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/', path: '/',
@@ -101,14 +126,53 @@ const resourcesRoute = createRoute({
component: ResourceCatalogPage, component: ResourceCatalogPage,
}) })
// ─── Admin routes (guard de platform_admin en AdminLayout) ────────────────────
const adminLayoutRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/admin',
component: AdminLayout,
})
const adminIndexRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: '/',
component: AdminIndexRedirect,
})
const adminOrgsRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: '/organizations',
component: OrganizationsPage,
})
const adminOrgDetailRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: '/organizations/$orgId',
component: OrganizationDetailPage,
})
const adminUsersRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: '/users',
component: UsersPage,
})
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
loginRoute, loginRoute,
resetPasswordRoute,
indexRoute, indexRoute,
importRoute, importRoute,
workspaceRoute, workspaceRoute,
reportRoute, reportRoute,
settingsRoute, settingsRoute,
resourcesRoute, resourcesRoute,
adminLayoutRoute.addChildren([
adminIndexRoute,
adminOrgsRoute,
adminOrgDetailRoute,
adminUsersRoute,
]),
]) ])
export const router = createRouter({ routeTree }) export const router = createRouter({ routeTree })

View File

@@ -0,0 +1 @@
v2.109.0

View File

@@ -0,0 +1 @@
{"ref":"xgqbkxcliyjzisoccsur","name":"inq-roi-desa","organization_id":"ekmgshaupdqaebwypwwj","organization_slug":"ekmgshaupdqaebwypwwj"}

424
supabase/docker-compose.yml Normal file
View 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

View 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 },
)
})

View 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' } },
)
}
})

View 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 },
)
})

View 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');

View 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')
);

View 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()
})
})
})