build: actualizar dist con la versión de producción más reciente

This commit is contained in:
2026-08-14 16:34:38 -04:00
parent a5fe1f7b27
commit abfd2c4fae
15 changed files with 1794 additions and 350 deletions
+24
View File
@@ -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.
+69
View File
@@ -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.
+159 -45
View File
@@ -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,15 +717,57 @@ 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
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 ( insert into public.glm_hub_access_request_approvers (
request_id, request_id,
approver_name, approver_name,
@@ -738,29 +775,14 @@ begin
decision_token, decision_token,
decision_token_hash decision_token_hash
) )
values select
(
p_request_id, p_request_id,
'Isaac Aracena', tokenized.approver_name,
'iaracena@gomezleemarketing.com', tokenized.approver_email,
v_token_isaac, tokenized.decision_token,
encode(extensions.digest(v_token_isaac, 'sha256'), 'hex') encode(extensions.digest(tokenized.decision_token, 'sha256'), 'hex')
), from tokenized
( on conflict (request_id, approver_email) do nothing;
p_request_id,
'José Leopoldo Gómez',
'jgomez@gomezleemarketing.com',
v_token_jose,
encode(extensions.digest(v_token_jose, 'sha256'), 'hex')
),
(
p_request_id,
'Máximo Gómez',
'mgomez@gomezleemarketing.com',
v_token_maximo,
encode(extensions.digest(v_token_maximo, 'sha256'), 'hex')
);
end if;
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_agg(
jsonb_build_object( jsonb_build_object(
'name', approver.approver_name, 'name', approver.approver_name,
'email', approver.approver_email, 'email', approver.approver_email,
'token', approver.decision_token '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';
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+179
View File
File diff suppressed because one or more lines are too long
-179
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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
+4 -3
View File
@@ -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);
+74 -7
View File
@@ -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">
<div className="quick-launch__meta">
<span className="quick-launch__category">{app.category}</span> <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)}
+12
View File
@@ -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)
+69 -1
View File
@@ -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,6 +2339,8 @@ 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;
} }