Saltar al contenido principal

Configuración del Sistema — OnnixConnect

Fecha: 2026-05-04 Tabla: system_settings Módulo UI: Sidebar → Configuración → Seguridad (solo superadmin)

Almacén de parámetros globales editables en runtime sin necesidad de tocar .env ni redeployar. Acceso restringido a superadmin global (team_id=0).


1. Tabla system_settings

id bigint primary key
key varchar UNIQUE NOT NULL -- ej: "security.die_on_tab_close"
value text NULL -- siempre string serializado
type varchar(20) DEFAULT 'string' -- string|bool|int|json
description text NULL
created_at, updated_at

Migración: database/migrations/2026_05_04_113500_create_system_settings_table.php.

2. Modelo SystemSetting

app/Models/SystemSetting.php. Acceso vía métodos estáticos cacheados:

// Lectura (con default y casteo automático)
$enabled = SystemSetting::getValue('security.die_on_tab_close', false); // bool
$mins = SystemSetting::getValue('security.stale_close_minutes', 15); // int

// Escritura (especifica el tipo para serializar bien)
SystemSetting::setValue('security.die_on_tab_close', true, 'bool',
'Cierra sesión cuando se cierran todas las pestañas de la app.');

Tipos soportados:

typeSerialización en BDCasteo al leer
string (default)(string) $valuestring
bool'1' o '0'bool ('1' o 'true'true)
int(string) $valueint
jsonjson_encode($value, JSON_UNESCAPED_UNICODE)json_decode(true) (array)

Caché: Cache::rememberForever('sys_setting:'.$key, ...). setValue invalida el cache automáticamente con Cache::forget.


3. Settings actualmente registrados

KeyTipoDefaultDescripción
security.die_on_tab_closeboolfalseCierra sesión al cerrar todas las pestañas de la app (ver §5).
security.session_lifetime_minutesint120Lifetime informativo (referencia para el job de stale).
security.stale_close_minutesint15Minutos sin actividad para que CloseStaleSessionsJob cierre la sesión.

4. Módulo UI — Configuración → Seguridad

  • Ruta: GET /configuracion/seguridadview('configuracion.seguridad').
  • Componente: resources/views/components/⚡config-seguridad.blade.php (Volt anonymous).
  • Sidebar: ítem "Configuración" visible solo a superadmin (@if($isSuperadmin) en layouts/sneat-app.blade.php).
  • Auth: el componente aborta 403 en mount() si el user no es superadmin global (consulta directo a model_has_roles con team_id=0 para evitar el filtro de Spatie teams).

Vista

  • Card 1 — Sesión por pestaña
    • Switch tema-aware (.cs-switch con var(--primary)/var(--bg-elevated)).
    • wire:click="toggleDieOnTabClose" invierte el flag y persiste en system_settings.
    • Toast de feedback con SweetAlert (Livewire.on('toast', ...)).
  • Card 2 — Tiempos de sesión
    • Inputs numéricos para sessionLifetimeMinutes (5..1440) y stalefulCloseMinutes (1..120).
    • Submit wire:submit.prevent="guardarLifetime" valida y guarda los dos en system_settings.

5. Feature: "Cerrar sesión al cerrar todas las pestañas"

Cuando security.die_on_tab_close = true, la app implementa un esquema de heartbeat global en localStorage que detecta cuándo todas las pestañas de la app fueron cerradas, sin romper el flujo "abrir link en pestaña nueva".

Lógica

  1. Cada pestaña activa en el layout autenticado (sneat-app) hace localStorage.setItem('onnix_app_heartbeat', Date.now()) cada 3 segundos.
  2. Al cargar una pestaña nueva en el layout, se compara Date.now() - lastHeartbeat:
    • Si < 10 s → otra pestaña está latiendo → la nueva hereda sesión y empieza a latir también.
    • Si >= 10 s → no había nadie latiendo → POST /logout-tab-closed → redirect a /login?reason=tab_closed.
  3. Al hacer login (auth/login.blade.php):
    • Al cargar la página de login: localStorage.removeItem('onnix_app_heartbeat') (estamos pre-auth, limpiamos cualquier heartbeat residual).
    • Al hacer submit: localStorage.setItem('onnix_app_heartbeat', Date.now()) para que el redirect post-login encuentre uno fresco y no dispare logout falso.

Endpoint server /logout-tab-closed

POST /logout-tab-closedLoginController::logoutTabClosed. Valida el setting en server (no confía en el cliente):

if (SystemSetting::getValue('security.die_on_tab_close', false)) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
}
return response('', 204);

Si el setting está OFF, devuelve 204 sin hacer nada — inmune a clientes manipulados.

Ruta: Route::post('/logout-tab-closed', ...)->middleware('auth')->withoutMiddleware([VerifyCsrfToken::class]) (CSRF-exempt porque el JS no puede leer la cookie httpOnly al hacer fetch desde otra pestaña).

Cuándo dispara y cuándo NO

AcciónResultado
Login → /homeHeartbeat fresco (lo dejó el form de login) → no dispara logout
F5 dentro de una pestañaHeartbeat de la propia pestaña recién detenida (~200 ms) → no dispara
Ctrl+Click → abrir link en pestaña nuevaPestaña original sigue latiendo → la nueva hereda sesión
Cerrar todas las pestañas, esperar > 15 s, abrir nuevaHeartbeat envejecido → dispara logout y manda a /login?reason=tab_closed
Cerrar el browser entero, reabrirHeartbeat envejecido → dispara logout
Logout manualEl server invalida sesión y manda a /login. La página de login borra el heartbeat

Por qué no usamos pagehide/beforeunload

El primer intento usaba navigator.sendBeacon en pagehide para cerrar la sesión instantáneamente. Resultó roto porque pagehide también dispara en navegaciones internas (wire:navigate, click en links), causando logout no deseado en cada page transition.

El heartbeat resuelve el problema: solo dispara logout cuando nadie está latiendo, y eso solo ocurre cuando todas las pestañas están realmente cerradas.


6. Layout cooperante

resources/views/layouts/sneat-app.blade.php

Bloque condicional @if(SystemSetting::getValue('security.die_on_tab_close', false)) { ... } con la lógica del heartbeat. Solo se renderiza al activar el setting; con OFF la app se comporta normal.

resources/views/auth/login.blade.php

@push('scripts') con dos IIFEs: una limpia el heartbeat al cargar y lo re-marca en submit; la otra muestra un toast "Sesión cerrada — todas las pestañas de la app fueron cerradas." si llegamos con ?reason=tab_closed.

resources/views/layouts/sneat-auth.blade.php

Sin lógica de heartbeat (login, registro vía invitación, aceptar invitación). Estos contextos son pre-auth y no deben heredar/interferir con la sesión.


.env: SESSION_EXPIRE_ON_CLOSE=true. Cuando se cierra el navegador entero, la cookie de sesión se borra automáticamente — independiente del setting die_on_tab_close. Esto cubre el caso "el browser crashea" en el que el heartbeat queda con timestamp viejo pero la cookie también se va.


8. Verificación end-to-end

# Migraciones
php artisan migrate
# → system_settings creado.

# Setting OFF (default)
# → comportamiento estándar; cerrar pestaña no afecta.

# Activar el setting
# Login como superadmin → Sidebar → Configuración → toggle ON.

# Caso 1: una pestaña, F5
# → no pide login.

# Caso 2: dos pestañas, cerrar una
# → la otra sigue funcionando.

# Caso 3: cerrar TODAS, esperar 15s, abrir nueva
# → POST /logout-tab-closed → redirect /login?reason=tab_closed.

# Caso 4: Ctrl+Click en link interno
# → la nueva pestaña hereda sesión sin re-login.

9. Extensibilidad

Para agregar un setting nuevo:

// 1. Decidir key con prefijo de dominio (ej: 'security.', 'sifen.', 'ui.')
// 2. Para leerlo:
$valor = SystemSetting::getValue('mi.key', $default);

// 3. Para escribirlo desde un componente:
SystemSetting::setValue('mi.key', $value, 'bool', 'Descripción humana');

// 4. (Opcional) seedearlo en RolePermissionSeeder o un seeder dedicado:
SystemSetting::firstOrCreate(
['key' => 'mi.key'],
['value' => '0', 'type' => 'bool', 'description' => '...']
);

10. Pendientes / follow-up

  • Migrar otros parámetros hardcoded a system_settings (ej: lifetime de invitación, max DTEs por lote, cantidad por defecto en paginación).
  • Agregar tab "Notificaciones" en Configuración (toggle alerta IP nueva, etc.).
  • Histórico de cambios — agregar trait LogsActivity al modelo SystemSetting para auditar quién cambió qué.

Referencias cruzadas