feat: versión inicial de GLM Hub

This commit is contained in:
2026-07-29 12:17:56 -04:00
commit 1c3d534322
43 changed files with 10381 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
VITE_SUPABASE_URL="https://tu-supabase.example.com"
VITE_SUPABASE_ANON_KEY="TU_SUPABASE_ANON_KEY"
VITE_ICON_GENERATOR_WEBHOOK_URL="https://tu-n8n.example.com/webhook/glm-hub-generar-icono"
VITE_ACCESS_REQUEST_WEBHOOK_URL="https://tu-n8n.example.com/webhook/glm-hub-solicitar-acceso"
+8
View File
@@ -0,0 +1,8 @@
node_modules/
.env
.env.*
!.env.example
*.log
.vite/
.DS_Store
Thumbs.db
+26
View File
@@ -0,0 +1,26 @@
# GLM Hub — correcciones del 28 de julio de 2026
## Aplicación
- La renovación automática del token de Supabase ya no reinicia la vista, no regresa al Hub y no vuelve a cargar el catálogo cuando el usuario cambia de pestaña y regresa.
- El formulario de Administración conserva el texto, selección, scroll y pantalla activa durante los eventos `SIGNED_IN` repetidos y `TOKEN_REFRESHED` del mismo usuario.
- La solicitud de generación usa un POST `text/plain` con el JWT dentro del cuerpo para evitar que un preflight CORS bloquee la llamada antes de llegar a n8n.
- El frontend acepta directamente la imagen binaria devuelta por n8n y conserva compatibilidad con la respuesta JSON del workflow anterior.
- Se añadió timeout de 3 minutos y mensajes de error más claros.
## Workflow n8n
Archivo: `n8n/GLM-Hub-Generar-Icono-Gemini-Nodo-Nativo.json`
- Usa el nodo nativo **Google Gemini → Image → Generate an Image**.
- Modelo configurado: `models/gemini-3-pro-image`.
- La imagen se devuelve directamente como binario por el webhook.
- El webhook acepta el JWT en el cuerpo o en el encabezado Authorization.
- Continúa validando en Supabase que el usuario sea administrador.
- CORS está permitido para que funcione tanto en producción como durante pruebas locales.
## Único ajuste manual después de importar
Abre el nodo **Generate an image** y confirma la credencial existente **Isaac - Gemini Api Pago**. El ID interno de una credencial no puede incluirse de forma portable en un JSON exportado.
Antes de activar este workflow, desactiva el workflow anterior que utiliza la misma ruta `glm-hub-generar-icono`.
+141
View File
@@ -0,0 +1,141 @@
# GLM Hub — Implementación compartida con Supabase e íconos con Gemini
Este proyecto queda preparado para que el catálogo sea compartido por todo el equipo. Las aplicaciones ya no se guardan como catálogo operativo en el navegador: se almacenan en Supabase y se actualizan en las sesiones abiertas mediante Realtime.
## Archivos principales
- `SUPABASE-GLM-HUB-COMPLETO.sql`: crea o actualiza todo lo necesario en Supabase.
- `n8n/GLM-Hub-Generar-Icono-Gemini.json`: workflow importable de n8n.
- `.env`: variables de la app web.
## Orden correcto de implementación
### 1. Ejecutar el SQL completo en Supabase
En Supabase Studio abre **SQL Editor**, pega el contenido completo de:
`SUPABASE-GLM-HUB-COMPLETO.sql`
Luego ejecútalo una sola vez. El script es reutilizable: usa `if not exists`, reemplaza funciones y recrea políticas de forma controlada.
El script configura:
- Lista de usuarios autorizados y roles.
- Los cinco administradores definidos para GLM Hub.
- Catálogo compartido `glm_hub_apps`.
- Favoritos personales `glm_hub_favorites`.
- Accesos recientes personales `glm_hub_recent_apps`.
- Bucket público `glm-hub-icons` con máximo de 1 MB.
- Políticas RLS: todos los usuarios activos ven el catálogo publicado; solo administradores pueden crear, editar y eliminar.
- Publicación de cambios de aplicaciones en Supabase Realtime.
- Función segura `glm_hub_icon_generation_access()` para validar desde n8n que quien solicita el ícono sea administrador.
### 2. Importar y configurar el workflow de n8n
Importa:
`n8n/GLM-Hub-Generar-Icono-Gemini.json`
Configura estas variables de entorno en la instancia de n8n:
```env
SUPABASE_URL=https://dbit.digitalcompass.agency
SUPABASE_ANON_KEY=TU_CLAVE_ANON_DE_SUPABASE
GEMINI_API_KEY=TU_API_KEY_DE_GEMINI
```
Reinicia n8n después de añadirlas, abre el workflow y actívalo.
Webhook de producción esperado:
```text
https://agenteit.digitalcompass.agency/webhook/glm-hub-generar-icono
```
El workflow:
1. Recibe nombre y descripción desde GLM Hub.
2. Verifica el JWT activo de Supabase.
3. Confirma en la base de datos que el usuario sea administrador.
4. Genera un ícono cuadrado sin texto con Gemini.
5. Devuelve la imagen al formulario como PNG.
La clave de Gemini debe permanecer únicamente en n8n. No debe añadirse al `.env` de Vite ni enviarse al navegador.
### 3. Revisar las variables del frontend
El `.env` incluido contiene:
```env
VITE_SUPABASE_URL="https://dbit.digitalcompass.agency"
VITE_SUPABASE_ANON_KEY="..."
VITE_ICON_GENERATOR_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/glm-hub-generar-icono"
```
Cambia la URL del webhook solamente si el workflow se publica en otro dominio o ruta.
### 4. Instalar, validar y compilar
Desde la carpeta raíz del proyecto:
```bash
npm install
npm run typecheck
npm run build
```
La compilación queda en `dist/`.
### 5. Migrar las aplicaciones que ya estaban creadas
Después de ejecutar el SQL y desplegar esta versión:
1. Inicia sesión como administrador desde el mismo navegador donde estaban guardadas las aplicaciones anteriores.
2. La app compara el catálogo local con `glm_hub_apps` y migra solamente las aplicaciones cuyo nombre todavía no exista en Supabase.
3. Los íconos se suben al bucket `glm-hub-icons`.
4. Desde ese momento, cualquier usuario autorizado verá el mismo catálogo publicado al iniciar sesión.
La comparación por nombre evita duplicados y permite reanudar la migración en otro inicio de sesión si una carga anterior quedó incompleta. Cuando termina correctamente, el navegador registra la migración como completada para que una aplicación eliminada después desde Supabase no vuelva a crearse desde el catálogo local antiguo.
## Funcionamiento del Hub
- **Acceso rápido:** incluye todas las aplicaciones marcadas como favoritas por el usuario. Si todavía tiene menos de tres favoritas, se completa temporalmente con recientes o aplicaciones disponibles. Se muestran seis por página y la paginación aparece solo cuando hace falta.
- **Todas las aplicaciones:** muestra diez por página en Todas, Administración, Recursos Humanos y CDC. En cada filtro, los favoritos se ordenan antes que el resto y después se aplica la paginación.
- **Administración:** también pagina el catálogo cada diez registros.
- **Cambios compartidos:** al crear, editar o eliminar una aplicación, el cambio se guarda en Supabase y llega al resto de sesiones abiertas.
- **Favoritos y recientes:** son personales para cada usuario; no afectan la vista de los demás.
## Generación del ícono
El botón **Generar ícono con IA** se habilita solamente cuando se completan:
- Nombre de la aplicación.
- Descripción breve.
Mientras Gemini está trabajando:
- Se muestra una pantalla de carga bloqueante.
- No se puede publicar, cancelar, editar ni salir accidentalmente de la página.
- Al finalizar, el ícono aparece en la vista previa y puede publicarse junto con la aplicación.
## Dar o quitar acceso
Desde `public.glm_hub_authorized_users`:
```sql
-- Dar acceso normal
insert into public.glm_hub_authorized_users (email, full_name, role, is_active)
values ('persona@gomezleemarketing.com', 'Nombre de la persona', 'member', true);
-- Quitar acceso sin borrar
update public.glm_hub_authorized_users
set is_active = false
where email = 'persona@gomezleemarketing.com';
-- Reactivar
update public.glm_hub_authorized_users
set is_active = true
where email = 'persona@gomezleemarketing.com';
```
Los administradores están restringidos por una validación de base de datos a los cinco correos definidos en el script.
+75
View File
@@ -0,0 +1,75 @@
# GLM Hub — Solicitudes de acceso
## Cambios incluidos
- Se retiró el texto de ayuda **“Directa y fácil de escanear.”** debajo de Descripción breve.
- **Control del catálogo** sigue renderizándose exclusivamente para usuarios con `role = admin`.
- Los usuarios normales ven una nueva sección **¿No tienes acceso a una aplicación?**.
- El formulario de solicitud se alimenta directamente del catálogo publicado en Supabase, por lo que al crear, ocultar o eliminar aplicaciones el dropdown se actualiza sin editar código.
- La pantalla queda bloqueada mientras n8n registra la solicitud y envía los correos.
- Los correos de aprobación se envían individualmente a Isaac Aracena, José Leopoldo Gómez y Máximo Gómez.
- La primera decisión confirmada queda registrada de forma atómica en Supabase. Los clics posteriores no pueden cambiarla ni volver a avisar a IT Support.
- Tras la decisión se envía un correo HTML a `itsupport@gomezleemarketing.com` con solicitante, aplicación, decisión y persona que decidió.
## 1. Supabase
Ejecuta completo:
`SUPABASE-GLM-HUB-COMPLETO.sql`
El script conserva las tablas anteriores y agrega:
- `glm_hub_access_requests`
- `glm_hub_access_request_approvers`
- `glm_hub_prepare_access_request(uuid)`
- `glm_hub_issue_access_request_tokens(uuid)`
- `glm_hub_decide_access_request(text, text)`
La función que entrega los enlaces de aprobación solo puede ejecutarse con `service_role`; los enlaces nunca se devuelven al navegador del solicitante.
## 2. n8n
Importa:
`n8n/GLM-Hub-Solicitudes-Acceso-Aprobacion.json`
Después configura:
1. En **Crear solicitud en Supabase** reemplaza `TU_SUPABASE_ANON_KEY` en `apikey`.
2. En **Emitir enlaces de aprobación** reemplaza `TU_SUPABASE_SERVICE_ROLE_KEY` en `apikey` y `Authorization`.
3. En **Registrar decisión en Supabase** reemplaza `TU_SUPABASE_ANON_KEY` en `apikey` y dentro de `Bearer TU_SUPABASE_ANON_KEY`.
4. Selecciona una credencial Gmail válida en:
- **Enviar solicitud a aprobadores**
- **Enviar decisión a IT Support**
5. Guarda, publica y activa el workflow.
El workflow no usa variables de entorno de n8n.
## 3. Frontend
El `.env` ya incluye:
```env
VITE_ACCESS_REQUEST_WEBHOOK_URL="https://agenteit.digitalcompass.agency/webhook/glm-hub-solicitar-acceso"
```
El ZIP también contiene un `dist` actualizado. La versión fuente puede recompilarse normalmente con:
```bash
npm install
npm run build
```
## Decisión desde correo
Los botones del correo abren una pantalla de confirmación antes de registrar la decisión. Esto evita que un escáner automático de enlaces de Gmail o seguridad corporativa apruebe o rechace una solicitud sin intervención humana.
Los botones permanecen visibles en los correos ya entregados, pero después de la primera decisión Supabase bloquea cualquier cambio posterior y muestra que la solicitud ya fue atendida.
## Corrección 2026-07-29
- El nodo **Crear solicitud en Supabase** usa la clave `anon` en `apikey` y el JWT vigente del usuario en `Authorization`.
- Los nodos internos para emitir enlaces y registrar decisiones conservan la clave `service_role`.
- Los errores visibles en el portal son mensajes para usuarios; los detalles técnicos quedan solamente en la ejecución de n8n.
- El selector de áreas incluye **CDC**.
+11
View File
@@ -0,0 +1,11 @@
# GLM Hub — Supabase compartido
La guía vigente está en:
`README-IMPLEMENTACION-COMPLETA.md`
Ejecuta únicamente el script completo:
`SUPABASE-GLM-HUB-COMPLETO.sql`
El catálogo, los íconos, favoritos, recientes, RLS y Realtime están incluidos en ese único archivo.
+894
View File
@@ -0,0 +1,894 @@
-- ============================================================================
-- GLM HUB — ESQUEMA COMPLETO DE SUPABASE
-- ============================================================================
-- Incluye:
-- 1) Usuarios autorizados y roles.
-- 2) Catálogo compartido para todos los usuarios del Hub.
-- 3) Favoritos y accesos recientes por usuario.
-- 4) Bucket público para los íconos.
-- 5) RLS, permisos y Realtime.
-- 6) Función segura que usa n8n para validar al administrador.
-- 7) Solicitudes de acceso, aprobadores y decisión única.
--
-- Ejecutar el archivo completo una sola vez en:
-- Supabase Studio > SQL Editor > New query > Run.
-- El script es idempotente y puede volver a ejecutarse para restaurar políticas.
-- ============================================================================
begin;
create extension if not exists pgcrypto with schema extensions;
-- --------------------------------------------------------------------------
-- 1. USUARIOS AUTORIZADOS
-- --------------------------------------------------------------------------
create table if not exists public.glm_hub_authorized_users (
id uuid primary key default gen_random_uuid(),
email text not null unique,
full_name text,
role text not null default 'member',
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint glm_hub_authorized_users_role_check
check (role in ('admin', 'member')),
constraint glm_hub_authorized_users_email_normalized_check
check (email = lower(btrim(email))),
constraint glm_hub_authorized_users_domain_check
check (email ~ '^[a-z0-9.!#$%&''*+/=?^_`{|}~-]+@gomezleemarketing\.com$'),
constraint glm_hub_authorized_users_admin_check
check (
role <> 'admin'
or email in (
'jgomez@gomezleemarketing.com',
'iaracena@gomezleemarketing.com',
'ethen@gomezleemarketing.com',
'mgomez@gomezleemarketing.com',
'lmatos@gomezleemarketing.com'
)
)
);
create or replace function public.glm_hub_normalize_authorized_user()
returns trigger
language plpgsql
set search_path = public
as $$
begin
new.email := lower(btrim(new.email));
new.full_name := nullif(btrim(new.full_name), '');
new.updated_at := now();
return new;
end;
$$;
drop trigger if exists glm_hub_normalize_authorized_user_trigger
on public.glm_hub_authorized_users;
create trigger glm_hub_normalize_authorized_user_trigger
before insert or update on public.glm_hub_authorized_users
for each row
execute function public.glm_hub_normalize_authorized_user();
alter table public.glm_hub_authorized_users enable row level security;
alter table public.glm_hub_authorized_users force row level security;
revoke all on table public.glm_hub_authorized_users from anon;
revoke all on table public.glm_hub_authorized_users from authenticated;
grant select on table public.glm_hub_authorized_users to authenticated;
grant all on table public.glm_hub_authorized_users to service_role;
drop policy if exists "GLM Hub users can read only their active access"
on public.glm_hub_authorized_users;
create policy "GLM Hub users can read only their active access"
on public.glm_hub_authorized_users
for select
to authenticated
using (
is_active = true
and email = lower(coalesce((select auth.jwt() ->> 'email'), ''))
);
insert into public.glm_hub_authorized_users (email, full_name, role, is_active)
values
('jgomez@gomezleemarketing.com', 'José Leopoldo Gómez', 'admin', true),
('iaracena@gomezleemarketing.com', 'Isaac Aracena', 'admin', true),
('ethen@gomezleemarketing.com', 'Eidan Then', 'admin', true),
('mgomez@gomezleemarketing.com', 'Máximo Gomez', 'admin', true),
('lmatos@gomezleemarketing.com', 'Luis Matos', 'admin', true)
on conflict (email) do update
set
full_name = excluded.full_name,
role = excluded.role,
is_active = true,
updated_at = now();
-- Estas funciones se ejecutan con los permisos/RLS del usuario autenticado.
create or replace function public.glm_hub_is_active_user()
returns boolean
language sql
stable
security invoker
set search_path = public, auth
as $$
select exists (
select 1
from public.glm_hub_authorized_users as access
where access.is_active = true
and access.email = lower(coalesce((select auth.jwt() ->> 'email'), ''))
);
$$;
create or replace function public.glm_hub_is_admin()
returns boolean
language sql
stable
security invoker
set search_path = public, auth
as $$
select exists (
select 1
from public.glm_hub_authorized_users as access
where access.is_active = true
and access.role = 'admin'
and access.email = lower(coalesce((select auth.jwt() ->> 'email'), ''))
);
$$;
-- n8n llama esta función usando el JWT recibido desde el frontend.
-- SECURITY DEFINER permite consultar la tabla interna sin quedar bloqueado por RLS,
-- pero el correo y el usuario siempre se toman del JWT real de Supabase.
create or replace function public.glm_hub_icon_generation_access()
returns jsonb
language plpgsql
stable
security definer
set search_path = ''
as $$
declare
v_email text;
v_user_id text;
v_authorized boolean;
begin
v_email := lower(coalesce(auth.jwt() ->> 'email', ''));
v_user_id := coalesce(auth.uid()::text, '');
select exists (
select 1
from public.glm_hub_authorized_users as access
where lower(access.email) = v_email
and access.role = 'admin'
and access.is_active = true
)
into v_authorized;
return jsonb_build_object(
'authorized', coalesce(v_authorized, false),
'email', v_email,
'userId', v_user_id
);
end;
$$;
alter function public.glm_hub_icon_generation_access() owner to postgres;
revoke all on function public.glm_hub_is_active_user() from public, anon;
revoke all on function public.glm_hub_is_admin() from public, anon;
revoke all on function public.glm_hub_icon_generation_access() from public, anon;
grant execute on function public.glm_hub_is_active_user() to authenticated;
grant execute on function public.glm_hub_is_admin() to authenticated;
grant execute on function public.glm_hub_icon_generation_access() to authenticated;
grant execute on function public.glm_hub_is_active_user() to service_role;
grant execute on function public.glm_hub_is_admin() to service_role;
grant execute on function public.glm_hub_icon_generation_access() to service_role;
-- --------------------------------------------------------------------------
-- 2. CATÁLOGO COMPARTIDO
-- --------------------------------------------------------------------------
create table if not exists public.glm_hub_apps (
id uuid primary key default gen_random_uuid(),
name text not null,
description text not null,
category text not null,
url text,
icon_url text not null,
visibility text not null default 'published',
created_by uuid default auth.uid() references auth.users(id) on delete set null,
updated_by uuid default auth.uid() references auth.users(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint glm_hub_apps_name_length_check
check (char_length(btrim(name)) between 2 and 60),
constraint glm_hub_apps_description_length_check
check (char_length(btrim(description)) between 1 and 180),
constraint glm_hub_apps_category_check
check (category in ('Administración', 'Recursos Humanos', 'CDC')),
constraint glm_hub_apps_visibility_check
check (visibility in ('published', 'hidden')),
constraint glm_hub_apps_icon_url_check
check (icon_url ~ '^https://')
);
create unique index if not exists glm_hub_apps_name_unique_ci
on public.glm_hub_apps (lower(btrim(name)));
create index if not exists glm_hub_apps_visibility_category_name_idx
on public.glm_hub_apps (visibility, category, name);
create or replace function public.glm_hub_normalize_app()
returns trigger
language plpgsql
set search_path = public
as $$
begin
new.name := btrim(new.name);
new.description := btrim(new.description);
new.category := btrim(new.category);
new.url := nullif(btrim(new.url), '');
new.icon_url := btrim(new.icon_url);
new.updated_at := now();
return new;
end;
$$;
drop trigger if exists glm_hub_normalize_app_trigger on public.glm_hub_apps;
create trigger glm_hub_normalize_app_trigger
before insert or update on public.glm_hub_apps
for each row
execute function public.glm_hub_normalize_app();
alter table public.glm_hub_apps enable row level security;
alter table public.glm_hub_apps force row level security;
alter table public.glm_hub_apps replica identity full;
revoke all on table public.glm_hub_apps from anon;
revoke all on table public.glm_hub_apps from authenticated;
grant select, insert, update, delete on table public.glm_hub_apps to authenticated;
grant all on table public.glm_hub_apps to service_role;
drop policy if exists "GLM Hub active users read shared apps" on public.glm_hub_apps;
drop policy if exists "GLM Hub admins create apps" on public.glm_hub_apps;
drop policy if exists "GLM Hub admins update apps" on public.glm_hub_apps;
drop policy if exists "GLM Hub admins delete apps" on public.glm_hub_apps;
create policy "GLM Hub active users read shared apps"
on public.glm_hub_apps
for select
to authenticated
using (
public.glm_hub_is_active_user()
and (visibility = 'published' or public.glm_hub_is_admin())
);
create policy "GLM Hub admins create apps"
on public.glm_hub_apps
for insert
to authenticated
with check (
public.glm_hub_is_admin()
and created_by = (select auth.uid())
and updated_by = (select auth.uid())
);
create policy "GLM Hub admins update apps"
on public.glm_hub_apps
for update
to authenticated
using (public.glm_hub_is_admin())
with check (
public.glm_hub_is_admin()
and updated_by = (select auth.uid())
);
create policy "GLM Hub admins delete apps"
on public.glm_hub_apps
for delete
to authenticated
using (public.glm_hub_is_admin());
-- --------------------------------------------------------------------------
-- 3. FAVORITOS POR USUARIO
-- --------------------------------------------------------------------------
create table if not exists public.glm_hub_favorites (
user_id uuid not null references auth.users(id) on delete cascade,
app_id uuid not null references public.glm_hub_apps(id) on delete cascade,
created_at timestamptz not null default now(),
primary key (user_id, app_id)
);
create index if not exists glm_hub_favorites_user_created_idx
on public.glm_hub_favorites (user_id, created_at desc);
alter table public.glm_hub_favorites enable row level security;
alter table public.glm_hub_favorites force row level security;
revoke all on table public.glm_hub_favorites from anon;
revoke all on table public.glm_hub_favorites from authenticated;
grant select, insert, update, delete on table public.glm_hub_favorites to authenticated;
grant all on table public.glm_hub_favorites to service_role;
drop policy if exists "GLM Hub users read their favorites" on public.glm_hub_favorites;
drop policy if exists "GLM Hub users create their favorites" on public.glm_hub_favorites;
drop policy if exists "GLM Hub users update their favorites" on public.glm_hub_favorites;
drop policy if exists "GLM Hub users delete their favorites" on public.glm_hub_favorites;
create policy "GLM Hub users read their favorites"
on public.glm_hub_favorites
for select
to authenticated
using (public.glm_hub_is_active_user() and user_id = (select auth.uid()));
create policy "GLM Hub users create their favorites"
on public.glm_hub_favorites
for insert
to authenticated
with check (public.glm_hub_is_active_user() and user_id = (select auth.uid()));
create policy "GLM Hub users update their favorites"
on public.glm_hub_favorites
for update
to authenticated
using (public.glm_hub_is_active_user() and user_id = (select auth.uid()))
with check (public.glm_hub_is_active_user() and user_id = (select auth.uid()));
create policy "GLM Hub users delete their favorites"
on public.glm_hub_favorites
for delete
to authenticated
using (public.glm_hub_is_active_user() and user_id = (select auth.uid()));
-- --------------------------------------------------------------------------
-- 4. ACCESOS RECIENTES POR USUARIO
-- --------------------------------------------------------------------------
create table if not exists public.glm_hub_recent_apps (
user_id uuid not null references auth.users(id) on delete cascade,
app_id uuid not null references public.glm_hub_apps(id) on delete cascade,
last_opened_at timestamptz not null default now(),
primary key (user_id, app_id)
);
create index if not exists glm_hub_recent_apps_user_date_idx
on public.glm_hub_recent_apps (user_id, last_opened_at desc);
alter table public.glm_hub_recent_apps enable row level security;
alter table public.glm_hub_recent_apps force row level security;
revoke all on table public.glm_hub_recent_apps from anon;
revoke all on table public.glm_hub_recent_apps from authenticated;
grant select, insert, update, delete on table public.glm_hub_recent_apps to authenticated;
grant all on table public.glm_hub_recent_apps to service_role;
drop policy if exists "GLM Hub users read their recent apps" on public.glm_hub_recent_apps;
drop policy if exists "GLM Hub users create their recent apps" on public.glm_hub_recent_apps;
drop policy if exists "GLM Hub users update their recent apps" on public.glm_hub_recent_apps;
drop policy if exists "GLM Hub users delete their recent apps" on public.glm_hub_recent_apps;
create policy "GLM Hub users read their recent apps"
on public.glm_hub_recent_apps
for select
to authenticated
using (public.glm_hub_is_active_user() and user_id = (select auth.uid()));
create policy "GLM Hub users create their recent apps"
on public.glm_hub_recent_apps
for insert
to authenticated
with check (public.glm_hub_is_active_user() and user_id = (select auth.uid()));
create policy "GLM Hub users update their recent apps"
on public.glm_hub_recent_apps
for update
to authenticated
using (public.glm_hub_is_active_user() and user_id = (select auth.uid()))
with check (public.glm_hub_is_active_user() and user_id = (select auth.uid()));
create policy "GLM Hub users delete their recent apps"
on public.glm_hub_recent_apps
for delete
to authenticated
using (public.glm_hub_is_active_user() and user_id = (select auth.uid()));
-- --------------------------------------------------------------------------
-- 5. STORAGE PARA LOS ÍCONOS
-- --------------------------------------------------------------------------
insert into storage.buckets (
id,
name,
public,
file_size_limit,
allowed_mime_types
)
values (
'glm-hub-icons',
'glm-hub-icons',
true,
1048576,
array['image/png', 'image/jpeg', 'image/webp']::text[]
)
on conflict (id) do update
set
name = excluded.name,
public = true,
file_size_limit = excluded.file_size_limit,
allowed_mime_types = excluded.allowed_mime_types;
drop policy if exists "GLM Hub active users read icons" on storage.objects;
drop policy if exists "GLM Hub admins upload icons" on storage.objects;
drop policy if exists "GLM Hub admins update icons" on storage.objects;
drop policy if exists "GLM Hub admins delete icons" on storage.objects;
create policy "GLM Hub active users read icons"
on storage.objects
for select
to authenticated
using (
bucket_id = 'glm-hub-icons'
and public.glm_hub_is_active_user()
);
create policy "GLM Hub admins upload icons"
on storage.objects
for insert
to authenticated
with check (
bucket_id = 'glm-hub-icons'
and public.glm_hub_is_admin()
);
create policy "GLM Hub admins update icons"
on storage.objects
for update
to authenticated
using (
bucket_id = 'glm-hub-icons'
and public.glm_hub_is_admin()
)
with check (
bucket_id = 'glm-hub-icons'
and public.glm_hub_is_admin()
);
create policy "GLM Hub admins delete icons"
on storage.objects
for delete
to authenticated
using (
bucket_id = 'glm-hub-icons'
and public.glm_hub_is_admin()
);
-- --------------------------------------------------------------------------
-- 6. SOLICITUDES DE ACCESO A APLICACIONES
-- --------------------------------------------------------------------------
create table if not exists public.glm_hub_access_requests (
id uuid primary key default gen_random_uuid(),
requester_user_id uuid not null references auth.users(id) on delete cascade,
requester_name text not null,
requester_email text not null,
app_id uuid references public.glm_hub_apps(id) on delete set null,
app_name text not null,
app_category text not null,
status text not null default 'pending',
decided_by_name text,
decided_by_email text,
decided_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint glm_hub_access_requests_status_check
check (status in ('pending', 'approved', 'rejected')),
constraint glm_hub_access_requests_requester_email_check
check (requester_email = lower(btrim(requester_email))),
constraint glm_hub_access_requests_category_check
check (app_category in ('Administración', 'Recursos Humanos', 'CDC'))
);
create unique index if not exists glm_hub_access_requests_one_pending_idx
on public.glm_hub_access_requests (requester_email, app_id)
where status = 'pending' and app_id is not null;
create index if not exists glm_hub_access_requests_status_created_idx
on public.glm_hub_access_requests (status, created_at desc);
create table if not exists public.glm_hub_access_request_approvers (
id uuid primary key default gen_random_uuid(),
request_id uuid not null references public.glm_hub_access_requests(id) on delete cascade,
approver_name text not null,
approver_email text not null,
decision_token text not null unique,
decision_token_hash text not null unique,
clicked_at timestamptz,
created_at timestamptz not null default now(),
constraint glm_hub_access_request_approvers_email_check
check (
approver_email in (
'iaracena@gomezleemarketing.com',
'jgomez@gomezleemarketing.com',
'mgomez@gomezleemarketing.com'
)
),
constraint glm_hub_access_request_approvers_unique_request_email
unique (request_id, approver_email)
);
alter table public.glm_hub_access_requests enable row level security;
alter table public.glm_hub_access_requests force row level security;
alter table public.glm_hub_access_request_approvers enable row level security;
alter table public.glm_hub_access_request_approvers force row level security;
revoke all on table public.glm_hub_access_requests from anon, authenticated;
revoke all on table public.glm_hub_access_request_approvers from anon, authenticated;
grant all on table public.glm_hub_access_requests to service_role;
grant all on table public.glm_hub_access_request_approvers to service_role;
-- Los usuarios pueden consultar solamente sus propias solicitudes desde Supabase,
-- aunque el frontend actual no necesita leer esta tabla.
drop policy if exists "GLM Hub users read their access requests"
on public.glm_hub_access_requests;
create policy "GLM Hub users read their access requests"
on public.glm_hub_access_requests
for select
to authenticated
using (
requester_user_id = auth.uid()
and public.glm_hub_is_active_user()
);
grant select on table public.glm_hub_access_requests to authenticated;
-- Registra la solicitud usando exclusivamente la identidad del JWT del usuario.
-- No devuelve enlaces de aprobación al navegador, evitando que el solicitante
-- pueda obtenerlos o aprobarse a sí mismo.
create or replace function public.glm_hub_prepare_access_request(p_app_id uuid)
returns jsonb
language plpgsql
volatile
security definer
set search_path = ''
as $$
declare
v_user_id uuid;
v_email text;
v_name text;
v_role text;
v_app_name text;
v_app_category text;
v_request_id uuid;
begin
v_user_id := auth.uid();
v_email := lower(coalesce(auth.jwt() ->> 'email', ''));
if v_user_id is null or v_email = '' then
return jsonb_build_object('ok', false, 'error', 'Tu sesión no es válida. Cierra sesión e inicia nuevamente.');
end if;
select coalesce(nullif(btrim(access.full_name), ''), split_part(v_email, '@', 1)), access.role
into v_name, v_role
from public.glm_hub_authorized_users as access
where access.email = v_email
and access.is_active = true
limit 1;
if not found then
return jsonb_build_object('ok', false, 'error', 'El usuario no está autorizado en GLM Hub.');
end if;
if v_role = 'admin' then
return jsonb_build_object('ok', false, 'error', 'Los administradores no necesitan solicitar acceso desde el Hub.');
end if;
select app.name, app.category
into v_app_name, v_app_category
from public.glm_hub_apps as app
where app.id = p_app_id
and app.visibility = 'published'
limit 1;
if not found then
return jsonb_build_object('ok', false, 'error', 'La aplicación seleccionada ya no está publicada.');
end if;
if exists (
select 1
from public.glm_hub_access_requests as request
where request.requester_email = v_email
and request.app_id = p_app_id
and request.status = 'pending'
) then
return jsonb_build_object(
'ok', false,
'code', 'PENDING_EXISTS',
'error', 'Ya existe una solicitud pendiente para esta aplicación.'
);
end if;
insert into public.glm_hub_access_requests (
requester_user_id,
requester_name,
requester_email,
app_id,
app_name,
app_category
)
values (
v_user_id,
v_name,
v_email,
p_app_id,
v_app_name,
v_app_category
)
returning id into v_request_id;
return jsonb_build_object(
'ok', true,
'requestId', v_request_id,
'requesterName', v_name,
'requesterEmail', v_email,
'appName', v_app_name,
'appCategory', v_app_category
);
end;
$$;
alter function public.glm_hub_prepare_access_request(uuid) owner to postgres;
revoke all on function public.glm_hub_prepare_access_request(uuid) from public, anon;
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;
-- Emite o recupera los tres enlaces de decisión. 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)
returns jsonb
language plpgsql
volatile
security definer
set search_path = ''
as $$
declare
v_request public.glm_hub_access_requests%rowtype;
v_token_isaac text;
v_token_jose text;
v_token_maximo text;
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;
if not exists (
select 1
from public.glm_hub_access_request_approvers as approver
where approver.request_id = p_request_id
) then
v_token_isaac := encode(extensions.gen_random_bytes(32), 'hex');
v_token_jose := encode(extensions.gen_random_bytes(32), 'hex');
v_token_maximo := encode(extensions.gen_random_bytes(32), 'hex');
insert into public.glm_hub_access_request_approvers (
request_id,
approver_name,
approver_email,
decision_token,
decision_token_hash
)
values
(
p_request_id,
'Isaac Aracena',
'iaracena@gomezleemarketing.com',
v_token_isaac,
encode(extensions.digest(v_token_isaac, 'sha256'), 'hex')
),
(
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(
'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 jsonb_agg(
jsonb_build_object(
'name', approver.approver_name,
'email', approver.approver_email,
'token', approver.decision_token
)
order by approver.approver_email
)
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;
-- Registra una sola decisión de forma atómica. Aunque los botones sigan visibles
-- en correos ya entregados, cualquier segundo clic queda bloqueado en la base de datos.
create or replace function public.glm_hub_decide_access_request(
p_token text,
p_decision text
)
returns jsonb
language plpgsql
volatile
security definer
set search_path = ''
as $$
declare
v_request_id uuid;
v_approver_name text;
v_approver_email text;
v_request public.glm_hub_access_requests%rowtype;
v_normalized_decision text;
begin
v_normalized_decision := lower(btrim(coalesce(p_decision, '')));
if v_normalized_decision not in ('approved', 'rejected') then
return jsonb_build_object('ok', false, 'error', 'La decisión recibida no es válida.');
end if;
select approver.request_id, approver.approver_name, approver.approver_email
into v_request_id, v_approver_name, v_approver_email
from public.glm_hub_access_request_approvers as approver
where approver.decision_token_hash = encode(
extensions.digest(btrim(coalesce(p_token, '')), 'sha256'),
'hex'
)
limit 1;
if not found then
return jsonb_build_object('ok', false, 'error', 'El enlace de decisión no es válido o ya no existe.');
end if;
update public.glm_hub_access_requests as request
set
status = v_normalized_decision,
decided_by_name = v_approver_name,
decided_by_email = v_approver_email,
decided_at = now(),
updated_at = now()
where request.id = v_request_id
and request.status = 'pending'
returning request.* into v_request;
if found then
update public.glm_hub_access_request_approvers
set clicked_at = now()
where request_id = v_request_id
and approver_email = v_approver_email;
return jsonb_build_object(
'ok', true,
'alreadyDecided', false,
'requestId', v_request.id,
'status', v_request.status,
'requesterName', v_request.requester_name,
'requesterEmail', v_request.requester_email,
'appName', v_request.app_name,
'appCategory', v_request.app_category,
'decidedByName', v_request.decided_by_name,
'decidedByEmail', v_request.decided_by_email,
'decidedAt', v_request.decided_at
);
end if;
select request.*
into v_request
from public.glm_hub_access_requests as request
where request.id = v_request_id;
return jsonb_build_object(
'ok', false,
'alreadyDecided', true,
'status', v_request.status,
'requesterName', v_request.requester_name,
'requesterEmail', v_request.requester_email,
'appName', v_request.app_name,
'decidedByName', coalesce(v_request.decided_by_name, ''),
'decidedByEmail', coalesce(v_request.decided_by_email, ''),
'decidedAt', v_request.decided_at,
'error', 'Esta solicitud ya fue atendida y no admite otra decisión.'
);
end;
$$;
alter function public.glm_hub_decide_access_request(text, text) owner to postgres;
revoke all on function public.glm_hub_decide_access_request(text, text) from public;
grant execute on function public.glm_hub_decide_access_request(text, text) to anon;
grant execute on function public.glm_hub_decide_access_request(text, text) to authenticated;
grant execute on function public.glm_hub_decide_access_request(text, text) to service_role;
-- --------------------------------------------------------------------------
-- 7. REALTIME DEL CATÁLOGO
-- --------------------------------------------------------------------------
-- Hace que una aplicación creada/editada/eliminada por un administrador
-- aparezca en las sesiones abiertas de los demás usuarios sin recargar.
do $$
begin
if exists (
select 1 from pg_publication where pubname = 'supabase_realtime'
) and not exists (
select 1
from pg_publication_tables
where pubname = 'supabase_realtime'
and schemaname = 'public'
and tablename = 'glm_hub_apps'
) then
execute 'alter publication supabase_realtime add table public.glm_hub_apps';
end if;
end;
$$;
commit;
-- ============================================================================
-- OPERACIONES DE ADMINISTRACIÓN (EJECUTAR DESDE SQL EDITOR)
-- ============================================================================
-- DAR ACCESO A UN USUARIO NORMAL:
-- insert into public.glm_hub_authorized_users (email, full_name, role, is_active)
-- values ('usuario@gomezleemarketing.com', 'Nombre del usuario', 'member', true)
-- on conflict (email) do update
-- set full_name = excluded.full_name, role = 'member', is_active = true;
-- QUITAR ACCESO SIN BORRAR EL REGISTRO:
-- update public.glm_hub_authorized_users
-- set is_active = false
-- where email = 'usuario@gomezleemarketing.com';
-- REACTIVAR ACCESO:
-- update public.glm_hub_authorized_users
-- set is_active = true
-- where email = 'usuario@gomezleemarketing.com';
-- VER SOLICITUDES DE ACCESO:
-- select requester_name, requester_email, app_name, status, decided_by_name, decided_by_email, created_at, decided_at
-- from public.glm_hub_access_requests
-- order by created_at desc;
-- VER EL CATÁLOGO COMPARTIDO:
-- select id, name, category, visibility, url, icon_url, updated_at
-- from public.glm_hub_apps
-- order by name;
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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#ffffff" />
<meta name="description" content="Hub interno de aplicaciones de GomezLee Marketing." />
<link rel="icon" type="image/png" sizes="512x512" 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" />
<title>GLM Hub — GomezLee Marketing</title>
<script type="module" crossorigin src="/assets/index-xOqwTEny.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BT8YJLeR.css">
</head>
<body>
<div id="root"></div>
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2.110.8"></script>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#ffffff" />
<meta name="description" content="Hub interno de aplicaciones de GomezLee Marketing." />
<link rel="icon" type="image/png" sizes="512x512" 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" />
<title>GLM Hub — GomezLee Marketing</title>
</head>
<body>
<div id="root"></div>
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2.110.8"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,464 @@
{
"name": "GLM Hub - Generar ícono con Gemini (Nodo nativo)",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "glm-hub-generar-icono",
"responseMode": "responseNode",
"options": {
"allowedOrigins": "*"
}
},
"id": "47ec047e-250d-4056-a2af-2bb6c5f072b0",
"name": "Webhook - Generar ícono",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
-1120,
0
],
"webhookId": "bc20a398-5ecf-42a2-9d17-9396aa278708"
},
{
"parameters": {
"jsCode": "const request = $input.first().json ?? {};\nconst rawBody = request.body ?? request;\nlet body = rawBody;\n\nif (typeof rawBody === 'string') {\n try {\n body = JSON.parse(rawBody);\n } catch {\n body = {};\n }\n}\n\nconst headers = request.headers ?? {};\nconst headerAuthorization = String(headers.authorization ?? headers.Authorization ?? '').trim();\nconst accessToken = String(body.accessToken ?? '').trim();\nconst authorization = headerAuthorization.toLowerCase().startsWith('bearer ')\n ? headerAuthorization\n : accessToken\n ? `Bearer ${accessToken}`\n : '';\nconst name = String(body.name ?? '').trim();\nconst description = String(body.description ?? '').trim();\n\nlet error = '';\nif (!authorization.toLowerCase().startsWith('bearer ')) {\n error = 'La sesión de Supabase no fue enviada o no es válida.';\n} else if (name.length < 2 || name.length > 60) {\n error = 'El nombre debe contener entre 2 y 60 caracteres.';\n} else if (!description || description.length > 180) {\n error = 'La descripción debe contener entre 1 y 180 caracteres.';\n}\n\nreturn [{ json: { valid: !error, error, authorization, name, description } }];"
},
"id": "934fb8be-c5bc-43bd-b44a-53f9db105b32",
"name": "Validar solicitud",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-860,
0
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "308c498d-7c3f-4414-a21f-571276aee28b",
"leftValue": "={{ $json.valid }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "3344b885-db2b-473d-a444-a51ca00d063d",
"name": "¿Solicitud válida?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
-640,
0
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { ok: false, error: $json.error } }}",
"options": {
"responseCode": 400,
"responseHeaders": {
"entries": [
{
"name": "Cache-Control",
"value": "no-store"
}
]
}
}
},
"id": "af555c88-6ec6-4e0f-9d01-64b6cb16a5c5",
"name": "Responder 400",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.4,
"position": [
-400,
180
]
},
{
"parameters": {
"method": "POST",
"url": "={{ ($env.SUPABASE_URL || '').replace(/\\/$/, '') + '/rest/v1/rpc/glm_hub_icon_generation_access' }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "apikey",
"value": "={{ $env.SUPABASE_ANON_KEY }}"
},
{
"name": "Authorization",
"value": "={{ $('Validar solicitud').item.json.authorization }}"
}
]
},
"sendBody": true,
"contentType": "raw",
"rawContentType": "application/json",
"body": "{}",
"options": {
"response": {
"response": {
"neverError": true,
"responseFormat": "json"
}
},
"timeout": 30000
}
},
"id": "42ba23d6-9f3a-4b67-ae48-8253adfb7110",
"name": "Validar administrador en Supabase",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
-400,
-80
]
},
{
"parameters": {
"jsCode": "const permission = $input.first().json ?? {};\nconst request = $('Validar solicitud').first().json;\nconst authorized = permission.authorized === true;\nreturn [{\n json: {\n authorized,\n error: authorized ? '' : 'Tu sesión no tiene permisos de administrador para generar íconos.',\n name: request.name,\n description: request.description,\n email: String(permission.email ?? '')\n }\n}];"
},
"id": "489c0999-9ca3-4361-8262-18404f5aad35",
"name": "Confirmar autorización",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-180,
-80
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "c9275da4-0bca-4ce9-bd75-cc7d06e4cf56",
"leftValue": "={{ $json.authorized }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "7c199474-f2b6-4c1c-873d-e578d1f7d500",
"name": "¿Es administrador?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
40,
-80
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { ok: false, error: $json.error } }}",
"options": {
"responseCode": 403,
"responseHeaders": {
"entries": [
{
"name": "Cache-Control",
"value": "no-store"
}
]
}
}
},
"id": "1b105633-29f8-4e75-bead-d3022420dfed",
"name": "Responder 403",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.4,
"position": [
280,
100
]
},
{
"parameters": {
"jsCode": "const { name, description } = $input.first().json;\nconst prompt = `Crea un ícono cuadrado 1:1, profesional, minimalista y fácil de reconocer para una aplicación interna de GomezLee Marketing.\n\nNombre de la aplicación: ${name}\nDescripción funcional: ${description}\n\nRequisitos visuales obligatorios:\n- Representa la función principal con un único símbolo simple, claro y reconocible.\n- Estética corporativa moderna, limpia y tecnológica.\n- Paleta inspirada en GLM: azul grisáceo, verde claro y verde azulado.\n- Fondo blanco o transparente.\n- Composición centrada, con buen espacio de respiración.\n- Sin texto, sin letras, sin siglas, sin números, sin marcas comerciales y sin logotipos existentes.\n- Sin mockups, sin fotografías, sin personas, sin sombras exageradas y sin fondos complejos.\n- Debe seguir siendo legible al reducirse a 32 x 32 píxeles.\n- Entrega solamente el ícono final.`;\nreturn [{ json: { prompt } }];"
},
"id": "1ae24908-c67d-4742-917e-573b69f4ca19",
"name": "Construir prompt del ícono",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
280,
-180
]
},
{
"parameters": {
"resource": "image",
"operation": "generate",
"modelId": {
"__rl": true,
"value": "models/gemini-3-pro-image",
"mode": "list",
"cachedResultName": "models/gemini-3-pro-image (Nano Banana Pro)"
},
"prompt": "={{ $json.prompt }}",
"options": {
"binaryPropertyOutput": "data"
}
},
"id": "bdeed7c1-71ba-4af4-9424-b248c06c9e75",
"name": "Generate an image",
"type": "@n8n/n8n-nodes-langchain.googleGemini",
"typeVersion": 1.2,
"position": [
520,
-180
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"respondWith": "binary",
"responseDataSource": "set",
"inputFieldName": "data",
"options": {
"responseCode": 200,
"responseHeaders": {
"entries": [
{
"name": "Cache-Control",
"value": "no-store"
},
{
"name": "Content-Disposition",
"value": "inline; filename=\"glm-hub-icon.png\""
}
]
}
}
},
"id": "7349fabd-6d25-48b1-94be-64e36eef5f7e",
"name": "Responder con ícono",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.4,
"position": [
800,
-260
]
},
{
"parameters": {
"jsCode": "const input = $input.first().json ?? {};\nconst rawError = input.error ?? input.message ?? input.description ?? input;\nlet message = 'Gemini no pudo generar el ícono. Revisa la credencial, el modelo y la cuota disponible.';\n\nif (typeof rawError === 'string' && rawError.trim()) {\n message = rawError.trim();\n} else if (rawError && typeof rawError === 'object') {\n message = String(rawError.message ?? rawError.description ?? rawError.status ?? message);\n}\n\nreturn [{ json: { ok: false, error: message } }];"
},
"id": "fc7b61a0-fb00-42a4-bc4a-2e530da36a12",
"name": "Preparar error de Gemini",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
800,
-80
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { ok: false, error: $json.error } }}",
"options": {
"responseCode": 502,
"responseHeaders": {
"entries": [
{
"name": "Cache-Control",
"value": "no-store"
}
]
}
}
},
"id": "c0bc7fee-a55e-4f40-90b5-f875c41b131c",
"name": "Responder error de Gemini",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.4,
"position": [
1040,
-80
]
},
{
"parameters": {
"content": "## CONFIGURACIÓN ÚNICA\n\nEn el nodo **Generate an image**, confirma la credencial existente **Isaac - Gemini Api Pago** después de importar.\n\nEl webhook devuelve la imagen directamente como archivo binario al GLM Hub.",
"height": 220,
"width": 380,
"color": 5
},
"id": "fd8a87b4-3924-412b-8d3d-b7707bf6388b",
"name": "Nota de configuración",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
420,
100
]
}
],
"pinData": {},
"connections": {
"Webhook - Generar ícono": {
"main": [
[
{
"node": "Validar solicitud",
"type": "main",
"index": 0
}
]
]
},
"Validar solicitud": {
"main": [
[
{
"node": "¿Solicitud válida?",
"type": "main",
"index": 0
}
]
]
},
"¿Solicitud válida?": {
"main": [
[
{
"node": "Validar administrador en Supabase",
"type": "main",
"index": 0
}
],
[
{
"node": "Responder 400",
"type": "main",
"index": 0
}
]
]
},
"Validar administrador en Supabase": {
"main": [
[
{
"node": "Confirmar autorización",
"type": "main",
"index": 0
}
]
]
},
"Confirmar autorización": {
"main": [
[
{
"node": "¿Es administrador?",
"type": "main",
"index": 0
}
]
]
},
"¿Es administrador?": {
"main": [
[
{
"node": "Construir prompt del ícono",
"type": "main",
"index": 0
}
],
[
{
"node": "Responder 403",
"type": "main",
"index": 0
}
]
]
},
"Construir prompt del ícono": {
"main": [
[
{
"node": "Generate an image",
"type": "main",
"index": 0
}
]
]
},
"Generate an image": {
"main": [
[
{
"node": "Responder con ícono",
"type": "main",
"index": 0
}
],
[
{
"node": "Preparar error de Gemini",
"type": "main",
"index": 0
}
]
]
},
"Preparar error de Gemini": {
"main": [
[
{
"node": "Responder error de Gemini",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1",
"saveManualExecutions": true,
"callerPolicy": "workflowsFromSameOwner"
},
"versionId": "ae79a50a-79d1-43f8-b856-b4106bae3e60",
"meta": {
"templateCredsSetupCompleted": false
},
"tags": []
}
File diff suppressed because one or more lines are too long
+1872
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "glm-app-hub",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit --pretty false"
},
"dependencies": {
"lucide-react": "0.575.0",
"react": "19.2.7",
"react-dom": "19.2.7"
},
"devDependencies": {
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "5.2.0",
"typescript": "5.9.3",
"vite": "6.4.3"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

+98
View File
@@ -0,0 +1,98 @@
import fs from 'node:fs';
import path from 'node:path';
import ts from '../node_modules/typescript/lib/typescript.js';
const root = path.resolve(new URL('..', import.meta.url).pathname);
const srcDir = path.join(root, 'src');
const distDir = path.join(root, 'dist');
const assetsDir = path.join(distDir, 'assets');
const env = {
VITE_SUPABASE_URL: 'https://dbit.digitalcompass.agency',
VITE_SUPABASE_ANON_KEY: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE',
VITE_ICON_GENERATOR_WEBHOOK_URL: 'https://agenteit.digitalcompass.agency/webhook/glm-hub-generar-icono',
VITE_ACCESS_REQUEST_WEBHOOK_URL: 'https://agenteit.digitalcompass.agency/webhook/glm-hub-solicitar-acceso',
BASE_URL: '/',
};
function replaceEnv(source) {
return source
.replaceAll('import.meta.env.VITE_SUPABASE_URL', JSON.stringify(env.VITE_SUPABASE_URL))
.replaceAll('import.meta.env.VITE_SUPABASE_ANON_KEY', JSON.stringify(env.VITE_SUPABASE_ANON_KEY))
.replaceAll('import.meta.env.VITE_ICON_GENERATOR_WEBHOOK_URL', JSON.stringify(env.VITE_ICON_GENERATOR_WEBHOOK_URL))
.replaceAll('import.meta.env.VITE_ACCESS_REQUEST_WEBHOOK_URL', JSON.stringify(env.VITE_ACCESS_REQUEST_WEBHOOK_URL))
.replaceAll('import.meta.env.BASE_URL', JSON.stringify(env.BASE_URL));
}
function fixLocalImports(code) {
return code
.replace(/^import\s+["']\.\/styles\.css["'];?\s*$/gm, '')
.replace(/(from\s+["'])(\.\.?\/[^"']+)(["'])/g, (_m, start, spec, end) => {
if (/\.(?:js|mjs|cjs|json|css)$/.test(spec)) return `${start}${spec}${end}`;
return `${start}${spec}.js${end}`;
});
}
fs.rmSync(distDir, { recursive: true, force: true });
fs.mkdirSync(assetsDir, { recursive: true });
const sourceFiles = ['main.tsx', 'App.tsx', 'data.ts', 'repository.ts', 'storage.ts', 'supabase.ts', 'types.ts'];
for (const file of sourceFiles) {
const sourcePath = path.join(srcDir, file);
const source = replaceEnv(fs.readFileSync(sourcePath, 'utf8'));
const result = ts.transpileModule(source, {
fileName: file,
compilerOptions: {
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.ESNext,
jsx: ts.JsxEmit.ReactJSX,
importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove,
sourceMap: false,
},
reportDiagnostics: true,
});
const errors = (result.diagnostics ?? []).filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error);
if (errors.length) {
throw new Error(`${file}: ${errors.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')).join('\n')}`);
}
const outputName = file.replace(/\.tsx?$/, '.js');
fs.writeFileSync(path.join(assetsDir, outputName), fixLocalImports(result.outputText));
}
fs.copyFileSync(path.join(srcDir, 'styles.css'), path.join(assetsDir, 'styles.css'));
for (const asset of ['glm-logo.png', 'glm-favicon.png', 'glm-tab-icon-16-20260722.png', 'glm-tab-icon-32-20260722.png', 'glm-tab-icon-64-20260722.png', 'glm-tab-icon-180-20260722.png', 'glm-tab-icon-20260722.ico']) {
const source = path.join(root, 'public', asset);
if (fs.existsSync(source)) fs.copyFileSync(source, path.join(distDir, asset));
}
const indexHtml = `<!doctype html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#ffffff" />
<meta name="description" content="Hub interno de aplicaciones de GomezLee Marketing." />
<link rel="icon" type="image/png" sizes="512x512" 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" />
<title>GLM Hub — GomezLee Marketing</title>
<link rel="stylesheet" href="/assets/styles.css" />
<script type="importmap">
{
"imports": {
"react": "https://cdn.jsdelivr.net/npm/react@19.2.7/+esm",
"react/jsx-runtime": "https://cdn.jsdelivr.net/npm/react@19.2.7/jsx-runtime/+esm",
"react-dom/client": "https://cdn.jsdelivr.net/npm/react-dom@19.2.7/client/+esm",
"lucide-react": "https://cdn.jsdelivr.net/npm/lucide-react@0.575.0/+esm"
}
}
</script>
</head>
<body>
<div id="root"></div>
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2.110.8"></script>
<script type="module" src="/assets/main.js"></script>
</body>
</html>`;
fs.writeFileSync(path.join(distDir, 'index.html'), indexHtml);
console.log(`Static ESM build created at ${distDir}`);
+21
View File
@@ -0,0 +1,21 @@
$ErrorActionPreference = "Stop"
$projectRoot = [System.IO.Path]::GetFullPath((Split-Path -Parent $PSScriptRoot))
Push-Location $projectRoot
try {
& npm run typecheck
if ($LASTEXITCODE -ne 0) {
throw "TypeScript encontró errores."
}
& npm exec vite build
if ($LASTEXITCODE -ne 0) {
throw "Vite no pudo compilar la aplicación."
}
Write-Host "GLM Hub compilado en $(Join-Path $projectRoot 'dist')"
}
finally {
Pop-Location
}
+40
View File
@@ -0,0 +1,40 @@
import { createReadStream, existsSync, statSync } from "node:fs";
import { createServer } from "node:http";
import { extname, join, normalize, resolve } from "node:path";
const root = resolve(process.cwd(), process.argv[2] ?? "dist");
const port = Number(process.env.PORT ?? 4173);
const mimeTypes = new Map([
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".js", "text/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"],
[".png", "image/png"],
[".svg", "image/svg+xml"],
[".webp", "image/webp"],
]);
const server = createServer((request, response) => {
const rawPath = decodeURIComponent((request.url ?? "/").split("?")[0]);
const relativePath = normalize(rawPath).replace(/^([/\\])+/, "");
let filePath = resolve(join(root, relativePath || "index.html"));
if (!filePath.startsWith(root)) {
response.writeHead(403).end("Forbidden");
return;
}
if (!existsSync(filePath) || statSync(filePath).isDirectory()) {
filePath = join(root, "index.html");
}
response.setHeader("Content-Type", mimeTypes.get(extname(filePath)) ?? "application/octet-stream");
response.setHeader("X-Content-Type-Options", "nosniff");
response.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
response.setHeader("Cross-Origin-Opener-Policy", "same-origin");
createReadStream(filePath).pipe(response);
});
server.listen(port, "127.0.0.1", () => {
console.log(`GLM Hub disponible en http://127.0.0.1:${port}`);
});
+2235
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
import type { HubApp, HubData } from "./types";
const SEED_DATE = "2026-07-16T12:00:00.000Z";
export const CATEGORY_ORDER = [
"Administración",
"Recursos Humanos",
"CDC",
] as const;
export const SEED_APPS: HubApp[] = [];
export function createSeedData(): HubData {
return {
schemaVersion: 1,
apps: SEED_APPS.map((app) => ({ ...app, keywords: [...app.keywords] })),
favoritesByUser: {},
recentByUser: {},
updatedAt: SEED_DATE,
};
}
+34
View File
@@ -0,0 +1,34 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
const GLM_FAVICON_URL =
"https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM.png?v=glm-hub-20260722-v3";
function ensureGlmFavicon(): void {
document.querySelectorAll<HTMLLinkElement>('link[rel="icon"], link[rel="shortcut icon"]').forEach((link) => {
link.remove();
});
const icon = document.createElement("link");
icon.rel = "icon";
icon.type = "image/png";
icon.sizes = "512x512";
icon.href = GLM_FAVICON_URL;
document.head.appendChild(icon);
const shortcut = document.createElement("link");
shortcut.rel = "shortcut icon";
shortcut.type = "image/png";
shortcut.href = GLM_FAVICON_URL;
document.head.appendChild(shortcut);
}
ensureGlmFavicon();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+270
View File
@@ -0,0 +1,270 @@
import { supabase } from "./supabase";
import type { AppCategory, AppVisibility, HubApp, HubData, HubUser } from "./types";
const APPS_TABLE = "glm_hub_apps";
const FAVORITES_TABLE = "glm_hub_favorites";
const RECENTS_TABLE = "glm_hub_recent_apps";
const ICON_BUCKET = "glm-hub-icons";
const LOCAL_MIGRATION_MARKER = "glm-hub:supabase-catalog-migrated:v1";
interface HubAppRow {
id: string;
name: string;
description: string;
category: AppCategory;
url: string | null;
icon_url: string;
visibility: AppVisibility;
created_at: string;
updated_at: string;
}
export interface SaveHubAppInput {
name: string;
description: string;
category: AppCategory;
url: string;
iconDataUrl: string;
visibility: AppVisibility;
}
function rowToApp(row: HubAppRow): HubApp {
return {
id: row.id,
name: row.name,
description: row.description,
category: row.category,
keywords: [],
url: row.url ?? "",
mark: "",
accent: "#4F758B",
iconDataUrl: row.icon_url,
visibility: row.visibility,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function dataUrlToBlob(dataUrl: string): { blob: Blob; mimeType: string; extension: string } {
const match = /^data:(image\/(?:png|jpeg|webp));base64,([a-zA-Z0-9+/=]+)$/.exec(dataUrl);
if (!match) throw new Error("El ícono no tiene un formato válido.");
const mimeType = match[1];
const binary = atob(match[2]);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return {
blob: new Blob([bytes], { type: mimeType }),
mimeType,
extension: mimeType === "image/jpeg" ? "jpg" : mimeType.split("/")[1],
};
}
function storagePathFromPublicUrl(url: string): string | null {
try {
const parsed = new URL(url);
const marker = `/storage/v1/object/public/${ICON_BUCKET}/`;
const markerIndex = parsed.pathname.indexOf(marker);
if (markerIndex < 0) return null;
return decodeURIComponent(parsed.pathname.slice(markerIndex + marker.length));
} catch {
return null;
}
}
async function uploadIcon(appId: string, iconDataUrl: string): Promise<string> {
if (!iconDataUrl.startsWith("data:image/")) return iconDataUrl;
const { blob, mimeType, extension } = dataUrlToBlob(iconDataUrl);
const path = `apps/${appId}/${Date.now()}-${crypto.randomUUID()}.${extension}`;
const { error: uploadError } = await supabase.storage
.from(ICON_BUCKET)
.upload(path, blob, { cacheControl: "31536000", contentType: mimeType, upsert: false });
if (uploadError) throw new Error(`No se pudo subir el ícono a Supabase: ${uploadError.message}`);
const { data } = supabase.storage.from(ICON_BUCKET).getPublicUrl(path);
const publicUrl = data?.publicUrl as string | undefined;
if (!publicUrl) throw new Error("Supabase no devolvió la URL pública del ícono.");
return publicUrl;
}
async function removeStoredIcon(url: string): Promise<void> {
const path = storagePathFromPublicUrl(url);
if (!path) return;
const { error } = await supabase.storage.from(ICON_BUCKET).remove([path]);
if (error) console.warn("No se pudo retirar el ícono anterior de Storage:", error);
}
export async function fetchHubApps(): Promise<HubApp[]> {
const { data, error } = await supabase
.from(APPS_TABLE)
.select("id,name,description,category,url,icon_url,visibility,created_at,updated_at")
.order("name", { ascending: true });
if (error) throw new Error(`No se pudo cargar el catálogo compartido: ${error.message}`);
return ((data ?? []) as HubAppRow[]).map(rowToApp);
}
async function fetchFavoriteIds(userId: string): Promise<string[]> {
const { data, error } = await supabase
.from(FAVORITES_TABLE)
.select("app_id,created_at")
.eq("user_id", userId)
.order("created_at", { ascending: false });
if (error) throw new Error(`No se pudieron cargar los favoritos: ${error.message}`);
return (data ?? []).map((row: { app_id: string }) => row.app_id);
}
async function fetchRecentIds(userId: string): Promise<string[]> {
const { data, error } = await supabase
.from(RECENTS_TABLE)
.select("app_id,last_opened_at")
.eq("user_id", userId)
.order("last_opened_at", { ascending: false })
.limit(30);
if (error) throw new Error(`No se pudieron cargar los accesos recientes: ${error.message}`);
return (data ?? []).map((row: { app_id: string }) => row.app_id);
}
export async function fetchHubData(userId: string): Promise<HubData> {
const [apps, favorites, recents] = await Promise.all([
fetchHubApps(),
fetchFavoriteIds(userId),
fetchRecentIds(userId),
]);
return {
schemaVersion: 1,
apps,
favoritesByUser: { [userId]: favorites },
recentByUser: { [userId]: recents },
updatedAt: new Date().toISOString(),
};
}
export async function saveHubApp(
input: SaveHubAppInput,
existing: HubApp | null,
userId: string,
): Promise<HubApp> {
const appId = existing?.id ?? crypto.randomUUID();
const iconUrl = await uploadIcon(appId, input.iconDataUrl);
const payload = {
name: input.name,
description: input.description,
category: input.category,
url: input.url || null,
icon_url: iconUrl,
visibility: input.visibility,
updated_by: userId,
};
let result: { data: unknown; error: { message: string } | null };
if (existing) {
result = await supabase
.from(APPS_TABLE)
.update(payload)
.eq("id", appId)
.select("id,name,description,category,url,icon_url,visibility,created_at,updated_at")
.single();
} else {
result = await supabase
.from(APPS_TABLE)
.insert({ id: appId, ...payload, created_by: userId })
.select("id,name,description,category,url,icon_url,visibility,created_at,updated_at")
.single();
}
if (result.error) {
if (iconUrl !== input.iconDataUrl) await removeStoredIcon(iconUrl);
throw new Error(`No se pudo guardar la aplicación: ${result.error.message}`);
}
if (existing && existing.iconDataUrl !== iconUrl) await removeStoredIcon(existing.iconDataUrl);
return rowToApp(result.data as HubAppRow);
}
export async function deleteHubApp(app: HubApp): Promise<void> {
const { error } = await supabase.from(APPS_TABLE).delete().eq("id", app.id);
if (error) throw new Error(`No se pudo eliminar la aplicación: ${error.message}`);
await removeStoredIcon(app.iconDataUrl);
}
export async function setFavorite(userId: string, appId: string, favorite: boolean): Promise<void> {
if (favorite) {
const { error } = await supabase
.from(FAVORITES_TABLE)
.upsert({ user_id: userId, app_id: appId }, { onConflict: "user_id,app_id" });
if (error) throw new Error(`No se pudo guardar el favorito: ${error.message}`);
return;
}
const { error } = await supabase
.from(FAVORITES_TABLE)
.delete()
.eq("user_id", userId)
.eq("app_id", appId);
if (error) throw new Error(`No se pudo quitar el favorito: ${error.message}`);
}
export async function recordRecent(userId: string, appId: string): Promise<void> {
const { error } = await supabase
.from(RECENTS_TABLE)
.upsert(
{ user_id: userId, app_id: appId, last_opened_at: new Date().toISOString() },
{ onConflict: "user_id,app_id" },
);
if (error) throw new Error(`No se pudo registrar el acceso reciente: ${error.message}`);
}
export async function migrateLocalAppsIfNeeded(user: HubUser, localApps: HubApp[]): Promise<number> {
if (user.role !== "admin" || localApps.length === 0) return 0;
if (localStorage.getItem(LOCAL_MIGRATION_MARKER) === "completed") return 0;
const remoteApps = await fetchHubApps();
const remoteNames = new Set(remoteApps.map((app) => app.name.trim().toLocaleLowerCase("es-DO")));
const missingApps = localApps.filter((app) => {
const normalizedName = app.name.trim().toLocaleLowerCase("es-DO");
return Boolean(app.iconDataUrl.trim()) && !remoteNames.has(normalizedName);
});
let migratedCount = 0;
for (const app of missingApps) {
await saveHubApp(
{
name: app.name,
description: app.description,
category: app.category,
url: app.url,
iconDataUrl: app.iconDataUrl,
visibility: app.visibility,
},
null,
user.id,
);
remoteNames.add(app.name.trim().toLocaleLowerCase("es-DO"));
migratedCount += 1;
}
localStorage.setItem(LOCAL_MIGRATION_MARKER, "completed");
return migratedCount;
}
export function subscribeToHubApps(onChange: () => void): () => void {
const channel = supabase
.channel("glm-hub-apps-shared")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: APPS_TABLE },
() => onChange(),
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}
+171
View File
@@ -0,0 +1,171 @@
import { createSeedData } from "./data";
import type { HubApp, HubData } from "./types";
const DATA_KEY = "glm-hub:state:v1";
export interface LoadDataResult {
data: HubData;
warning: string;
}
function isHubApp(value: unknown): value is HubApp {
if (!value || typeof value !== "object") return false;
const app = value as Partial<HubApp>;
return (
typeof app.id === "string" &&
typeof app.name === "string" &&
typeof app.description === "string" &&
typeof app.category === "string" &&
Array.isArray(app.keywords) &&
typeof app.url === "string" &&
typeof app.mark === "string" &&
typeof app.accent === "string" &&
typeof app.iconDataUrl === "string" &&
(app.visibility === "published" || app.visibility === "hidden")
);
}
function isHubData(value: unknown): value is HubData {
if (!value || typeof value !== "object") return false;
const data = value as Partial<HubData>;
return (
data.schemaVersion === 1 &&
Array.isArray(data.apps) &&
data.apps.every(isHubApp) &&
!!data.favoritesByUser &&
typeof data.favoritesByUser === "object" &&
!!data.recentByUser &&
typeof data.recentByUser === "object"
);
}
function keepExistingAppIds(groups: Record<string, string[]>, appIds: Set<string>): Record<string, string[]> {
return Object.fromEntries(
Object.entries(groups).map(([userId, ids]) => [userId, ids.filter((id) => appIds.has(id))]),
);
}
function migrateCategory(category: string): HubApp["category"] {
switch (category) {
case "Administración":
case "Recursos Humanos":
case "CDC":
return category;
case "Recursos":
return "Recursos Humanos";
case "Clientes":
case "Creatividad":
return "CDC";
case "Operaciones":
case "Analítica":
default:
return "Administración";
}
}
function migrateAppCategories(data: HubData): HubData {
let changed = false;
const apps = data.apps.map((app) => {
const category = migrateCategory(app.category);
if (category === app.category) return app;
changed = true;
return { ...app, category };
});
return changed
? { ...data, apps, updatedAt: new Date().toISOString() }
: data;
}
function removeAppsWithoutLogo(data: HubData): HubData {
const apps = data.apps.filter((app) => Boolean(app.iconDataUrl.trim()));
if (apps.length === data.apps.length) return data;
const appIds = new Set(apps.map((app) => app.id));
return {
...data,
apps,
favoritesByUser: keepExistingAppIds(data.favoritesByUser, appIds),
recentByUser: keepExistingAppIds(data.recentByUser, appIds),
updatedAt: new Date().toISOString(),
};
}
export function loadHubData(): LoadDataResult {
const fallback = createSeedData();
try {
const raw = localStorage.getItem(DATA_KEY);
if (!raw) {
localStorage.setItem(DATA_KEY, JSON.stringify(fallback));
return { data: fallback, warning: "" };
}
const parsed: unknown = JSON.parse(raw);
if (!isHubData(parsed)) {
localStorage.setItem(`${DATA_KEY}:backup`, raw);
localStorage.setItem(DATA_KEY, JSON.stringify(fallback));
return {
data: fallback,
warning: "Los datos locales no eran válidos. Restauramos el catálogo inicial.",
};
}
const migrated = migrateAppCategories(parsed);
const cleaned = removeAppsWithoutLogo(migrated);
if (cleaned !== parsed) {
localStorage.setItem(DATA_KEY, JSON.stringify(cleaned));
}
return { data: cleaned, warning: "" };
} catch {
return {
data: fallback,
warning: "No pudimos leer los datos guardados. Estás viendo el catálogo inicial.",
};
}
}
export function saveHubData(data: HubData): { ok: boolean; error: string } {
try {
localStorage.setItem(DATA_KEY, JSON.stringify(data));
return { ok: true, error: "" };
} catch (error) {
const isQuota =
error instanceof DOMException &&
(error.name === "QuotaExceededError" || error.name === "NS_ERROR_DOM_QUOTA_REACHED");
return {
ok: false,
error: isQuota
? "El almacenamiento está lleno. Prueba con un logo más liviano."
: "No pudimos guardar los cambios en este navegador.",
};
}
}
export function resetHubData(): HubData {
const next = createSeedData();
localStorage.setItem(DATA_KEY, JSON.stringify(next));
return next;
}
export function normalizeSearch(value: string): string {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLocaleLowerCase("es-DO")
.trim();
}
export function getSafeUrl(value: string): string | null {
if (!value.trim()) return null;
try {
const url = new URL(value.trim());
const isLocalHttp =
url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1");
if (url.protocol !== "https:" && !isLocalHttp) return null;
if (url.username || url.password) return null;
return url.toString();
} catch {
return null;
}
}
+2700
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
type SupabaseBrowserLibrary = {
createClient: (url: string, key: string, options?: unknown) => any;
};
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL?.trim();
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY?.trim();
const browserLibrary = (globalThis as typeof globalThis & { supabase?: SupabaseBrowserLibrary }).supabase;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error("Faltan VITE_SUPABASE_URL o VITE_SUPABASE_ANON_KEY en el archivo .env.");
}
if (!browserLibrary) {
throw new Error("No se pudo cargar la librería de Supabase.");
}
export const supabase = browserLibrary.createClient(supabaseUrl, supabaseAnonKey, {
auth: {
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: true,
flowType: "pkce",
},
});
+51
View File
@@ -0,0 +1,51 @@
export type UserRole = "admin" | "member";
export type AppCategory =
| "Administración"
| "Recursos Humanos"
| "CDC";
export type AppVisibility = "published" | "hidden";
export interface HubApp {
id: string;
name: string;
description: string;
category: AppCategory;
keywords: string[];
url: string;
mark: string;
accent: string;
iconDataUrl: string;
visibility: AppVisibility;
createdAt: string;
updatedAt: string;
}
export interface HubUser {
id: string;
name: string;
email: string;
role: UserRole;
avatarUrl?: string;
}
export interface Session {
user: HubUser;
expiresAt: number;
}
export interface HubData {
schemaVersion: 1;
apps: HubApp[];
favoritesByUser: Record<string, string[]>;
recentByUser: Record<string, string[]>;
updatedAt: string;
}
export interface ToastMessage {
id: string;
kind: "success" | "info" | "error";
title: string;
detail?: string;
}
+12
View File
@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_SUPABASE_URL: string;
readonly VITE_SUPABASE_ANON_KEY: string;
readonly VITE_ICON_GENERATOR_WEBHOOK_URL: string;
readonly VITE_ACCESS_REQUEST_WEBHOOK_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src", "vite.config.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
// base: "/glm-hub/",
plugins: [react()],
server: {
host: "127.0.0.1",
port: 4173,
},
preview: {
host: "127.0.0.1",
port: 4173,
},
});