'use strict';
(() => {
const app = document.getElementById('app');
const dialog = document.getElementById('modal');
const modalContent = document.getElementById('modal-content');
const notification = document.getElementById('notification');
const state = { session: null, profiles: [], segments: [], protocols: [], profileFilters: { search: '', segment: '', status: '', protocol: '' }, userFilters: { search: '', status: 'all' }, userPage: 1, userPageSize: 10, users: [], page: 1, pageSize: 10, segmentPage: 1, segmentPageSize: 10, trafficPage: 1, trafficPageSize: 10, trafficPeriod: 'month', auditPage: 1, auditPageSize: 10, auditSnapshot: null, auditFilters: {}, warningPage: 1, warningPageSize: 10, routeVersion: 0, recoveryCodes: null, totp: null };
let toastTimer;
let infoCounter = 0;
let openInfo = null;
let openNavTooltip = null;
let dismissedNavTooltip = null;
let openActionTooltip = null;
let dismissedActionTooltip = null;
let openActionMenu = null;
let actionCounter = 0;
let avatarCrop = null;
let avatarLoadVersion = 0;
const hoverNavigation = matchMedia('(hover: hover) and (pointer: fine)');
const refreshControls = (root, options) => window.SimlaControls?.refresh(root, options);
const closeControls = () => window.SimlaControls?.close();
const systemTheme = matchMedia('(prefers-color-scheme: dark)');
let theme = 'auto';
try { const saved = localStorage.getItem('simla-vpn-theme'); if (['light', 'dark', 'auto'].includes(saved)) theme = saved; } catch {}
const brandMark = () => 'VPN Simla Manager ';
// SVG originales del selector de simlatech-web, con geometría y caja intactas.
// Copia offline y procedencia: static/assets/simla-theme/manifest.json.
const themeIcons = {
auto: ' ',
light: ' ',
dark: ' '
};
const themeControl = () => `
${[['auto', 'Automático'], ['light', 'Claro'], ['dark', 'Oscuro']].map(([value, label]) => `${themeIcons[value]} `).join('')}
`;
function applyTheme() {
const mode = theme === 'auto' ? (systemTheme.matches ? 'dark' : 'light') : theme;
document.documentElement.dataset.theme = mode;
document.querySelectorAll('[data-logo]').forEach(image => { image.src = `/static/assets/omnica-logos/${mode === 'dark' ? 'simla-horizontal-color-white' : 'simla-horizontal-color-black'}.svg`; });
document.querySelectorAll('button[data-theme]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.theme === theme)));
}
applyTheme();
systemTheme.addEventListener('change', () => { if (theme === 'auto') applyTheme(); });
// Cada SVG conserva su tamaño original dentro del espacio de 24 px de Omnica.
const icons = new Set(['grid', 'key', 'segments', 'traffic', 'settings', 'logout', 'plus', 'close', 'copy', 'download', 'shield', 'info', 'refresh', 'arrow', 'next', 'clock', 'audit', 'server', 'edit', 'calendar', 'chevron-down', 'more', 'user', 'camera', 'trash', 'group', 'eye', 'search', 'done', 'pause', 'play', 'filter', 'zoom-in', 'zoom-out', 'my-location', 'user-add', 'lock', 'email', 'send', 'users-switch', 'group-add', 'account-circle', 'slightly-smiling', 'robot-upper', 'podium', 'diamond', 'star', 'chart-bar-2', 'chart-pie-1', 'wallet-outlined', 'shopping-cart-outlined', 'shop-outlined', 'lightbulb', 'flag-outlined', 'folder-outlined', 'call-talking', 'help-outlined', 'sms-chat-outlined', 'notifications', 'code', 'tools', 'website', 'flash', 'visible', 'blocked', 'earth-east', 'warehouse', 'flight', 'pinned']);
// Iconos de grupo de Material Symbols (1.12.0): prefijo ms-, carpeta propia y siempre como máscara (clase icon-ms).
const materialIcons = new Set(['ms-crown', 'ms-workspace-premium', 'ms-supervisor-account', 'ms-manage-accounts', 'ms-admin-panel-settings', 'ms-support-agent', 'ms-headset-mic', 'ms-handshake', 'ms-badge', 'ms-school', 'ms-engineering', 'ms-code-blocks', 'ms-design-services', 'ms-campaign', 'ms-calculate', 'ms-local-shipping', 'ms-person-add', 'ms-person-check', 'ms-person-off', 'ms-lock-person', 'ms-groups', 'ms-diversity-3', 'ms-person-apron', 'ms-person-raised-hand']);
const icon = (name) => {
if (materialIcons.has(name)) return ` `;
const asset = icons.has(name) ? name : 'info'; return ` `;
};
const escape = (value) => String(value ?? '').replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[character]));
const loading = (label = 'Cargando información', page = false) => `${icon('refresh')}
`;
const integer = (value) => new Intl.NumberFormat('es-ES').format(Number(value) || 0);
function bytes(value) {
const number = Math.max(0, Number(value) || 0);
if (number === 0) return '0 B';
const index = Math.min(4, Math.floor(Math.log(number) / Math.log(1000)));
return `${new Intl.NumberFormat('es-ES', { maximumFractionDigits: index > 0 ? 2 : 0 }).format(number / 1000 ** index)} ${['B', 'KB', 'MB', 'GB', 'TB'][index]}`;
}
// Fechas en DD/MM/AAAA y, con hora, DD/MM/AAAA HH:MM (24 h) en la zona horaria del panel (decisión del propietario, 1.7.0).
const dateFormats = new Map();
function date(value, includeTime = false) {
if (!value) return 'Sin fecha';
// Un día sin hora (AAAA-MM-DD, como los campos de fecha) es ese día en cualquier zona horaria.
const day = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (day && !includeTime) return `${day[3]}/${day[2]}/${day[1]}`;
const parsed = new Date(value);
if (!Number.isFinite(parsed.valueOf())) return 'Sin fecha';
const zone = state.session?.timezone || 'Europe/Moscow', key = zone + (includeTime ? '|t' : '');
if (!dateFormats.has(key)) dateFormats.set(key, new Intl.DateTimeFormat('es-ES', { timeZone: zone, day: '2-digit', month: '2-digit', year: 'numeric', ...(includeTime ? { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' } : {}) }));
const part = Object.fromEntries(dateFormats.get(key).formatToParts(parsed).map(item => [item.type, item.value]));
return `${part.day}/${part.month}/${part.year}${includeTime ? ` ${part.hour}:${part.minute}` : ''}`;
}
function timezoneLabel(zone = state.session?.timezone || 'Europe/Moscow') {
try { return new Intl.DateTimeFormat('es-ES', { timeZone: zone, timeZoneName: 'long' }).formatToParts(new Date()).find(part => part.type === 'timeZoneName')?.value || zone; }
catch { return 'Zona horaria del servidor'; }
}
const statusLabels = { active: 'Activo', paused: 'En pausa', expired: 'Vencido', limited: 'Límite alcanzado', revoked: 'Eliminado' };
const policyLabels = { full: 'Internet completo', allowlist: 'Whitelist', blocklist: 'Blacklist' };
const statusBadge = (status) => ` ${escape(statusLabels[status] || 'Estado no disponible')} `;
const notice = (text, type = '') => `${icon('info')}
${escape(text)}
`;
const enforcement = 'Los límites, la pausa, el vencimiento y la revocación impiden nuevas conexiones. Las conexiones abiertas pueden finalizar después.';
const empty = (title, description = '', action = '', image = 'key') => `${icon(image)}
${escape(title)} ${description ? `
${escape(description)}
` : ''}${action}
`;
const quota = (profile) => profile.quota_gb == null ? 'Sin límite' : `${new Intl.NumberFormat('es-ES').format(profile.quota_gb)} GB`;
const pathId = (id) => encodeURIComponent(String(id));
const profilePath = (id) => `/profile/${pathId(id)}`;
const isOwner = () => ['owner', 'superadmin'].includes(state.session?.user?.role);
const can = (section, action = 'read') => {
const user = state.session?.user;
if (!user) return false;
if (isOwner()) return true;
if (user.permissions) return Boolean(user.permissions[section]?.includes(action)) && (!['users','groups','audit'].includes(section) || Boolean(user.permissions.settings?.includes('read')));
// Compatibilidad con sesiones existentes durante la actualización del panel.
return section === 'users' || section === 'groups' ? Boolean(user.can_manage_users) : user.role === 'admin';
};
const managesUsers = () => can('users', 'manage');
const ownScope = () => state.session?.user?.profile_scope === 'own';
const roleName = (user) => ['owner', 'superadmin'].includes(typeof user === 'string' ? user : user.role) ? 'Propietario' : typeof user === 'object' ? user.group?.name || 'Sin grupo' : 'Administrador';
const protocolName = (profile) => {
if ((typeof profile === 'string' ? profile : profile.protocol) !== 'amneziawg') return 'VLESS';
if (typeof profile === 'string') return 'AmneziaWG (todas)';
const version = profile.awg_version || profile.version;
return ['1.0', '2.0', '3.1'].includes(version) ? `AmneziaWG ${version}` : 'AmneziaWG · versión sin verificar';
};
const eventSourceName = (event) => event.source === 'amneziawg' ? (event.awg_version ? protocolName({protocol:event.source, awg_version:event.awg_version}) : 'AmneziaWG · sin versión registrada') : 'Xray';
function profileProtocolOptions() {
const options = [['vless', 'VLESS'], ['amneziawg', 'AmneziaWG (todas)']];
const versions = new Map((state.protocols.find(item => item.id === 'amneziawg')?.versions || []).map(item => [item.id, {protocol:'amneziawg', ...item}]));
for (const profile of state.profiles) if (profile.protocol === 'amneziawg' && !versions.has(profile.awg_instance)) versions.set(profile.awg_instance, profile);
for (const [instance, profile] of versions) options.push(['amneziawg:' + instance, protocolName(profile)]);
return options;
}
function accountAvatar(user, size = 36) {
const url = String(user.avatar_url || '');
return /^\/api\/(?:account\/avatar|users\/[^/]+\/avatar)(?:\?|$)/.test(url) ? ` ` : `${escape((user.name || user.email || '?').trim().split(/\s+/).filter(Boolean).slice(0, 2).map(part => [...part][0]).join('').toLocaleUpperCase('es'))} `;
}
// Datos de la cuenta que muestra su menú: si cambian, shell() lo renueva aunque conserve la barra.
const accountFrame = user => JSON.stringify([user.name, user.email, user.avatar_url, user.role, user.group, user.groups]);
function accountHeader(user) {
return ``;
}
function actionAttributes(item) {
return Object.entries(item.attrs || {}).map(([name, value]) => ` ${name}="${escape(value)}"`).join('');
}
function rowAction(item) {
const id = `action-tooltip-${++actionCounter}`;
const tag = item.href ? 'a' : 'button';
return `<${tag} class="btn icon-only row-icon-action${item.attrs?.['data-action-menu'] ? ' row-menu-trigger' : ''}${item.danger ? ' danger' : ''}"${item.href ? ` href="${escape(item.href)}" data-nav` : ' type="button"'}${actionAttributes(item)} data-app-tooltip="${id}" aria-describedby="${id}" aria-label="${escape(item.label)}"${item.disabled ? ' disabled' : ''}>${icon(item.icon)}${tag}>${escape(item.label)} `;
}
function rowActions(items) {
const visible = items.filter(Boolean);
if (!visible.length) return '— ';
const primary = visible.length === 1 ? visible : [];
const extras = visible.length > 1 ? visible : [];
const id = `action-menu-${++actionCounter}`;
return `${primary.map(rowAction).join('')}${extras.length ? `${rowAction({ label: 'Más acciones', icon: 'more', attrs: { 'data-action-menu': id, 'aria-haspopup': 'menu', 'aria-expanded': 'false', 'aria-controls': id } })} ` : ''}
`;
}
function profileRowActions(profile, personal = false) {
return rowActions([
!personal && can('profiles', 'manage') && profile.status !== 'revoked' && { label: 'Editar perfil', icon: 'edit', attrs: { 'data-edit-profile': profile.id } },
personal && { label: 'Detalles del perfil', icon: 'eye', href: profilePath(profile.id) },
can('profiles', 'download') && { label: 'Ver perfil', icon: 'eye', disabled: profile.status === 'revoked', attrs: { 'data-share': profile.id } },
// Pausar o activar también desde las listas (1.8.0), incluida Mi perfil para quien gestiona perfiles; el servidor
// comprueba permiso y alcance en cada cambio.
can('profiles', 'manage') && profile.status !== 'revoked' && { label: profile.enabled ? 'Pausar perfil' : 'Activar perfil', icon: profile.enabled ? 'pause' : 'play',
attrs: { 'data-toggle-profile': profile.id, 'data-enabled': profile.enabled ? '1' : '0' } },
!personal && can('profiles', 'manage') && profile.status !== 'revoked' && { label: 'Eliminar perfil', icon: 'trash', danger: true, attrs: { 'data-revoke': profile.id } }
]);
}
function userRowActions(user) {
if (!editableUser(user)) return rowActions([]);
if (user.current) return rowActions([{ label: 'Editar mi perfil', icon: 'edit', href: '/account' }]);
return rowActions([
{ label: 'Editar usuario', icon: 'edit', attrs: { 'data-edit-user': user.id } },
!user.current && user.can_reset_access !== false && { label: 'Restablecer acceso', icon: 'refresh', attrs: { 'data-reset-user': user.id } },
!user.current && user.can_delete !== false && { label: 'Eliminar usuario', icon: 'trash', danger: true, attrs: { 'data-delete-user': user.id } }
]);
}
function groupRowActions(group) {
return rowActions([
{ label: editableGroupIcon(group) ? 'Editar grupo' : 'Ver permisos', icon: 'edit', attrs: { [editableGroupIcon(group) ? 'data-edit-group' : 'data-view-group']: group.id } },
editableGroup(group) && !groupBuiltin(group) && { label: 'Eliminar grupo', icon: 'trash', danger: true, disabled: Number(group.user_count ?? group.members_count) > 0, attrs: { 'data-delete-group': group.id } }
]);
}
function segmentRowActions(segment) {
if (!can('segments', 'manage')) return '';
return rowActions([
{ label: 'Editar segmento', icon: 'edit', attrs: { 'data-edit-segment': segment.id } },
{ label: 'Eliminar segmento', icon: 'trash', danger: true, attrs: { 'data-delete-segment': segment.id } }
]);
}
function closeActionTooltip() {
if (!openActionTooltip) return;
const { control, bubble } = openActionTooltip;
if (bubble.isConnected && bubble.matches(':popover-open')) bubble.hidePopover();
control.removeAttribute('data-tooltip-open'); openActionTooltip = null;
}
function showActionTooltip(control) {
if (!control || dismissedActionTooltip === control || openActionMenu || control.disabled || openActionTooltip?.control === control) return;
closeActionTooltip(); closeNavTooltip();
const bubble = document.getElementById(control.dataset.appTooltip); if (!bubble) return;
control.setAttribute('data-tooltip-open', ''); bubble.showPopover(); openActionTooltip = { control, bubble };
}
function closeActionMenu(returnFocus = false) {
if (!openActionMenu) return;
const { trigger, menu } = openActionMenu;
if (menu.isConnected && menu.matches(':popover-open')) menu.hidePopover();
trigger.setAttribute('aria-expanded', 'false'); trigger.removeAttribute('data-menu-open'); openActionMenu = null;
if (returnFocus && trigger.isConnected) trigger.focus({ preventScroll: true });
}
function showActionMenu(trigger, fromEnd = false) {
const same = openActionMenu?.trigger === trigger; closeActionMenu(); closeActionTooltip(); closeInfo(); closeNavTooltip(); closeControls();
if (same) return;
const menu = document.getElementById(trigger.dataset.actionMenu); if (!menu) return;
trigger.setAttribute('data-menu-open', ''); trigger.setAttribute('aria-expanded', 'true'); menu.showPopover();
openActionMenu = { trigger, menu };
menu.querySelectorAll('[role=menuitem]').forEach(item => { item.tabIndex = -1; });
const items = [...menu.querySelectorAll('[role=menuitem]:not(:disabled)')];
const first = fromEnd ? items.at(-1) : items[0]; if (first) { first.tabIndex = 0; first.focus({ preventScroll: true }); }
}
// Con `image` y `label`, la misma ayuda con otro icono oficial y nombre (1.14.0: el ojo de «Abierto» en Envíos).
function info(title, text, image = 'info', label = '') {
const id = `info-${++infoCounter}`;
return `${icon(image)} ${escape(title)} ${escape(text)} `;
}
function closeInfo(returnFocus = false) {
if (!openInfo) return;
const { button, bubble } = openInfo;
bubble.hidePopover(); button.setAttribute('aria-expanded', 'false'); openInfo = null;
if (returnFocus && button.isConnected) button.focus();
}
function closeNavTooltip() {
if (!openNavTooltip) return;
const { link, bubble } = openNavTooltip;
if (bubble.isConnected && bubble.matches(':popover-open')) bubble.hidePopover();
link.removeAttribute('data-tooltip-open');
openNavTooltip = null;
}
function showNavTooltip(link) {
if (!link || dialog.open || dismissedNavTooltip === link || openNavTooltip?.link === link || document.querySelector('.account-menu:popover-open')) return;
closeNavTooltip();
closeControls();
const bubble = document.getElementById(link.dataset.navTooltip);
if (!bubble) return;
link.setAttribute('data-tooltip-open', '');
bubble.showPopover();
openNavTooltip = { link, bubble };
}
async function api(path, options = {}) {
const method = options.method || 'GET';
const headers = { Accept: 'application/json' };
if (method !== 'GET') {
headers['X-CSRF-Token'] = state.session?.csrf || '';
headers['Content-Type'] = options.blob ? options.blob.type : 'application/json';
}
let response;
try { response = await fetch(path, { method, credentials: 'same-origin', cache: 'no-store', headers, ...(method !== 'GET' ? { body: options.blob || JSON.stringify(options.body || {}) } : {}) }); }
catch { throw new Error('Conexión no disponible. Comprueba el acceso a Internet e inténtalo de nuevo.'); }
let data;
try { data = await response.json(); } catch { data = {}; }
if (!response.ok) {
const error = new Error(data.error || (response.status === 401 ? 'La sesión ha finalizado. Inicia sesión de nuevo.' : 'No se pudo completar la operación. Inténtalo de nuevo.'));
error.status = response.status; error.requires_totp = data.requires_totp; error.problems = data.problems;
if (data.csrf) state.session = { ...state.session, csrf: data.csrf };
throw error;
}
return data;
}
function toast(text, error = false) {
clearTimeout(toastTimer); notification.textContent = text; notification.classList.toggle('error', error); notification.hidden = false;
toastTimer = setTimeout(() => { notification.hidden = true; }, 6000);
}
function navigate(path, replace = false) {
closeInfo();
closeNavTooltip();
closeActionTooltip();
closeActionMenu();
closeControls();
closeModal();
if (location.pathname !== path) history[replace ? 'replaceState' : 'pushState']({}, '', path);
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
renderRoute();
}
function setSession(data) {
const previousUser = state.session?.user?.id;
const nextUser = data.user?.id;
if (data.authenticated === false || nextUser != null && String(previousUser) !== String(nextUser)) {
state.profiles = []; state.segments = []; state.users = []; state.groups = []; state.protocols = [];
state.currentProfile = null; state.trafficData = null; state.auditData = null; state.permissionOptions = null;
state.shared = null; state.credentials = null;
state.profileFilters = { search: '', segment: '', status: '', protocol: '' };
state.userFilters = { search: '', status: 'all' };
state.page = 1; state.userPage = 1; state.groupPage = 1; state.segmentPage = 1;
state.trafficPage = 1; state.auditPage = 1; state.warningPage = 1; state.auditSnapshot = null; state.auditFilters = {};
state.accountData = null; state.accountProfiles = []; state.accountProfilePage = 1; state.accountProfilePageSize = 10; state.personalProfileContext = false;
releaseAvatarCrop();
}
state.session = { ...state.session, ...data };
if (data.authenticated === false) state.session.user = null;
if (!state.session.user && data.email) state.session.user = data;
window.SimlaControls?.setTimezone?.(state.session.timezone || 'Europe/Moscow');
}
function pageTrail(path) {
const item = (label, href) => ({ label, href });
const vpn = item('VPN', can('overview') ? '/' : null);
const profiles = item(ownScope() ? 'Mis perfiles' : 'Perfiles', '/profiles');
const account = item('Mi perfil', '/account');
let trail = [vpn, item('Página no encontrada')];
if (path === '/') trail = [vpn, item('Estado')];
else if (path === '/profiles') trail = [vpn, item(profiles.label)];
else if (path === '/profiles/new') trail = [profiles, item('Crear perfil')];
else if (/^\/profile\/[^/]+(?:\/edit)?$/.test(path)) {
const personal = state.personalProfileContext;
const parent = personal ? item('Perfiles VPN', '/account/profiles') : profiles;
const detail = item('Detalle del perfil', path.replace(/\/edit$/, ''));
trail = [...(personal ? [account] : []), parent, detail];
if (path.endsWith('/edit')) trail.push(item('Editar perfil'));
} else if (path === '/segments' || path.startsWith('/segments/')) {
trail = path === '/segments' ? [vpn, item('Segmentos')] : [item('Segmentos', '/segments'), item(path.endsWith('/new') ? 'Nuevo segmento' : 'Editar segmento')];
} else if (path === '/traffic') trail = [vpn, item('Uso')];
else if (path === '/settings/security') trail = [item('Mi perfil'), item('Seguridad')];
else if (path === '/settings' || path.startsWith('/settings/')) {
trail = [item('Ajustes', can('settings') ? '/settings' : null)];
const section = settingsSections().find(([url]) => path === url || path.startsWith(url + '/'));
if (section) {
const [url, label] = section;
trail.push(item(label, url));
if (url === '/settings/email' && path !== url) trail.push(item(path.endsWith('/deliveries') ? 'Envíos' : path.endsWith('/design') ? 'Diseño' : 'Plantilla'));
else if (path !== url) {
const creating = path.endsWith('/new');
trail.push(item(url.endsWith('/users') ? creating ? 'Nuevo usuario' : 'Editar usuario' : creating ? 'Nuevo grupo' : new URLSearchParams(location.search).has('view') ? 'Permisos del grupo' : 'Editar grupo'));
}
}
} else if (path === '/account' || path.startsWith('/account/')) {
trail = [account, item(path === '/account/profiles' ? 'Perfiles VPN' : path === '/account/security' ? 'Seguridad' : 'Datos personales')];
}
return trail;
}
// Ruta corta sobre el título de las páginas internas: solo niveles superiores enlazados, sin «VPN» ni la página
// actual (es el título). Las raíces con pestañas (Ajustes y Mi perfil) no la necesitan.
function pagePath() {
const path = location.pathname.replace(/\/$/, '') || '/';
if (/^\/account(\/(profiles|security))?$/.test(path) || path === '/settings' || settingsSections().some(([url]) => url === path) || /^\/settings\/email\/(deliveries|design)$/.test(path)) return '';
const parents = pageTrail(path).slice(0, -1).filter(part => part.label !== 'VPN');
if (!parents.length) return '';
const personal = href => state.personalProfileContext && href.startsWith('/profile/') ? ' data-personal-profile' : '';
return `${parents.map((part, index) => `${index ? icon('next') : ''}${part.href && part.href !== path ? `${escape(part.label)} ` : `${escape(part.label)} `} `).join('')} `;
}
function shell(title = '') {
const user=state.session?.user||{},path=location.pathname;
const nav=[...(can('overview')?[['/','Estado','grid']]:[]),...(can('profiles')?[['/profiles',ownScope()?'Mis perfiles':'Perfiles VPN','key']]:[]),...(can('traffic')?[['/traffic','Uso','traffic']]:[]),...(can('segments')?[['/segments','Segmentos','segments']]:[])];
const current=url=>url==='/'?path==='/':path.startsWith(url)||url==='/profiles'&&path.startsWith('/profile/')&&!state.personalProfileContext;
const item=([url,label,image],index)=>``;
const page=`${loading('Cargando información', true)} `;
// Cabecera y barra se conservan entre rutas (1.14.4): solo cambian la selección y el contenido. Repintarlas en cada
// ruta hacía que los iconos de la barra, máscaras CSS que el panel sirve sin caché, desaparecieran en el móvil hasta
// que terminaba de cargar la página; la foto, que es un , seguía a la vista. Se repintan si cambian las secciones,
// la versión o la cuenta; si solo cambian los datos de la cuenta, se renueva su menú (como updateAccountHeader).
const frame=JSON.stringify([nav,can('settings'),can('overview'),state.session?.app_version,user.id]);
const kept=state.shellFrame===frame&&app.querySelector(':scope > .layout > .workspace > #main');
if(kept){
app.querySelectorAll('#account-menu:popover-open,#header-status-panel:popover-open').forEach(pop=>pop.hidePopover());
if(state.shellAccount!==accountFrame(user))updateAccountHeader();
app.querySelectorAll('.sidebar a.nav[data-nav]').forEach(link=>{const on=current(link.getAttribute('href'));if(on!==link.hasAttribute('aria-current'))on?link.setAttribute('aria-current','page'):link.removeAttribute('aria-current');});
kept.outerHTML=page;
}else{
state.shellFrame=frame;state.shellAccount=accountFrame(user);
app.innerHTML=``;
}
// En cada ruta la cabecera usa la última lectura de servicios y solo consulta la API si tiene más de un minuto.
if (can('overview')) { if (state.headerServices) updateHeaderStatus(state.headerServices); clearTimeout(state.headerStatusTimer); if (location.pathname !== '/' && Date.now() - (state.headerServicesAt || 0) > 60000) refreshHeaderStatus(); else state.headerStatusTimer = setTimeout(refreshHeaderStatus, 60000); }
}
const main = () => document.getElementById('main');
function finishRefresh(version) {
if (state.softRefresh === version) state.softRefresh = 0;
const element = main(); if (!element || version !== state.routeVersion) return;
element.dataset.refresh = 'done';
clearTimeout(state.refreshTimer);
state.refreshTimer = setTimeout(() => { if (element.dataset.refresh === 'done') delete element.dataset.refresh; }, 1600);
}
// Estado de servicios en la cabecera: icono oficial de servidor con una marca del estado global. Al pulsarlo muestra
// cada servicio y el acceso a Estado. Solo para quien puede ver Estado; la API exige el mismo permiso.
function headerStatus() {
if (!can('overview')) return '';
return ``;
}
function updateHeaderStatus(services) {
const button = document.getElementById('header-status-button'), panel = document.getElementById('header-status-panel');
state.headerServices = services; state.headerServicesAt = Date.now();
if (!button || !panel) return;
const items = services.filter(service => service.configured).map(service => ({ name: service.protocol === 'vless' ? 'Xray' : protocolName(service), status: Object.hasOwn(serviceLabels, service.status) ? service.status : 'unknown' }));
const count = status => items.filter(item => item.status === status).length;
const overall = count('unavailable') ? 'unavailable' : count('maintenance') ? 'maintenance' : items.length && count('healthy') === items.length ? 'healthy' : 'unknown';
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
const text = overall === 'unavailable' ? plural(count('unavailable'), 'servicio sin respuesta', 'servicios sin respuesta') : overall === 'maintenance' ? plural(count('maintenance'), 'servicio en mantenimiento', 'servicios en mantenimiento') : overall === 'healthy' ? 'Servicios en línea' : 'Sin lectura reciente de los servicios';
button.classList.remove('healthy', 'unavailable', 'maintenance', 'unknown'); button.classList.add(overall);
button.setAttribute('aria-label', `Estado de los servicios: ${text}`);
panel.innerHTML = `${items.map(item => ` `).join('')} ${location.pathname === '/' ? '' : 'Ver Estado '}`;
}
async function refreshHeaderStatus() {
clearTimeout(state.headerStatusTimer);
if (!document.getElementById('header-status-button')) return;
// En Estado la propia página refresca los servicios cada 30 s y actualiza la cabecera.
if (!document.hidden && location.pathname !== '/') { try { updateHeaderStatus((await api('/api/vpn/status')).services); } catch { /* conserva el último estado */ } }
state.headerStatusTimer = setTimeout(refreshHeaderStatus, 60000);
}
const defaultRoute = () => can('overview') ? '/' : can('profiles') ? '/profiles' : '/account/security';
function requireView(section, title) {
if (can(section)) return true;
main().innerHTML = pageHead(title, '') + notice('Tu grupo no tiene permiso para consultar esta sección.', 'error');
return false;
}
const collectionConfig = {
profiles: { target: 'profile-table', page: 'page', size: 'pageSize', sizes: [10, 25, 50], label: 'de perfiles', noun: 'perfiles' },
users: { target: 'user-collection', page: 'userPage', size: 'userPageSize', sizes: [10, 25, 50], label: 'de usuarios', noun: 'usuarios' },
segments: { target: 'segment-collection', page: 'segmentPage', size: 'segmentPageSize', sizes: [10, 25, 50], label: 'de segmentos', noun: 'segmentos' },
traffic: { target: 'traffic-collection', page: 'trafficPage', size: 'trafficPageSize', sizes: [10, 25, 50], label: 'de tráfico', noun: 'perfiles' },
audit: { target: 'audit-collection', page: 'auditPage', size: 'auditPageSize', sizes: [10, 25, 50], label: 'del registro', noun: 'registros' },
warnings: { target: 'warning-collection', page: 'warningPage', size: 'warningPageSize', sizes: [10, 25, 50], label: 'de avisos', noun: 'avisos' },
groups: { target: 'group-collection', page: 'groupPage', size: 'groupPageSize', sizes: [10, 25, 50], label: 'de grupos', noun: 'grupos' },
assignments: { target: 'assignment-collection', page: 'assignmentPage', size: 'assignmentPageSize', sizes: [10, 25, 50], label: 'de usuarios asignables', noun: 'usuarios' },
accountProfiles: { target: 'account-profile-collection', page: 'accountProfilePage', size: 'accountProfilePageSize', sizes: [10, 25, 50], label: 'de tus perfiles VPN', noun: 'perfiles' },
mailDeliveries: { target: 'mail-deliveries', page: 'mailDeliveryPage', size: 'mailDeliveryPageSize', sizes: [10, 25, 50], label: 'de envíos', noun: 'envíos' }
};
function localPagination(collection, items) {
const config = collectionConfig[collection];
const size = config.sizes.includes(state[config.size]) ? state[config.size] : config.sizes[0];
const pages = Math.max(1, Math.ceil(items.length / size));
const page = Math.max(1, Math.min(pages, Math.floor(Number(state[config.page])) || 1));
state[config.page] = page; state[config.size] = size;
return { page, per_page: size, total: items.length, total_pages: pages };
}
function pagination(collection, page) {
const config = collectionConfig[collection];
const noun = Number(page.total) === 1 ? ({ perfiles: 'perfil', usuarios: 'usuario', segmentos: 'segmento', registros: 'registro', avisos: 'aviso', grupos: 'grupo', 'envíos': 'envío' })[config.noun] : config.noun;
return ``;
}
function collectionResults(content, heading = '') {
return `${heading ? `
${escape(heading)} ` : ''}${content} `;
}
async function refreshCollection(collection) {
if (collection === 'profiles') renderProfileTable();
else if (collection === 'users') await loadUsers();
else if (collection === 'audit') await loadAudit();
else if (collection === 'segments') renderSegmentCollection();
else if (collection === 'traffic') renderTrafficCollection();
else if (collection === 'groups') renderGroupCollection();
else if (collection === 'assignments') await loadAssignments();
else if (collection === 'warnings') renderWarnings();
else if (collection === 'accountProfiles') await loadAccountProfiles();
else if (collection === 'mailDeliveries') await window.SimlaMail?.refreshDeliveries();
}
function replaceCollection(target, html) {
const active = document.activeElement;
const restore = target.contains(active) ? { id: active.id, label: active.getAttribute('aria-label'), pager: Boolean(active.closest('.pagination')) } : null;
if (openInfo && target.contains(openInfo.button)) closeInfo();
if (openActionMenu && target.contains(openActionMenu.trigger)) closeActionMenu();
if (openActionTooltip && target.contains(openActionTooltip.control)) closeActionTooltip();
target.innerHTML = html;
refreshControls(target);
if (restore) {
let same = restore.id ? target.querySelector(`#${CSS.escape(restore.id)}`) : restore.label ? target.querySelector(`[aria-label="${CSS.escape(restore.label)}"]`) : null;
if (same?.classList.contains('app-native-control')) same = target.querySelector(`#${CSS.escape(same.id)}-trigger`);
const next = same && !same.disabled ? same : target.querySelector(restore.pager ? '.pagination button:not(:disabled),.pagination select:not(:disabled):not(.app-native-control)' : 'button:not(:disabled),select:not(:disabled):not(.app-native-control)');
next?.focus({ preventScroll: true });
}
}
function pageHead(title, description, actions = '', help = '') { return `${pagePath()}
${escape(title)} ${help ? info(title, help) : ''}${description?`
${escape(description)}
`:''}
${actions ? `
${actions}
` : ''}
`; }
function editorHead(title, description) { return ``; }
function editorPage(content, kind = 'standard') { return `${content}
`; }
function messagePanel(content, actions) { return ``; }
function pageError(error) {
if (error.status === 401) { state.session = null; init(); return; }
main().innerHTML = pageHead('Información no disponible', '') + messagePanel(notice(error.message, 'error'), '' + icon('refresh') + 'Reintentar ');
}
function placeSymbols() {
const pop = document.getElementById('group-symbols'), button = document.querySelector('[popovertarget=group-symbols]');
if (!pop?.matches(':popover-open') || !button) return;
const edge = 16, gap = 11, r = button.getBoundingClientRect(), grid = pop.querySelector('.symbol-grid');
delete pop.dataset.compact; pop.style.removeProperty('--symbol-grid-max');
let height = pop.offsetHeight;
const below = innerHeight - r.bottom - gap - edge, above = r.top - gap - edge;
const side = height <= below || below >= above ? 'bottom' : 'top', room = side === 'bottom' ? below : above;
if (height > room && grid) { pop.dataset.compact = ''; pop.style.setProperty('--symbol-grid-max', `${Math.max(120, Math.floor(room - (height - grid.offsetHeight)))}px`); height = pop.offsetHeight; }
const left = Math.max(edge, Math.min(r.left - 12, innerWidth - edge - pop.offsetWidth));
pop.style.left = `${Math.round(left)}px`;
pop.style.top = `${Math.round(side === 'bottom' ? r.bottom + gap : r.top - gap - height)}px`;
pop.style.setProperty('--notch-x', `${Math.round(r.left + r.width / 2 - left - 8)}px`);
pop.dataset.side = side;
}
document.addEventListener('toggle', event => {
if (event.target.id !== 'group-symbols') return;
document.querySelector('[popovertarget=group-symbols]')?.toggleAttribute('data-open', event.newState === 'open');
if (event.newState !== 'open') { delete event.target.dataset.side; return; }
placeSymbols();
event.target.querySelector('input:checked:not(:disabled)')?.focus({ preventScroll: true });
}, true);
addEventListener('resize', placeSymbols);
addEventListener('scroll', placeSymbols, true);
// Aviso de versión nueva: una pestaña abierta antes de publicar sigue con el código anterior hasta recargarla.
async function checkAppVersion() {
if (!state.loadedVersion || document.getElementById('version-notice')) return;
let current;
try { current = (await (await fetch('/health', { cache: 'no-store', credentials: 'same-origin' })).json()).app_version; } catch { return; }
if (!current || current === state.loadedVersion || document.getElementById('version-notice')) return;
const notice = document.createElement('div');
notice.id = 'version-notice'; notice.className = 'version-notice'; notice.setAttribute('role', 'status');
notice.innerHTML = `Versión nueva del panel: v${escape(current)} Recarga la página cuando termines lo que estás haciendo.
${icon('refresh')}Recargar `;
document.body.append(notice);
}
addEventListener('focus', checkAppVersion);
// Piezas del panel para los módulos de static/ (mail.js): mismo marcado, mismas API y mismos componentes.
window.SimlaPanel = Object.freeze({ state, api, icon, escape, info, notice, empty, loading, toast, navigate, can, date, integer, stat, settingsHead,
editorHead, pagePath, pagination, collectionResults, replaceCollection, rowAction, timezoneLabel, openModal: (...args) => openModal(...args), closeModal: () => closeModal(),
busy: (...args) => busy(...args), setSession: data => setSession(data), refreshControls: (...args) => refreshControls(...args) });
setInterval(checkAppVersion, 300000);
function settingsSections() {
return [['/settings/groups','Grupos y permisos','group','groups'],['/settings/users','Usuarios','user','users'],['/settings/email','Correo','email','mail'],['/settings/audit','Registro','audit','audit']].filter(([, , , section])=>can(section));
}
// Ajustes: título de sección, pestañas con los apartados permitidos y una línea de resumen con su ayuda.
function settingsHead(summary, actions = '', help = '') {
const path = location.pathname.replace(/\/$/, ''), sections = settingsSections(), current = sections.find(([url]) => path === url || path.startsWith(url + '/'));
return `${sections.map(([url, label, image]) => `${icon(image)}${escape(label)} `).join('')} ${current ? `${escape(current[1])} ` : ''}${summary ? `
${escape(summary)}
` : ''}${help && current ? info(current[1], help) : ''}
${actions ? `
${actions}
` : ''}
`;
}
function vpnEventMessage(event) {
if (event.bucket == null) return event.message;
return `Tráfico: ${bytes(event.upload_bytes + event.download_bytes)} · subida ${bytes(event.upload_bytes)} · bajada ${bytes(event.download_bytes)} · ${integer(event.profiles)} ${event.profiles === 1 ? 'perfil' : 'perfiles'}${event.current_hour ? ' · hora en curso' : ''}`;
}
function consoleCanFollow(target) {
const body = target.querySelector('.console-body'), selection = window.getSelection();
return (state.consolePage || 1) === 1 && (!body || body.scrollTop <= 2 && !body.contains(document.activeElement)) && !(selection && !selection.isCollapsed && target.contains(selection.anchorNode));
}
async function loadConsole({fresh = false, background = false} = {}) {
const target=document.getElementById('vpn-console');if(!target || background && !consoleCanFollow(target))return;
const previousBody = target.querySelector('.console-body'), scrollTop = fresh ? 0 : previousBody?.scrollTop || 0;
const focus = target.contains(document.activeElement) ? document.activeElement : null;
const focusKey = focus && ['consoleSource','consoleMode','consolePage','action'].find(key => focus.dataset[key]);
const version=state.routeVersion,params=new URLSearchParams({page:fresh?1:state.consolePage||1,per_page:state.consoleSize||25,source:state.consoleSource||'all',mode:state.consoleMode||'readable'});
for(const [key,value] of Object.entries(fresh?{}:state.consoleSnapshot||{}))params.set(key,value);
const requestNumber=state.consoleRequest=(state.consoleRequest||0)+1;
let result;
try { result=await api(`/api/vpn/events?${params}`); }
catch (error) {
if (version!==state.routeVersion || !target.isConnected || requestNumber!==state.consoleRequest) return;
if (error.status === 401) { pageError(error); return; }
if (!target.querySelector('.console-error')) target.insertAdjacentHTML('afterbegin', `No se pudo actualizar el registro VPN. Reintentar
`);
return;
}
if(version!==state.routeVersion||!target.isConnected||requestNumber!==state.consoleRequest || background && !consoleCanFollow(target))return;
state.consoleSnapshot=result.snapshot;
state.consolePage=result.pagination.page;
target.innerHTML=`${result.events.length?result.events.map(e=>`
${date(e.created_at,true)} ${escape(eventSourceName(e))} ${escape(e.level)} ${state.consoleMode==='technical'?escape(JSON.stringify(e)):escape(vpnEventMessage(e))}
`).join(''):'
No hay registros con este filtro.
'}
`;
const body = target.querySelector('.console-body'); body.scrollTop = scrollTop;
if (focusKey) [...target.querySelectorAll('button')].find(button => button.dataset[focusKey] === focus.dataset[focusKey])?.focus({preventScroll:true});
else if (focus && focus === previousBody) body.focus({preventScroll:true});
}
const serviceLabels = {healthy: 'Funcionando', unavailable: 'Sin respuesta', maintenance: 'En mantenimiento', unknown: 'Sin lectura reciente', unconfigured: 'Sin configurar'};
function uptimeBlocks(service, now) {
return Array.from({length: 42}, (_, index) => {
const from = now - 7 * 86400000 + index * 4 * 3600000, to = from + 4 * 3600000;
const samples = service.samples.filter(sample => {
const stamp = Date.parse(sample.observed_at);
return stamp >= from && stamp < to;
});
const maintenance = (service.maintenance_history || []).some(item => Date.parse(item.started_at) < to && (!item.ended_at || Date.parse(item.ended_at) > from));
const failed = (service.incidents || []).some(item => item.status === 'unavailable' && Date.parse(item.started_at) < to && (!item.ended_at || Date.parse(item.ended_at) > from)) || samples.some(sample => sample.status === 'unavailable' || (!sample.status && !sample.healthy));
const status = failed ? 'unavailable' : maintenance ? 'maintenance' : samples.length ? 'healthy' : 'unknown';
const label = status === 'unknown' ? 'Sin datos' : serviceLabels[status];
return ` `;
}).join('');
}
function externalCheck(service) {
const check = service.external;
if (!check) return '';
const labels = {healthy: 'Correcta', unavailable: 'Sin respuesta', unknown: 'Sin lectura reciente'};
const scope = check.kind === 'https' ? 'Comprueba HTTPS/TCP 443 desde otra red; no verifica el túnel VLESS.' : `Comprueba un handshake de ${protocolName(service)} desde otra red; no confirma acceso desde todas las redes.`;
return `Comprobación externa ${info('Comprobación externa', scope + ' Una falta de respuesta no demuestra un bloqueo de IP.')}
${labels[check.status] || labels.unknown} ${date(check.checked_at, true)} `;
}
// Estado reúne cada protocolo en una tarjeta (1.6.1, diseño «Dos tarjetas con pestañas de versión»). AmneziaWG cambia
// de generación con pestañas que llevan su punto de estado: la marca del título resume todas y el estado grande y el
// detalle son los de la elegida. La elección se conserva al refrescar; sin elección propia, cada visita empieza por la
// primera generación con un problema.
const serviceStatusOf = service => !service.configured ? 'unconfigured' : Object.hasOwn(serviceLabels, service.status) ? service.status : 'unknown';
const serviceVersionName = service => ['1.0', '2.0', '3.1'].includes(service.version) ? service.version : 'Sin verificar';
function serviceBody(service) {
return `${service.maintenance ? `${escape(service.maintenance.reason)}
Desde ${date(service.maintenance.started_at, true)}
` : ''}
${service.state_source === 'system' ? 'Sin cortes desde' : 'Lecturas correctas desde'} ${service.healthy_since ? date(service.healthy_since, true) : '—'}
Puerto ${service.port ? `${escape(service.port)} ${escape(service.transport)}` : 'Sin configurar'}
Perfiles ${integer(service.profiles_active)} de ${integer(service.profiles_total)} con acceso
Última lectura ${service.last_sample_at ? date(service.last_sample_at, true) : 'Pendiente'} ${icon('clock')}${service.last_failure ? `Último fallo: ${date(service.last_failure.started_at, true)}` : service.availability == null ? 'Sin lecturas registradas' : `${service.availability} % de lecturas correctas · ${service.failed_samples ? `${integer(service.failed_samples)} con fallo` : 'Sin lecturas fallidas'}`}
${externalCheck(service)}`;
}
function serviceCard(services) {
const awg = services[0].protocol === 'amneziawg', configured = services.filter(service => service.configured);
const count = status => configured.filter(service => serviceStatusOf(service) === status).length;
const overall = !configured.length ? 'unconfigured' : count('unavailable') ? 'unavailable' : count('maintenance') ? 'maintenance' : count('healthy') === configured.length ? 'healthy' : 'unknown';
const problem = ['unavailable', 'maintenance', 'unknown'].map(status => configured.find(service => serviceStatusOf(service) === status)).find(Boolean);
const chosen = awg && (services.find(service => service.instance === state.serviceVersion) || services.find(service => service.instance === state.serviceVersionAuto)) || problem || services[0];
if (awg) state.serviceVersionAuto = chosen.instance;
const status = serviceStatusOf(chosen), tabs = services.length > 1;
const selector = tabs ? `${services.map(service => {
const current = serviceStatusOf(service), selected = service === chosen;
return ` ${serviceVersionName(service)} `;
}).join('')}
` : `${!awg ? 'Un solo servicio' : !chosen.configured ? '' : serviceVersionName(chosen) === 'Sin verificar' ? 'Versión sin verificar' : 'Versión ' + serviceVersionName(chosen)} `;
const subtitle = awg ? `WireGuard ofuscado${configured.length > 1 ? ` · ${count('healthy')} de ${configured.length} funcionando` : ''}` : 'VLESS · TLS';
return `
${awg ? 'AmneziaWG' : 'Xray'}${subtitle} ${selector}${serviceLabels[status]}
${serviceBody(chosen)}
`;
}
const serviceCards = services => `${['vless', 'amneziawg'].map(protocol => services.filter(service => service.protocol === protocol)).filter(list => list.length).map(serviceCard).join('')}
`;
function replaceServiceCards(focus) {
const target = document.getElementById('service-status'), cards = target?.querySelector('.service-cards'); if (!cards || !state.serviceCards) return;
focus ||= cards.contains(document.activeElement) ? document.activeElement.dataset.serviceVersion : undefined;
const template = document.createElement('template'); template.innerHTML = serviceCards(state.serviceCards);
cards.replaceWith(template.content.firstElementChild);
if (focus) target.querySelector(`[data-service-version="${CSS.escape(focus)}"]`)?.focus({preventScroll: true});
}
function selectServiceVersion(instance) { state.serviceVersion = instance; replaceServiceCards(instance); }
async function renderServiceStatus(refresh = false, recent = false) {
if (!refresh || recent) { state.consoleSnapshot = null; state.consolePage = 1; }
if (!refresh) state.serviceVersionAuto = null;
const version = state.routeVersion, data = await api('/api/vpn/status');
if (version !== state.routeVersion) return;
const target = document.getElementById('service-status'); if (!target) return;
state.services = state.serviceCards = data.services; updateHeaderStatus(data.services);
const now = Date.now();
const markup = `${serviceCards(data.services)}Últimos 7 días ${data.services.map(service => `${protocolName(service)} ${uptimeBlocks(service, now)}
`).join('')}${[['healthy','Funcionando'],['unavailable','Sin respuesta'],['maintenance','Mantenimiento'],['unknown','Sin datos']].map(([status,label]) => ` ${label} `).join('')}
${escape(data.observation)}
${data.can_read_console ? '' : ''}`;
if (refresh && target.querySelector('.service-cards')) {
const template = document.createElement('template'); template.innerHTML = markup;
const focused = target.querySelector('.service-cards').contains(document.activeElement) ? document.activeElement.dataset.serviceVersion : undefined;
for (const selector of ['.service-cards', '.uptime-panel']) target.querySelector(selector)?.replaceWith(template.content.querySelector(selector));
if (focused) target.querySelector(`[data-service-version="${CSS.escape(focused)}"]`)?.focus({preventScroll: true});
} else target.innerHTML = markup;
if (data.can_read_console) await loadConsole({fresh: refresh && (state.consolePage || 1) === 1, background: refresh && !recent});
clearTimeout(state.serviceTimer);
const schedule = () => { state.serviceTimer = setTimeout(async () => {
if (version !== state.routeVersion || location.pathname !== '/') return;
if (document.hidden) { schedule(); return; }
try { await renderServiceStatus(true); }
catch {
state.serviceCards = state.serviceCards?.map(service => service.configured ? {...service, status: 'unknown'} : service);
replaceServiceCards();
schedule();
}
}, 30000); };
schedule();
}
function trustControl(security) {
return `Recordar este dispositivo durante 30 días No se pedirá 2FA al volver a iniciar sesión en este navegador.${security.trusted_until?' Recordado hasta '+date(security.trusted_until*1000)+'.':''} `;
}
async function changeTrust(input){
const enable=input.checked;input.checked=!enable;
if(!enable){try{await api('/api/account/trusted-device',{method:'DELETE'});await renderRoute();toast('Se pedirá 2FA en el siguiente inicio de sesión.');}catch(e){toast(e.message,true);}return;}
openModal('Recordar este dispositivo','Confirma tu identidad para permitir el acceso sin 2FA durante 30 días.',``);
}
async function profileInsight(profile){
const version=state.routeVersion,data=await api(`/api/profiles/${pathId(profile.id)}/usage`);
const own=profile.assigned_user_id===state.session.user.id,material=own&&can('profiles','download')&&profile.status!=='revoked'?await api(`/api/profiles/${pathId(profile.id)}/share`):null;
const target=document.getElementById('profile-insight');if(version!==state.routeVersion||!target)return;
if(material)state.inlineConfig=material.config||material.uri;
const segment=data.segment||{name:'Sin segmento',policy:'full',domains:[]};
target.innerHTML=`${material?`
Tu configuración VPN ${escape(material.config||material.uri)} `:''}
Consumo mensual ${trafficChart(data.daily||[])}
${segment.policy==='full'?'Navegación sin restricciones de segmento':segment.policy==='blocklist'?'Sitios bloqueados':'Destinos permitidos'} ${escape(segment.name)} ${segment.policy==='full'?'Este perfil no tiene una lista de destinos restringidos.':segment.policy==='blocklist'?'Se bloquean los siguientes destinos y sus subdominios.':'Solo se permite acceder a los siguientes destinos y sus subdominios.'}
${segment.domains.map(d=>`${escape(d)} `).join('')}
`;
}
async function init() {
try { setSession(await api('/api/session')); state.loadedVersion ||= state.session?.app_version; renderRoute(); }
catch (error) { app.innerHTML = `${logo()}${notice(error.message, 'error')}${icon('refresh')}Reintentar `; }
}
async function renderRoute(options = {}) {
closeInfo();
closeNavTooltip();
closeActionTooltip();
closeActionMenu();
closeControls();
state.inlineConfig=null;
clearTimeout(state.serviceTimer);
const version = ++state.routeVersion;
if (location.pathname === '/reset-password') {
const token = new URLSearchParams(location.hash.slice(1)).get('token');
if (location.hash) { state.resetToken = /^[A-Za-z0-9_-]{20,128}$/.test(token || '') ? token : null; history.replaceState({}, '', '/reset-password'); }
renderLogin(false, '', 'reset'); return;
}
if (!state.session?.authenticated) {
if (location.pathname !== '/login') history.replaceState({}, '', '/login');
renderLogin(); return;
}
if (location.pathname === '/login') history.replaceState({}, '', defaultRoute());
if (state.recoveryCodes || state.session.user.must_change_password || !state.session.user.totp_enabled) {
if (location.pathname !== '/settings/security') history.replaceState({}, '', '/settings/security');
shell('Seguridad de la cuenta'); renderSetup(); return;
}
const path = location.pathname.replace(/\/$/, '') || '/';
state.editingUser = null;
if (path === '/users' || path === '/groups') { navigate('/settings' + path, true); return; }
if (path === '/settings' && can('settings') && settingsSections().length) { navigate(settingsSections()[0][0], true); return; }
if (!path.startsWith('/profile/')) state.personalProfileContext = false;
// Actualizar recarga los datos sin vaciar la página: el contenido sigue a la vista hasta que llega el nuevo.
const soft = Boolean(options.refresh) && main()?.dataset.path === path;
if (soft) { state.softRefresh = version; main().dataset.refresh = 'loading'; } else shell();
try {
if (path.startsWith('/settings') && path!=='/settings/security' && !requireView('settings','Ajustes')) return;
if (path === '/') {
if (!can('overview')) { navigate(defaultRoute(), true); return; }
const [overview, profiles, protocols] = await Promise.all([api('/api/overview'), can('profiles') ? api('/api/profiles') : Promise.resolve({ profiles: [] }), can('profiles') ? api('/api/protocols') : Promise.resolve({ protocols: [] })]);
if (version !== state.routeVersion) return;
state.profiles = profiles.profiles || []; state.protocols = protocols.protocols || []; const services = renderOverview(overview); if (soft) await services;
} else if (path === '/profiles/new' || /^\/profile\/[^/]+\/edit$/.test(path)) {
if (!requireView('profiles', 'Perfiles') || !can('profiles', 'manage')) { main().innerHTML = pageHead('Perfiles', '') + notice('Tu grupo no tiene permiso para administrar perfiles.', 'error'); return; }
await profileForm(path === '/profiles/new' ? null : decodeURIComponent(path.split('/')[2]), version);
} else if (path === '/profiles') {
if (!requireView('profiles', 'Perfiles')) return;
const [profiles, segments] = await Promise.all([api('/api/profiles'), can('segments') ? api('/api/segments') : Promise.resolve({ segments: [] })]);
if (version !== state.routeVersion) return;
state.profiles = profiles.profiles || []; state.segments = segments.segments || []; renderProfiles();
} else if (/^\/profile\/[^/]+$/.test(path)) {
if (!requireView('profiles', 'Perfil')) return;
const id = decodeURIComponent(path.split('/')[2]);
const [result, segments] = await Promise.all([api(`/api/profiles/${pathId(id)}`), can('segments') ? api('/api/segments') : Promise.resolve({ segments: [] })]);
if (version !== state.routeVersion) return;
state.segments = segments.segments || []; renderProfile(result.profile || result);
} else if (path === '/segments/new' || /^\/segments\/[^/]+$/.test(path)) {
if (!requireView('segments','Segmentos') || !can('segments','manage')) return;
const data=await api('/api/segments');if(version!==state.routeVersion)return;state.segments=data.segments||[];state.segmentPresets=data.presets||[];
segmentForm(path.endsWith('/new')?null:decodeURIComponent(path.split('/')[2]),true);
} else if (path === '/segments') {
if (!requireView('segments', 'Segmentos')) return;
const data = await api('/api/segments'); if (version !== state.routeVersion) return; state.segments = data.segments || []; state.segmentPresets = data.presets || []; renderSegments();
} else if (path === '/traffic') {
if (!requireView('traffic', 'Tráfico')) return;
const data = await api(`/api/traffic?period=${state.trafficPeriod}`); if (version !== state.routeVersion) return; renderTraffic(data);
} else if (path === '/settings/users/new' || /^\/settings\/users\/[^/]+$/.test(path)) {
if (!requireView('users','Usuarios') || !managesUsers()) return;
await userForm(path.endsWith('/new')?null:decodeURIComponent(path.split('/')[3]),true);
} else if (path === '/settings/groups/new' || /^\/settings\/groups\/[^/]+$/.test(path)) {
if (!requireView('groups','Grupos')) return;
await groupForm(path.endsWith('/new')?null:decodeURIComponent(path.split('/')[3]),new URLSearchParams(location.search).has('view'),true);
} else if (path === '/settings/users') {
if (!requireView('users', 'Usuarios')) return;
await loadUsers(version);
} else if (path === '/settings/groups') {
if (!requireView('groups', 'Grupos')) return;
await loadGroups(version);
} else if (path === '/account' || path === '/account/profiles' || path === '/account/security' || path === '/settings/security') {
if (path === '/settings/security') { navigate('/account/security', true); return; }
if (path === '/account/profiles' && !requireView('profiles', 'Mis perfiles VPN')) return;
const account = await api('/api/account');
if (version !== state.routeVersion) return;
state.accountData = account; setSession(account); updateAccountHeader();
renderAccount(path);
if (path === '/account/profiles') await loadAccountProfiles(version);
} else if (path === '/settings/email' || /^\/settings\/email\/(deliveries|design|templates\/[a-z_]+)$/.test(path)) {
// Ajustes → Correo (1.7.0): Plantillas, Envíos, Diseño y el editor viven en static/mail.js.
if (!requireView('mail','Correo')) return;
await window.SimlaMail.route(path, version);
} else if (path === '/settings/audit') {
if (!requireView('audit','Registro')) return;
state.auditPage=1;state.auditSnapshot=null;
main().innerHTML=settingsHead('Acciones del equipo y cambios del panel.')+`${loading('Cargando registros')}
`;
await loadAudit(version);
} else if (path === '/settings') {
if (!requireView('settings','Ajustes')) return;
main().innerHTML=settingsHead('')+notice('Tu grupo todavía no tiene apartados administrativos asignados.');
} else {
main().innerHTML = pageHead('Página no encontrada', '') + messagePanel(notice('La dirección solicitada no está disponible.'), '' + icon('arrow') + 'Volver al resumen ');
}
} catch (error) { if (version === state.routeVersion) pageError(error); }
finally { if (version === state.routeVersion) refreshControls(main()); if (soft) finishRefresh(version); }
}
// Acceso en tres modos con la misma tarjeta: credenciales y 2FA, pedir el enlace por correo y elegir una contraseña nueva.
const LOGIN_TITLES = { login: 'Iniciar sesión', recover: 'Recuperar contraseña', reset: 'Nueva contraseña' };
function loginBody(mode, email = '') {
const error = '
';
if (mode === 'recover') return `Escribe el correo de tu cuenta y te enviaremos un enlace para elegir una contraseña nueva.
${error}`;
if (mode === 'reset') return state.resetToken ? `Elige una contraseña nueva. Después iniciarás sesión con ella y con tu código de verificación.
${error}`
: `El enlace no es válido o ya se utilizó. Pide uno nuevo desde «¿Olvidaste tu contraseña?» en el acceso.
${error}${loginBar('login-home', 'login-home', 'Volver al acceso')}`;
const forgot = state.session?.mail?.password_reset ? '¿Olvidaste tu contraseña? ' : '';
return `Introduce tus credenciales para continuar.
${error}`;
}
// Barra inferior del acceso (1.7.0): «Volver» es un botón plano con flecha, no un texto suelto.
const loginBar = (id, action, label) => `${icon('arrow')}${escape(label)}
`;
// Código de verificación «Grandes con punto» (1.7.0, variante 6 de design/codigo-2fa.html): una sola entrada real
// —autocompletado del sistema, pegar y lectores de pantalla— bajo seis casillas dibujadas. Con el código de
// recuperación, un campo de texto normal. Guía para reutilizarlo: design/componentes/codigo-verificacion/.
const OTP_HELP = { code: 'Escribe o pega las 6 cifras de tu aplicación.', recovery: 'Con o sin guion; da igual mayúsculas o minúsculas.' };
// Las mismas casillas en el alta de la verificación, Recordar este dispositivo y el cambio de correo (1.9.0). `auto`
// envía el formulario al completar las seis cifras si el resto ya es válido.
function otpBoxes(id, { name = 'totp', box = `${id}-otp`, help = `${id}-help`, auto = false, attrs = '' } = {}) {
return ``;
}
const codeField = (id, label, options = {}) => `${escape(label)} ${otpBoxes(id, options)}
${OTP_HELP.code}
`;
function otpField(recovery = false) {
const control = recovery
? ' '
: otpBoxes('login-totp', { box: 'login-otp', help: 'login-otp-help', auto: true });
const help = recovery ? ['Código de recuperación', 'Cada código que guardaste al activar la verificación sirve una sola vez. Tiene la forma ABC123-DEF456.']
: ['Verificación en dos pasos', 'Introduce el código de seis dígitos de tu aplicación de autenticación. Si no la tienes a mano, usa un código de recuperación.'];
return `${recovery ? 'Código de recuperación' : 'Código de verificación'} ${info(...help)}
${control}
${recovery ? OTP_HELP.recovery : OTP_HELP.code}
`;
}
function syncOtp(input = document.getElementById('login-totp')) {
const cells = input?.closest('.otp')?.querySelectorAll('.otp-cell');
if (!input || !cells?.length) return;
cells.forEach((cell, index) => {
const digit = input.value[index] || '', span = cell.firstElementChild;
if (span.textContent !== digit) span.textContent = digit;
cell.classList.toggle('filled', Boolean(digit));
cell.classList.toggle('active', index === input.value.length);
cell.style.setProperty('--i', String(index));
});
}
function otpCaret(input) {
// El cursor siempre al final: escribir añade y Retroceso borra la última cifra.
const end = input.value.length;
if (input.selectionStart !== end || input.selectionEnd !== end) { try { input.setSelectionRange(end, end); } catch {} }
}
function onOtpInput(input) {
const recovery = Boolean(input.dataset.mode);
const value = recovery ? input.value.toUpperCase().replace(/[^0-9A-F-]/g, '').slice(0, 13) : input.value.replace(/\D/g, '').slice(0, 6);
if (value !== input.value) input.value = value;
const help = document.getElementById(input.getAttribute('aria-describedby') || '');
if (help?.dataset.hint) { delete help.dataset.hint; help.textContent = recovery ? OTP_HELP.recovery : OTP_HELP.code; }
syncOtp(input);
clearTimeout(state.otpTimer);
// Al completar las seis cifras se comprueba solo, una vez por código (data-sent guarda el último enviado).
if (!recovery && input.hasAttribute('data-otp-auto') && value.length === 6 && value !== input.dataset.sent) state.otpTimer = setTimeout(() => {
const form = input.form, button = form?.querySelector('button[type=submit]');
if (input.isConnected && input.value.length === 6 && input.value !== input.dataset.sent && button && !button.disabled && button.getAttribute('aria-busy') !== 'true' && form.checkValidity()) form.requestSubmit(button);
}, 260);
}
// Aviso neutro si falta algo: aún no se ha enviado nada, así que no es un error ni cuenta como intento.
function otpReady(input = document.getElementById('login-totp')) {
const help = document.getElementById(input?.getAttribute('aria-describedby') || '');
if (!input || !help) return true;
const recovery = Boolean(input.dataset.mode), missing = recovery ? 12 - input.value.replace(/[^0-9A-F]/gi, '').length : 6 - input.value.length;
if (missing <= 0) return true;
help.dataset.hint = '1';
help.textContent = recovery ? 'Escribe el código completo: 12 caracteres.' : `Faltan ${missing} ${missing === 1 ? 'cifra' : 'cifras'}.`;
input.focus();
return false;
}
// Código rechazado: las casillas se marcan, se vacían y recuperan el foco.
function otpFailed(input = document.getElementById('login-totp')) {
const field = input?.closest('.otp-field');
if (!field || !input) return;
field.classList.remove('otp-error'); field.getBoundingClientRect(); field.classList.add('otp-error');
clearTimeout(state.otpErrorTimer);
state.otpErrorTimer = setTimeout(() => {
field.classList.remove('otp-error');
if (!input.isConnected) return;
delete input.dataset.sent;
if (!input.dataset.mode) { input.value = ''; syncOtp(input); if (input.id === 'login-totp') document.querySelector('.login-art')?.setAttribute('data-digits', '0'); }
input.focus({ preventScroll: true });
}, matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 700);
}
function toggleOtpRecovery() {
const field = document.getElementById('login-totp-field'); if (!field) return;
const recovery = !document.getElementById('login-totp')?.dataset.mode;
closeInfo(); clearTimeout(state.otpTimer);
field.outerHTML = otpField(recovery);
document.getElementById('login-totp-field').hidden = false;
document.getElementById('login-description').textContent = recovery ? 'Usa uno de tus códigos de recuperación de un solo uso.' : 'Confirma tu identidad con la verificación en dos pasos.';
const label = document.querySelector('#login-recovery .login-ghost-label'); if (label) label.textContent = recovery ? 'Código de la aplicación' : 'Código de recuperación';
const errors = app.querySelector('[data-form-error]'); if (errors) errors.hidden = true;
document.querySelector('.login-art')?.setAttribute('data-digits', '0');
syncOtp(); document.getElementById('login-totp').focus();
}
function renderLogin(totpRequired = false, email = '', mode = 'login') {
app.innerHTML = ` `;
centerLogin();
document.fonts.ready.then(() => { if (document.getElementById('login-credentials')?.hidden === false) centerLogin(); });
document.querySelector('#login-email, #reset-new-password, #login-home')?.focus({preventScroll: true});
}
// Cambia de modo dentro de la misma tarjeta, sin volver a centrarla: la marca no se mueve.
function switchLogin(mode, email = '') {
const panel = document.querySelector('.login-form'); if (!panel) return;
document.getElementById('login-title').textContent = LOGIN_TITLES[mode];
while (panel.lastElementChild && !panel.lastElementChild.classList.contains('login-card-head')) panel.lastElementChild.remove();
panel.insertAdjacentHTML('beforeend', loginBody(mode, email));
document.querySelector('.login-art')?.setAttribute('data-step', 'cred');
document.querySelector('#login-email, #reset-new-password, #login-home')?.focus({preventScroll: true});
}
// Candado del acceso (solo presentación): escudo, arco, cuerpo, cerradura y el progreso del código alrededor.
const LOGIN_RING = [0, 1, 2, 3, 4, 5].map(step => {
const point = angle => [160 + Math.sin(angle * Math.PI / 180) * 50, 136 - Math.cos(angle * Math.PI / 180) * 50].map(value => value.toFixed(1));
const [x0, y0] = point(step * 60 + 6), [x1, y1] = point((step + 1) * 60 - 6);
return ` `;
}).join('');
// Fondo Guilloche: rosetones de líneas finas, como en un documento de seguridad, que giran muy despacio.
const LOGIN_GUILLOCHE = [[118, 9, 16], [168, 12, 22], [222, 14, 28]].map(([radius, wave, lobes], index) => {
const rosette = phase => { let d = ''; for (let i = 0; i <= 360; i++) { const angle = i / 180 * Math.PI, rho = radius + wave * Math.sin(lobes * angle + phase); d += `${i ? 'L' : 'M'}${(200 + Math.cos(angle) * rho).toFixed(1)} ${(196 + Math.sin(angle) * rho).toFixed(1)}`; } return ` `; };
return `${rosette(0)}${rosette(Math.PI)} `;
}).join('');
const LOGIN_SHIELD = 'M160 28 L244 59 V132 C244 190 209 233 160 254 C111 233 76 190 76 132 V59 Z';
function loginArt() {
return `${LOGIN_GUILLOCHE} ${LOGIN_RING} `;
}
function centerLogin() {
const page = document.querySelector('.login-page'), shell = page?.querySelector('.login-shell');
if (!shell) return;
// Centra el acceso inicial; errores y 2FA crecen hacia abajo sin mover la marca.
page.style.paddingTop = `${Math.max(32, (document.documentElement.clientHeight - shell.getBoundingClientRect().height) / 2)}px`;
page.dataset.centered = '';
}
function loginChallenge() {
const errors = app.querySelector('[data-form-error]'); errors.hidden = true; errors.textContent = '';
document.getElementById('login-trust').hidden=false;
document.getElementById('login-credentials').hidden = true;
document.getElementById('login-description').textContent = 'Confirma tu identidad con la verificación en dos pasos.';
document.getElementById('login-totp-field').hidden = false;
document.getElementById('login-submit').textContent = 'Verificar e iniciar sesión';
document.getElementById('login-otp-bar').hidden = false;
delete document.getElementById('login-totp').dataset.sent; syncOtp();
document.getElementById('login-totp').focus();
document.querySelector('.login-art')?.setAttribute('data-step', 'otp');
}
function renderSetup() {
const user = state.session.user;
let body;
const step = user.must_change_password ? 1 : 2;
if (state.recoveryCodes) {
body = `Códigos de recuperación Guarda estos códigos en un lugar seguro. Cada código permite iniciar sesión una sola vez cuando no esté disponible la aplicación de autenticación.
${state.recoveryCodes.map(code => `${escape(code)}`).join('')}
${notice('Estos códigos se muestran una sola vez. Guárdalos antes de continuar.')}${icon('download')}Descargar códigos ${icon('done')}He guardado los códigos
`;
} else if (user.must_change_password) {
body = `Crear una contraseña personal Sustituye la contraseña inicial para continuar.
`;
} else if (state.totp) {
body = `Vincular la aplicación de autenticación Escanea el código QR con una aplicación compatible y escribe el código de seis dígitos.
Introducir la clave manualmente ${escape(state.totp.secret)}
`;
} else {
body = `Activar la verificación en dos pasos El panel requiere una contraseña y un código temporal para proteger la administración de la VPN.
${icon('shield')}Configurar autenticación `;
}
main().innerHTML = `${pageHead('Seguridad de la cuenta', user.email)}
1 Contraseña2 Verificación en dos pasos
`;
refreshControls(main());
}
function stat(label, value, caption, image = '', tone = '') { return `${image ? `${icon(image)} ` : ''}${escape(label)}
${escape(value)}
${caption ? `${escape(caption)}
` : ''} `; }
function usageCell(profile) {
const used = Number(profile.used_bytes || 0);
if (profile.quota_gb == null) return `${bytes(used)} Sin límite `;
const percent = Math.min(100, used / (Number(profile.quota_gb) * 1e9) * 100 || 0);
return `${bytes(used)} / ${quota(profile)} `;
}
// Registro (1.11.0, opción 4 «Tabla con detalle» elegida por el propietario): una línea por registro con su icono
// y, al pulsarla, el detalle completo debajo. El icono sale de la acción; las desconocidas usan el del registro.
const AUDIT_ICONS = {
'Sesión iniciada': 'lock', 'Sesión cerrada': 'logout', 'Sesión revocada': 'logout', 'Código de recuperación utilizado': 'key',
'Contraseña cambiada': 'lock', 'Contraseña restablecida por correo': 'lock', 'Contraseña temporal creada': 'lock', 'Autenticador activado': 'shield',
'Dispositivo recordado durante 30 días': 'shield', 'Recordatorio del dispositivo desactivado': 'shield', 'Acceso del administrador recuperado': 'shield',
'Perfil creado': 'key', 'Perfil actualizado': 'edit', 'Perfil eliminado': 'trash', 'Credencial del perfil renovada': 'refresh', 'Perfil existente importado': 'download',
'Segmento creado': 'segments', 'Segmento actualizado': 'segments', 'Segmento eliminado': 'trash',
'Usuario creado': 'user-add', 'Administrador creado': 'user-add', 'Usuario actualizado': 'user', 'Usuario eliminado': 'trash',
'Perfil de cuenta actualizado': 'user', 'Correo de cuenta actualizado': 'email', 'Foto de cuenta actualizada': 'user', 'Foto de cuenta eliminada': 'user',
'Grupo creado': 'group', 'Grupo actualizado': 'group', 'Icono del grupo actualizado': 'group', 'Grupo eliminado': 'trash',
'Correo enviado': 'send', 'Correo no enviado': 'send', 'Correo configurado': 'email', 'Plantilla de correo guardada': 'email',
'Plantilla de correo activada': 'email', 'Plantilla de correo desactivada': 'email', 'Diseño de correo guardado': 'email',
};
const AUDIT_KEYS = { aviso: 'Aviso', motivo: 'Motivo', protocol: 'Protocolo', icon: 'Icono', activado: 'Envío', avisos: 'Avisos', autenticador_reiniciado: 'Autenticador reiniciado' };
function auditActor(item) {
const email = String(item.actor || ''), known = (state.auditData?.filter_options?.actors || []).find(actor => actor.actor === email);
return { name: email === 'panel' ? 'Panel' : known?.name || email || 'Panel', email: email.includes('@') ? email : '' };
}
function auditObject(item) {
const details = item.details || {}, target = String(item.target || '');
if (details.aviso) return `${details.aviso}${target.includes('@') ? ` → ${target}` : ''}`;
return details.name || (target.includes('@') ? target : '');
}
function auditValue(key, value, details) {
if (typeof value === 'boolean') return key === 'activado' ? (value ? 'Activado' : 'Desactivado') : value ? 'Sí' : 'No';
if (key === 'protocol') return protocolName({ protocol: value, awg_instance: details.awg_instance });
if (Array.isArray(value)) return value.join(', ') || '—';
if (value && typeof value === 'object') return Object.entries(value).map(([name, item]) => `${name}: ${item === true ? 'sí' : item === false ? 'no' : item}`).join(', ');
return String(value ?? '');
}
function auditDetail(item) {
const actor = auditActor(item), details = item.details || {}, object = auditObject(item), target = String(item.target || '');
const pairs = [['Autor', actor.name], ['Correo', actor.email], ['Objeto', object], ['Fecha y hora', date(item.created_at || item.timestamp, true)],
['Zona horaria', timezoneLabel(state.auditData?.timezone || state.session.timezone)]];
Object.entries(details).forEach(([key, value]) => { if (!['name', 'aviso', 'awg_instance'].includes(key)) pairs.push([AUDIT_KEYS[key] || key.replace(/_/g, ' ').replace(/^./, letter => letter.toUpperCase()), auditValue(key, value, details)]); });
if (target && !target.includes('@')) pairs.push(['Identificador', target]);
return `${pairs.filter(([, value]) => value).map(([label, value]) => `
${escape(label)} ${escape(value)} `).join('')} `;
}
function auditRows(items) {
if (!items?.length) return `${empty('Sin actividad registrada', 'Las acciones administrativas se registran al realizarlas.')}
`;
const open = state.auditOpen || (state.auditOpen = new Set());
return `Fecha y hora Acción Usuario Objeto Detalle ${items.map(item => {
const id = String(item.id), expanded = open.has(id), actor = auditActor(item), object = auditObject(item), when = date(item.created_at || item.timestamp, true);
return `${when} ${icon(AUDIT_ICONS[item.action] || 'audit')} ${escape(item.action || 'Cambio administrativo registrado')} ${accountAvatar({ name: actor.name }, 24)}${escape(actor.name)} ${object ? escape(object) : '— '} ${rowAction({ icon: 'chevron-down', label: expanded ? 'Ocultar detalle' : 'Ver detalle', attrs: { 'data-audit-toggle': id, 'aria-expanded': String(expanded), 'aria-controls': `audit-more-${id}` } })} ${auditDetail(item)} `;
}).join('')}
`;
}
function toggleAuditRow(id) {
const open = state.auditOpen || (state.auditOpen = new Set()), expanded = !open.has(id);
if (expanded) open.add(id); else open.delete(id);
const row = document.querySelector(`[data-audit-row="${CSS.escape(id)}"]`), more = document.getElementById(`audit-more-${id}`), button = row?.querySelector('[data-audit-toggle]');
if (!row || !more || !button) return;
row.classList.toggle('open', expanded); more.hidden = !expanded;
button.setAttribute('aria-expanded', String(expanded)); button.setAttribute('aria-label', expanded ? 'Ocultar detalle' : 'Ver detalle');
const tip = document.getElementById(button.getAttribute('aria-describedby')); if (tip) tip.textContent = expanded ? 'Ocultar detalle' : 'Ver detalle';
}
// En móvil los filtros se pliegan en una fila con el resumen de lo aplicado; Filtrar los abre y Cerrar los pliega.
function setAuditFilters(open) {
const panel = document.getElementById('audit-filter-controls'), button = panel?.querySelector('[data-audit-filters-toggle]');
if (!panel || !button) return;
panel.classList.toggle('open', open);
button.setAttribute('aria-expanded', String(open));
button.classList.toggle('primary', !open);
button.innerHTML = open ? `${icon('close')}Cerrar` : `${icon('filter')}Filtrar`;
if (open && matchMedia('(max-width: 680px), (max-width: 900px) and (max-height: 500px) and (pointer: coarse)').matches) panel.querySelector('#audit-actor')?.focus();
}
function auditFilterSummary() {
const filters = state.auditFilters || {}, actor = (state.auditData?.filter_options?.actors || []).find(item => item.actor === filters.actor);
const day = value => value ? value.split('-').reverse().join('/') : '';
const range = filters.date_from && filters.date_to ? `del ${day(filters.date_from)} al ${day(filters.date_to)}` : filters.date_from ? `desde el ${day(filters.date_from)}` : filters.date_to ? `hasta el ${day(filters.date_to)}` : 'cualquier fecha';
return [filters.actor ? actor?.name || filters.actor : 'Todos los usuarios', filters.action || 'todas las acciones', range].join(' · ');
}
function renderOverview(data) {
if (ownScope()) { renderPersonalOverview(data); return; }
const totals = data.totals || {};
const service = data.service || {};
const controllerError = can('settings') ? service.controller_error : null;
const warnings = data.warnings || [];
state.overviewWarnings = service.controller_error && warnings[0] === service.controller_error ? warnings.slice(1) : warnings;
const previous = state.softRefresh === state.routeVersion ? document.getElementById('service-status') : null;
main().innerHTML = pageHead('Estado', 'Servicios VPN, consumo y registro de actividad.', `${icon('refresh')}Actualizar ${can('profiles', 'manage') ? '' + icon('plus') + 'Crear perfil ' : ''}`) + (controllerError ? notice(controllerError, 'error') : '') + '
' + `${stat('Perfiles activos', integer(totals.active_profiles), `${integer(totals.profiles)} ${Number(totals.profiles) === 1 ? 'perfil registrado' : 'perfiles registrados'}`, 'key')}${stat('Tráfico del periodo', bytes(totals.used_bytes), '', 'traffic', 'purple')}${stat('Descarga', bytes(totals.download_bytes), '', 'download', 'green')}${stat('Subida', bytes(totals.upload_bytes), '', 'download', 'up')}
`;
// Al actualizar se conserva el bloque de servicios y consola: se renueva en su sitio, volviendo a lo reciente.
if (previous) main().append(previous); else main().insertAdjacentHTML('beforeend','
');
const services = renderServiceStatus(Boolean(previous), Boolean(previous)).catch(error=>{const t=document.getElementById('service-status');if(t)t.innerHTML=notice(error.message,'error');});
renderWarnings();
return services;
}
function renderWarnings() {
const target = document.getElementById('warning-collection'); if (!target) return;
const items = state.overviewWarnings || [];
const page = localPagination('warnings', items);
const start = (page.page - 1) * page.per_page;
replaceCollection(target, (items.length ? `${items.slice(start, start + page.per_page).map(text => notice(text, 'warning')).join('')}
` : '') + (items.length > 6 ? pagination('warnings', page) : ''));
}
function renderPersonalOverview(data) {
const totals = data.totals || {};
const profiles = state.profiles.slice(0, 6);
main().innerHTML = pageHead('Mi VPN', '', '' + icon('refresh') + 'Actualizar ') + `${stat('Perfiles activos', integer(totals.active_profiles), '', 'key')}${stat('Consumo del periodo', bytes(totals.used_bytes), '', 'traffic', 'purple')}${stat('Descarga', bytes(totals.download_bytes), '', 'download', 'green')}${stat('Subida', bytes(totals.upload_bytes), '', 'download', 'up')}
${can('profiles') ? `${profiles.length ? `Perfil Consumo Vencimiento Estado Acciones ${profiles.map(profile => `${escape(profile.name)} ${protocolName(profile)} ${usageCell(profile)} ${profile.expires_at ? date(profile.expires_at) : 'Sin vencimiento'} ${statusBadge(profile.status)} ${profileRowActions(profile, true)} `).join('')}
` : empty('Sin perfiles asignados')} ` : ''}`;
}
function renderProfiles() {
const filters = state.profileFilters;
main().innerHTML = pageHead(ownScope() ? 'Mis perfiles' : 'Perfiles', '', can('profiles', 'manage') ? '' + icon('plus') + 'Crear perfil ' : '', enforcement) + `Buscar perfil
Segmento Todos los segmentos ${state.segments.map(s => `${escape(s.name)} `).join('')}
Protocolo Todos los protocolos ${profileProtocolOptions().map(([value, label]) => `${label} `).join('')}
Estado Sin eliminar ${Object.entries(statusLabels).map(([value, label]) => `${label} `).join('')}
`;
renderProfileTable();
}
function renderProfileTable() {
const target = document.getElementById('profile-table'); if (!target) return;
const filters = state.profileFilters;
const search = filters.search.toLocaleLowerCase('es');
const list = state.profiles.filter(p => (!search || `${p.name} ${p.contact_email || ''}`.toLocaleLowerCase('es').includes(search)) && (!filters.segment || String(p.segment_id) === filters.segment) && (filters.status ? p.status === filters.status : p.status !== 'revoked') && (!filters.protocol || (p.protocol || 'vless') === filters.protocol || p.protocol === 'amneziawg' && filters.protocol === 'amneziawg:' + p.awg_instance));
const pages = Math.max(1, Math.ceil(list.length / state.pageSize)); state.page = Math.min(state.page, pages);
const start = (state.page - 1) * state.pageSize;
const shown = list.slice(start, start + state.pageSize);
const html = shown.length ? `Perfil Protocolo Tráfico del periodo Vencimiento Estado Acciones ${shown.map(p => `${escape(p.name)} ${escape(p.segment_name || segmentName(p.segment_id))} ${protocolName(p)} ${usageCell(p)} ${p.expires_at ? date(p.expires_at) : 'Sin vencimiento'} ${statusBadge(p.status)} ${profileRowActions(p)} `).join('')}
` : empty(state.profiles.length ? 'Sin perfiles coincidentes' : 'Sin perfiles creados', state.profiles.length ? 'Modifica los filtros para consultar otros perfiles.' : 'Cada perfil tiene una credencial independiente.', state.profiles.length || !can('profiles', 'manage') ? '' : `${icon('plus')}Crear perfil `);
replaceCollection(target, collectionResults(html) + pagination('profiles', { page: state.page, per_page: state.pageSize, total: list.length, total_pages: pages }));
}
function segmentName(id) { return state.segments.find(s => String(s.id) === String(id))?.name || 'Sin segmento'; }
function peerConnection(profile) {
if (profile.protocol !== 'amneziawg') return '';
const connection = profile.connection || {};
const labels = {recent: 'Handshake reciente', stale: 'Sin handshake reciente', unconfirmed: 'Sin handshake registrado', negotiating: 'Intentos recibidos; handshake pendiente', incomplete: 'No se completa el handshake', unknown: 'Sin lectura reciente', inactive: 'Perfil sin acceso', observing: 'Comprobando comunicación'};
return `
Último handshake ${connection.last_handshake_at ? date(connection.last_handshake_at, true) : 'Sin handshake registrado'}
Comunicación${info('Comunicación AmneziaWG', 'Un handshake reciente confirma el intercambio con el perfil. Los paquetes recibidos también pueden ser intentos sin completar. Tras cinco minutos sin handshake se registra un aviso, no una desconexión confirmada ni un bloqueo de IP. El dispositivo puede estar apagado o inactivo.')} ${labels[connection.status] || labels.unknown} `;
}
function renderProfile(profile) {
state.currentProfile = profile;
const manage = can('profiles', 'manage') && !state.personalProfileContext;
const actions = `${manage && profile.status !== 'revoked' ? `${icon('edit')}Editar perfil ` : ''}${can('profiles', 'download') && profile.status !== 'revoked' && profile.assigned_user_id !== state.session.user.id ? `${icon('eye')}Ver perfil ` : ''}`;
const disabled = profile.status === 'revoked' ? ' disabled' : '';
const controls = manage && profile.status !== 'revoked' ? `
Control del perfil ${info('Control del perfil', enforcement)}Acceso al perfil ${profile.enabled ? 'Pausa el acceso temporalmente. Puedes reactivarlo después.' : 'Reactiva el acceso conservando la configuración y los límites.'}
${icon(profile.enabled ? 'pause' : 'play')}${profile.enabled ? 'Pausar' : 'Activar'} ${can('profiles', 'download') ? `Credencial de acceso Genera una nueva configuración y deja de aceptar la anterior.
${icon('refresh')}Renovar ` : ''}Eliminar perfil Retira este acceso de forma permanente. No se puede deshacer.
${icon('trash')}Eliminar ` : profile.status === 'revoked' ? notice('Este perfil fue eliminado de forma permanente. Su historial se conserva, pero no puede reactivarse ni descargarse.') : '';
main().innerHTML = `` + `${statusBadge(profile.status)}${protocolName(profile)}
Creado el ${date(profile.created_at)} ${stat('Consumo del periodo', bytes(profile.used_bytes), quota(profile), 'traffic', 'purple')}${stat('Descarga del periodo', bytes(profile.download_bytes), 'Datos recibidos', 'download', 'green')}${stat('Subida del periodo', bytes(profile.upload_bytes), 'Datos enviados', 'download', 'up')}${stat('Consumo acumulado', bytes(profile.total_bytes ?? profile.used_bytes), 'Desde el inicio del registro', 'clock', 'amber')}
Acceso y límites
Segmento ${escape(profile.segment_name || segmentName(profile.segment_id))}
Límite de tráfico ${quota(profile)}
Reinicio mensual Día ${integer(profile.quota_reset_day || 1)} · 00:00, ${escape(timezoneLabel())}
Vencimiento ${profile.expires_at ? date(profile.expires_at, true) : 'Sin vencimiento'}
Última actividad ${profile.last_activity_at ? date(profile.last_activity_at, true) : 'Sin actividad registrada'} ${peerConnection(profile)}${profile.assigned_user ? `
Usuario asignado ${escape(profile.assigned_user.name || profile.assigned_user.email)} ` : ''}${controls}
`;
main().insertAdjacentHTML('beforeend','
');
profileInsight(profile).catch(error=>{const t=document.getElementById('profile-insight');if(t)t.innerHTML=notice(error.message,'error');});
}
function renderSegments() {
main().innerHTML = pageHead('Segmentos', '', can('segments', 'manage') ? '' + icon('plus') + 'Crear segmento ' : '', 'Las reglas de destinos se aplican a los perfiles del segmento. Sin segmento, el perfil navega sin restricciones de destinos. Blacklist bloquea la lista; Whitelist permite solo la lista. Las redes privadas y los servicios internos están bloqueados en todos los segmentos. AmneziaWG admite direcciones IPv4 y redes CIDR; bloquea IPv6 dentro del túnel. Los dominios requieren VLESS.') + '
';
renderSegmentCollection();
}
function segmentDestinations(segment) {
if (segment.policy === 'full') return 'Sin restricciones de destinos públicos ';
const domains = segment.domains || [];
const remaining = new Set(domains);
const labels = [];
for (const preset of state.segmentPresets || []) {
if (preset.domains.length && preset.domains.every(domain => remaining.has(domain))) {
labels.push(preset.name);
preset.domains.forEach(domain => remaining.delete(domain));
}
}
labels.push(...remaining);
return `${labels.slice(0, 2).map(label => `${escape(label)} `).join('')}${labels.length > 2 ? `+${integer(labels.length - 2)} más ` : ''}
`;
}
function renderSegmentCollection() {
const target = document.getElementById('segment-collection'); if (!target) return;
const page = localPagination('segments', state.segments);
const start = (page.page - 1) * page.per_page;
const shown = state.segments.slice(start, start + page.per_page);
const manage = can('segments', 'manage');
replaceCollection(target, (shown.length ? `Segmento Tipo Destinos Perfiles Creado ${manage ? 'Acciones ' : ''}${shown.map(segment => `${escape(policyLabels[segment.policy] || policyLabels.full)} ${segmentDestinations(segment)} ${integer(segment.profile_count)} ${Number(segment.profile_count) === 1 ? 'perfil' : 'perfiles'} ${date(segment.created_at)} ${manage ? `${segmentRowActions(segment)} ` : ''} `).join('')}
` : empty('Aún no se ha creado ningún segmento', 'Los perfiles sin segmento tienen acceso sin restricciones de destinos.', '', 'segments')) + (state.segments.length ? pagination('segments', page) : ''));
}
function trafficChart(daily) {
if (!daily?.length) return empty('Sin lecturas de tráfico', 'El tráfico se registra desde la activación del panel.');
const days = daily.slice(-31);
const maximum = Math.max(1, ...days.map(day => Number(day.upload_bytes || 0) + Number(day.download_bytes || 0)));
const width = 680; const chartWidth = 620; const barWidth = chartWidth / days.length; const baseline = 166;
return `${escape(bytes(maximum))} ${days.map((day, index) => {
const down = Number(day.download_bytes || 0) / maximum * 140; const up = Number(day.upload_bytes || 0) / maximum * 140; const x = 44 + index * barWidth + 2; const bw = Math.max(2, barWidth - 5);
return `${escape(date(day.date))}: descarga ${escape(bytes(day.download_bytes))}, subida ${escape(bytes(day.upload_bytes))} ${index === 0 || index === days.length - 1 || index % Math.max(1, Math.ceil(days.length / 6)) === 0 ? `${escape(String(day.date).slice(8, 10))} ` : ''} `;
}).join('')} Descarga Subida
`;
}
function renderTraffic(data) {
const daily = data.daily || [];
const profiles = data.profiles || [];
const down = profiles.reduce((sum, profile) => sum + Number(profile.download_bytes || 0), 0);
const up = profiles.reduce((sum, profile) => sum + Number(profile.upload_bytes || 0), 0);
main().innerHTML = pageHead(ownScope() ? 'Mi consumo' : 'Tráfico', '', ''+icon('refresh')+'Actualizar ', 'Se contabilizan los datos transferidos, sin registrar el historial de navegación. La actividad no equivale a un recuento de dispositivos conectados. Las fechas siguen la zona horaria del servidor.') + `${[['day', 'Hoy'], ['week', 'Últimos 7 días'], ['month', 'Mes actual']].map(([value, label]) => `${label} `).join('')}
${stat('Tráfico total', bytes(down + up), 'Periodo seleccionado', 'traffic', 'purple')}${stat('Descarga', bytes(down), 'Datos recibidos', 'download', 'green')}${stat('Subida', bytes(up), 'Datos enviados', 'download', 'up')}${stat('Perfiles con consumo', integer(profiles.filter(p => Number(p.download_bytes || 0) + Number(p.upload_bytes || 0) > 0).length), 'Periodo seleccionado', 'key')}
Consumo diario ${data.updated_at ? date(data.updated_at, true) : 'Sin lecturas'} ${trafficChart(daily)}
`;
state.trafficData = data; renderTrafficCollection();
}
function renderTrafficCollection() {
const target = document.getElementById('traffic-collection'); if (!target) return;
const profiles = state.trafficData?.profiles || [];
const page = localPagination('traffic', profiles);
const start = (page.page - 1) * page.per_page;
const shown = profiles.slice(start, start + page.per_page);
replaceCollection(target, collectionResults(`${profiles.length ? `Perfil Descarga Subida Total ${shown.map(profile => `${can('profiles') ? `${escape(profile.name || profile.profile_name)} ` : `${escape(profile.name || profile.profile_name)} `}${protocolName(profile)} · ${escape(profile.segment_name || 'Sin segmento')} ${bytes(profile.download_bytes)} ${bytes(profile.upload_bytes)} ${bytes(Number(profile.download_bytes || 0) + Number(profile.upload_bytes || 0))} `).join('')}
` : empty('Sin consumo registrado', 'El tráfico aparecerá cuando los perfiles establezcan conexiones.')}`, 'Consumo por perfil') + pagination('traffic', page));
}
async function loadAudit(version = state.routeVersion) {
const request = state.auditRequest = (state.auditRequest || 0) + 1;
const target = document.getElementById('audit-collection');
const parameters = new URLSearchParams({ page: state.auditPage, per_page: state.auditPageSize });
if (state.auditSnapshot != null) parameters.set('before_id', String(state.auditSnapshot));
Object.entries(state.auditFilters).forEach(([key,value]) => { if (value) parameters.set(key,value); });
target?.setAttribute('aria-busy', 'true');
let result;
try { result = await api(`/api/audit?${parameters}`); }
catch (error) { if (version !== state.routeVersion || request !== state.auditRequest) return null; throw error; }
finally { if (version === state.routeVersion && request === state.auditRequest) target?.removeAttribute('aria-busy'); }
if (version !== state.routeVersion || request !== state.auditRequest) return null;
state.auditData = result;
state.auditPage = result.pagination.page;
state.auditPageSize = result.pagination.per_page;
state.auditSnapshot = result.pagination.snapshot_id;
renderAuditFilters();
renderAuditCollection();
return result;
}
function renderAuditFilters() {
const target = document.getElementById('audit-filter-controls'); if (!target || target.children.length) return;
const filters = state.auditFilters, options = state.auditData.filter_options || {};
const select = (name,label,all,items) => `${label} ${all} ${items.map(([value,text])=>`${escape(text)} `).join('')}
`;
const dateHelp = `Las fechas y los registros usan ${timezoneLabel(state.auditData.timezone || state.session.timezone)}. Para consultar un día, elige la misma fecha en Desde y Hasta. Se incluyen ambos días del rango.`;
target.hidden = false;
target.innerHTML = `${escape(auditFilterSummary())} ${icon('filter')}Filtrar
`;
refreshControls(target);
}
function renderAuditCollection() {
const target = document.getElementById('audit-collection'); if (!target || !state.auditData) return;
replaceCollection(target, `${state.auditData.events.length ? auditRows(state.auditData.events) : Object.values(state.auditFilters).some(Boolean) ? `${empty('Sin resultados', 'No hay registros que coincidan con estos filtros.')}
` : auditRows([])} ` + pagination('audit', state.auditData.pagination));
const summary = document.getElementById('audit-filter-text'); if (summary) summary.textContent = auditFilterSummary();
}
function updateAccountHeader() {
const current=document.querySelector('.account-header-wrap'); if(current){current.outerHTML=accountHeader(state.session.user);state.shellAccount=accountFrame(state.session.user);}
}
function accountTabs(path) {
const tabs = [['/account', 'Datos personales', 'user'], ...(can('profiles') ? [['/account/profiles', 'Perfiles VPN', 'key']] : []), ['/account/security', 'Seguridad', 'shield']];
return `${tabs.map(([url, label, image]) => `${icon(image)}${label} `).join('')} `;
}
function avatarActions(user) {
return [
rowAction({ label: user.avatar_url ? 'Cambiar foto' : 'Añadir foto', icon: 'camera', attrs: { 'data-action': 'choose-avatar' } }),
user.avatar_url ? rowAction({ label: 'Quitar foto', icon: 'trash', danger: true, attrs: { 'data-action': 'delete-avatar' } }) : ''
].join('');
}
function accountIdentity(user) {
return `${escape(user.name || user.email)} ${escape(user.email)}
${icon(user.group?.icon || 'group')}${escape(user.group?.name || roleName(user))} `;
}
function renderAccount(path) {
const account = state.accountData || {};
const user = account.user || state.session.user;
const security = account.security || {};
const header = pageHead('Mi perfil', '') + accountTabs(path);
if (path === '/account/profiles') {
main().innerHTML = header + `${loading('Cargando tus perfiles')}
`;
return;
}
if (path === '/account/security') {
main().innerHTML = header + `
Seguridad de la cuenta
Contraseña ${info('Contraseña', 'Usa una contraseña exclusiva para este panel. Cambiarla cierra las demás sesiones de tu cuenta y conserva la sesión actual.')}${icon('lock')}Cambiar contraseña
Verificación en dos pasos ${info('Protección de la cuenta', 'Tu aplicación de autenticación aporta el segundo factor. Los códigos de recuperación permiten iniciar sesión si no tienes la aplicación; no sirven para cambiar el correo.')}${user.totp_enabled ? 'Activada' : 'Pendiente de activar'} ${trustControl(security)}
Correo electrónico ${escape(user.email)}
Sesiones activas ${integer(security.session_count ?? account.session_count ?? 1)} `;
return;
}
main().innerHTML = header + ``;
}
function updateEmailProof() {
const field = document.getElementById('account-email');
const proof = document.getElementById('account-email-proof');
if (!field || !proof) return;
const changed = field.value.trim().toLowerCase() !== String(state.session.user.email).toLowerCase();
proof.hidden = !changed;
proof.querySelectorAll('input').forEach(input => { input.disabled = !changed; input.required = changed; if (!changed) input.value = ''; });
proof.querySelectorAll('.otp-input').forEach(input => syncOtp(input));
}
async function loadAccountProfiles(version = state.routeVersion) {
const request = state.accountProfileRequest = (state.accountProfileRequest || 0) + 1;
const target = document.getElementById('account-profile-collection'); if (!target) return;
const params = new URLSearchParams({ page: state.accountProfilePage || 1, per_page: state.accountProfilePageSize || 10 });
target.setAttribute('aria-busy', 'true');
let result;
try { result = await api(`/api/account/profiles?${params}`); }
catch (error) { if (version !== state.routeVersion || request !== state.accountProfileRequest) return; throw error; }
finally { if (version === state.routeVersion && request === state.accountProfileRequest) target.removeAttribute('aria-busy'); }
if (version !== state.routeVersion || request !== state.accountProfileRequest || !target.isConnected) return;
state.accountProfiles = result.profiles || [];
state.accountProfilePage = result.pagination.page; state.accountProfilePageSize = result.pagination.per_page;
const html = state.accountProfiles.length ? `Perfil Protocolo Consumo y límite Vencimiento Estado Acciones ${state.accountProfiles.map(profile => `${escape(profile.name)} ${escape(profile.segment_name || 'Sin segmento')} ${protocolName(profile)} ${usageCell(profile)} ${profile.expires_at ? date(profile.expires_at) : 'Sin vencimiento'} ${statusBadge(profile.status)} ${profileRowActions(profile, true)} `).join('')}
` : `${empty('Sin perfiles VPN asignados', 'Cuando te asignen un perfil, aquí podrás consultar su consumo y obtener su configuración.')} `;
replaceCollection(target, html + pagination('accountProfiles', result.pagination));
}
function updateAccountPhoto(result) {
if(state.editingUser && result.user?.id===state.editingUser.id){
state.editingUser=result.user;
const photo=document.getElementById('account-photo');if(photo)photo.innerHTML=accountAvatar(result.user,128);
const actions=document.getElementById('account-photo-actions');if(actions)actions.innerHTML=avatarActions(result.user);
return;
}
setSession(result); state.accountData = { ...state.accountData, ...result };
updateAccountHeader();
const photo = document.getElementById('account-photo'); if (photo) photo.innerHTML = accountAvatar(state.session.user, 128);
const actions = document.getElementById('account-photo-actions'); if (actions) actions.innerHTML = avatarActions(state.session.user);
}
function releaseAvatarCrop() {
avatarLoadVersion += 1;
if (avatarCrop) { avatarCrop.bitmap.close?.(); avatarCrop = null; }
dialog.classList.remove('avatar-dialog');
}
function clampAvatarCrop() {
if (!avatarCrop) return;
const crop = avatarCrop;
const scale = crop.baseScale * crop.zoom;
const xLimit = Math.max(0, (crop.bitmap.width * scale - 512) / 2);
const yLimit = Math.max(0, (crop.bitmap.height * scale - 512) / 2);
crop.x = Math.max(-xLimit, Math.min(xLimit, crop.x));
crop.y = Math.max(-yLimit, Math.min(yLimit, crop.y));
}
function drawAvatarCrop() {
if (!avatarCrop) return;
const canvas = document.getElementById('avatar-canvas'); if (!canvas) return;
clampAvatarCrop();
const crop = avatarCrop, scale = crop.baseScale * crop.zoom;
const context = canvas.getContext('2d');
context.fillStyle = '#fff';
context.fillRect(0, 0, 512, 512);
context.drawImage(crop.bitmap, (512 - crop.bitmap.width * scale) / 2 + crop.x, (512 - crop.bitmap.height * scale) / 2 + crop.y, crop.bitmap.width * scale, crop.bitmap.height * scale);
const zoom = document.getElementById('avatar-zoom');
zoom.value = String(crop.zoom); zoom.setAttribute('aria-valuetext', `${Math.round(crop.zoom * 100)} %`);
document.getElementById('avatar-crop-status').textContent = `Ampliación: ${Math.round(crop.zoom * 100)} %`;
document.querySelector('[data-action="zoom-avatar-out"]').disabled = crop.zoom <= 1;
document.querySelector('[data-action="zoom-avatar-in"]').disabled = crop.zoom >= 4;
}
function zoomAvatar(value) {
if (!avatarCrop || avatarCrop.saving) return;
const next = Math.max(1, Math.min(4, Number(value) || 1));
const ratio = next / avatarCrop.zoom;
avatarCrop.x *= ratio; avatarCrop.y *= ratio; avatarCrop.zoom = next; drawAvatarCrop();
}
async function openAvatarCrop(file) {
if (!file) return;
const accepted = state.accountData?.avatar_limits?.accepted_types || ['image/jpeg', 'image/png', 'image/webp'];
if (!accepted.includes(file.type)) throw new Error('Selecciona una imagen JPEG, PNG o WebP.');
if (file.size > 20 * 1024 * 1024) throw new Error('La imagen seleccionada debe ocupar como máximo 20 MB.');
releaseAvatarCrop(); const version = avatarLoadVersion, owner = state.session.user.id, route = state.routeVersion;
let bitmap;
try { bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' }); }
catch { throw new Error('No se pudo abrir la imagen. Selecciona una imagen válida.'); }
if (version !== avatarLoadVersion || route !== state.routeVersion || String(owner) !== String(state.session?.user?.id) || !(location.pathname === '/account' || state.editingUser)) { bitmap.close?.(); return; }
if (!bitmap.width || !bitmap.height || bitmap.width > 16000 || bitmap.height > 16000 || bitmap.width * bitmap.height > 64000000) { bitmap.close?.(); throw new Error('La imagen es demasiado grande. Reduce sus dimensiones antes de continuar.'); }
openModal('Ajustar foto', '', `${formError()}Mueve la imagen para encuadrarla. Usa las flechas del teclado para ajustar la posición y los controles para ampliar.
`);
avatarCrop = { bitmap, baseScale: 512 / Math.min(bitmap.width, bitmap.height), zoom: 1, x: 0, y: 0, pointers: new Map(), saving: false, owner };
dialog.classList.add('avatar-dialog');
const canvas = document.getElementById('avatar-canvas');
canvas.addEventListener('pointerdown', event => {
if (!avatarCrop || avatarCrop.saving) return;
event.preventDefault(); canvas.focus({ preventScroll: true }); canvas.setPointerCapture(event.pointerId);
avatarCrop.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
});
canvas.addEventListener('pointermove', event => {
if (!avatarCrop || avatarCrop.saving || !avatarCrop.pointers.has(event.pointerId)) return;
const crop = avatarCrop, before = [...crop.pointers.values()];
const previous = crop.pointers.get(event.pointerId);
crop.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (crop.pointers.size === 2) {
const after = [...crop.pointers.values()];
const distance = points => Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y);
if (distance(before) > 0) zoomAvatar(crop.zoom * distance(after) / distance(before));
} else {
const rect = canvas.getBoundingClientRect();
crop.x += (event.clientX - previous.x) * 512 / rect.width;
crop.y += (event.clientY - previous.y) * 512 / rect.height; drawAvatarCrop();
}
});
const release = event => { avatarCrop?.pointers.delete(event.pointerId); if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId); };
canvas.addEventListener('pointerup', release); canvas.addEventListener('pointercancel', release);
canvas.addEventListener('wheel', event => { if (!avatarCrop || avatarCrop.saving) return; event.preventDefault(); zoomAvatar(avatarCrop.zoom * (event.deltaY > 0 ? .95 : 1.05)); }, { passive: false });
canvas.addEventListener('keydown', event => {
if (!avatarCrop || avatarCrop.saving) return;
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) {
event.preventDefault(); const step = event.shiftKey ? 24 : 8;
avatarCrop.x += event.key === 'ArrowLeft' ? -step : event.key === 'ArrowRight' ? step : 0;
avatarCrop.y += event.key === 'ArrowUp' ? -step : event.key === 'ArrowDown' ? step : 0; drawAvatarCrop();
} else if (['+', '=', '-'].includes(event.key)) { event.preventDefault(); zoomAvatar(avatarCrop.zoom + (event.key === '-' ? -.1 : .1)); }
else if (event.key === 'Home') { event.preventDefault(); avatarCrop.x = 0; avatarCrop.y = 0; avatarCrop.zoom = 1; drawAvatarCrop(); }
});
drawAvatarCrop(); canvas.focus({ preventScroll: true });
}
async function saveAvatar() {
const crop = avatarCrop; if (!crop || crop.saving) return;
crop.saving = true;
const canvas = document.getElementById('avatar-canvas');
document.getElementById('avatar-zoom').disabled = true;
document.querySelector('[data-action="center-avatar"]').disabled = true;
try {
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', .92));
if (!blob || blob.size > (state.accountData?.avatar_limits?.max_bytes || 2097152)) throw new Error('No se pudo preparar la foto. Reduce la ampliación o selecciona otra imagen.');
if (avatarCrop !== crop || String(crop.owner) !== String(state.session?.user?.id)) return;
const result = await api('/api/account/avatar'+(state.editingUser?'?user_id='+pathId(state.editingUser.id):''), { method: 'PUT', blob });
if (avatarCrop !== crop || String(crop.owner) !== String(state.session?.user?.id)) return;
closeModal(); updateAccountPhoto(result); toast('Foto actualizada.'); document.querySelector('#account-photo-actions .row-icon-action')?.focus();
} finally { if (avatarCrop === crop) { crop.saving = false; document.getElementById('avatar-zoom').disabled = false; document.querySelector('[data-action="center-avatar"]').disabled = false; drawAvatarCrop(); } }
}
async function loadUsers(version = state.routeVersion) {
const request = state.userRequest = (state.userRequest || 0) + 1;
const target = document.getElementById('user-collection');
const parameters = new URLSearchParams({ page: state.userPage, per_page: state.userPageSize, search: state.userFilters.search, status: state.userFilters.status });
target?.setAttribute('aria-busy', 'true');
let result;
try { result = await api(`/api/users?${parameters}`); }
catch (error) { if (version !== state.routeVersion || request !== state.userRequest) return; throw error; }
finally { if (version === state.routeVersion && request === state.userRequest) target?.removeAttribute('aria-busy'); }
if (version !== state.routeVersion || request !== state.userRequest) return;
state.users = result.users || []; state.userPagination = result.pagination;
state.userPage = result.pagination?.page || state.userPage;
renderUsers();
}
function editableUser(user) {
if (!managesUsers()) return false;
if (typeof user.can_edit === 'boolean') return user.can_edit;
if (['owner', 'superadmin'].includes(user.role) && !isOwner()) return false;
if (isOwner() || (state.session.user.group?.builtin === 'admin' || state.session.user.group_id === 'admin') || !state.session.user.permissions) return true;
if (ownScope() && user.profile_scope !== 'own') return false;
return Object.entries(user.permissions || {}).every(([section, actions]) => actions.every(action => can(section, action)));
}
function renderUsers() {
const page = state.userPagination || { page: 1, per_page: 10, total: state.users.length, total_pages: 1 };
if (!document.getElementById('user-collection')) main().innerHTML = settingsHead('Cuentas, grupos de permisos y perfiles asignados.', managesUsers() ? '' + icon('plus') + 'Crear usuario ' : '', 'Cada cuenta recibe uno o varios grupos de permisos y perfiles asignados explícitamente. Las cuentas nuevas deben cambiar su contraseña y activar la verificación en dos pasos; el panel no envía correos.') + `
`;
refreshControls(main());
const target = document.getElementById('user-collection');
replaceCollection(target, collectionResults(`${state.users.length ? `Usuario Grupo Perfiles asignados Estado Seguridad Acciones ${state.users.map(user => `${accountAvatar(user)}
${escape(user.name || user.email)} ${user.name ? `${escape(user.email)} ` : ''}${user.current ? 'Tu cuenta ' : ''}
${(user.groups||[user.group||{name:roleName(user)}]).map(g=>`${icon(g.icon||'group')}${escape(g.name)} `).join(' ')} ${integer(user.assigned_profile_count)} ${user.enabled ? 'Activo' : 'Desactivado'} ${user.totp_enabled && !user.must_change_password ? 'Dos pasos activo' : 'Configuración pendiente'} ${userRowActions(user)} `).join('')}
` : empty('Sin usuarios coincidentes')}`) + pagination('users', page));
}
async function userForm(id, onRoute=false) {
if(!onRoute){navigate(id?`/settings/users/${pathId(id)}`:'/settings/users/new');return;}
const version=state.routeVersion;
const [options,result]=await Promise.all([api('/api/permissions'),id?api(`/api/users/${pathId(id)}`):Promise.resolve(null)]);
if(version!==state.routeVersion)return;
state.permissionOptions=options;
const groups=options.assignable_groups||[],initial=result?.user||{first_name:'',last_name:'',email:'',department:'',phone:'',timezone:state.session.timezone,group_id:'member',enabled:true};
if(id&&!editableUser(initial))throw new Error('No tienes permiso para editar esta cuenta.');
state.editingUser=id?initial:null;
if(id)state.users=[initial,...state.users.filter(u=>u.id!==id)];
const selected=(initial.groups||[{id:initial.group_id}]).map(g=>g.id);
const field=(name,label,type='text',required=false)=>`${label}${required?' *':''}
`;
const departments=['','Dirección','Ventas','Marketing','Soporte','Tecnología','Operaciones'];if(!departments.includes(initial.department))departments.push(initial.department);
const zones=[...new Set([initial.timezone||state.session.timezone,'Europe/Moscow','Europe/Madrid','America/Bogota','America/Mexico_City','America/Lima','UTC'])];
main().innerHTML=editorPage((id?``:editorHead('Nuevo usuario','Crea su cuenta antes de asignarle una VPN.'))+``, id ? 'standard' : 'user-create');
refreshControls(main());
}
async function loadGroups(version = state.routeVersion) {
const request = state.groupRequest = (state.groupRequest || 0) + 1;
const [result, options] = await Promise.all([api('/api/groups'), api('/api/permissions')]);
if (version !== state.routeVersion || request !== state.groupRequest) return;
state.groups = result.groups || [];
state.permissionOptions = options;
if (!document.getElementById('group-collection')) main().innerHTML = settingsHead('Permisos por sección y alcance de los perfiles.', can('groups', 'manage') ? '' + icon('plus') + 'Crear grupo ' : '', 'Los grupos definen permisos por sección y el alcance de los perfiles. Puedes editar Usuario y tus grupos personalizados. Administrador conserva el acceso total y permite cambiar su icono.') + '
';
renderGroupCollection();
}
function groupBuiltin(group) { return Boolean(group.builtin) || ['admin', 'member'].includes(String(group.id)); }
function editableGroupIcon(group) {
if (!can('groups', 'manage')) return false;
if (String(group.id) === 'admin' && !state.permissionOptions?.can_assign_admin) return false;
if (ownScope() && group.profile_scope !== 'own') return false;
const grants = state.permissionOptions?.grantable_permissions;
return !grants || Object.entries(group.permissions || {}).every(([section, actions]) => actions.every(action => grants[section]?.includes(action)));
}
function editableGroup(group) {
if (!can('groups', 'manage') || String(group.id) === 'admin' || (state.session.user.groups||[{id:state.session.user.group_id}]).some(g=>String(g.id)===String(group.id))) return false;
if (ownScope() && group.profile_scope !== 'own') return false;
const grants = state.permissionOptions?.grantable_permissions;
return !grants || Object.entries(group.permissions || {}).every(([section, actions]) => actions.every(action => grants[section]?.includes(action)));
}
function renderGroupCollection() {
const target = document.getElementById('group-collection'); if (!target) return;
const groups = state.groups || [];
const page = localPagination('groups', groups);
const start = (page.page - 1) * page.per_page;
const shown = groups.slice(start, start + page.per_page);
replaceCollection(target, collectionResults(`${shown.length ? `Grupo Alcance de perfiles Usuarios Acciones ${shown.map(group => `${icon(group.icon || 'group')}${escape(group.name)} ${group.profile_scope === 'all' ? 'Todos los perfiles' : 'Perfiles asignados'} ${integer(group.user_count ?? group.members_count)} ${groupRowActions(group)} `).join('')}
` : empty('Sin grupos disponibles')}`) + pagination('groups', page));
}
function permissionMatrix(permissions={},readOnly=false) {
const sections=state.permissionOptions?.sections||[],grantable=state.permissionOptions?.grantable_permissions||{};
return [{title:'VPN',icon:'key',ids:['overview','profiles','traffic','segments']},{title:'Administración',icon:'settings',ids:['settings','users','groups','mail','audit']}].map(m=>{const rows=sections.filter(s=>m.ids.includes(s.id));const total=rows.reduce((n,s)=>n+s.actions.length,0),checked=rows.reduce((n,s)=>n+(permissions[s.id]||[]).length,0);return `${icon(m.icon)} ${m.title} ${checked} de ${total} permisos ${icon('chevron-down')}${rows.map(s=>`
`).join('')}
`;}).join('');
}
async function groupForm(id,readOnly=false,onRoute=false) {
if(!onRoute){navigate(id?`/settings/groups/${pathId(id)}${readOnly?'?view=1':''}`:'/settings/groups/new');return;}
const version=state.routeVersion,[result,options]=await Promise.all([api('/api/groups'),api('/api/permissions')]);if(version!==state.routeVersion)return;
state.groups=result.groups;state.permissionOptions=options;
const group=state.groups.find(g=>g.id===id);if(id&&!group)throw new Error('No se encontró el grupo.');
const initial=group||{name:'',icon:'group',profile_scope:'own',permissions:{}},locked=readOnly||group&&!editableGroup(group),iconLocked=readOnly||group&&!editableGroupIcon(group),iconOnly=locked&&!iconLocked;
const symbols=options.group_icons?.length?options.group_icons:[{id:'group',name:'Equipo'}],currentIcon=initial.icon||'group';
main().innerHTML=editorPage(editorHead(iconLocked?'Permisos del grupo':id?'Editar grupo':'Nuevo grupo','Define las secciones y acciones disponibles para sus usuarios.')+``);
refreshControls(main());
}
function accessCredentials(result) {
state.credentials = { email: result.user.email, password: result.temporary_password };
const sent = result.mail ? notice(result.mail.sent ? `Enviamos el acceso a ${result.mail.recipient}.` : `No se pudo enviar el correo: ${result.mail.error} Comparte el acceso por un medio seguro.`, result.mail.sent ? 'success' : 'error') : '';
openModal('Acceso del usuario', '', `${sent}
Correo electrónico ${escape(result.user.email)}
Contraseña inicial ${info('Contraseña inicial', 'Se muestra una sola vez. Comparte el acceso por un medio seguro. Al iniciar sesión, el usuario deberá crear su contraseña y activar la verificación en dos pasos.')}${escape(result.temporary_password)}
`);
}
// También desde Ajustes → Correo → Envíos (1.13.0), que da el correo porque allí no se carga la lista de usuarios.
function resetUser(id, email = '') {
const user = state.users.find(item => String(item.id) === String(id));
openModal('Restablecer acceso', user?.email || email, ``);
}
function openModal(title, subtitle, body) {
releaseAvatarCrop();
closeNavTooltip();
closeActionTooltip();
closeActionMenu();
closeInfo();
closeControls();
dialog.classList.remove('assignment-dialog');
modalContent.innerHTML = `${escape(title)} ${subtitle ? `
${escape(subtitle)}
` : ''}
${icon('close')} ${body}`;
if (!dialog.open) dialog.showModal();
refreshControls(modalContent);
const field = modalContent.querySelector('input:not([type=checkbox]):not(.app-native-control),button.app-select-trigger'); if (field) field.focus();
}
function closeModal() { const assignment = dialog.classList.contains('assignment-dialog'); closeInfo(); closeActionTooltip(); closeActionMenu(); closeControls(); releaseAvatarCrop(); if (dialog.open) dialog.close(); modalContent.innerHTML = ''; state.credentials = null; dialog.classList.remove('assignment-dialog'); if (assignment) document.getElementById('choose-account')?.focus(); }
const formError = () => '
';
function assignmentSummary() {
const user = state.assignmentUser;
// Sin cuenta, un hueco punteado del tamaño de la foto: el icono de persona queda solo en Elegir (1.6.1).
return `${user ? accountAvatar(user) : '
'}
${escape(user?.name || user?.email || (state.assignmentId ? 'Usuario asignado' : 'Sin usuario asignado'))} ${user?.email ? `${escape(user.email)} ` : state.assignmentId ? '' : 'Elige la cuenta que usará este perfil. '}
${icon(state.assignmentId ? 'edit' : 'user-add')}${state.assignmentId ? 'Cambiar' : 'Elegir'} `;
}
function assignmentField(profile) {
state.assignmentId = profile.assigned_user_id == null ? '' : String(profile.assigned_user_id);
state.assignmentUser = profile.assigned_user || null;
state.assignmentSearch = ''; state.assignmentPage = 1; state.assignmentPageSize = 10;
return `Usuario ${info('Asignación de perfiles', 'La cuenta elegida podrá ver este perfil y obtener su configuración según los permisos de su grupo. Cada cuenta admite un perfil VLESS y uno AmneziaWG.')} ${assignmentSummary()}
`;
}
async function openAssignments() {
openModal('Elegir una cuenta', '', `${formError()}
${loading('Cargando usuarios')}
`);
dialog.classList.add('assignment-dialog');
await loadAssignments();
}
async function loadAssignments() {
const form = document.querySelector('form[data-form="profile"]');
const target = document.getElementById('assignment-collection'); if (!form || !target) return;
const request = state.assignmentRequest = (state.assignmentRequest || 0) + 1;
const parameters = new URLSearchParams({ page: state.assignmentPage, per_page: state.assignmentPageSize, search: state.assignmentSearch, status: 'enabled' });
target.setAttribute('aria-busy', 'true');
let result;
try { result = await api(`/api/profile-assignees?${parameters}`); }
catch (error) { if (!form.isConnected || !target.isConnected || request !== state.assignmentRequest) return; throw error; }
finally { if (target.isConnected && request === state.assignmentRequest) target.removeAttribute('aria-busy'); }
if (!form.isConnected || !target.isConnected || request !== state.assignmentRequest) return;
state.assignmentUsers = result.users || []; state.assignmentPagination = result.pagination; state.assignmentPage = result.pagination.page;
renderAssignments();
}
function renderAssignments() {
const target = document.getElementById('assignment-collection'); if (!target) return;
const users = state.assignmentUsers || [];
const page = state.assignmentPagination || { page: 1, per_page: 10, total: users.length, total_pages: 1 };
replaceCollection(target, `${users.length ? `${users.map(user => `
${accountAvatar(user)}
${escape(user.name || user.email)} ${escape(user.email)} ${user.assigned_protocols?.length ? `
${(user.assigned_profile_protocols || user.assigned_protocols.map(protocol => ({protocol}))).map(profile => `${protocolName(profile)} `).join('')}
` : ''}
${icon(user.assigned_protocols?.includes(document.querySelector('input[name=protocol]:checked')?.value)&&user.id!==state.editingProfile?.assigned_user_id ? 'lock' : String(user.id) === state.assignmentId ? 'done' : 'user-add')}${user.assigned_protocols?.includes(document.querySelector('input[name=protocol]:checked')?.value)&&user.id!==state.editingProfile?.assigned_user_id?'Protocolo asignado':String(user.id) === state.assignmentId ? 'Seleccionado' : 'Elegir'} `).join('')}
` : `${empty('Sin usuarios coincidentes')}
`}` + pagination('assignments', page));
}
function suggestProfileName() {
const name = document.getElementById('profile-name');
if (!name || state.editingProfile || name.value && name.value !== state.suggestedProfileName) return;
const protocol = document.querySelector('input[name="protocol"]:checked')?.value;
const user = state.assignmentUser;
if (!user) return;
const generation = state.protocols.find(item => item.id === 'amneziawg')?.versions?.find(item => item.id === document.getElementById('profile-awg-version')?.value);
state.suggestedProfileName = `${user.name || user.email} · ${protocolName({protocol, version:generation?.version})}`.slice(0, 80);
name.value = state.suggestedProfileName;
}
function chooseAssignment(id) {
const candidate=state.assignmentUsers?.find(u=>u.id===id);const protocol=document.querySelector('input[name=protocol]:checked')?.value;
if(!candidate || candidate.assigned_protocols?.includes(protocol) && id!==state.editingProfile?.assigned_user_id)return;
state.assignmentId = id;
state.assignmentUser = state.assignmentUsers?.find(item => String(item.id) === id) || null;
const field = document.getElementById('profile-assigned-user'); if (field) field.value = id;
const selected = document.getElementById('assignment-selected'); if (selected) selected.innerHTML = assignmentSummary();
updateProfileProtocol(); closeModal(); document.getElementById('choose-account')?.focus();
}
function updateQuotaControls() {
const limited = document.querySelector('input[name="quota_mode"]:checked')?.value === 'limited';
const options = document.getElementById('quota-options'); if (!options) return;
options.hidden = false; options.classList.toggle("quota-disabled",!limited);
const input = document.getElementById('profile-quota'); input.disabled = !limited; input.required = limited;
document.querySelectorAll('[data-quota-preset]').forEach(button => {button.disabled=!limited;button.setAttribute('aria-pressed', String(limited && Number(input.value) === Number(button.dataset.quotaPreset)));});
}
function updateProfileProtocol() {
for (const input of document.querySelectorAll('input[name="protocol"]')) {
const option = state.protocols.find(item => item.id === input.value);
const assigned = !state.editingProfile && !ownScope() && state.assignmentUser?.assigned_protocols?.includes(input.value);
input.disabled = Boolean(state.editingProfile || !option?.available || assigned);
const description = document.getElementById(`${input.id}-description`);
if (description) description.textContent = !option?.available && !state.editingProfile ? 'No disponible' : input.value === 'vless' ? 'Conexión cifrada TLS. Obtén un enlace o un código QR para conectarte.' : 'Conexión UDP con ofuscación. Importa el archivo de configuración en Amnezia.';
}
const protocol = document.querySelector('input[name="protocol"]:checked')?.value;
const versionField = document.getElementById('profile-awg-version-field');
const versionSelect = document.getElementById('profile-awg-version');
if (versionField && versionSelect) {
versionField.hidden = protocol !== 'amneziawg';
versionSelect.disabled = protocol !== 'amneziawg' || Boolean(state.editingProfile);
versionSelect.required = protocol === 'amneziawg';
refreshControls(versionSelect);
}
const select = document.getElementById('profile-segment'); if (!select) return;
for (const option of select.options) if (option.value) option.disabled = protocol === 'amneziawg' && option.dataset.awgCompatible !== 'true';
if (select.selectedOptions[0]?.disabled) select.value = '';
refreshControls(select); suggestProfileName();
}
async function profileForm(id, version = state.routeVersion) {
const [options, result] = await Promise.all([api('/api/profile-options'), id ? api(`/api/profiles/${pathId(id)}`) : Promise.resolve(null)]);
if (version !== state.routeVersion) return;
state.segments = options.segments || []; state.protocols = options.protocols || [];
const profile = result ? result.profile || result : null;
const initial = profile || { protocol: state.protocols.find(item => item.available)?.id || 'vless', name: '', contact_email: '', segment_id: null, quota_gb: null, quota_reset_day: 1, expires_at: null, enabled: true };
const awg = state.protocols.find(item => item.id === 'amneziawg');
const awgVersions = awg?.versions || [];
const selectedAwg = initial.awg_instance || awgVersions.find(item => item.available)?.id || 'primary';
if (profile?.protocol === 'amneziawg' && !awgVersions.some(item => item.id === selectedAwg)) {
awgVersions.push({id:selectedAwg, version:profile.awg_version, port:profile.awg_port, available:false});
}
const dateValue = initial.expires_at ? new Intl.DateTimeFormat('sv-SE', { timeZone: state.session?.timezone || 'Europe/Moscow' }).format(new Date(initial.expires_at)) : '';
state.editingProfile = profile; state.suggestedProfileName = null; state.assignmentUser = null; state.assignmentId = '';
const back = '/profiles';
main().innerHTML = editorPage(editorHead(profile ? 'Editar perfil' : 'Crear perfil', '') + ``, 'profile');
updateQuotaControls(); updateProfileProtocol(); refreshControls(main());
}
// «Perfil activo» (1.8.0, opción 1 «Fila con interruptor» de design/perfil-activo.html): una fila como «Recordar este
// dispositivo», con la explicación que cambia con el estado y el interruptor del panel. Se guarda con el formulario.
const profileActiveHelp = on => on ? 'Conecta con normalidad.' : 'No conecta hasta reactivarlo; conserva configuración, límites y consumo.';
const profileActiveRow = initial => `Perfil activo ${profileActiveHelp(initial.enabled)} `;
const segmentPresetDescriptions = {
ads: 'Anuncios, píxeles y medidores de audiencia.',
social: 'Instagram, TikTok, Facebook, X y Snapchat.',
adult: 'Sitios para mayores de edad.',
crypto: 'Casas de cambio y plataformas de criptomonedas.',
games: 'Tiendas y plataformas de juego en línea.',
media: 'Netflix, YouTube, Spotify y Twitch.',
russia: 'Bancos rusos, Gosuslugi y sitios del Gobierno; Ozon, Wildberries, Avito, M.Video, Eldorado y Lamoda; Yandex, VK, Odnoklassniki, MAX y operadores móviles.'
};
function segmentDraftDomains() {
return [...new Set([...state.segmentDraft.customDomains, ...(state.segmentPresets || []).filter(p => state.segmentDraft.presetIds.has(p.id)).flatMap(p => p.domains)])];
}
function renderSegmentDestinations() {
document.getElementById('segment-domains').value = segmentDraftDomains().join('\n');
const target = document.getElementById('segment-destination-list');
target.innerHTML = state.segmentDraft.customDomains.map((domain, index) => `${escape(domain)} ${icon('close')} `).join('');
target.hidden = !state.segmentDraft.customDomains.length;
}
function addSegmentDestination(focusInput = true) {
const input = document.getElementById('segment-destination');
const feedback = document.getElementById('segment-destination-feedback');
let value = input.value.trim().toLowerCase().replace(/\.$/, '');
feedback.textContent = ''; input.removeAttribute('aria-invalid');
if (!value) { if (focusInput) input.focus(); return true; }
// Keep the server authoritative for public IP/CIDR and destination validation.
if (/\s|:\/\//.test(value) || value.length > 256 || /[?#@]/.test(value)) {
feedback.textContent = 'Añade un dominio, una IP o un rango CIDR, sin enlaces ni espacios.';
input.setAttribute('aria-invalid', 'true'); input.focus(); return false;
}
if (!value.includes(':') && !value.includes('/')) {
try { value = new URL('https://' + value).hostname; } catch { /* The API provides the final validation. */ }
}
if (segmentDraftDomains().includes(value)) {
feedback.textContent = 'Este destino ya está incluido en la lista.';
input.value = ''; if (focusInput) input.focus(); return true;
}
if (segmentDraftDomains().length >= 200) {
feedback.textContent = 'Puedes añadir hasta 200 destinos.';
input.setAttribute('aria-invalid', 'true'); input.focus(); return false;
}
state.segmentDraft.customDomains.push(value); renderSegmentDestinations();
input.value = ''; if (focusInput) input.focus(); return true;
}
function segmentPresetsMarkup() {
return ``;
}
function segmentForm(id, onRoute = false) {
if (!onRoute) { navigate(id ? '/segments/' + pathId(id) : '/segments/new'); return; }
const found = state.segments.find(segment => String(segment.id) === String(id));
if (id && !found) { main().innerHTML = pageHead('Segmento no encontrado', '') + messagePanel(notice('Este segmento ya no existe.'), '' + icon('arrow') + 'Todos los segmentos '); return; }
const initial = found || { name: '', policy: 'blocklist', domains: [] };
const presets = (state.segmentPresets || []).filter(p => p.domains.length && p.domains.every(domain => (initial.domains || []).includes(domain)));
const covered = new Set(presets.flatMap(p => p.domains));
state.segmentDraft = { customDomains: (initial.domains || []).filter(domain => !covered.has(domain)), presetIds: new Set(presets.map(p => p.id)) };
const policies = [['blocklist', 'Blacklist', 'Se corta el acceso a los destinos de la lista; el resto del tráfico sale con normalidad.'], ['allowlist', 'Whitelist', 'Solo hay acceso a los destinos de la lista; todo lo demás queda bloqueado.']];
main().innerHTML = editorPage(editorHead(id ? 'Editar segmento' : 'Nuevo segmento', 'Define el nombre, el tipo y los destinos. Después, asigna el segmento a los perfiles correspondientes.') + `
`, 'segment');
renderSegmentDestinations(); refreshControls(main());
}
async function shareProfile(id, rotated = null) {
const result = rotated && (typeof rotated.config === 'string' || typeof rotated.uri === 'string') ? rotated : await api(`/api/profiles/${pathId(id)}/share`);
const conf = result.protocol === 'amneziawg' || result.format === 'conf';
const text = conf ? result.config : result.uri;
if (typeof text !== 'string' || !text.trim()) throw new Error('La configuración de este perfil no está disponible.');
state.shared = { id, text, protocol: result.protocol || 'vless', name: result.name || 'Perfil VPN' };
openModal('Ver perfil', result.name || '', `${protocolName({...result, protocol:conf ? 'amneziawg' : 'vless'})} ${info('Compartir acceso', 'La configuración contiene la credencial de acceso. Compártela únicamente con su destinatario. Un perfil VPN no permite entrar al panel.')}
${result.warning ? notice(result.warning, 'warning') : ''}${conf ? 'Configuración del perfil' : 'Enlace del perfil'} ${escape(text)}
`);
}
function confirmAction(title, description, label, action, id, danger = true) {
openModal(title, '', `${escape(description)}
`);
}
function passwordModal() {
openModal('Cambiar contraseña', 'Actualizar la contraseña de acceso al panel.', ``);
}
function showFormError(container, error) {
const target = container.querySelector('[data-form-error]');
if (target) { target.textContent = error.message || String(error); target.hidden = false; target.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }
else toast(error.message || String(error), true);
}
// Mientras una acción espera al servidor, el botón no cambia de aspecto (1.9.0, petición del propietario): sin indicador
// de carga ni estilo deshabilitado. aria-busy lo anuncia y evita repetirla; si tarda más de medio segundo, la barra fina
// de la cabecera indica que el panel sigue trabajando.
async function busy(button, action) {
if (button.disabled || button.getAttribute('aria-busy') === 'true') return;
button.setAttribute('aria-busy', 'true');
state.working = (state.working || 0) + 1;
const slow = setTimeout(() => document.documentElement.setAttribute('data-working', ''), 500);
try { await action(); }
finally {
clearTimeout(slow); button.removeAttribute('aria-busy');
state.working -= 1; if (!state.working) document.documentElement.removeAttribute('data-working');
}
}
function downloadText(text, name) {
const object = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' }));
const anchor = document.createElement('a'); anchor.href = object; anchor.download = name; anchor.click(); setTimeout(() => URL.revokeObjectURL(object), 1000);
}
document.addEventListener('pointerover', event => {
if (event.pointerType !== 'mouse' || !hoverNavigation.matches) return;
const actionControl = event.target.closest('[data-app-tooltip]');
if (actionControl) { showActionTooltip(actionControl); return; }
const link = event.target.closest('a[data-nav-tooltip]');
if (link) showNavTooltip(link);
});
document.addEventListener('pointerout', event => {
const actionControl = event.target.closest('[data-app-tooltip]');
if (actionControl && !actionControl.contains(event.relatedTarget) && dismissedActionTooltip === actionControl) dismissedActionTooltip = null;
if (openActionTooltip && (openActionTooltip.control.contains(event.target) || openActionTooltip.bubble.contains(event.target)) && !openActionTooltip.control.contains(event.relatedTarget) && !openActionTooltip.bubble.contains(event.relatedTarget) && !(openActionTooltip.control === document.activeElement && openActionTooltip.control.matches(':focus-visible'))) closeActionTooltip();
const link = event.target.closest('a[data-nav-tooltip]');
if (link && !link.contains(event.relatedTarget) && dismissedNavTooltip === link) dismissedNavTooltip = null;
if (!openNavTooltip) return;
const { link: current, bubble } = openNavTooltip;
if (!current.contains(event.target) && !bubble.contains(event.target)) return;
if (!current.contains(event.relatedTarget) && !bubble.contains(event.relatedTarget) && !(current === document.activeElement && current.matches(':focus-visible'))) closeNavTooltip();
});
// Casillas del código: foco visible en la casilla activa y cursor siempre al final.
document.addEventListener('focusin', event => { if (event.target.classList?.contains('otp-input')) { event.target.closest('.otp')?.classList.add('otp-focus'); syncOtp(event.target); otpCaret(event.target); } });
document.addEventListener('focusout', event => { if (event.target.classList?.contains('otp-input')) event.target.closest('.otp')?.classList.remove('otp-focus'); });
['pointerup', 'keyup', 'select'].forEach(type => document.addEventListener(type, event => { if (event.target.classList?.contains('otp-input')) otpCaret(event.target); }));
document.addEventListener('focusin', event => {
const actionControl = event.target.closest('[data-app-tooltip]');
if (actionControl?.matches(':focus-visible')) showActionTooltip(actionControl);
const link = event.target.closest('a[data-nav-tooltip]');
if (link?.matches(':focus-visible')) showNavTooltip(link);
});
document.addEventListener('focusout', event => {
const actionControl = event.target.closest('[data-app-tooltip]');
if (actionControl) {
if (dismissedActionTooltip === actionControl) dismissedActionTooltip = null;
if (openActionTooltip?.control === actionControl) closeActionTooltip();
}
const link = event.target.closest('a[data-nav-tooltip]');
if (!link) return;
if (dismissedNavTooltip === link) dismissedNavTooltip = null;
if (openNavTooltip?.link === link) closeNavTooltip();
});
document.addEventListener('pointerdown', event => { if (event.pointerType === 'touch') { closeNavTooltip(); closeActionTooltip(); } state.symbolPointer = Boolean(event.target.closest?.('#group-symbols .symbol-choice')); });
hoverNavigation.addEventListener('change', () => { if (!hoverNavigation.matches) { closeNavTooltip(); closeActionTooltip(); } });
window.addEventListener('resize', () => { closeNavTooltip(); closeActionTooltip(); closeActionMenu(); centerLogin(); });
window.addEventListener('scroll', () => { closeNavTooltip(); closeActionTooltip(); closeActionMenu(); }, { passive: true });
document.addEventListener('simla:control-open', () => { closeInfo(); closeNavTooltip(); closeActionTooltip(); closeActionMenu(); });
document.addEventListener('click', async (event) => {
const themeButton = event.target.closest('button[data-theme]');
if (themeButton) { closeActionMenu(); closeActionTooltip(); theme = themeButton.dataset.theme; try { localStorage.setItem('simla-vpn-theme', theme); } catch {} applyTheme(); return; }
const infoButton = event.target.closest('button[data-info]');
if (infoButton) {
closeActionMenu(); closeActionTooltip();
closeControls();
const same = openInfo?.button === infoButton; closeInfo();
if (!same) { const bubble = document.getElementById(infoButton.dataset.info); infoButton.setAttribute('aria-expanded', 'true'); bubble.showPopover(); openInfo = { button: infoButton, bubble }; }
return;
}
if (openInfo && !openInfo.bubble.contains(event.target)) closeInfo();
const auditToggle = event.target.closest('[data-audit-toggle]');
if (auditToggle) { toggleAuditRow(auditToggle.dataset.auditToggle); return; }
const auditRow = event.target.closest('tr[data-audit-row]');
if (auditRow && !event.target.closest('a,button,input,select,textarea') && !String(window.getSelection?.() || '')) { toggleAuditRow(auditRow.dataset.auditRow); return; }
if (event.target.closest('[data-audit-filters-toggle]')) { setAuditFilters(event.target.closest('[data-audit-filters-toggle]').getAttribute('aria-expanded') !== 'true'); return; }
const menuTrigger = event.target.closest('button[data-action-menu]');
if (menuTrigger) { showActionMenu(menuTrigger); return; }
if (openActionMenu && event.target.closest('[role=menuitem]')) closeActionMenu(true);
else if (openActionMenu && !openActionMenu.menu.contains(event.target)) closeActionMenu();
const anchor = event.target.closest('a[data-nav]');
if (anchor && !event.ctrlKey && !event.metaKey && !event.shiftKey && event.button === 0) { event.preventDefault(); state.personalProfileContext = anchor.hasAttribute('data-personal-profile') || anchor.getAttribute('href').startsWith('/profile/') && location.pathname === '/account/profiles'; navigate(anchor.getAttribute('href')); return; }
const button = event.target.closest('button'); if (!button || button.disabled) return;
const action = button.dataset.action;
if (action === 'add-segment-destination') { addSegmentDestination(); return; }
if (button.hasAttribute('data-remove-destination')) {
const index = Number(button.dataset.removeDestination);
state.segmentDraft.customDomains.splice(index, 1); renderSegmentDestinations();
const remaining = document.querySelectorAll('[data-remove-destination]');
(remaining[Math.min(index, remaining.length - 1)] || document.getElementById('segment-destination')).focus(); return;
}
try {
if(action==='copy-inline-config'){await navigator.clipboard.writeText(state.inlineConfig);toast('Configuración copiada.');return;}
if(button.dataset.serviceVersion){selectServiceVersion(button.dataset.serviceVersion);return;}
if(button.dataset.consoleSource){state.consoleSource=button.dataset.consoleSource;state.consolePage=1;await loadConsole({fresh:true});return;}
if(button.dataset.action==='refresh-console'){await loadConsole({fresh:true});return;}
if(button.dataset.consoleMode){state.consoleMode=button.dataset.consoleMode;await loadConsole();return;}
if(button.dataset.consolePage){state.consolePage=Number(button.dataset.consolePage);await loadConsole();return;}
if (button.dataset.collectionPage && collectionConfig[button.dataset.collection]) {
const collection = button.dataset.collection;
if (button.disabled) return;
state[collectionConfig[collection].page] = Number(button.dataset.collectionPage);
await refreshCollection(collection);
return;
}
if (button.dataset.collectionSize && collectionConfig[button.dataset.collectionSize]) {
const collection = button.dataset.collectionSize, config = collectionConfig[collection];
const size = Number(button.dataset.size); if (!config.sizes.includes(size)) return;
state[config.size] = size; state[config.page] = 1;
await refreshCollection(collection); return;
}
if (action === 'close-modal') { closeModal(); return; }
if (action === 'login-back') { const email = document.getElementById('login-email')?.value ?? state.loginEmail ?? ''; renderLogin(false, email); return; }
if (action === 'login-forgot') { switchLogin('recover', document.getElementById('login-email')?.value || ''); return; }
if (action === 'login-recovery') { toggleOtpRecovery(); return; }
if (action === 'login-home') { state.resetToken = null; navigate('/login', true); return; }
if (action === 'init') { init(); return; }
if (action === 'reload-app') { location.reload(); return; }
if (action === 'reload') { await busy(button, () => renderRoute({ refresh: true })); return; }
if (action === 'logout') { await busy(button, async () => { await api('/api/logout', { method: 'POST' }); state.session = null; state.recoveryCodes = null; state.totp = null; await init(); }); return; }
if (action === 'setup-totp') { await busy(button, async () => { state.totp = await api('/api/totp/setup', { method: 'POST' }); renderSetup(); }); return; }
if (action === 'download-recovery') { downloadText(`Códigos de recuperación · Simla VPN\nCuenta: ${state.session.user.email}\nCada código se puede utilizar una sola vez.\n\n${state.recoveryCodes.join('\n')}\n`, 'simla-vpn-recovery.txt'); return; }
if (action === 'finish-security') { state.recoveryCodes = null; state.totp = null; navigate('/'); return; }
if (action === 'change-password') { passwordModal(); return; }
if (action === 'choose-avatar') { const file = document.getElementById('account-avatar-file'); if (file) { file.value = ''; file.click(); } return; }
if (action === 'delete-avatar') { confirmAction('Quitar foto', 'Tu cuenta volverá a mostrar tus iniciales.', 'Quitar foto', 'confirm-delete-avatar', state.session.user.id); return; }
if (action === 'confirm-delete-avatar') { await busy(button, async () => { const result = await api('/api/account/avatar'+(state.editingUser?'?user_id='+pathId(state.editingUser.id):''), { method: 'DELETE' }); closeModal(); updateAccountPhoto(result); toast('Foto eliminada.'); document.querySelector('#account-photo-actions .row-icon-action')?.focus(); }); return; }
if (action === 'zoom-avatar-out' || action === 'zoom-avatar-in') { zoomAvatar(avatarCrop?.zoom + (action === 'zoom-avatar-out' ? -.1 : .1)); return; }
if (action === 'center-avatar') { if (avatarCrop && !avatarCrop.saving) { avatarCrop.x = 0; avatarCrop.y = 0; drawAvatarCrop(); } return; }
if (action === 'save-avatar') { await busy(button, saveAvatar); return; }
if (action === 'new-group') { groupForm(); return; }
if (button.dataset.editGroup) { groupForm(button.dataset.editGroup); return; }
if (button.dataset.viewGroup) { groupForm(button.dataset.viewGroup, true); return; }
if (button.dataset.deleteGroup) { confirmAction('Eliminar grupo', 'El grupo solo puede eliminarse si no tiene usuarios asociados. El registro de administración se conservará.', 'Eliminar grupo', 'confirm-delete-group', button.dataset.deleteGroup); return; }
if (action === 'confirm-delete-group') { await busy(button, async () => { await api(`/api/groups/${pathId(button.dataset.id)}`, { method: 'DELETE' }); closeModal(); toast('Grupo eliminado.'); await loadGroups(); }); return; }
// El cajón se abre al momento con su propio indicador de carga: el botón no cambia (antes parpadeaba con el suyo).
if (action === 'choose-account') { await openAssignments(); return; }
if (action === 'choose-assignment') { chooseAssignment(button.dataset.userId); return; }
if (button.dataset.quotaPreset) { document.getElementById('profile-quota').value = button.dataset.quotaPreset; updateQuotaControls(); return; }
if (action === 'search-assignments') { state.assignmentSearch = document.getElementById('assignment-search').value.trim(); state.assignmentPage = 1; await busy(button, loadAssignments); return; }
if (action === 'new-user') { await busy(button, () => userForm()); return; }
if (button.dataset.editUser) { await busy(button, () => userForm(button.dataset.editUser)); return; }
if (button.dataset.resetUser) { resetUser(button.dataset.resetUser, button.dataset.email); return; }
if (button.dataset.deleteUser) { confirmAction('Eliminar usuario', 'Se cerrarán las sesiones y se eliminará su acceso al panel. Los perfiles VPN no se eliminarán.', 'Eliminar usuario', 'confirm-delete-user', button.dataset.deleteUser); return; }
if (action === 'confirm-delete-user') { await busy(button, async () => { await api(`/api/users/${pathId(button.dataset.id)}`, { method: 'DELETE' }); closeModal(); toast('Usuario eliminado.'); await loadUsers(); }); return; }
if (action === 'search-users') { state.userPage = 1; await busy(button, () => loadUsers()); return; }
if (action === 'download-access' || action === 'copy-access') {
const credentials = state.credentials;
if (!credentials) return;
const text = `Acceso a VPN Simla Manager\nPanel: ${location.origin}/login\nCorreo: ${credentials.email}\nContraseña inicial: ${credentials.password}\n\nAl iniciar sesión, crea tu contraseña personal y configura la verificación en dos pasos.\n`;
if (action === 'download-access') downloadText(text, 'simla-vpn-access.txt');
else { try { await navigator.clipboard.writeText(text); document.getElementById('copy-status').textContent = 'Acceso copiado.'; } catch { document.getElementById('copy-status').textContent = 'No se pudo copiar. Descarga el acceso para compartirlo.'; } }
return;
}
if (action === 'new-profile') { navigate('/profiles/new'); return; }
if (button.dataset.editProfile) { navigate(`/profile/${pathId(button.dataset.editProfile)}/edit`); return; }
if (button.dataset.share) { await busy(button, () => shareProfile(button.dataset.share)); return; }
if (button.dataset.toggleProfile) {
// Desde una lista el estado viaja en data-enabled; desde el detalle, en el perfil abierto.
const id = button.dataset.toggleProfile, enabled = button.dataset.enabled ? button.dataset.enabled === '1' : Boolean(state.currentProfile?.enabled);
await busy(button, async () => { await api(`/api/profiles/${pathId(id)}`, { method: 'PATCH', body: { enabled: !enabled } }); toast(enabled ? 'Perfil pausado.' : 'Perfil activado.'); await renderRoute({ refresh: true }); }); return;
}
if (button.dataset.revoke) {
const id = button.dataset.revoke;
const profile = location.pathname === '/profiles' ? state.profiles.find(item => item.id === id) : state.currentProfile?.id === id ? state.currentProfile : null;
if (!profile || profile.status === 'revoked') return;
const connection = profile.protocol === 'amneziawg'
? 'Se retirará su acceso del servidor AmneziaWG y la configuración dejará de funcionar.'
: 'Su enlace y código QR dejarán de aceptar nuevas conexiones. Las conexiones VLESS ya abiertas pueden finalizar después.';
confirmAction('Eliminar perfil', `Vas a eliminar «${profile.name}» (${protocolName(profile)}). ${connection} No podrás reactivarlo ni recuperar su credencial. Se conservarán el consumo y la auditoría.`, 'Eliminar perfil', 'confirm-revoke', id);
return;
}
if (action === 'confirm-revoke') {
await busy(button, async () => { await api(`/api/profiles/${pathId(button.dataset.id)}`, { method: 'DELETE' }); closeModal(); toast('Perfil eliminado.'); if (location.pathname === '/profiles') await renderRoute(); else navigate('/profiles'); }); return;
}
if (button.dataset.rotate) { confirmAction('Renovar credencial', 'Se generará una configuración nueva. La configuración anterior dejará de aceptar nuevas conexiones. El destinatario deberá importar el nuevo perfil.', 'Renovar credencial', 'confirm-rotate', button.dataset.rotate, false); return; }
if (action === 'confirm-rotate') {
await busy(button, async () => { const result = await api(`/api/profiles/${pathId(button.dataset.id)}/rotate`, { method: 'POST' }); const id = button.dataset.id; await shareProfile(id, result); toast(result.warning || 'Credencial renovada. Comparte la configuración nueva.'); await renderRoute(); }); return;
}
if (action === 'copy-uri') {
const status = document.getElementById('copy-status');
try { await navigator.clipboard.writeText(state.shared.text); status.textContent = state.shared.protocol === 'amneziawg' ? 'Configuración copiada.' : 'Enlace copiado.'; }
catch { const code = document.getElementById('profile-uri'); code.closest('details').open = true; const range = document.createRange(); range.selectNodeContents(code); const selection = getSelection(); selection.removeAllRanges(); selection.addRange(range); status.textContent = 'Configuración seleccionada. Usa la función de copiar del navegador.'; }
return;
}
if (action === 'new-segment') { segmentForm(); return; }
if (button.dataset.editSegment) { segmentForm(button.dataset.editSegment); return; }
if (button.dataset.deleteSegment) { confirmAction('Eliminar segmento', 'El segmento solo puede eliminarse si no tiene perfiles asignados. El registro de administración se conservará.', 'Eliminar segmento', 'confirm-delete-segment', button.dataset.deleteSegment); return; }
if (action === 'confirm-delete-segment') { await busy(button, async () => { await api(`/api/segments/${pathId(button.dataset.id)}`, { method: 'DELETE' }); closeModal(); toast('Segmento eliminado.'); await renderRoute(); }); return; }
if (button.dataset.period) { state.trafficPeriod = button.dataset.period; state.trafficPage = 1; await busy(button, renderRoute); return; }
} catch (error) { if (dialog.open) showFormError(modalContent, error); else toast(error.message, true); }
});
document.addEventListener('input', (event) => {
if (event.target.id === 'login-totp' || event.target.classList?.contains('otp-input')) onOtpInput(event.target);
if (event.target.id === 'account-email') updateEmailProof();
if (event.target.id === 'avatar-zoom') zoomAvatar(event.target.value);
if (event.target.dataset.filter === 'search') { state.profileFilters.search = event.target.value; state.page = 1; renderProfileTable(); }
if (event.target.dataset.userFilter === 'search') state.userFilters.search = event.target.value;
});
document.addEventListener('input', event => { if (event.target.id === 'profile-quota') updateQuotaControls(); });
document.addEventListener('change', (event) => {
const target = event.target;
if(target.name==='remember_this_device'){changeTrust(target);return;}
if(target.name==='icon'){const b=document.querySelector('[popovertarget=group-symbols]');if(b)b.innerHTML=icon(target.value);if(state.symbolPointer){state.symbolPointer=false;document.getElementById('group-symbols')?.hidePopover();b?.focus({preventScroll:true});}}
if (target.id === 'account-avatar-file') { openAvatarCrop(target.files[0]).catch(error => showFormError(document.querySelector('form[data-form="account"]') || main(), error)); return; }
if (target.hasAttribute('data-assignment-id')) { chooseAssignment(target.dataset.assignmentId); return; }
if (target.dataset.permissionSection) {
const section = target.dataset.permissionSection;
const related = [...target.closest('form').querySelectorAll('input[data-permission-section]')].filter(input => input.dataset.permissionSection === section);
if (target.dataset.permissionAction === 'read' && !target.checked) related.forEach(input => { if (input.dataset.permissionAction !== 'read') input.checked = false; });
else if (target.checked && ['manage', 'download'].includes(target.dataset.permissionAction)) { const read = related.find(input => input.dataset.permissionAction === 'read'); if (read && !read.disabled) read.checked = true; }
target.closest('form').querySelectorAll('.permission-module').forEach(m=>{const inputs=[...m.querySelectorAll('[data-permission-section]')],n=inputs.filter(i=>i.checked).length;m.querySelector('[data-permission-count]').textContent=`${n} de ${inputs.length} permisos`;m.querySelector('progress').value=n;});
return;
}
if (target.dataset.filter && target.dataset.filter !== 'search') { state.profileFilters[target.dataset.filter] = target.value; state.page = 1; renderProfileTable(); }
if (target.dataset.userFilter === 'status') { state.userFilters.status = target.value; state.userPage = 1; loadUsers().catch(error => toast(error.message, true)); }
if (target.dataset.segmentPreset) {
if (target.checked) state.segmentDraft.presetIds.add(target.dataset.segmentPreset);
else state.segmentDraft.presetIds.delete(target.dataset.segmentPreset);
renderSegmentDestinations(); return;
}
if (target.name === 'quota_mode') { updateQuotaControls(); return; }
if (target.name === 'enabled' && target.form?.dataset.form === 'profile') { const help = document.getElementById('profile-enabled-help'); if (help) help.textContent = profileActiveHelp(target.checked); return; }
if (target.name === 'protocol') { updateProfileProtocol(); return; }
if (target.name === 'awg_instance') { suggestProfileName(); return; }
if (target.name === 'policy') document.getElementById('segment-rules').hidden = target.value === 'full';
});
document.addEventListener('keydown', event => {
if (event.key === 'Enter' && event.target.id === 'segment-destination') { event.preventDefault(); addSegmentDestination(); return; }
if (event.target.matches?.('#group-symbols input[name=icon]')) {
if (event.key === 'Enter') { event.preventDefault(); document.getElementById('group-symbols').hidePopover(); document.querySelector('[popovertarget=group-symbols]')?.focus({ preventScroll: true }); return; }
if (['ArrowUp', 'ArrowDown'].includes(event.key)) {
// Arriba y abajo recorren columnas de la rejilla; izquierda y derecha siguen el orden nativo del grupo.
const radios = [...document.querySelectorAll('#group-symbols input[name=icon]:not(:disabled)')], top = radios[0].closest('label').offsetTop;
const columns = radios.filter(radio => radio.closest('label').offsetTop === top).length || 1;
const next = radios[radios.indexOf(event.target) + (event.key === 'ArrowDown' ? columns : -columns)];
event.preventDefault();
if (next) { next.checked = true; next.focus({ preventScroll: true }); next.dispatchEvent(new Event('change', { bubbles: true })); }
return;
}
}
const versionTab = event.target.closest?.('[role=tab][data-service-version]');
if (versionTab && ['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
// Pestañas de versión de Estado: las flechas, Inicio y Fin eligen y enfocan la generación (selección automática).
const tabs = [...versionTab.parentElement.querySelectorAll('[role=tab]')], current = tabs.indexOf(versionTab);
const index = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (current + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
event.preventDefault(); selectServiceVersion(tabs[index].dataset.serviceVersion); return;
}
if (event.key === 'Escape' && dialog.open && dialog.classList.contains('assignment-dialog')) { event.preventDefault(); closeModal(); return; }
const menuTrigger = event.target.closest('button[data-action-menu]');
if (menuTrigger && ['ArrowDown', 'ArrowUp'].includes(event.key)) { event.preventDefault(); showActionMenu(menuTrigger, event.key === 'ArrowUp'); return; }
if (openActionMenu) {
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); dismissedActionTooltip = openActionMenu.trigger; closeActionMenu(true); return; }
if (event.key === 'Tab') { closeActionMenu(true); return; }
if (openActionMenu.menu.contains(event.target) && ['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
event.preventDefault();
const items = [...openActionMenu.menu.querySelectorAll('[role=menuitem]:not(:disabled)')];
const current = items.indexOf(document.activeElement);
const index = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : (current + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length;
items.forEach((item, position) => { item.tabIndex = position === index ? 0 : -1; }); items[index]?.focus({ preventScroll: true }); return;
}
}
if (event.key === 'Escape' && openActionTooltip) { event.preventDefault(); dismissedActionTooltip = openActionTooltip.control; closeActionTooltip(); }
if (event.key === 'Escape' && openNavTooltip) { event.preventDefault(); dismissedNavTooltip = openNavTooltip.link; closeNavTooltip(); }
if (event.key === 'Escape' && openInfo) { event.preventDefault(); closeInfo(true); }
// Esc en el código vuelve al paso anterior, como «Volver», salvo que antes cierre una burbuja o un menú.
if (event.key === 'Escape' && !event.defaultPrevented && document.getElementById('login-otp-bar')?.hidden === false && !document.getElementById('login-submit')?.disabled) { event.preventDefault(); document.getElementById('login-back')?.click(); return; }
if (event.key === 'Enter' && event.target.id === 'user-search') { event.preventDefault(); state.userPage = 1; loadUsers().catch(error => toast(error.message, true)); }
if (event.key === 'Enter' && event.target.id === 'assignment-search') { event.preventDefault(); state.assignmentSearch = event.target.value.trim(); state.assignmentPage = 1; loadAssignments().catch(error => showFormError(modalContent, error)); }
});
document.addEventListener('reset', async (event) => {
const form = event.target; if (form.dataset.form !== 'audit-filters') return;
event.preventDefault();
form.querySelectorAll('select,input[name]').forEach(input => { input.value = ''; });
form.querySelector('[data-form-error]').hidden = true; refreshControls(form);
state.auditFilters = {}; state.auditPage = 1; state.auditSnapshot = null;
try { await loadAudit(); } catch (error) { showFormError(form,error); }
});
document.addEventListener('submit', async (event) => {
const form = event.target; if (!form.dataset.form) return; event.preventDefault();
if (form.dataset.form === 'account') updateEmailProof();
if (!form.reportValidity()) return;
const button = form.querySelector('button[type=submit]');
const fields = new FormData(form);
const errorBox = form.querySelector('[data-form-error]') || form.parentElement.querySelector('[data-form-error]'); if (errorBox) errorBox.hidden = true;
await busy(button, async () => {
try {
if (form.dataset.form === 'audit-filters') {
const filters = Object.fromEntries(['actor','action','date_from','date_to'].map(key=>[key,String(fields.get(key)||'')]));
if (filters.date_from && filters.date_to && filters.date_from > filters.date_to) throw new Error('La fecha final no puede ser anterior a la inicial.');
state.auditFilters = filters; state.auditPage = 1; state.auditSnapshot = null;
await loadAudit();
setAuditFilters(false);
} else if (form.dataset.form === 'login') {
const challenge = document.getElementById('login-totp-field')?.hidden === false;
if (challenge && !otpReady()) return;
clearTimeout(state.otpTimer);
const code = String(fields.get('totp') || '').trim(), otp = document.getElementById('login-totp-field');
if (challenge) { document.getElementById('login-totp').dataset.sent = code; if (!document.getElementById('login-totp')?.dataset.mode) { otp.classList.remove('otp-complete'); otp.getBoundingClientRect(); otp.classList.add('otp-complete'); } }
const body = { email: String(fields.get('email')).trim(), password: String(fields.get('password')), remember_device:fields.has('remember_device'), ...(code ? { totp: code } : {}) };
try { const result = await api('/api/login', { method: 'POST', body }); if (result.requires_totp && !result.authenticated) { if (result.csrf) state.session.csrf = result.csrf; if (result.timezone) state.session.timezone = result.timezone; loginChallenge(); } else { setSession(result); await window.SimlaMotion?.unlock?.(); navigate('/'); } }
catch (error) {
if (error.requires_totp && !code) loginChallenge();
else { showFormError(form.parentElement, error); if (challenge) otpFailed(); }
}
finally { otp?.classList.remove('otp-complete'); }
} else if (form.dataset.form === 'forgot') {
const email = String(fields.get('email')).trim();
try {
await api('/api/password/forgot', { method: 'POST', body: { email } });
state.loginEmail = email;
document.getElementById('login-description').textContent = 'Si el correo pertenece a una cuenta activa, en unos minutos recibirás un enlace para elegir una contraseña nueva. Revisa también la carpeta de correo no deseado.';
form.innerHTML = loginBar('login-back', 'login-back', 'Volver al acceso');
document.getElementById('login-back').focus();
} catch (error) { showFormError(form.parentElement, error); }
} else if (form.dataset.form === 'reset-password') {
try {
if (fields.get('new_password') !== fields.get('confirm_password')) throw new Error('Las contraseñas no coinciden.');
await api('/api/password/reset', { method: 'POST', body: { token: state.resetToken, new_password: String(fields.get('new_password')) } });
state.resetToken = null; state.session = null;
history.replaceState({}, '', '/login'); await init();
toast('Contraseña actualizada. Inicia sesión con la contraseña nueva y tu código de verificación.');
} catch (error) { showFormError(form.parentElement, error); }
} else if (form.dataset.form === 'trust-device') {
const code = form.querySelector('.otp-input'); if (!otpReady(code)) return; code.dataset.sent = code.value;
try { await api('/api/account/trusted-device',{method:'PUT',body:{current_password:String(fields.get('current_password')),totp:String(fields.get('totp'))}}); }
catch (error) { otpFailed(code); throw error; }
closeModal();await renderRoute();toast('Dispositivo recordado durante 30 días.');
} else if (form.dataset.form === 'account') {
const actor = String(state.session.user.id), csrf = state.session.csrf, version = state.routeVersion;
const code = form.querySelector('.otp-input:not(:disabled)'); if (code && !otpReady(code)) return;
const first = String(fields.get('first_name')).trim(), last = String(fields.get('last_name') || '').trim();
if (`${first}${last ? ' ' + last : ''}`.length > 100) throw new Error('El nombre y apellido deben ocupar como máximo 100 caracteres.');
const body = { first_name: first, last_name: last, email: String(fields.get('email')).trim(), ...(fields.has('current_password') ? { current_password: String(fields.get('current_password')) } : {}), ...(fields.has('totp') ? { totp: String(fields.get('totp')).trim() } : {}) };
const result = await api('/api/account', { method: 'PATCH', body });
if (!state.session?.authenticated || actor !== String(state.session?.user?.id) || csrf !== state.session.csrf) return;
setSession(result); state.accountData = result; updateAccountHeader();
if (version === state.routeVersion && location.pathname === '/account') { renderAccount('/account'); document.querySelector('form[data-form="account"] button[type="submit"]')?.focus(); }
toast('Perfil actualizado.');
} else if (form.dataset.form === 'password') {
if (fields.get('new_password') !== fields.get('confirm_password')) throw new Error('Las contraseñas no coinciden.');
const result = await api('/api/password', { method: 'POST', body: { current_password: String(fields.get('current_password')), new_password: String(fields.get('new_password')) } });
setSession(result); closeModal(); toast('Contraseña actualizada.'); renderRoute();
} else if (form.dataset.form === 'totp') {
const code = form.querySelector('.otp-input'); if (!otpReady(code)) return; code.dataset.sent = code.value;
let result;
try { result = await api('/api/totp/enable', { method: 'POST', body: { code: String(fields.get('code')).trim() } }); }
catch (error) { otpFailed(code); throw error; }
setSession(result); state.recoveryCodes = result.recovery_codes || []; state.totp = null; renderSetup();
} else if (form.dataset.form === 'profile') {
const expiry = String(fields.get('expires_at') || '');
if(!fields.get('assigned_user_id')&&!form.dataset.id)throw new Error('Elige un usuario registrado para este perfil.');
const body = { ...(!form.dataset.id ? { protocol: String(fields.get('protocol')), ...(fields.get('protocol') === 'amneziawg' ? {awg_instance:String(fields.get('awg_instance'))} : {}) } : {}), name: String(fields.get('name')).trim(), segment_id: String(fields.get('segment_id') || '') || null, quota_gb: fields.get('quota_mode') === 'unlimited' || !fields.get('quota_gb') ? null : Number(fields.get('quota_gb')), quota_reset_day: 1, expires_at: expiry || null, enabled: fields.has('enabled'), ...(fields.has('assigned_user_id') ? { assigned_user_id: String(fields.get('assigned_user_id')) || null } : {}) };
const id = form.dataset.id;
const result = await api(id ? `/api/profiles/${pathId(id)}` : '/api/profiles', { method: id ? 'PATCH' : 'POST', body });
const profile = result.profile || result;
closeModal(); toast(id ? 'Perfil actualizado.' : 'Perfil creado.');
navigate(profilePath(id || profile.id));
if (!id && profile.id && can('profiles', 'download')) await shareProfile(profile.id);
} else if (form.dataset.form === 'user') {
const id = form.dataset.id;
const existing = state.users.find(user => String(user.id) === String(id));
const chosen=fields.getAll('group_ids').map(String);
if(!existing?.current&&!chosen.length)throw new Error('Selecciona al menos un grupo de permisos.');
const body={first_name:String(fields.get('first_name')).trim(),last_name:String(fields.get('last_name')||'').trim(),email:String(fields.get('email')).trim(),department:String(fields.get('department')||''),...(fields.has('phone')?{phone:String(fields.get('phone'))}:{}),...(fields.has('timezone')?{timezone:String(fields.get('timezone'))}:{}),...(!existing?.current?{group_ids:chosen,enabled:fields.has('enabled')}: {})};
if (!id && fields.has('send_access')) body.send_access = true;
const result = await api(id ? `/api/users/${pathId(id)}` : '/api/users', { method: id ? 'PATCH' : 'POST', body });
if (result.reauthenticate) { closeModal(); state.session = null; await init(); toast('Correo actualizado. Inicia sesión con tu nuevo correo.'); return; }
closeModal(); setSession(await api('/api/session')); navigate('/settings/users');
if (result.temporary_password) accessCredentials(result);
else toast('Usuario actualizado.');
} else if (form.dataset.form === 'group') {
const id = form.dataset.id;
const permissions = {};
form.querySelectorAll('input[data-permission-section]:checked:not(:disabled)').forEach(input => { const section = input.dataset.permissionSection; (permissions[section] ||= []).push(input.dataset.permissionAction); });
const initial = state.groups.find(group => group.id === id);
const sameGrants = initial && Object.entries({...initial.permissions,...permissions}).every(([key]) => [...(initial.permissions[key]||[])].sort().join() === [...(permissions[key]||[])].sort().join());
const cosmetic = initial && initial.name === String(fields.get('name')).trim() && initial.profile_scope === fields.get('profile_scope') && sameGrants;
const body = form.dataset.iconOnly === 'true' || cosmetic ? { icon: String(fields.get('icon')) } : { name: String(fields.get('name')).trim(), profile_scope: String(fields.get('profile_scope')), icon:String(fields.get('icon')||'group'), permissions };
await api(id ? `/api/groups/${pathId(id)}` : '/api/groups', { method: id ? 'PATCH' : 'POST', body });
closeModal(); setSession(await api('/api/session'));
toast(id ? 'Grupo actualizado.' : 'Grupo creado.');
navigate('/settings/groups');
} else if (form.dataset.form === 'reset-user') {
const result = await api(`/api/users/${pathId(form.dataset.id)}/reset-password`, { method: 'POST', body: { reset_totp: fields.has('reset_totp'), ...(fields.has('send_access') ? { send_access: true } : {}) } });
closeModal(); if (location.pathname === '/settings/users') await loadUsers(); else if (location.pathname === '/settings/email/deliveries') await window.SimlaMail?.refreshDeliveries(); accessCredentials(result);
} else if (form.dataset.form === 'segment') {
const id = form.dataset.id;
const policy = id === 'full' ? 'full' : String(fields.get('policy'));
if (policy !== 'full' && !addSegmentDestination(false)) return;
const domains = policy === 'full' ? [] : segmentDraftDomains();
if (policy !== 'full' && !domains.length) throw new Error('Añade al menos un destino para esta política.');
const body = { name: String(fields.get('name')).trim(), policy, domains };
await api(id ? `/api/segments/${pathId(id)}` : '/api/segments', { method: id ? 'PATCH' : 'POST', body });
closeModal(); toast(id ? 'Segmento actualizado.' : 'Segmento creado.'); navigate('/segments');
}
} catch (error) { showFormError(form.isConnected ? form : modalContent, error); }
});
});
dialog.addEventListener('click', event => { if (event.target === dialog) { const rect = dialog.getBoundingClientRect(); if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) closeModal(); } });
dialog.addEventListener('close', () => { closeInfo(); closeActionMenu(); closeActionTooltip(); releaseAvatarCrop(); state.shared = null; state.credentials = null; });
window.addEventListener('popstate', () => { closeModal(); window.scrollTo({ top: 0, left: 0, behavior: 'instant' }); renderRoute(); });
// Al volver a la pestaña se renuevan los datos, salvo si hay algo a medio editar: formularios y, desde 1.7.0, el editor
// de plantillas y el Diseño de correo (sin