build: actualizar dist con la versión de producción más reciente
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
# GLM Hub · Accesos centralizados
|
||||||
|
|
||||||
|
La versión final utiliza `public.glm_hub_app_access` como **registro informativo único para el LED del Hub**. Esta tabla no reemplaza el login ni los permisos reales de las aplicaciones.
|
||||||
|
|
||||||
|
## Cómo se usa
|
||||||
|
|
||||||
|
- **Aplicación abierta a todo GLM:** una fila con el nombre de la aplicación y `email = *`.
|
||||||
|
- **Aplicación restringida:** una fila por usuario con el nombre de la aplicación y su correo.
|
||||||
|
- **Revocar acceso en el Hub:** eliminar la fila o cambiar `is_active` a `false`.
|
||||||
|
- El `app_id` se completa automáticamente. Desde Supabase Table Editor solo necesitas completar `app_name` y `email`.
|
||||||
|
|
||||||
|
## Flujo operativo
|
||||||
|
|
||||||
|
Cuando IT otorgue acceso real a una aplicación restringida, debe agregar también al usuario en `glm_hub_app_access`. El Hub mostrará **Disponible** en verde. Si no existe un registro activo para ese usuario y tampoco existe una fila `*` para esa aplicación, mostrará **Sin acceso** en rojo.
|
||||||
|
|
||||||
|
## Aplicaciones actuales
|
||||||
|
|
||||||
|
El script final realiza una carga inicial automática: BambooHR, CDC Brief, cinco Cruces de Seguridad Social, Validación de IR - Nicaragua y GLM ID Card Generator quedan abiertos a todo GLM. CDC Project Management se copia inicialmente desde `tablero_cdc_allowed_users`; Portal de Verificación de Nóminas desde `cruce_cuentas_usuarios_autorizados`; y Seguimiento de Impuestos GLM desde `tax_calendar_access`.
|
||||||
|
|
||||||
|
Después de esa carga inicial, la fuente del LED es únicamente `glm_hub_app_access`. Los logins de las aplicaciones siguen funcionando como hoy.
|
||||||
|
|
||||||
|
## n8n
|
||||||
|
|
||||||
|
No se necesita ninguna modificación adicional en n8n para la tabla central. El workflow de solicitudes incluido en el ZIP conserva la mejora previa del jefe inmediato.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# GLM Hub — LED de acceso + jefe inmediato
|
||||||
|
|
||||||
|
## Qué cambia
|
||||||
|
|
||||||
|
Esta versión agrega dos mejoras sin cambiar el modelo actual de autenticación del Hub:
|
||||||
|
|
||||||
|
1. Cada aplicación publicada muestra el estado **Con acceso / Sin acceso** del usuario conectado.
|
||||||
|
2. Las solicitudes de acceso se envían a **Isaac Aracena, José Leopoldo Gómez, Máximo Gómez y al jefe inmediato del solicitante**, cuando `empleados_glm.supervisor_email` está disponible.
|
||||||
|
|
||||||
|
La primera decisión registrada sigue siendo definitiva para todos los aprobadores.
|
||||||
|
|
||||||
|
## Fuentes de acceso utilizadas
|
||||||
|
|
||||||
|
El Hub no replica los permisos de las aplicaciones restringidas: consulta su fuente real en Supabase.
|
||||||
|
|
||||||
|
- **CDC Project Management** → `tablero_cdc_allowed_users` (`email`, `is_active`).
|
||||||
|
- **Portal de Verificación de Nóminas** → `cruce_cuentas_usuarios_autorizados` (`email`).
|
||||||
|
- **Seguimiento de Impuestos GLM** → `tax_calendar_access` (`email`, `active`).
|
||||||
|
- **BambooHR, CDC Brief, los 5 Cruces de Seguridad Social, Validación de IR - Nicaragua y GLM ID Card Generator** → acceso general para usuarios autenticados `@gomezleemarketing.com`.
|
||||||
|
- Cualquier aplicación futura que no esté clasificada se muestra por seguridad como **Sin acceso** hasta agregar su regla al RPC.
|
||||||
|
|
||||||
|
El estado se vuelve a comprobar al iniciar sesión, cada 60 segundos y cuando el usuario vuelve a enfocar la pestaña del Hub.
|
||||||
|
|
||||||
|
## Orden de implementación
|
||||||
|
|
||||||
|
### 1. Supabase
|
||||||
|
|
||||||
|
Ejecutar completo:
|
||||||
|
|
||||||
|
`SUPABASE-GLM-HUB-LED-ACCESO-JEFE-INMEDIATO.sql`
|
||||||
|
|
||||||
|
Este script:
|
||||||
|
|
||||||
|
- elimina la restricción que limitaba los aprobadores a tres correos exactos;
|
||||||
|
- conserva a los tres aprobadores base;
|
||||||
|
- obtiene el jefe desde `empleados_glm.work_email → supervisor_email`;
|
||||||
|
- evita duplicados;
|
||||||
|
- no rompe la solicitud si el jefe no está disponible;
|
||||||
|
- crea el RPC `glm_hub_get_my_app_access()` para el LED.
|
||||||
|
|
||||||
|
### 2. n8n
|
||||||
|
|
||||||
|
Actualizar el workflow **GLM Hub - Solicitudes de acceso y aprobación** con:
|
||||||
|
|
||||||
|
`n8n/GLM-Hub-Solicitudes-Acceso-Aprobacion.json`
|
||||||
|
|
||||||
|
No se modificó el workflow de Gemini. Tampoco se cambió la forma actual en que el workflow usa las credenciales/keys de Supabase, por solicitud expresa.
|
||||||
|
|
||||||
|
### 3. Frontend
|
||||||
|
|
||||||
|
Publicar el contenido nuevo de `dist/` en el mismo subdominio del GLM Hub.
|
||||||
|
|
||||||
|
## Prueba recomendada
|
||||||
|
|
||||||
|
1. Iniciar sesión con un usuario GLM que no sea administrador.
|
||||||
|
2. Confirmar que cada app muestre **Con acceso** o **Sin acceso**.
|
||||||
|
3. Verificar un usuario presente y otro ausente en cada una de las tres tablas restringidas.
|
||||||
|
4. Enviar una solicitud de acceso.
|
||||||
|
5. En la ejecución de n8n, revisar `Emitir enlaces de aprobación`: debe devolver 3 aprobadores base y, cuando exista, un cuarto aprobador correspondiente al `supervisor_email`.
|
||||||
|
6. Confirmar que todos reciban su correo.
|
||||||
|
7. Aprobar con uno de ellos y comprobar que un segundo intento muestre que la solicitud ya fue atendida.
|
||||||
|
8. Agregar/eliminar al usuario en la tabla real de una app restringida y volver al Hub; al recuperar el foco (o como máximo en 60 segundos) el LED debe actualizarse.
|
||||||
|
|
||||||
|
## Comportamiento de seguridad
|
||||||
|
|
||||||
|
- Un usuario sin registro en `glm_hub_authorized_users` sigue entrando al Hub si pertenece a `@gomezleemarketing.com`.
|
||||||
|
- `glm_hub_authorized_users` continúa usándose para administradores y bloqueos excepcionales.
|
||||||
|
- El LED es informativo: no sustituye la protección interna de cada aplicación.
|
||||||
|
- Las aplicaciones no clasificadas no se marcan automáticamente como autorizadas.
|
||||||
+170
-56
@@ -546,13 +546,10 @@ create table if not exists public.glm_hub_access_request_approvers (
|
|||||||
clicked_at timestamptz,
|
clicked_at timestamptz,
|
||||||
created_at timestamptz not null default now(),
|
created_at timestamptz not null default now(),
|
||||||
|
|
||||||
constraint glm_hub_access_request_approvers_email_check
|
constraint glm_hub_access_request_approvers_email_normalized_check
|
||||||
check (
|
check (
|
||||||
approver_email in (
|
approver_email = lower(btrim(approver_email))
|
||||||
'iaracena@gomezleemarketing.com',
|
and approver_email ~ '^[a-z0-9.!#$%&''*+/=?^_`{|}~-]+@[a-z0-9.-]+\.[a-z]{2,}$'
|
||||||
'jgomez@gomezleemarketing.com',
|
|
||||||
'mgomez@gomezleemarketing.com'
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
constraint glm_hub_access_request_approvers_unique_request_email
|
constraint glm_hub_access_request_approvers_unique_request_email
|
||||||
unique (request_id, approver_email)
|
unique (request_id, approver_email)
|
||||||
@@ -696,8 +693,9 @@ revoke all on function public.glm_hub_prepare_access_request(uuid) from public,
|
|||||||
grant execute on function public.glm_hub_prepare_access_request(uuid) to authenticated;
|
grant execute on function public.glm_hub_prepare_access_request(uuid) to authenticated;
|
||||||
grant execute on function public.glm_hub_prepare_access_request(uuid) to service_role;
|
grant execute on function public.glm_hub_prepare_access_request(uuid) to service_role;
|
||||||
|
|
||||||
-- Emite o recupera los tres enlaces de decisión. Solo n8n, usando la clave
|
-- Emite o recupera los enlaces de decisión para los tres aprobadores base y,
|
||||||
-- service_role guardada en su nodo HTTP Request, puede ejecutar esta función.
|
-- cuando está disponible, para el jefe inmediato del solicitante. Solo n8n,
|
||||||
|
-- usando la clave service_role guardada en su nodo HTTP Request, puede ejecutar esta función.
|
||||||
create or replace function public.glm_hub_issue_access_request_tokens(p_request_id uuid)
|
create or replace function public.glm_hub_issue_access_request_tokens(p_request_id uuid)
|
||||||
returns jsonb
|
returns jsonb
|
||||||
language plpgsql
|
language plpgsql
|
||||||
@@ -707,9 +705,6 @@ set search_path = ''
|
|||||||
as $$
|
as $$
|
||||||
declare
|
declare
|
||||||
v_request public.glm_hub_access_requests%rowtype;
|
v_request public.glm_hub_access_requests%rowtype;
|
||||||
v_token_isaac text;
|
|
||||||
v_token_jose text;
|
|
||||||
v_token_maximo text;
|
|
||||||
begin
|
begin
|
||||||
select request.*
|
select request.*
|
||||||
into v_request
|
into v_request
|
||||||
@@ -722,45 +717,72 @@ begin
|
|||||||
return jsonb_build_object('ok', false, 'error', 'La solicitud no existe o ya fue atendida.');
|
return jsonb_build_object('ok', false, 'error', 'La solicitud no existe o ya fue atendida.');
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
if not exists (
|
with supervisor_candidate as (
|
||||||
select 1
|
select
|
||||||
from public.glm_hub_access_request_approvers as approver
|
coalesce(
|
||||||
where approver.request_id = p_request_id
|
nullif(btrim(employee.supervisor), ''),
|
||||||
) then
|
nullif(btrim(supervisor_employee.name), ''),
|
||||||
v_token_isaac := encode(extensions.gen_random_bytes(32), 'hex');
|
split_part(lower(btrim(employee.supervisor_email)), '@', 1)
|
||||||
v_token_jose := encode(extensions.gen_random_bytes(32), 'hex');
|
) as approver_name,
|
||||||
v_token_maximo := encode(extensions.gen_random_bytes(32), 'hex');
|
lower(btrim(employee.supervisor_email)) as approver_email,
|
||||||
|
40 as priority
|
||||||
insert into public.glm_hub_access_request_approvers (
|
from public.empleados_glm as employee
|
||||||
request_id,
|
left join public.empleados_glm as supervisor_employee
|
||||||
|
on lower(btrim(supervisor_employee.work_email)) = lower(btrim(employee.supervisor_email))
|
||||||
|
where lower(btrim(employee.work_email)) = v_request.requester_email
|
||||||
|
and nullif(btrim(employee.supervisor_email), '') is not null
|
||||||
|
order by
|
||||||
|
case when employee.status::text = 'Active' then 0 else 1 end,
|
||||||
|
employee.id
|
||||||
|
limit 1
|
||||||
|
),
|
||||||
|
candidate_approvers as (
|
||||||
|
select 'Isaac Aracena'::text as approver_name,
|
||||||
|
'iaracena@gomezleemarketing.com'::text as approver_email,
|
||||||
|
10 as priority
|
||||||
|
union all
|
||||||
|
select 'José Leopoldo Gómez', 'jgomez@gomezleemarketing.com', 20
|
||||||
|
union all
|
||||||
|
select 'Máximo Gómez', 'mgomez@gomezleemarketing.com', 30
|
||||||
|
union all
|
||||||
|
select approver_name, approver_email, priority
|
||||||
|
from supervisor_candidate
|
||||||
|
),
|
||||||
|
deduplicated as (
|
||||||
|
select distinct on (approver_email)
|
||||||
approver_name,
|
approver_name,
|
||||||
approver_email,
|
approver_email,
|
||||||
decision_token,
|
priority
|
||||||
decision_token_hash
|
from candidate_approvers
|
||||||
)
|
where approver_email is not null
|
||||||
values
|
and approver_email <> ''
|
||||||
(
|
and approver_email <> v_request.requester_email
|
||||||
p_request_id,
|
and approver_email ~ '^[a-z0-9.!#$%&''*+/=?^_`{|}~-]+@[a-z0-9.-]+\.[a-z]{2,}$'
|
||||||
'Isaac Aracena',
|
order by approver_email, priority
|
||||||
'iaracena@gomezleemarketing.com',
|
),
|
||||||
v_token_isaac,
|
tokenized as (
|
||||||
encode(extensions.digest(v_token_isaac, 'sha256'), 'hex')
|
select
|
||||||
),
|
approver_name,
|
||||||
(
|
approver_email,
|
||||||
p_request_id,
|
priority,
|
||||||
'José Leopoldo Gómez',
|
encode(extensions.gen_random_bytes(32), 'hex') as decision_token
|
||||||
'jgomez@gomezleemarketing.com',
|
from deduplicated
|
||||||
v_token_jose,
|
)
|
||||||
encode(extensions.digest(v_token_jose, 'sha256'), 'hex')
|
insert into public.glm_hub_access_request_approvers (
|
||||||
),
|
request_id,
|
||||||
(
|
approver_name,
|
||||||
p_request_id,
|
approver_email,
|
||||||
'Máximo Gómez',
|
decision_token,
|
||||||
'mgomez@gomezleemarketing.com',
|
decision_token_hash
|
||||||
v_token_maximo,
|
)
|
||||||
encode(extensions.digest(v_token_maximo, 'sha256'), 'hex')
|
select
|
||||||
);
|
p_request_id,
|
||||||
end if;
|
tokenized.approver_name,
|
||||||
|
tokenized.approver_email,
|
||||||
|
tokenized.decision_token,
|
||||||
|
encode(extensions.digest(tokenized.decision_token, 'sha256'), 'hex')
|
||||||
|
from tokenized
|
||||||
|
on conflict (request_id, approver_email) do nothing;
|
||||||
|
|
||||||
return jsonb_build_object(
|
return jsonb_build_object(
|
||||||
'ok', true,
|
'ok', true,
|
||||||
@@ -770,13 +792,23 @@ begin
|
|||||||
'appName', v_request.app_name,
|
'appName', v_request.app_name,
|
||||||
'appCategory', v_request.app_category,
|
'appCategory', v_request.app_category,
|
||||||
'approvers', (
|
'approvers', (
|
||||||
select jsonb_agg(
|
select coalesce(
|
||||||
jsonb_build_object(
|
jsonb_agg(
|
||||||
'name', approver.approver_name,
|
jsonb_build_object(
|
||||||
'email', approver.approver_email,
|
'name', approver.approver_name,
|
||||||
'token', approver.decision_token
|
'email', approver.approver_email,
|
||||||
)
|
'token', approver.decision_token
|
||||||
order by approver.approver_email
|
)
|
||||||
|
order by
|
||||||
|
case approver.approver_email
|
||||||
|
when 'iaracena@gomezleemarketing.com' then 1
|
||||||
|
when 'jgomez@gomezleemarketing.com' then 2
|
||||||
|
when 'mgomez@gomezleemarketing.com' then 3
|
||||||
|
else 4
|
||||||
|
end,
|
||||||
|
approver.approver_email
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
)
|
)
|
||||||
from public.glm_hub_access_request_approvers as approver
|
from public.glm_hub_access_request_approvers as approver
|
||||||
where approver.request_id = p_request_id
|
where approver.request_id = p_request_id
|
||||||
@@ -886,7 +918,89 @@ grant execute on function public.glm_hub_decide_access_request(text, text) to au
|
|||||||
grant execute on function public.glm_hub_decide_access_request(text, text) to service_role;
|
grant execute on function public.glm_hub_decide_access_request(text, text) to service_role;
|
||||||
|
|
||||||
-- --------------------------------------------------------------------------
|
-- --------------------------------------------------------------------------
|
||||||
-- 7. REALTIME DEL CATÁLOGO
|
-- 7. ESTADO DE ACCESO POR APLICACIÓN
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
create or replace function public.glm_hub_get_my_app_access()
|
||||||
|
returns table (
|
||||||
|
app_id uuid,
|
||||||
|
app_name text,
|
||||||
|
has_access boolean
|
||||||
|
)
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = ''
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_email text;
|
||||||
|
begin
|
||||||
|
v_email := lower(btrim(coalesce(auth.jwt() ->> 'email', '')));
|
||||||
|
|
||||||
|
if auth.uid() is null or v_email = '' then
|
||||||
|
return;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if v_email !~ '^[a-z0-9.!#$%&''*+/=?^_`{|}~-]+@gomezleemarketing\.com$' then
|
||||||
|
return;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return query
|
||||||
|
select
|
||||||
|
app.id,
|
||||||
|
app.name,
|
||||||
|
case
|
||||||
|
-- Aplicaciones restringidas: consultar su fuente real de autorización.
|
||||||
|
when lower(btrim(app.name)) = 'cdc project management' then exists (
|
||||||
|
select 1
|
||||||
|
from public.tablero_cdc_allowed_users as allowed_user
|
||||||
|
where lower(btrim(allowed_user.email)) = v_email
|
||||||
|
and coalesce(allowed_user.is_active, false) = true
|
||||||
|
)
|
||||||
|
|
||||||
|
when lower(btrim(app.name)) = 'portal de verificación de nóminas' then exists (
|
||||||
|
select 1
|
||||||
|
from public.cruce_cuentas_usuarios_autorizados as allowed_user
|
||||||
|
where lower(btrim(allowed_user.email)) = v_email
|
||||||
|
)
|
||||||
|
|
||||||
|
when lower(btrim(app.name)) = 'seguimiento de impuestos glm' then exists (
|
||||||
|
select 1
|
||||||
|
from public.tax_calendar_access as allowed_user
|
||||||
|
where lower(btrim(allowed_user.email)) = v_email
|
||||||
|
and coalesce(allowed_user.active, false) = true
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Aplicaciones disponibles para cualquier usuario GLM autenticado.
|
||||||
|
when lower(btrim(app.name)) in (
|
||||||
|
'bamboohr',
|
||||||
|
'cdc brief',
|
||||||
|
'cruce de seguridad social - honduras',
|
||||||
|
'cruce de seguridad social - guatemala',
|
||||||
|
'cruce de seguridad social - costa rica',
|
||||||
|
'cruce de seguridad social - república dominicana',
|
||||||
|
'cruce de seguridad social - el salvador',
|
||||||
|
'validación de ir - nicaragua',
|
||||||
|
'glm id card generator'
|
||||||
|
) then true
|
||||||
|
|
||||||
|
-- Seguridad por defecto: una aplicación nueva/no clasificada nunca se
|
||||||
|
-- muestra como autorizada hasta definir su fuente de permisos.
|
||||||
|
else false
|
||||||
|
end as has_access
|
||||||
|
from public.glm_hub_apps as app
|
||||||
|
where app.visibility = 'published'
|
||||||
|
order by app.name;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
alter function public.glm_hub_get_my_app_access() owner to postgres;
|
||||||
|
revoke all on function public.glm_hub_get_my_app_access() from public, anon;
|
||||||
|
grant execute on function public.glm_hub_get_my_app_access() to authenticated;
|
||||||
|
grant execute on function public.glm_hub_get_my_app_access() to service_role;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- 8. REALTIME DEL CATÁLOGO
|
||||||
-- --------------------------------------------------------------------------
|
-- --------------------------------------------------------------------------
|
||||||
|
|
||||||
-- Hace que una aplicación creada/editada/eliminada por un administrador
|
-- Hace que una aplicación creada/editada/eliminada por un administrador
|
||||||
|
|||||||
@@ -0,0 +1,487 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- GLM HUB · VERSIÓN FINAL
|
||||||
|
-- LED DE DISPONIBILIDAD + REGISTRO CENTRAL DE ACCESOS + JEFE INMEDIATO
|
||||||
|
-- GomezLee Marketing
|
||||||
|
-- Fecha: 2026-08-14
|
||||||
|
--
|
||||||
|
-- OBJETIVO
|
||||||
|
-- 1) Mantener a Isaac + José Leopoldo + Máximo como aprobadores base.
|
||||||
|
-- 2) Agregar al jefe inmediato desde empleados_glm.supervisor_email.
|
||||||
|
-- 3) Crear UNA tabla central que el Hub usa únicamente para saber si debe mostrar
|
||||||
|
-- "Disponible" o "Sin acceso".
|
||||||
|
-- 4) NO modifica el login ni los permisos reales de ninguna aplicación.
|
||||||
|
-- 5) NO modifica claves, credenciales ni configuración de EasyPanel/n8n.
|
||||||
|
--
|
||||||
|
-- TABLA CENTRAL
|
||||||
|
-- public.glm_hub_app_access
|
||||||
|
-- app_name = nombre visible de la aplicación en el Hub
|
||||||
|
-- email = correo autorizado
|
||||||
|
-- email='*' significa que TODOS los usuarios @gomezleemarketing.com tienen acceso
|
||||||
|
-- is_active=false permite desactivar el registro sin borrarlo
|
||||||
|
--
|
||||||
|
-- OPERACIÓN FUTURA
|
||||||
|
-- - App abierta a todo GLM: agregar una fila con app_name + email='*'.
|
||||||
|
-- - App restringida: agregar una fila por usuario con app_name + su correo.
|
||||||
|
-- - Revocar: borrar la fila o cambiar is_active a false.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
begin;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- 1. APROBADORES DINÁMICOS: 3 BASE + JEFE INMEDIATO
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
alter table public.glm_hub_access_request_approvers
|
||||||
|
drop constraint if exists glm_hub_access_request_approvers_email_check;
|
||||||
|
|
||||||
|
alter table public.glm_hub_access_request_approvers
|
||||||
|
drop constraint if exists glm_hub_access_request_approvers_email_normalized_check;
|
||||||
|
|
||||||
|
alter table public.glm_hub_access_request_approvers
|
||||||
|
add constraint glm_hub_access_request_approvers_email_normalized_check
|
||||||
|
check (
|
||||||
|
approver_email = lower(btrim(approver_email))
|
||||||
|
and approver_email ~ '^[a-z0-9.!#$%&''*+/=?^_`{|}~-]+@[a-z0-9.-]+\.[a-z]{2,}$'
|
||||||
|
);
|
||||||
|
|
||||||
|
create or replace function public.glm_hub_issue_access_request_tokens(p_request_id uuid)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
volatile
|
||||||
|
security definer
|
||||||
|
set search_path = ''
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_request public.glm_hub_access_requests%rowtype;
|
||||||
|
begin
|
||||||
|
select request.*
|
||||||
|
into v_request
|
||||||
|
from public.glm_hub_access_requests as request
|
||||||
|
where request.id = p_request_id
|
||||||
|
and request.status = 'pending'
|
||||||
|
for update;
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
return jsonb_build_object('ok', false, 'error', 'La solicitud no existe o ya fue atendida.');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
with supervisor_candidate as (
|
||||||
|
select
|
||||||
|
coalesce(
|
||||||
|
nullif(btrim(employee.supervisor), ''),
|
||||||
|
nullif(btrim(supervisor_employee.name), ''),
|
||||||
|
split_part(lower(btrim(employee.supervisor_email)), '@', 1)
|
||||||
|
) as approver_name,
|
||||||
|
lower(btrim(employee.supervisor_email)) as approver_email,
|
||||||
|
40 as priority
|
||||||
|
from public.empleados_glm as employee
|
||||||
|
left join public.empleados_glm as supervisor_employee
|
||||||
|
on lower(btrim(supervisor_employee.work_email)) = lower(btrim(employee.supervisor_email))
|
||||||
|
where lower(btrim(employee.work_email)) = v_request.requester_email
|
||||||
|
and nullif(btrim(employee.supervisor_email), '') is not null
|
||||||
|
order by
|
||||||
|
case when employee.status::text = 'Active' then 0 else 1 end,
|
||||||
|
employee.id
|
||||||
|
limit 1
|
||||||
|
),
|
||||||
|
candidate_approvers as (
|
||||||
|
select 'Isaac Aracena'::text as approver_name,
|
||||||
|
'iaracena@gomezleemarketing.com'::text as approver_email,
|
||||||
|
10 as priority
|
||||||
|
union all
|
||||||
|
select 'José Leopoldo Gómez', 'jgomez@gomezleemarketing.com', 20
|
||||||
|
union all
|
||||||
|
select 'Máximo Gómez', 'mgomez@gomezleemarketing.com', 30
|
||||||
|
union all
|
||||||
|
select approver_name, approver_email, priority
|
||||||
|
from supervisor_candidate
|
||||||
|
),
|
||||||
|
deduplicated as (
|
||||||
|
select distinct on (approver_email)
|
||||||
|
approver_name,
|
||||||
|
approver_email,
|
||||||
|
priority
|
||||||
|
from candidate_approvers
|
||||||
|
where approver_email is not null
|
||||||
|
and approver_email <> ''
|
||||||
|
and approver_email <> v_request.requester_email
|
||||||
|
and approver_email ~ '^[a-z0-9.!#$%&''*+/=?^_`{|}~-]+@[a-z0-9.-]+\.[a-z]{2,}$'
|
||||||
|
order by approver_email, priority
|
||||||
|
),
|
||||||
|
tokenized as (
|
||||||
|
select
|
||||||
|
approver_name,
|
||||||
|
approver_email,
|
||||||
|
priority,
|
||||||
|
encode(extensions.gen_random_bytes(32), 'hex') as decision_token
|
||||||
|
from deduplicated
|
||||||
|
)
|
||||||
|
insert into public.glm_hub_access_request_approvers (
|
||||||
|
request_id,
|
||||||
|
approver_name,
|
||||||
|
approver_email,
|
||||||
|
decision_token,
|
||||||
|
decision_token_hash
|
||||||
|
)
|
||||||
|
select
|
||||||
|
p_request_id,
|
||||||
|
tokenized.approver_name,
|
||||||
|
tokenized.approver_email,
|
||||||
|
tokenized.decision_token,
|
||||||
|
encode(extensions.digest(tokenized.decision_token, 'sha256'), 'hex')
|
||||||
|
from tokenized
|
||||||
|
on conflict (request_id, approver_email) do nothing;
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'ok', true,
|
||||||
|
'requestId', v_request.id,
|
||||||
|
'requesterName', v_request.requester_name,
|
||||||
|
'requesterEmail', v_request.requester_email,
|
||||||
|
'appName', v_request.app_name,
|
||||||
|
'appCategory', v_request.app_category,
|
||||||
|
'approvers', (
|
||||||
|
select coalesce(
|
||||||
|
jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'name', approver.approver_name,
|
||||||
|
'email', approver.approver_email,
|
||||||
|
'token', approver.decision_token
|
||||||
|
)
|
||||||
|
order by
|
||||||
|
case approver.approver_email
|
||||||
|
when 'iaracena@gomezleemarketing.com' then 1
|
||||||
|
when 'jgomez@gomezleemarketing.com' then 2
|
||||||
|
when 'mgomez@gomezleemarketing.com' then 3
|
||||||
|
else 4
|
||||||
|
end,
|
||||||
|
approver.approver_email
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
from public.glm_hub_access_request_approvers as approver
|
||||||
|
where approver.request_id = p_request_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
alter function public.glm_hub_issue_access_request_tokens(uuid) owner to postgres;
|
||||||
|
revoke all on function public.glm_hub_issue_access_request_tokens(uuid) from public, anon, authenticated;
|
||||||
|
grant execute on function public.glm_hub_issue_access_request_tokens(uuid) to service_role;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- 2. TABLA CENTRAL DE ACCESOS DEL HUB
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- Esta tabla es INFORMATIVA para el Hub. No sustituye el login/seguridad real
|
||||||
|
-- de cada aplicación.
|
||||||
|
--
|
||||||
|
-- app_id es rellenado automáticamente a partir de app_name, de forma que desde
|
||||||
|
-- el Table Editor basta con escribir el nombre de la app y el correo.
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
create table if not exists public.glm_hub_app_access (
|
||||||
|
id uuid primary key default extensions.gen_random_uuid(),
|
||||||
|
app_name text not null,
|
||||||
|
email text not null,
|
||||||
|
is_active boolean not null default true,
|
||||||
|
app_id uuid references public.glm_hub_apps(id) on delete cascade,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
constraint glm_hub_app_access_email_check
|
||||||
|
check (
|
||||||
|
email = '*'
|
||||||
|
or email ~ '^[a-z0-9.!#$%&''*+/=?^_`{|}~-]+@[a-z0-9.-]+\.[a-z]{2,}$'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
comment on table public.glm_hub_app_access is
|
||||||
|
'Registro central informativo de accesos mostrado por GLM Hub. No reemplaza los permisos reales de cada aplicación.';
|
||||||
|
comment on column public.glm_hub_app_access.app_name is
|
||||||
|
'Nombre de la aplicación tal como aparece en glm_hub_apps.';
|
||||||
|
comment on column public.glm_hub_app_access.email is
|
||||||
|
'Correo autorizado. El valor * significa todos los usuarios GLM autenticados.';
|
||||||
|
comment on column public.glm_hub_app_access.is_active is
|
||||||
|
'TRUE = acceso mostrado como Disponible; FALSE = registro desactivado.';
|
||||||
|
comment on column public.glm_hub_app_access.app_id is
|
||||||
|
'Se completa automáticamente usando app_name.';
|
||||||
|
|
||||||
|
create unique index if not exists glm_hub_app_access_app_email_uidx
|
||||||
|
on public.glm_hub_app_access (app_id, email);
|
||||||
|
|
||||||
|
create index if not exists glm_hub_app_access_email_idx
|
||||||
|
on public.glm_hub_app_access (email)
|
||||||
|
where is_active = true;
|
||||||
|
|
||||||
|
-- Normaliza correos y permite trabajar por nombre de aplicación desde Table Editor.
|
||||||
|
create or replace function public.glm_hub_app_access_prepare_row()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = ''
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_app_id uuid;
|
||||||
|
v_app_name text;
|
||||||
|
begin
|
||||||
|
new.app_name := btrim(coalesce(new.app_name, ''));
|
||||||
|
new.email := lower(btrim(coalesce(new.email, '')));
|
||||||
|
|
||||||
|
if new.app_name = '' then
|
||||||
|
raise exception 'Debes indicar app_name.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if new.email = '' then
|
||||||
|
raise exception 'Debes indicar email. Usa * si la aplicación está disponible para todo GLM.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Si el usuario cambió app_name manualmente, ese nombre manda y se resuelve
|
||||||
|
-- nuevamente el UUID. En INSERT también permite dejar app_id vacío.
|
||||||
|
if tg_op = 'INSERT' then
|
||||||
|
select app.id, app.name
|
||||||
|
into v_app_id, v_app_name
|
||||||
|
from public.glm_hub_apps as app
|
||||||
|
where lower(btrim(app.name)) = lower(new.app_name)
|
||||||
|
order by app.created_at
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if v_app_id is null then
|
||||||
|
raise exception 'No existe una aplicación en GLM Hub llamada "%".', new.app_name;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
new.app_id := v_app_id;
|
||||||
|
new.app_name := v_app_name;
|
||||||
|
elsif new.app_id is null or new.app_name is distinct from old.app_name then
|
||||||
|
select app.id, app.name
|
||||||
|
into v_app_id, v_app_name
|
||||||
|
from public.glm_hub_apps as app
|
||||||
|
where lower(btrim(app.name)) = lower(new.app_name)
|
||||||
|
order by app.created_at
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if v_app_id is null then
|
||||||
|
raise exception 'No existe una aplicación en GLM Hub llamada "%".', new.app_name;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
new.app_id := v_app_id;
|
||||||
|
new.app_name := v_app_name;
|
||||||
|
else
|
||||||
|
select app.name
|
||||||
|
into v_app_name
|
||||||
|
from public.glm_hub_apps as app
|
||||||
|
where app.id = new.app_id;
|
||||||
|
|
||||||
|
if v_app_name is null then
|
||||||
|
raise exception 'El app_id indicado no existe en glm_hub_apps.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
new.app_name := v_app_name;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
new.updated_at := now();
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists glm_hub_app_access_prepare_row_trigger
|
||||||
|
on public.glm_hub_app_access;
|
||||||
|
|
||||||
|
create trigger glm_hub_app_access_prepare_row_trigger
|
||||||
|
before insert or update on public.glm_hub_app_access
|
||||||
|
for each row
|
||||||
|
execute function public.glm_hub_app_access_prepare_row();
|
||||||
|
|
||||||
|
-- Si un administrador cambia el nombre de una app en el Hub, el nombre visible
|
||||||
|
-- de los registros centrales se mantiene sincronizado por app_id.
|
||||||
|
create or replace function public.glm_hub_sync_access_app_name()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = ''
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if new.name is distinct from old.name then
|
||||||
|
update public.glm_hub_app_access
|
||||||
|
set app_name = new.name,
|
||||||
|
updated_at = now()
|
||||||
|
where app_id = new.id;
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists glm_hub_sync_access_app_name_trigger
|
||||||
|
on public.glm_hub_apps;
|
||||||
|
|
||||||
|
create trigger glm_hub_sync_access_app_name_trigger
|
||||||
|
after update of name on public.glm_hub_apps
|
||||||
|
for each row
|
||||||
|
execute function public.glm_hub_sync_access_app_name();
|
||||||
|
|
||||||
|
-- Nadie necesita leer esta tabla directamente desde el navegador. El frontend
|
||||||
|
-- consulta únicamente el RPC seguro glm_hub_get_my_app_access().
|
||||||
|
alter table public.glm_hub_app_access enable row level security;
|
||||||
|
revoke all on table public.glm_hub_app_access from anon, authenticated;
|
||||||
|
grant all on table public.glm_hub_app_access to service_role;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- 3. CARGA INICIAL DE LOS ACCESOS ACTUALES
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- A) Apps abiertas para cualquier usuario GLM -> una sola fila con email='*'.
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
insert into public.glm_hub_app_access (app_id, app_name, email, is_active)
|
||||||
|
select app.id, app.name, '*', true
|
||||||
|
from public.glm_hub_apps as app
|
||||||
|
where lower(btrim(app.name)) in (
|
||||||
|
'bamboohr',
|
||||||
|
'cdc brief',
|
||||||
|
'cruce de seguridad social - honduras',
|
||||||
|
'cruce de seguridad social - guatemala',
|
||||||
|
'cruce de seguridad social - costa rica',
|
||||||
|
'cruce de seguridad social - república dominicana',
|
||||||
|
'cruce de seguridad social - el salvador',
|
||||||
|
'validación de ir - nicaragua',
|
||||||
|
'glm id card generator'
|
||||||
|
)
|
||||||
|
on conflict (app_id, email)
|
||||||
|
do update set
|
||||||
|
app_name = excluded.app_name,
|
||||||
|
is_active = true,
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
-- B) CDC Project Management -> snapshot inicial desde tablero_cdc_allowed_users.
|
||||||
|
insert into public.glm_hub_app_access (app_id, app_name, email, is_active)
|
||||||
|
select
|
||||||
|
app.id,
|
||||||
|
app.name,
|
||||||
|
lower(btrim(allowed_user.email)),
|
||||||
|
true
|
||||||
|
from public.glm_hub_apps as app
|
||||||
|
cross join public.tablero_cdc_allowed_users as allowed_user
|
||||||
|
where lower(btrim(app.name)) = 'cdc project management'
|
||||||
|
and nullif(btrim(allowed_user.email), '') is not null
|
||||||
|
and coalesce(allowed_user.is_active, false) = true
|
||||||
|
on conflict (app_id, email)
|
||||||
|
do update set
|
||||||
|
app_name = excluded.app_name,
|
||||||
|
is_active = true,
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
-- C) Portal de Verificación de Nóminas -> snapshot inicial desde la tabla actual.
|
||||||
|
insert into public.glm_hub_app_access (app_id, app_name, email, is_active)
|
||||||
|
select
|
||||||
|
app.id,
|
||||||
|
app.name,
|
||||||
|
lower(btrim(allowed_user.email)),
|
||||||
|
true
|
||||||
|
from public.glm_hub_apps as app
|
||||||
|
cross join public.cruce_cuentas_usuarios_autorizados as allowed_user
|
||||||
|
where lower(btrim(app.name)) = 'portal de verificación de nóminas'
|
||||||
|
and nullif(btrim(allowed_user.email), '') is not null
|
||||||
|
on conflict (app_id, email)
|
||||||
|
do update set
|
||||||
|
app_name = excluded.app_name,
|
||||||
|
is_active = true,
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
-- D) Seguimiento de Impuestos GLM -> snapshot inicial desde tax_calendar_access.
|
||||||
|
insert into public.glm_hub_app_access (app_id, app_name, email, is_active)
|
||||||
|
select
|
||||||
|
app.id,
|
||||||
|
app.name,
|
||||||
|
lower(btrim(allowed_user.email)),
|
||||||
|
true
|
||||||
|
from public.glm_hub_apps as app
|
||||||
|
cross join public.tax_calendar_access as allowed_user
|
||||||
|
where lower(btrim(app.name)) = 'seguimiento de impuestos glm'
|
||||||
|
and nullif(btrim(allowed_user.email), '') is not null
|
||||||
|
and coalesce(allowed_user.active, false) = true
|
||||||
|
on conflict (app_id, email)
|
||||||
|
do update set
|
||||||
|
app_name = excluded.app_name,
|
||||||
|
is_active = true,
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- 4. RPC FINAL PARA EL LED DEL HUB
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- El Hub ya NO consulta las tablas particulares de cada aplicación.
|
||||||
|
-- Solo lee este registro central.
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
create or replace function public.glm_hub_get_my_app_access()
|
||||||
|
returns table (
|
||||||
|
app_id uuid,
|
||||||
|
app_name text,
|
||||||
|
has_access boolean
|
||||||
|
)
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = ''
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_email text;
|
||||||
|
begin
|
||||||
|
v_email := lower(btrim(coalesce(auth.jwt() ->> 'email', '')));
|
||||||
|
|
||||||
|
if auth.uid() is null or v_email = '' then
|
||||||
|
return;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- El GLM Hub continúa reservado al dominio corporativo principal.
|
||||||
|
if v_email !~ '^[a-z0-9.!#$%&''*+/=?^_`{|}~-]+@gomezleemarketing\.com$' then
|
||||||
|
return;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return query
|
||||||
|
select
|
||||||
|
app.id,
|
||||||
|
app.name,
|
||||||
|
exists (
|
||||||
|
select 1
|
||||||
|
from public.glm_hub_app_access as access_row
|
||||||
|
where access_row.app_id = app.id
|
||||||
|
and access_row.is_active = true
|
||||||
|
and (
|
||||||
|
access_row.email = '*'
|
||||||
|
or access_row.email = v_email
|
||||||
|
)
|
||||||
|
) as has_access
|
||||||
|
from public.glm_hub_apps as app
|
||||||
|
where app.visibility = 'published'
|
||||||
|
order by app.name;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
alter function public.glm_hub_get_my_app_access() owner to postgres;
|
||||||
|
revoke all on function public.glm_hub_get_my_app_access() from public, anon;
|
||||||
|
grant execute on function public.glm_hub_get_my_app_access() to authenticated;
|
||||||
|
grant execute on function public.glm_hub_get_my_app_access() to service_role;
|
||||||
|
|
||||||
|
commit;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- VERIFICACIONES OPCIONALES DESPUÉS DE EJECUTAR
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Ver toda la matriz central:
|
||||||
|
-- select app_name, email, is_active
|
||||||
|
-- from public.glm_hub_app_access
|
||||||
|
-- order by app_name, email;
|
||||||
|
|
||||||
|
-- Agregar acceso individual a una futura app restringida DESDE SQL:
|
||||||
|
-- insert into public.glm_hub_app_access (app_name, email)
|
||||||
|
-- values ('Nombre exacto de la aplicación', 'usuario@gomezleemarketing.com');
|
||||||
|
--
|
||||||
|
-- En el Table Editor puedes hacer exactamente lo mismo dejando app_id vacío.
|
||||||
|
|
||||||
|
-- Marcar una futura app como abierta para todo GLM:
|
||||||
|
-- insert into public.glm_hub_app_access (app_name, email)
|
||||||
|
-- values ('Nombre exacto de la aplicación', '*');
|
||||||
|
|
||||||
|
-- Revocar sin borrar historial:
|
||||||
|
-- update public.glm_hub_app_access
|
||||||
|
-- set is_active = false
|
||||||
|
-- where lower(app_name) = lower('Nombre exacto de la aplicación')
|
||||||
|
-- and email = 'usuario@gomezleemarketing.com';
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- GLM HUB · VERSIÓN FINAL
|
||||||
|
-- LED DE DISPONIBILIDAD + REGISTRO CENTRAL DE ACCESOS + JEFE INMEDIATO
|
||||||
|
-- GomezLee Marketing
|
||||||
|
-- Fecha: 2026-08-14
|
||||||
|
--
|
||||||
|
-- OBJETIVO
|
||||||
|
-- 1) Mantener a Isaac + José Leopoldo + Máximo como aprobadores base.
|
||||||
|
-- 2) Agregar al jefe inmediato desde empleados_glm.supervisor_email.
|
||||||
|
-- 3) Crear UNA tabla central que el Hub usa únicamente para saber si debe mostrar
|
||||||
|
-- "Disponible" o "Sin acceso".
|
||||||
|
-- 4) NO modifica el login ni los permisos reales de ninguna aplicación.
|
||||||
|
-- 5) NO modifica claves, credenciales ni configuración de EasyPanel/n8n.
|
||||||
|
--
|
||||||
|
-- TABLA CENTRAL
|
||||||
|
-- public.glm_hub_app_access
|
||||||
|
-- app_name = nombre visible de la aplicación en el Hub
|
||||||
|
-- email = correo autorizado
|
||||||
|
-- email='*' significa que TODOS los usuarios @gomezleemarketing.com tienen acceso
|
||||||
|
-- is_active=false permite desactivar el registro sin borrarlo
|
||||||
|
--
|
||||||
|
-- OPERACIÓN FUTURA
|
||||||
|
-- - App abierta a todo GLM: agregar una fila con app_name + email='*'.
|
||||||
|
-- - App restringida: agregar una fila por usuario con app_name + su correo.
|
||||||
|
-- - Revocar: borrar la fila o cambiar is_active a false.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
begin;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- 1. APROBADORES DINÁMICOS: 3 BASE + JEFE INMEDIATO
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
alter table public.glm_hub_access_request_approvers
|
||||||
|
drop constraint if exists glm_hub_access_request_approvers_email_check;
|
||||||
|
|
||||||
|
alter table public.glm_hub_access_request_approvers
|
||||||
|
drop constraint if exists glm_hub_access_request_approvers_email_normalized_check;
|
||||||
|
|
||||||
|
alter table public.glm_hub_access_request_approvers
|
||||||
|
add constraint glm_hub_access_request_approvers_email_normalized_check
|
||||||
|
check (
|
||||||
|
approver_email = lower(btrim(approver_email))
|
||||||
|
and approver_email ~ '^[a-z0-9.!#$%&''*+/=?^_`{|}~-]+@[a-z0-9.-]+\.[a-z]{2,}$'
|
||||||
|
);
|
||||||
|
|
||||||
|
create or replace function public.glm_hub_issue_access_request_tokens(p_request_id uuid)
|
||||||
|
returns jsonb
|
||||||
|
language plpgsql
|
||||||
|
volatile
|
||||||
|
security definer
|
||||||
|
set search_path = ''
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_request public.glm_hub_access_requests%rowtype;
|
||||||
|
begin
|
||||||
|
select request.*
|
||||||
|
into v_request
|
||||||
|
from public.glm_hub_access_requests as request
|
||||||
|
where request.id = p_request_id
|
||||||
|
and request.status = 'pending'
|
||||||
|
for update;
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
return jsonb_build_object('ok', false, 'error', 'La solicitud no existe o ya fue atendida.');
|
||||||
|
end if;
|
||||||
|
|
||||||
|
with supervisor_candidate as (
|
||||||
|
select
|
||||||
|
coalesce(
|
||||||
|
nullif(btrim(employee.supervisor), ''),
|
||||||
|
nullif(btrim(supervisor_employee.name), ''),
|
||||||
|
split_part(lower(btrim(employee.supervisor_email)), '@', 1)
|
||||||
|
) as approver_name,
|
||||||
|
lower(btrim(employee.supervisor_email)) as approver_email,
|
||||||
|
40 as priority
|
||||||
|
from public.empleados_glm as employee
|
||||||
|
left join public.empleados_glm as supervisor_employee
|
||||||
|
on lower(btrim(supervisor_employee.work_email)) = lower(btrim(employee.supervisor_email))
|
||||||
|
where lower(btrim(employee.work_email)) = v_request.requester_email
|
||||||
|
and nullif(btrim(employee.supervisor_email), '') is not null
|
||||||
|
order by
|
||||||
|
case when employee.status::text = 'Active' then 0 else 1 end,
|
||||||
|
employee.id
|
||||||
|
limit 1
|
||||||
|
),
|
||||||
|
candidate_approvers as (
|
||||||
|
select 'Isaac Aracena'::text as approver_name,
|
||||||
|
'iaracena@gomezleemarketing.com'::text as approver_email,
|
||||||
|
10 as priority
|
||||||
|
union all
|
||||||
|
select 'José Leopoldo Gómez', 'jgomez@gomezleemarketing.com', 20
|
||||||
|
union all
|
||||||
|
select 'Máximo Gómez', 'mgomez@gomezleemarketing.com', 30
|
||||||
|
union all
|
||||||
|
select approver_name, approver_email, priority
|
||||||
|
from supervisor_candidate
|
||||||
|
),
|
||||||
|
deduplicated as (
|
||||||
|
select distinct on (approver_email)
|
||||||
|
approver_name,
|
||||||
|
approver_email,
|
||||||
|
priority
|
||||||
|
from candidate_approvers
|
||||||
|
where approver_email is not null
|
||||||
|
and approver_email <> ''
|
||||||
|
and approver_email <> v_request.requester_email
|
||||||
|
and approver_email ~ '^[a-z0-9.!#$%&''*+/=?^_`{|}~-]+@[a-z0-9.-]+\.[a-z]{2,}$'
|
||||||
|
order by approver_email, priority
|
||||||
|
),
|
||||||
|
tokenized as (
|
||||||
|
select
|
||||||
|
approver_name,
|
||||||
|
approver_email,
|
||||||
|
priority,
|
||||||
|
encode(extensions.gen_random_bytes(32), 'hex') as decision_token
|
||||||
|
from deduplicated
|
||||||
|
)
|
||||||
|
insert into public.glm_hub_access_request_approvers (
|
||||||
|
request_id,
|
||||||
|
approver_name,
|
||||||
|
approver_email,
|
||||||
|
decision_token,
|
||||||
|
decision_token_hash
|
||||||
|
)
|
||||||
|
select
|
||||||
|
p_request_id,
|
||||||
|
tokenized.approver_name,
|
||||||
|
tokenized.approver_email,
|
||||||
|
tokenized.decision_token,
|
||||||
|
encode(extensions.digest(tokenized.decision_token, 'sha256'), 'hex')
|
||||||
|
from tokenized
|
||||||
|
on conflict (request_id, approver_email) do nothing;
|
||||||
|
|
||||||
|
return jsonb_build_object(
|
||||||
|
'ok', true,
|
||||||
|
'requestId', v_request.id,
|
||||||
|
'requesterName', v_request.requester_name,
|
||||||
|
'requesterEmail', v_request.requester_email,
|
||||||
|
'appName', v_request.app_name,
|
||||||
|
'appCategory', v_request.app_category,
|
||||||
|
'approvers', (
|
||||||
|
select coalesce(
|
||||||
|
jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'name', approver.approver_name,
|
||||||
|
'email', approver.approver_email,
|
||||||
|
'token', approver.decision_token
|
||||||
|
)
|
||||||
|
order by
|
||||||
|
case approver.approver_email
|
||||||
|
when 'iaracena@gomezleemarketing.com' then 1
|
||||||
|
when 'jgomez@gomezleemarketing.com' then 2
|
||||||
|
when 'mgomez@gomezleemarketing.com' then 3
|
||||||
|
else 4
|
||||||
|
end,
|
||||||
|
approver.approver_email
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
from public.glm_hub_access_request_approvers as approver
|
||||||
|
where approver.request_id = p_request_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
alter function public.glm_hub_issue_access_request_tokens(uuid) owner to postgres;
|
||||||
|
revoke all on function public.glm_hub_issue_access_request_tokens(uuid) from public, anon, authenticated;
|
||||||
|
grant execute on function public.glm_hub_issue_access_request_tokens(uuid) to service_role;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- 2. TABLA CENTRAL DE ACCESOS DEL HUB
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- Esta tabla es INFORMATIVA para el Hub. No sustituye el login/seguridad real
|
||||||
|
-- de cada aplicación.
|
||||||
|
--
|
||||||
|
-- app_id es rellenado automáticamente a partir de app_name, de forma que desde
|
||||||
|
-- el Table Editor basta con escribir el nombre de la app y el correo.
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
create table if not exists public.glm_hub_app_access (
|
||||||
|
id uuid primary key default extensions.gen_random_uuid(),
|
||||||
|
app_name text not null,
|
||||||
|
email text not null,
|
||||||
|
is_active boolean not null default true,
|
||||||
|
app_id uuid references public.glm_hub_apps(id) on delete cascade,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
constraint glm_hub_app_access_email_check
|
||||||
|
check (
|
||||||
|
email = '*'
|
||||||
|
or email ~ '^[a-z0-9.!#$%&''*+/=?^_`{|}~-]+@[a-z0-9.-]+\.[a-z]{2,}$'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
comment on table public.glm_hub_app_access is
|
||||||
|
'Registro central informativo de accesos mostrado por GLM Hub. No reemplaza los permisos reales de cada aplicación.';
|
||||||
|
comment on column public.glm_hub_app_access.app_name is
|
||||||
|
'Nombre de la aplicación tal como aparece en glm_hub_apps.';
|
||||||
|
comment on column public.glm_hub_app_access.email is
|
||||||
|
'Correo autorizado. El valor * significa todos los usuarios GLM autenticados.';
|
||||||
|
comment on column public.glm_hub_app_access.is_active is
|
||||||
|
'TRUE = acceso mostrado como Disponible; FALSE = registro desactivado.';
|
||||||
|
comment on column public.glm_hub_app_access.app_id is
|
||||||
|
'Se completa automáticamente usando app_name.';
|
||||||
|
|
||||||
|
create unique index if not exists glm_hub_app_access_app_email_uidx
|
||||||
|
on public.glm_hub_app_access (app_id, email);
|
||||||
|
|
||||||
|
create index if not exists glm_hub_app_access_email_idx
|
||||||
|
on public.glm_hub_app_access (email)
|
||||||
|
where is_active = true;
|
||||||
|
|
||||||
|
-- Normaliza correos y permite trabajar por nombre de aplicación desde Table Editor.
|
||||||
|
create or replace function public.glm_hub_app_access_prepare_row()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = ''
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_app_id uuid;
|
||||||
|
v_app_name text;
|
||||||
|
begin
|
||||||
|
new.app_name := btrim(coalesce(new.app_name, ''));
|
||||||
|
new.email := lower(btrim(coalesce(new.email, '')));
|
||||||
|
|
||||||
|
if new.app_name = '' then
|
||||||
|
raise exception 'Debes indicar app_name.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if new.email = '' then
|
||||||
|
raise exception 'Debes indicar email. Usa * si la aplicación está disponible para todo GLM.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- Si el usuario cambió app_name manualmente, ese nombre manda y se resuelve
|
||||||
|
-- nuevamente el UUID. En INSERT también permite dejar app_id vacío.
|
||||||
|
if tg_op = 'INSERT' then
|
||||||
|
select app.id, app.name
|
||||||
|
into v_app_id, v_app_name
|
||||||
|
from public.glm_hub_apps as app
|
||||||
|
where lower(btrim(app.name)) = lower(new.app_name)
|
||||||
|
order by app.created_at
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if v_app_id is null then
|
||||||
|
raise exception 'No existe una aplicación en GLM Hub llamada "%".', new.app_name;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
new.app_id := v_app_id;
|
||||||
|
new.app_name := v_app_name;
|
||||||
|
elsif new.app_id is null or new.app_name is distinct from old.app_name then
|
||||||
|
select app.id, app.name
|
||||||
|
into v_app_id, v_app_name
|
||||||
|
from public.glm_hub_apps as app
|
||||||
|
where lower(btrim(app.name)) = lower(new.app_name)
|
||||||
|
order by app.created_at
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if v_app_id is null then
|
||||||
|
raise exception 'No existe una aplicación en GLM Hub llamada "%".', new.app_name;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
new.app_id := v_app_id;
|
||||||
|
new.app_name := v_app_name;
|
||||||
|
else
|
||||||
|
select app.name
|
||||||
|
into v_app_name
|
||||||
|
from public.glm_hub_apps as app
|
||||||
|
where app.id = new.app_id;
|
||||||
|
|
||||||
|
if v_app_name is null then
|
||||||
|
raise exception 'El app_id indicado no existe en glm_hub_apps.';
|
||||||
|
end if;
|
||||||
|
|
||||||
|
new.app_name := v_app_name;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
new.updated_at := now();
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists glm_hub_app_access_prepare_row_trigger
|
||||||
|
on public.glm_hub_app_access;
|
||||||
|
|
||||||
|
create trigger glm_hub_app_access_prepare_row_trigger
|
||||||
|
before insert or update on public.glm_hub_app_access
|
||||||
|
for each row
|
||||||
|
execute function public.glm_hub_app_access_prepare_row();
|
||||||
|
|
||||||
|
-- Si un administrador cambia el nombre de una app en el Hub, el nombre visible
|
||||||
|
-- de los registros centrales se mantiene sincronizado por app_id.
|
||||||
|
create or replace function public.glm_hub_sync_access_app_name()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = ''
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if new.name is distinct from old.name then
|
||||||
|
update public.glm_hub_app_access
|
||||||
|
set app_name = new.name,
|
||||||
|
updated_at = now()
|
||||||
|
where app_id = new.id;
|
||||||
|
end if;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists glm_hub_sync_access_app_name_trigger
|
||||||
|
on public.glm_hub_apps;
|
||||||
|
|
||||||
|
create trigger glm_hub_sync_access_app_name_trigger
|
||||||
|
after update of name on public.glm_hub_apps
|
||||||
|
for each row
|
||||||
|
execute function public.glm_hub_sync_access_app_name();
|
||||||
|
|
||||||
|
-- Nadie necesita leer esta tabla directamente desde el navegador. El frontend
|
||||||
|
-- consulta únicamente el RPC seguro glm_hub_get_my_app_access().
|
||||||
|
alter table public.glm_hub_app_access enable row level security;
|
||||||
|
revoke all on table public.glm_hub_app_access from anon, authenticated;
|
||||||
|
grant all on table public.glm_hub_app_access to service_role;
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- 3. CARGA INICIAL DE LOS ACCESOS ACTUALES
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- A) Apps abiertas para cualquier usuario GLM -> una sola fila con email='*'.
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
insert into public.glm_hub_app_access (app_id, app_name, email, is_active)
|
||||||
|
select app.id, app.name, '*', true
|
||||||
|
from public.glm_hub_apps as app
|
||||||
|
where lower(btrim(app.name)) in (
|
||||||
|
'bamboohr',
|
||||||
|
'cdc brief',
|
||||||
|
'cruce de seguridad social - honduras',
|
||||||
|
'cruce de seguridad social - guatemala',
|
||||||
|
'cruce de seguridad social - costa rica',
|
||||||
|
'cruce de seguridad social - república dominicana',
|
||||||
|
'cruce de seguridad social - el salvador',
|
||||||
|
'validación de ir - nicaragua',
|
||||||
|
'glm id card generator'
|
||||||
|
)
|
||||||
|
on conflict (app_id, email)
|
||||||
|
do update set
|
||||||
|
app_name = excluded.app_name,
|
||||||
|
is_active = true,
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
-- B) CDC Project Management -> snapshot inicial desde tablero_cdc_allowed_users.
|
||||||
|
insert into public.glm_hub_app_access (app_id, app_name, email, is_active)
|
||||||
|
select
|
||||||
|
app.id,
|
||||||
|
app.name,
|
||||||
|
lower(btrim(allowed_user.email)),
|
||||||
|
true
|
||||||
|
from public.glm_hub_apps as app
|
||||||
|
cross join public.tablero_cdc_allowed_users as allowed_user
|
||||||
|
where lower(btrim(app.name)) = 'cdc project management'
|
||||||
|
and nullif(btrim(allowed_user.email), '') is not null
|
||||||
|
and coalesce(allowed_user.is_active, false) = true
|
||||||
|
on conflict (app_id, email)
|
||||||
|
do update set
|
||||||
|
app_name = excluded.app_name,
|
||||||
|
is_active = true,
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
-- C) Portal de Verificación de Nóminas -> snapshot inicial desde la tabla actual.
|
||||||
|
insert into public.glm_hub_app_access (app_id, app_name, email, is_active)
|
||||||
|
select
|
||||||
|
app.id,
|
||||||
|
app.name,
|
||||||
|
lower(btrim(allowed_user.email)),
|
||||||
|
true
|
||||||
|
from public.glm_hub_apps as app
|
||||||
|
cross join public.cruce_cuentas_usuarios_autorizados as allowed_user
|
||||||
|
where lower(btrim(app.name)) = 'portal de verificación de nóminas'
|
||||||
|
and nullif(btrim(allowed_user.email), '') is not null
|
||||||
|
on conflict (app_id, email)
|
||||||
|
do update set
|
||||||
|
app_name = excluded.app_name,
|
||||||
|
is_active = true,
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
-- D) Seguimiento de Impuestos GLM -> snapshot inicial desde tax_calendar_access.
|
||||||
|
insert into public.glm_hub_app_access (app_id, app_name, email, is_active)
|
||||||
|
select
|
||||||
|
app.id,
|
||||||
|
app.name,
|
||||||
|
lower(btrim(allowed_user.email)),
|
||||||
|
true
|
||||||
|
from public.glm_hub_apps as app
|
||||||
|
cross join public.tax_calendar_access as allowed_user
|
||||||
|
where lower(btrim(app.name)) = 'seguimiento de impuestos glm'
|
||||||
|
and nullif(btrim(allowed_user.email), '') is not null
|
||||||
|
and coalesce(allowed_user.active, false) = true
|
||||||
|
on conflict (app_id, email)
|
||||||
|
do update set
|
||||||
|
app_name = excluded.app_name,
|
||||||
|
is_active = true,
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- 4. RPC FINAL PARA EL LED DEL HUB
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
-- El Hub ya NO consulta las tablas particulares de cada aplicación.
|
||||||
|
-- Solo lee este registro central.
|
||||||
|
-- --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
create or replace function public.glm_hub_get_my_app_access()
|
||||||
|
returns table (
|
||||||
|
app_id uuid,
|
||||||
|
app_name text,
|
||||||
|
has_access boolean
|
||||||
|
)
|
||||||
|
language plpgsql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = ''
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
v_email text;
|
||||||
|
begin
|
||||||
|
v_email := lower(btrim(coalesce(auth.jwt() ->> 'email', '')));
|
||||||
|
|
||||||
|
if auth.uid() is null or v_email = '' then
|
||||||
|
return;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
-- El GLM Hub continúa reservado al dominio corporativo principal.
|
||||||
|
if v_email !~ '^[a-z0-9.!#$%&''*+/=?^_`{|}~-]+@gomezleemarketing\.com$' then
|
||||||
|
return;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return query
|
||||||
|
select
|
||||||
|
app.id,
|
||||||
|
app.name,
|
||||||
|
exists (
|
||||||
|
select 1
|
||||||
|
from public.glm_hub_app_access as access_row
|
||||||
|
where access_row.app_id = app.id
|
||||||
|
and access_row.is_active = true
|
||||||
|
and (
|
||||||
|
access_row.email = '*'
|
||||||
|
or access_row.email = v_email
|
||||||
|
)
|
||||||
|
) as has_access
|
||||||
|
from public.glm_hub_apps as app
|
||||||
|
where app.visibility = 'published'
|
||||||
|
order by app.name;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
alter function public.glm_hub_get_my_app_access() owner to postgres;
|
||||||
|
revoke all on function public.glm_hub_get_my_app_access() from public, anon;
|
||||||
|
grant execute on function public.glm_hub_get_my_app_access() to authenticated;
|
||||||
|
grant execute on function public.glm_hub_get_my_app_access() to service_role;
|
||||||
|
|
||||||
|
commit;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- VERIFICACIONES OPCIONALES DESPUÉS DE EJECUTAR
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Ver toda la matriz central:
|
||||||
|
-- select app_name, email, is_active
|
||||||
|
-- from public.glm_hub_app_access
|
||||||
|
-- order by app_name, email;
|
||||||
|
|
||||||
|
-- Agregar acceso individual a una futura app restringida DESDE SQL:
|
||||||
|
-- insert into public.glm_hub_app_access (app_name, email)
|
||||||
|
-- values ('Nombre exacto de la aplicación', 'usuario@gomezleemarketing.com');
|
||||||
|
--
|
||||||
|
-- En el Table Editor puedes hacer exactamente lo mismo dejando app_id vacío.
|
||||||
|
|
||||||
|
-- Marcar una futura app como abierta para todo GLM:
|
||||||
|
-- insert into public.glm_hub_app_access (app_name, email)
|
||||||
|
-- values ('Nombre exacto de la aplicación', '*');
|
||||||
|
|
||||||
|
-- Revocar sin borrar historial:
|
||||||
|
-- update public.glm_hub_app_access
|
||||||
|
-- set is_active = false
|
||||||
|
-- where lower(app_name) = lower('Nombre exacto de la aplicación')
|
||||||
|
-- and email = 'usuario@gomezleemarketing.com';
|
||||||
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+179
File diff suppressed because one or more lines are too long
Vendored
-179
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -9,8 +9,8 @@
|
|||||||
<link rel="shortcut icon" type="image/png" href="https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM.png?v=glm-hub-20260722-v3" />
|
<link rel="shortcut icon" type="image/png" href="https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM.png?v=glm-hub-20260722-v3" />
|
||||||
<link rel="apple-touch-icon" href="https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM.png?v=glm-hub-20260722-v3" />
|
<link rel="apple-touch-icon" href="https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM.png?v=glm-hub-20260722-v3" />
|
||||||
<title>GLM Hub — GomezLee Marketing</title>
|
<title>GLM Hub — GomezLee Marketing</title>
|
||||||
<script type="module" crossorigin src="/assets/index-Kd6NFVcJ.js"></script>
|
<script type="module" crossorigin src="/assets/index-CnXPZvfE.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-9ByAKvcq.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CIGqNuJ5.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -6,6 +6,7 @@ const root = path.resolve(new URL('..', import.meta.url).pathname);
|
|||||||
const srcDir = path.join(root, 'src');
|
const srcDir = path.join(root, 'src');
|
||||||
const distDir = path.join(root, 'dist');
|
const distDir = path.join(root, 'dist');
|
||||||
const assetsDir = path.join(distDir, 'assets');
|
const assetsDir = path.join(distDir, 'assets');
|
||||||
|
const BUILD_VERSION = '20260814-single-access-status';
|
||||||
|
|
||||||
const env = {
|
const env = {
|
||||||
VITE_SUPABASE_URL: 'https://dbit.digitalcompass.agency',
|
VITE_SUPABASE_URL: 'https://dbit.digitalcompass.agency',
|
||||||
@@ -29,7 +30,7 @@ function fixLocalImports(code) {
|
|||||||
.replace(/^import\s+["']\.\/styles\.css["'];?\s*$/gm, '')
|
.replace(/^import\s+["']\.\/styles\.css["'];?\s*$/gm, '')
|
||||||
.replace(/(from\s+["'])(\.\.?\/[^"']+)(["'])/g, (_m, start, spec, end) => {
|
.replace(/(from\s+["'])(\.\.?\/[^"']+)(["'])/g, (_m, start, spec, end) => {
|
||||||
if (/\.(?:js|mjs|cjs|json|css)$/.test(spec)) return `${start}${spec}${end}`;
|
if (/\.(?:js|mjs|cjs|json|css)$/.test(spec)) return `${start}${spec}${end}`;
|
||||||
return `${start}${spec}.js${end}`;
|
return `${start}${spec}.js?v=${BUILD_VERSION}${end}`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +77,7 @@ const indexHtml = `<!doctype html>
|
|||||||
<link rel="shortcut icon" type="image/png" href="https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM.png?v=glm-hub-20260728-access" />
|
<link rel="shortcut icon" type="image/png" href="https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM.png?v=glm-hub-20260728-access" />
|
||||||
<link rel="apple-touch-icon" href="https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM.png?v=glm-hub-20260728-access" />
|
<link rel="apple-touch-icon" href="https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM.png?v=glm-hub-20260728-access" />
|
||||||
<title>GLM Hub — GomezLee Marketing</title>
|
<title>GLM Hub — GomezLee Marketing</title>
|
||||||
<link rel="stylesheet" href="/assets/styles.css" />
|
<link rel="stylesheet" href="/assets/styles.css?v=${BUILD_VERSION}" />
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
@@ -91,7 +92,7 @@ const indexHtml = `<!doctype html>
|
|||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2.110.8"></script>
|
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2.110.8"></script>
|
||||||
<script type="module" src="/assets/main.js"></script>
|
<script type="module" src="/assets/main.js?v=${BUILD_VERSION}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
fs.writeFileSync(path.join(distDir, 'index.html'), indexHtml);
|
fs.writeFileSync(path.join(distDir, 'index.html'), indexHtml);
|
||||||
|
|||||||
+75
-8
@@ -35,6 +35,7 @@ import {
|
|||||||
deleteHubApp,
|
deleteHubApp,
|
||||||
fetchHubApps,
|
fetchHubApps,
|
||||||
fetchHubData,
|
fetchHubData,
|
||||||
|
fetchMyAppAccess,
|
||||||
migrateLocalAppsIfNeeded,
|
migrateLocalAppsIfNeeded,
|
||||||
recordRecent,
|
recordRecent,
|
||||||
saveHubApp,
|
saveHubApp,
|
||||||
@@ -536,14 +537,29 @@ function MobileNav({ user, view, onNavigate, onLogout }: MobileNavProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AccessState = boolean | null;
|
||||||
|
|
||||||
|
function AccessIndicator({ hasAccess }: { hasAccess: AccessState }) {
|
||||||
|
const label = hasAccess === null ? "Verificando acceso" : hasAccess ? "Disponible" : "Sin acceso";
|
||||||
|
const stateClass = hasAccess === null ? "access-indicator--checking" : hasAccess ? "access-indicator--granted" : "access-indicator--denied";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`access-indicator ${stateClass}`} aria-label={label}>
|
||||||
|
<span aria-hidden="true" />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface QuickLaunchProps {
|
interface QuickLaunchProps {
|
||||||
app: HubApp;
|
app: HubApp;
|
||||||
isFavorite: boolean;
|
isFavorite: boolean;
|
||||||
|
hasAccess: AccessState;
|
||||||
onActivate: (app: HubApp) => void;
|
onActivate: (app: HubApp) => void;
|
||||||
onFavorite: (appId: string) => void;
|
onFavorite: (appId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function QuickLaunch({ app, isFavorite, onActivate, onFavorite }: QuickLaunchProps) {
|
function QuickLaunch({ app, isFavorite, hasAccess, onActivate, onFavorite }: QuickLaunchProps) {
|
||||||
const available = Boolean(getSafeUrl(app.url));
|
const available = Boolean(getSafeUrl(app.url));
|
||||||
return (
|
return (
|
||||||
<article className="quick-launch">
|
<article className="quick-launch">
|
||||||
@@ -560,7 +576,10 @@ function QuickLaunch({ app, isFavorite, onActivate, onFavorite }: QuickLaunchPro
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="quick-launch__copy">
|
<div className="quick-launch__copy">
|
||||||
<span className="quick-launch__category">{app.category}</span>
|
<div className="quick-launch__meta">
|
||||||
|
<span className="quick-launch__category">{app.category}</span>
|
||||||
|
<AccessIndicator hasAccess={hasAccess} />
|
||||||
|
</div>
|
||||||
<h3>{app.name}</h3>
|
<h3>{app.name}</h3>
|
||||||
<p>{app.description}</p>
|
<p>{app.description}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -576,11 +595,12 @@ interface DirectoryRowProps {
|
|||||||
app: HubApp;
|
app: HubApp;
|
||||||
index: number;
|
index: number;
|
||||||
isFavorite: boolean;
|
isFavorite: boolean;
|
||||||
|
hasAccess: AccessState;
|
||||||
onActivate: (app: HubApp) => void;
|
onActivate: (app: HubApp) => void;
|
||||||
onFavorite: (appId: string) => void;
|
onFavorite: (appId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DirectoryRow({ app, index, isFavorite, onActivate, onFavorite }: DirectoryRowProps) {
|
function DirectoryRow({ app, index, isFavorite, hasAccess, onActivate, onFavorite }: DirectoryRowProps) {
|
||||||
const available = Boolean(getSafeUrl(app.url));
|
const available = Boolean(getSafeUrl(app.url));
|
||||||
return (
|
return (
|
||||||
<article className="directory-row">
|
<article className="directory-row">
|
||||||
@@ -591,9 +611,8 @@ function DirectoryRow({ app, index, isFavorite, onActivate, onFavorite }: Direct
|
|||||||
<p>{app.description}</p>
|
<p>{app.description}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="directory-row__category">{app.category}</span>
|
<span className="directory-row__category">{app.category}</span>
|
||||||
<span className={`availability ${available ? "availability--ready" : "availability--pending"}`}>
|
<span className="directory-row__status">
|
||||||
<span aria-hidden="true" />
|
<AccessIndicator hasAccess={hasAccess} />
|
||||||
{available ? "Disponible" : "Enlace pendiente"}
|
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -880,11 +899,13 @@ function Pagination({ currentPage, totalPages, onPageChange, compact = false, la
|
|||||||
|
|
||||||
const HUB_ITEMS_PER_PAGE = 10;
|
const HUB_ITEMS_PER_PAGE = 10;
|
||||||
const QUICK_ITEMS_PER_PAGE = 6;
|
const QUICK_ITEMS_PER_PAGE = 6;
|
||||||
const ADMIN_ITEMS_PER_PAGE = 10;
|
const ADMIN_ITEMS_PER_PAGE = 12;
|
||||||
|
|
||||||
interface HubViewProps {
|
interface HubViewProps {
|
||||||
user: HubUser;
|
user: HubUser;
|
||||||
data: HubData;
|
data: HubData;
|
||||||
|
accessByApp: Record<string, boolean>;
|
||||||
|
accessReady: boolean;
|
||||||
storageWarning: string;
|
storageWarning: string;
|
||||||
onActivate: (app: HubApp) => void;
|
onActivate: (app: HubApp) => void;
|
||||||
onFavorite: (appId: string) => void;
|
onFavorite: (appId: string) => void;
|
||||||
@@ -892,7 +913,7 @@ interface HubViewProps {
|
|||||||
onRequestAccess: (app: HubApp) => Promise<boolean>;
|
onRequestAccess: (app: HubApp) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HubView({ user, data, storageWarning, onActivate, onFavorite, onAdmin, onRequestAccess }: HubViewProps) {
|
function HubView({ user, data, accessByApp, accessReady, storageWarning, onActivate, onFavorite, onAdmin, onRequestAccess }: HubViewProps) {
|
||||||
const [categorySearches, setCategorySearches] = useState<Record<string, string>>({
|
const [categorySearches, setCategorySearches] = useState<Record<string, string>>({
|
||||||
Todas: "",
|
Todas: "",
|
||||||
Administración: "",
|
Administración: "",
|
||||||
@@ -1068,6 +1089,7 @@ function HubView({ user, data, storageWarning, onActivate, onFavorite, onAdmin,
|
|||||||
key={app.id}
|
key={app.id}
|
||||||
app={app}
|
app={app}
|
||||||
isFavorite={favorites.has(app.id)}
|
isFavorite={favorites.has(app.id)}
|
||||||
|
hasAccess={accessReady ? accessByApp[app.id] === true : null}
|
||||||
onActivate={onActivate}
|
onActivate={onActivate}
|
||||||
onFavorite={onFavorite}
|
onFavorite={onFavorite}
|
||||||
/>
|
/>
|
||||||
@@ -1152,6 +1174,7 @@ function HubView({ user, data, storageWarning, onActivate, onFavorite, onAdmin,
|
|||||||
app={app}
|
app={app}
|
||||||
index={(currentPage - 1) * HUB_ITEMS_PER_PAGE + index}
|
index={(currentPage - 1) * HUB_ITEMS_PER_PAGE + index}
|
||||||
isFavorite={favorites.has(app.id)}
|
isFavorite={favorites.has(app.id)}
|
||||||
|
hasAccess={accessReady ? accessByApp[app.id] === true : null}
|
||||||
onActivate={onActivate}
|
onActivate={onActivate}
|
||||||
onFavorite={onFavorite}
|
onFavorite={onFavorite}
|
||||||
/>
|
/>
|
||||||
@@ -1861,6 +1884,8 @@ function App() {
|
|||||||
const [authReady, setAuthReady] = useState(false);
|
const [authReady, setAuthReady] = useState(false);
|
||||||
const [catalogReady, setCatalogReady] = useState(false);
|
const [catalogReady, setCatalogReady] = useState(false);
|
||||||
const [catalogWarning, setCatalogWarning] = useState("");
|
const [catalogWarning, setCatalogWarning] = useState("");
|
||||||
|
const [accessByApp, setAccessByApp] = useState<Record<string, boolean>>({});
|
||||||
|
const [accessReady, setAccessReady] = useState(false);
|
||||||
const [loginError, setLoginError] = useState("");
|
const [loginError, setLoginError] = useState("");
|
||||||
const authorizationSequence = useRef(0);
|
const authorizationSequence = useRef(0);
|
||||||
const authorizedUserIdRef = useRef<string | null>(null);
|
const authorizedUserIdRef = useRef<string | null>(null);
|
||||||
@@ -2011,6 +2036,44 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, [activeUser?.id, activeUser?.role, initialLocalData.data.apps, notify]);
|
}, [activeUser?.id, activeUser?.role, initialLocalData.data.apps, notify]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeUser) {
|
||||||
|
setAccessByApp({});
|
||||||
|
setAccessReady(false);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let active = true;
|
||||||
|
let loading = false;
|
||||||
|
|
||||||
|
const refreshAccess = async () => {
|
||||||
|
if (loading) return;
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const accessMap = await fetchMyAppAccess();
|
||||||
|
if (!active) return;
|
||||||
|
setAccessByApp(accessMap);
|
||||||
|
setAccessReady(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("No se pudo verificar el acceso a las aplicaciones:", error);
|
||||||
|
if (active) setAccessReady(false);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void refreshAccess();
|
||||||
|
const intervalId = window.setInterval(() => void refreshAccess(), 60_000);
|
||||||
|
const handleFocus = () => void refreshAccess();
|
||||||
|
window.addEventListener("focus", handleFocus);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
window.removeEventListener("focus", handleFocus);
|
||||||
|
};
|
||||||
|
}, [activeUser?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const syncViewWithHash = () => {
|
const syncViewWithHash = () => {
|
||||||
const requestedView: AppView = window.location.hash === "#admin" ? "admin" : "hub";
|
const requestedView: AppView = window.location.hash === "#admin" ? "admin" : "hub";
|
||||||
@@ -2240,6 +2303,8 @@ function App() {
|
|||||||
}
|
}
|
||||||
setSession(null);
|
setSession(null);
|
||||||
setCatalogReady(false);
|
setCatalogReady(false);
|
||||||
|
setAccessByApp({});
|
||||||
|
setAccessReady(false);
|
||||||
setView("hub");
|
setView("hub");
|
||||||
window.history.replaceState(null, "", getAppUrl());
|
window.history.replaceState(null, "", getAppUrl());
|
||||||
};
|
};
|
||||||
@@ -2281,6 +2346,8 @@ function App() {
|
|||||||
<HubView
|
<HubView
|
||||||
user={session.user}
|
user={session.user}
|
||||||
data={data}
|
data={data}
|
||||||
|
accessByApp={accessByApp}
|
||||||
|
accessReady={accessReady}
|
||||||
storageWarning={catalogWarning}
|
storageWarning={catalogWarning}
|
||||||
onActivate={handleActivate}
|
onActivate={handleActivate}
|
||||||
onFavorite={(appId) => void handleFavorite(appId)}
|
onFavorite={(appId) => void handleFavorite(appId)}
|
||||||
|
|||||||
@@ -97,6 +97,18 @@ async function removeStoredIcon(url: string): Promise<void> {
|
|||||||
if (error) console.warn("No se pudo retirar el ícono anterior de Storage:", error);
|
if (error) console.warn("No se pudo retirar el ícono anterior de Storage:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export type HubAccessMap = Record<string, boolean>;
|
||||||
|
|
||||||
|
export async function fetchMyAppAccess(): Promise<HubAccessMap> {
|
||||||
|
const { data, error } = await supabase.rpc("glm_hub_get_my_app_access");
|
||||||
|
|
||||||
|
if (error) throw new Error(`No se pudo comprobar el acceso a las aplicaciones: ${error.message}`);
|
||||||
|
|
||||||
|
const rows = (data ?? []) as Array<{ app_id: string; has_access: boolean }>;
|
||||||
|
return Object.fromEntries(rows.map((row) => [row.app_id, row.has_access === true]));
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchHubApps(): Promise<HubApp[]> {
|
export async function fetchHubApps(): Promise<HubApp[]> {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from(APPS_TABLE)
|
.from(APPS_TABLE)
|
||||||
|
|||||||
+70
-2
@@ -1069,6 +1069,60 @@ img {
|
|||||||
margin-top: 48px;
|
margin-top: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.quick-launch__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-indicator {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.67rem;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-indicator > span {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-indicator--granted {
|
||||||
|
color: #4f758b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-indicator--granted > span {
|
||||||
|
background: var(--glm-green);
|
||||||
|
box-shadow: 0 0 0 3px var(--green-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-indicator--denied {
|
||||||
|
color: #b3261e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-indicator--denied > span {
|
||||||
|
background: #d32f2f;
|
||||||
|
box-shadow: 0 0 0 3px #fde8e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-indicator--checking {
|
||||||
|
color: #7a7a7a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-indicator--checking > span {
|
||||||
|
background: #a8a8a8;
|
||||||
|
box-shadow: 0 0 0 3px #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
.quick-launch__category {
|
.quick-launch__category {
|
||||||
color: var(--glm-blue);
|
color: var(--glm-blue);
|
||||||
font-size: 0.64rem;
|
font-size: 0.64rem;
|
||||||
@@ -1366,6 +1420,13 @@ img {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.directory-row__status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.directory-row__category {
|
.directory-row__category {
|
||||||
color: var(--glm-blue);
|
color: var(--glm-blue);
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
@@ -1921,6 +1982,10 @@ img {
|
|||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ai-icon-button:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.ai-icon-actions {
|
.ai-icon-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2274,7 +2339,9 @@ img {
|
|||||||
grid-template-columns: 34px 44px minmax(190px, 1fr) 112px 44px 80px;
|
grid-template-columns: 34px 44px minmax(190px, 1fr) 112px 44px 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.directory-row__category {
|
|
||||||
|
|
||||||
|
.directory-row__category {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2624,7 +2691,8 @@ img {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.directory-row__category,
|
.directory-row__category,
|
||||||
.directory-row .availability {
|
.directory-row .availability,
|
||||||
|
.directory-row__status {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user