Configuración del Sistema — OnnixConnect
Fecha: 2026-05-04 Tabla:
system_settingsMó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:
type | Serialización en BD | Casteo al leer |
|---|---|---|
string (default) | (string) $value | string |
bool | '1' o '0' | bool ('1' o 'true' ⇒ true) |
int | (string) $value | int |
json | json_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
| Key | Tipo | Default | Descripción |
|---|---|---|---|
security.die_on_tab_close | bool | false | Cierra sesión al cerrar todas las pestañas de la app (ver §5). |
security.session_lifetime_minutes | int | 120 | Lifetime informativo (referencia para el job de stale). |
security.stale_close_minutes | int | 15 | Minutos sin actividad para que CloseStaleSessionsJob cierre la sesión. |
4. Módulo UI — Configuración → Seguridad
- Ruta:
GET /configuracion/seguridad→view('configuracion.seguridad'). - Componente:
resources/views/components/⚡config-seguridad.blade.php(Volt anonymous). - Sidebar: ítem "Configuración" visible solo a superadmin (
@if($isSuperadmin)enlayouts/sneat-app.blade.php). - Auth: el componente aborta
403enmount()si el user no es superadmin global (consulta directo amodel_has_rolesconteam_id=0para evitar el filtro de Spatie teams).
Vista
- Card 1 — Sesión por pestaña
- Switch tema-aware (
.cs-switchconvar(--primary)/var(--bg-elevated)). wire:click="toggleDieOnTabClose"invierte el flag y persiste ensystem_settings.- Toast de feedback con SweetAlert (
Livewire.on('toast', ...)).
- Switch tema-aware (
- Card 2 — Tiempos de sesión
- Inputs numéricos para
sessionLifetimeMinutes(5..1440) ystalefulCloseMinutes(1..120). - Submit
wire:submit.prevent="guardarLifetime"valida y guarda los dos ensystem_settings.
- Inputs numéricos para
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
- Cada pestaña activa en el layout autenticado (
sneat-app) hacelocalStorage.setItem('onnix_app_heartbeat', Date.now())cada 3 segundos. - 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.
- 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.
- Al cargar la página de login:
Endpoint server /logout-tab-closed
POST /logout-tab-closed → LoginController::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ón | Resultado |
|---|---|
Login → /home | Heartbeat fresco (lo dejó el form de login) → no dispara logout |
| F5 dentro de una pestaña | Heartbeat de la propia pestaña recién detenida (~200 ms) → no dispara |
| Ctrl+Click → abrir link en pestaña nueva | Pestaña original sigue latiendo → la nueva hereda sesión |
| Cerrar todas las pestañas, esperar > 15 s, abrir nueva | Heartbeat envejecido → dispara logout y manda a /login?reason=tab_closed |
| Cerrar el browser entero, reabrir | Heartbeat envejecido → dispara logout |
| Logout manual | El 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.
7. Cookie session-only (respaldo)
.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
SystemSettingpara auditar quién cambió qué.
Referencias cruzadas
- Auditoría de Sesiones —
user_sessions,CloseStaleSessionsJob, modal Actividad. - Seguridad Web — rate limiting, headers, CSRF.
- Auth Testing — endpoints
/configuracion/seguridad,/logout-tab-closed.