/** * admin.js — admin panel * User management (+ granular permissions) + rotator configuration */ (function() { 'use strict'; // ── Permission definitions ──────────────────────────────────────────────────── // NOTE: labels are translated lazily via the _permLabel() function (not at // the moment the array is defined) - I18n may not be initialized yet // (language unknown) when this file loads, and besides, PERM_DEFS must // always reflect the CURRENT language (the badge in the user table renders repeatedly). const PERM_DEFS = [ { key: 'ptt', get label() { return I18n.t('perm_ptt'); }, group: 'radio' }, { key: 'rfPower', get label() { return I18n.t('perm_rfpower'); }, group: 'radio' }, { key: 'rfGain', get label() { return I18n.t('perm_rfgain'); }, group: 'radio' }, { key: 'mode', get label() { return I18n.t('perm_mode'); }, group: 'radio' }, { key: 'band', get label() { return I18n.t('perm_band'); }, group: 'radio' }, { key: 'freq', get label() { return I18n.t('perm_freq'); }, group: 'radio' }, // NOTE (2026-09-03): deliberately NO per-user 'split' entry - the user // decided split should be governed ONLY by the per-rig static feature // whitelist in KONFIGURACJA -> FUNKCJE RADIA (features.py's FEATURES), // not by an additional per-operator checkbox here. It briefly had a // checkbox (#perm-split) that was then removed again - see auth.js's // applyPermissions() for how the SPLIT button's visibility is actually // decided now (static feature only, permission check bypassed). { key: 'cw', get label() { return I18n.t('perm_cw'); }, group: 'radio' }, { key: 'rotator', get label() { return I18n.t('perm_rotator'); }, group: 'sprzet' }, { key: 'log', get label() { return I18n.t('perm_log'); }, group: 'system' }, { key: 'settings', get label() { return I18n.t('perm_settings'); }, group: 'system' }, { key: 'admin', get label() { return I18n.t('perm_admin'); }, group: 'system' }, // Arduino relays (0-7) - each assigned individually { key: 'relay_0', get label() { return I18n.t('perm_relay_n').replace('{n}', 0); }, group: 'relay' }, { key: 'relay_1', get label() { return I18n.t('perm_relay_n').replace('{n}', 1); }, group: 'relay' }, { key: 'relay_2', get label() { return I18n.t('perm_relay_n').replace('{n}', 2); }, group: 'relay' }, { key: 'relay_3', get label() { return I18n.t('perm_relay_n').replace('{n}', 3); }, group: 'relay' }, { key: 'relay_4', get label() { return I18n.t('perm_relay_n').replace('{n}', 4); }, group: 'relay' }, { key: 'relay_5', get label() { return I18n.t('perm_relay_n').replace('{n}', 5); }, group: 'relay' }, { key: 'relay_6', get label() { return I18n.t('perm_relay_n').replace('{n}', 6); }, group: 'relay' }, { key: 'relay_7', get label() { return I18n.t('perm_relay_n').replace('{n}', 7); }, group: 'relay' }, ]; const DEFAULT_PERMS = { ptt:true, rfPower:true, rfGain:true, mode:true, band:true, freq:true, cw:true, rotator:true, log:true, settings:false, admin:false, }; // ── Users ───────────────────────────────────────────────────────────────────── async function loadUsers() { const token = localStorage.getItem('token') || ''; const r = await fetch('/api/users', { headers: token ? {'Authorization': `Bearer ${token}`} : {} }); if (!r.ok) { console.warn('[admin] loadUsers HTTP', r.status); return; } const data = await r.json(); renderUsers(data.users || data); } function renderUsers(users) { const tbody = document.getElementById('users-tbody'); if (!tbody) return; const ROLE_COLORS = { admin:'var(--amber)', operator:'var(--green)', viewer:'var(--dim)' }; tbody.innerHTML = users.map(u => { // Same per-key merge as the edit-modal fix above - a badge list // built from an all-or-nothing fallback would drop badges for keys // an existing user's record simply never set (e.g. "split"). const perms = { ...DEFAULT_PERMS, ...(u.permissions || {}) }; const permBadges = PERM_DEFS .filter(p => perms[p.key]) .map(p => `${p.label.split('—')[0].split(' (')[0].trim()}`).join(''); // Escape user-supplied data before inserting into HTML - a user with // a name like could execute a script in the // ADMIN's browser (token theft). XSS fix. const _u = _escapeHtmlAdmin(u.username || ''); const _nm = _escapeHtmlAdmin(u.name || ''); const _cs = _escapeHtmlAdmin(u.callsign || '—'); const _uid = String(u.id).replace(/'/g, "\\'"); const _uUn = String(u.username || '').replace(/[\\'"]/g, '\\$&'); return ` ${_u} ${_nm} ${(u.role||'').toUpperCase()} ${_cs}
${permBadges || `${I18n.t('adm_no_perms')}`}
${u.active ? I18n.t('in_active') : I18n.t('in_stopped_badge')}
${u.id !== window.CurrentUser?.id ? `` : ''}
`; }).join(''); } // ── Modal: add user ─────────────────────────────────────────────────────────── function showAddUser() { _openModal(null); } async function editUser(id) { const token = localStorage.getItem('token') || ''; const r = await fetch('/api/users', { headers: token ? {'Authorization': `Bearer ${token}`} : {} }); if (!r.ok) { UI.showToast(I18n.t('adm_no_access'), 'error'); return; } const data = await r.json(); const users = data.users || data; const u = users.find(x => String(x.id) === String(id)); if (!u) { UI.showToast(I18n.t('adm_user_not_found'), 'error'); return; } _openModal(u); } function _openModal(u) { const modal = document.getElementById('user-modal'); if (!modal) return; const isNew = !u; const titleEl = document.getElementById('modal-title'); titleEl.removeAttribute('data-i18n'); // see the note at rot-status-badge (rotormini.js) titleEl.textContent = isNew ? I18n.t('adm_new_user_title') : I18n.t('adm_edit_user_title').replace('{username}', u.username); document.getElementById('user-id').value = u?.id || ''; document.getElementById('uname').value = u?.username || ''; document.getElementById('upass').value = ''; document.getElementById('urole').value = u?.role || 'operator'; document.getElementById('uname-full').value = u?.name || ''; document.getElementById('ucallsign').value = u?.callsign || ''; document.getElementById('ulocator').value = u?.locator || ''; document.getElementById('uemail').value = u?.email || ''; document.getElementById('upass-row').style.display = isNew ? '' : 'none'; // Build the relay section (dynamically, with real names from the // config). Done BEFORE setting the checkboxes, so perm-relay_N already // exist. _renderRelayPerms(u?.permissions || {}); // Set the permission checkboxes. FIX (2026-09-03): this used to be // all-or-nothing (u.permissions if the object exists AT ALL, else the // whole DEFAULT_PERMS) - for an existing user missing just ONE key // (e.g. "split", which only started being enforced this session) it // showed that single checkbox as unchecked, misleading the admin into // thinking someone had deliberately denied it. Merge per-key instead - // any key actually present in the stored record (true or false) wins, // a genuinely absent one falls back to the default. Saving this form // now (even with no changes) also migrates the user's stored record to // have an explicit value for every key, matching webapp.py's // _has_perm/auth.js's applyPermissions fallback so all three agree. const perms = { ...DEFAULT_PERMS, ...(u?.permissions || {}) }; PERM_DEFS.forEach(p => { const cb = document.getElementById('perm-' + p.key); if (cb) cb.checked = !!perms[p.key]; }); // Admin always has all permissions — disable the checkboxes _updatePermsByRole(document.getElementById('urole').value); modal.style.display = 'flex'; } // Build the relay checkboxes from the current relay config. // Shows ONLY configured relays with their real names (e.g. "Antena 40m" // instead of "Relay 3"). The admin selects which ones the user sees. async function _renderRelayPerms(userPerms) { const section = document.getElementById('perm-relay-section'); const list = document.getElementById('perm-relay-list'); if (!section || !list) return; let relays = []; try { const token = localStorage.getItem('token') || ''; const r = await fetch('/api/relay/config', { headers: token ? {'Authorization': `Bearer ${token}`} : {} }); if (r.ok) { const data = await r.json(); relays = (data.config?.relays) || []; } } catch(e) { console.warn('[admin] relay config fetch:', e); } // Show only enabled relays (they have a name). If none - hide the section. const active = relays.filter(r => r && r.name); if (!active.length) { section.style.display = 'none'; list.innerHTML = ''; return; } section.style.display = ''; list.innerHTML = active.map(r => { const key = 'relay_' + r.id; const checked = userPerms[key] ? 'checked' : ''; const nm = _escapeHtmlAdmin(r.name); return ``; }).join(''); // Disable if admin (admin always sees all of them) const role = document.getElementById('urole')?.value; if (role === 'admin') { active.forEach(r => { const cb = document.getElementById('perm-relay_' + r.id); if (cb) { cb.checked = true; cb.disabled = true; } }); } } function _escapeHtmlAdmin(s) { return String(s).replace(/[&<>"']/g, c => ( {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c] )); } function _updatePermsByRole(role) { const isAdmin = role === 'admin'; PERM_DEFS.forEach(p => { const cb = document.getElementById('perm-' + p.key); if (!cb) return; if (isAdmin) { cb.checked = true; cb.disabled = true; } else { cb.disabled = false; } }); // Relay checkboxes too (generated dynamically) - admin sees all of them for (let i = 0; i < 8; i++) { const cb = document.getElementById('perm-relay_' + i); if (!cb) continue; if (isAdmin) { cb.checked = true; cb.disabled = true; } else { cb.disabled = false; } } } async function saveUser() { const token = localStorage.getItem('token') || ''; const id = document.getElementById('user-id')?.value?.trim(); const username = document.getElementById('uname')?.value?.trim(); const password = document.getElementById('upass')?.value; const role = document.getElementById('urole')?.value; const name = document.getElementById('uname-full')?.value?.trim(); const callsign = document.getElementById('ucallsign')?.value?.trim().toUpperCase(); const locator = document.getElementById('ulocator')?.value?.trim().toUpperCase(); const email = document.getElementById('uemail')?.value?.trim(); if (!username) { UI.showToast(I18n.t('adm_enter_login'), 'error'); return; } // Collect permissions from the checkboxes — admin gets everything const permissions = {}; PERM_DEFS.forEach(p => { const cb = document.getElementById('perm-' + p.key); // relay_N may not exist if the relay isn't configured - that's fine // (no checkbox = no access). Warn only for the fixed permissions. if (!cb && !p.key.startsWith('relay_')) { console.warn('[admin] missing checkbox:', 'perm-' + p.key); } permissions[p.key] = (role === 'admin') ? true : !!(cb?.checked); }); console.log('[admin] saveUser id=', id, 'permissions=', permissions); let r; try { if (id) { r = await fetch(`/api/users/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', ...(token ? {'Authorization': `Bearer ${token}`} : {}) }, body: JSON.stringify({ role, name, callsign, locator, email, permissions }), }); } else { if (!password) { UI.showToast(I18n.t('adm_enter_password'), 'error'); return; } r = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? {'Authorization': `Bearer ${token}`} : {}) }, body: JSON.stringify({ username, password, role, name, callsign, locator, email, permissions }), }); } } catch(e) { UI.showToast(I18n.t('adm_network_error') + e.message, 'error'); return; } let res = {}; try { res = await r.json(); } catch(e) { /* empty body */ } console.log('[admin] saveUser response status=', r.status, 'body=', res); if (!r.ok) { UI.showToast('✗ ' + (res.error || `HTTP ${r.status}`), 'error'); return; } closeModal(); loadUsers(); UI.showToast(id ? I18n.t('adm_user_updated') : I18n.t('adm_user_added')); // If the admin edited themselves — refresh the permissions in the UI immediately if (id && window.CurrentUser && String(window.CurrentUser.id) === String(id)) { fetch('/api/auth/me').then(r => r.json()).then(data => { if (data.user) { window.CurrentUser = data.user; Auth.applyPermissions(data.user); } }); } } async function toggleActive(id, isActive) { const r = await fetch(`/api/users/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ active: !isActive }), }); const res = await r.json(); if (res.ok) { loadUsers(); UI.showToast(isActive ? I18n.t('adm_account_deactivated') : I18n.t('adm_account_activated')); } else UI.showToast('✗ ' + res.error, 'error'); } async function deleteUser(id, username) { if (!await UI.confirmModal(I18n.t('adm_confirm_delete_user').replace('{username}', username), { danger: true, okLabel: I18n.t('common_delete_btn') })) return; const r = await fetch(`/api/users/${id}`, { method: 'DELETE' }); const res = await r.json(); if (res.ok) { loadUsers(); UI.showToast(I18n.t('adm_user_deleted').replace('{username}', username)); } else UI.showToast('✗ ' + res.error, 'error'); } async function resetPwdDialog(id, username) { const pwd = await UI.textPrompt(I18n.t('adm_new_pwd_for').replace('{username}', username), ''); if (!pwd) return; if (pwd.length < 8) { UI.showToast(I18n.t('profile_pwd_min_len'), 'error'); return; } // Was POSTing to /api/users//reset-password, a route that only ever // existed on the frontend - the backend's /api/users/ route only // handles PUT/PATCH/DELETE, so this 404'd on every click (the button // looked like it did something because the .then() chain never checked // for a network/parse failure). The real endpoint for this - already // implemented server-side, just never wired to any button - is // POST /api/auth/admin-reset with {user_id, password}. fetch(`/api/auth/admin-reset`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: id, password: pwd }), }).then(r => r.json()).then(res => { if (res.ok) UI.showToast(I18n.t('adm_pwd_reset_done').replace('{username}', username)); else UI.showToast('✗ ' + (res.error || '?'), 'error'); }).catch(e => UI.showToast('✗ ' + e.message, 'error')); } function closeModal() { const m = document.getElementById('user-modal'); if (m) m.style.display = 'none'; } // ── Rotator configuration ───────────────────────────────────────────────────── async function loadRotatorConfig() { loadStationLocator(); // station locator — same settings section try { const r = await fetch('/api/config'); const cfg = await r.json(); _rotatorsCfg = cfg.rotators || []; renderRotatorConfig(_rotatorsCfg); } catch(e) { renderRotatorConfig([]); } } function renderRotatorConfig(rots) { const el = document.getElementById('rotator-config-list'); if (!el) return; // Remove the i18n marker from the static "Loading..." placeholder - // otherwise the next I18n.setLang() (apply() runs over the whole // document) would overwrite this container back to "Loading..." and // wipe out the rendered rotators. el.removeAttribute('data-i18n'); el.innerHTML = rots.map((rot, i) => `
`).join('') || `
${I18n.t('cfg_no_rotators')}
`; rots.forEach((rot, i) => { const sel = document.getElementById(`rcfg-port-${i}`); if (sel) window.SerialPorts?.populate(sel, rot.port || `COM${5+i}`); }); } let _rotatorsCfg = []; function addRotator() { const rows = _rotatorsCfg.length; _rotatorsCfg.push({ id:rows+1, name:`Rotator ${rows+1}`, model:'901', port:`COM${5+rows}`, speed:'1200', enabled:true }); renderRotatorConfig(_rotatorsCfg); } function removeRotator(idx) { _rotatorsCfg.splice(idx, 1); _rotatorsCfg.forEach((r, i) => r.id = i + 1); renderRotatorConfig(_rotatorsCfg); } async function saveRotatorConfig() { const rows = document.querySelectorAll('.rot-cfg-row'); const rots = []; rows.forEach((_, i) => { rots.push({ id: i+1, name: document.getElementById(`rcfg-name-${i}`)?.value || `Rotator ${i+1}`, model: document.getElementById(`rcfg-model-${i}`)?.value || '901', port: document.getElementById(`rcfg-port-${i}`)?.value || 'COM5', speed: document.getElementById(`rcfg-speed-${i}`)?.value || '1200', ph: parseInt(document.getElementById(`rcfg-ph-${i}`)?.value||'2')||2, enabled: document.getElementById(`rcfg-en-${i}`)?.value === '1', hamlibPort: 4533 + i, }); }); _rotatorsCfg = rots; try { const r = await fetch('/api/rotator/config', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ rotators: rots }), }); const res = await r.json(); if (res.ok) { window.UI?.showToast(I18n.t('cfg_toast_rotators_saved')); } else window.UI?.showToast('✗ ' + (res.error||I18n.t('profile_error_fallback')), 'error'); } catch(e) { window.UI?.showToast('✗ ' + I18n.t('cfg_conn_error'), 'error'); } } async function testRotator(idx) { const cfg = _rotatorsCfg[idx]; if (!cfg?.id) { window.UI?.showToast(I18n.t('cfg_save_config_first'), 'error'); return; } const resultEl = document.getElementById(`rot-test-result-${idx}`); if (resultEl) resultEl.textContent = I18n.t('cfg_testing'); try { const r = await fetch(`/api/rotator/${cfg.id}/test`, {method:'POST'}); const d = await r.json(); const msg = d.testOk ? I18n.t('cfg_rot_test_ok').replace('{az}', d.testPos?.az??'?').replace('{driver}', d.driverType) : `✗ ${d.testMsg||d.error}`; if (resultEl) { resultEl.textContent = msg; resultEl.style.color = d.testOk ? 'var(--green)' : 'var(--red)'; } window.UI?.showToast(msg, d.testOk ? 'ok' : 'error'); } catch(e) { if (resultEl) { resultEl.textContent = '✗ ' + I18n.t('log_error_prefix') + e.message; resultEl.style.color = 'var(--red)'; } } } // ── Radio features (whitelist for users) ───────────────────────────────────── let _rigFeaturesData = null; async function loadRigFeatures() { const el = document.getElementById('rig-features-config-list'); if (!el) return; el.innerHTML = I18n.t('settings_loading'); try { const token = localStorage.getItem('token'); // ?view=admin (2026-09-03): this config page needs the FULL // capabilities dump (every detected action/slider, including // disabled ones) to render its checkboxes - webapp.py now only // returns that shape here, never from the plain RADIO-tab fetch in // radiofunctions.js, so a "settings"-holding non-admin's regular // control panel can't leak every disabled button as a live control // (see the fix comment on GET /api/rig/features). const r = await fetch('/api/rig/features?view=admin', { headers: token ? { 'Authorization': `Bearer ${token}` } : {} }); const data = await r.json(); if (!data.ok) throw new Error(data.error || 'blad'); _rigFeaturesData = data; renderRigFeaturesConfig(data.features || [], data.dynamic || {actions:[],sliders:[]}); } catch(e) { el.innerHTML = `${I18n.t('log_error_prefix')}${e.message}`; } } function renderRigFeaturesConfig(features, dynamic) { const el = document.getElementById('rig-features-config-list'); if (!el) return; el.removeAttribute('data-i18n'); // see the comment in renderRotatorConfig() const actions = dynamic.actions || []; const sliders = dynamic.sliders || []; el.innerHTML = `
${_renderTransferWidget({ title: I18n.t('cfg_static_features_title'), items: features, dataAttr: 'data-feature-id', widgetId: 'feat-transfer', labelFn: f => (f.icon ? f.icon + ' ' : '') + f.label, sublabelFn: f => f.group?.toUpperCase() || '', })} ${_renderTransferWidget({ title: I18n.t('cfg_buttons_title'), items: actions, dataAttr: 'data-dynamic-id', widgetId: 'btn-transfer', labelFn: a => a.label.split('(')[0].trim(), sublabelFn: a => a.group.toUpperCase(), })} ${_renderTransferWidget({ title: I18n.t('cfg_sliders_title'), items: sliders, dataAttr: 'data-dynamic-id', widgetId: 'sld-transfer', labelFn: s => s.label, sublabelFn: s => `${s.min}…${s.max}`, })}
`; // Hook up events (after insertion into the DOM) ['feat-transfer', 'btn-transfer', 'sld-transfer'].forEach(id => _attachTransferEvents(id)); } // ── Transfer widget renderer ─────────────────────────────────────────────────── function _renderTransferWidget({ title, items, dataAttr, widgetId, labelFn, sublabelFn }) { const selected = items.filter(i => i.enabled !== false); const available = items.filter(i => i.enabled === false); const renderList = (list, side) => list.map(i => `
${labelFn(i)} ${sublabelFn(i)}
`).join(''); return `
${title}
${I18n.t('tw_visible_lbl')} ${selected.length}
${renderList(selected, 'selected')}
${I18n.t('tw_hint_remove')}
${I18n.t('tw_available_lbl')} ${available.length}
${renderList(available, 'available')}
${I18n.t('tw_hint_add')}
`; } // ── Transfer widget events ───────────────────────────────────────────────────── function _attachTransferEvents(widgetId) { const selList = document.getElementById(`${widgetId}-selected`); const avlList = document.getElementById(`${widgetId}-available`); if (!selList || !avlList) return; [selList, avlList].forEach(list => { // Highlight on click list.addEventListener('click', e => { const item = e.target.closest('.tw-item'); if (!item) return; // Toggle selection (ctrl = multi-select) if (!e.ctrlKey && !e.metaKey) { list.querySelectorAll('.tw-item.tw-selected').forEach(i => i.classList.remove('tw-selected')); } item.classList.toggle('tw-selected'); _twUpdateCounts(widgetId); }); // Double-click = move immediately list.addEventListener('dblclick', e => { const item = e.target.closest('.tw-item'); if (!item) return; const target = item.dataset.side === 'selected' ? avlList : selList; item.dataset.side = target.dataset.side; target.appendChild(item); _twUpdateCounts(widgetId); }); // Drag & drop list.addEventListener('dragover', e => { e.preventDefault(); list.classList.add('tw-drag-over'); }); list.addEventListener('dragleave', () => list.classList.remove('tw-drag-over')); list.addEventListener('drop', e => { e.preventDefault(); list.classList.remove('tw-drag-over'); const id = e.dataTransfer.getData('tw-item-id'); const item = document.querySelector(`.tw-item[data-drag-id="${id}"]`); if (item) { item.dataset.side = list.dataset.side; list.appendChild(item); _twUpdateCounts(widgetId); } }); }); // Drag start on the elements (delegation — also works on dynamically added ones) document.getElementById(widgetId).addEventListener('dragstart', e => { const item = e.target.closest('.tw-item'); if (!item) return; const uid = Math.random().toString(36).slice(2); item.setAttribute('data-drag-id', uid); e.dataTransfer.setData('tw-item-id', uid); }); } // ── Helper functions for the transfer buttons ───────────────────────────────── function _twMoveSelected(widgetId, toSide) { const fromSide = toSide === 'selected' ? 'available' : 'selected'; const fromList = document.getElementById(`${widgetId}-${fromSide}`); const toList = document.getElementById(`${widgetId}-${toSide}`); fromList?.querySelectorAll('.tw-item.tw-selected').forEach(item => { item.classList.remove('tw-selected'); item.dataset.side = toSide; toList.appendChild(item); }); _twUpdateCounts(widgetId); } function _twMoveAll(widgetId, toSide) { const fromSide = toSide === 'selected' ? 'available' : 'selected'; const fromList = document.getElementById(`${widgetId}-${fromSide}`); const toList = document.getElementById(`${widgetId}-${toSide}`); fromList?.querySelectorAll('.tw-item').forEach(item => { item.dataset.side = toSide; toList.appendChild(item); }); _twUpdateCounts(widgetId); } function _twMoveUp(widgetId) { const list = document.getElementById(`${widgetId}-selected`); list?.querySelectorAll('.tw-item.tw-selected').forEach(item => { if (item.previousElementSibling) list.insertBefore(item, item.previousElementSibling); }); } function _twMoveDown(widgetId) { const list = document.getElementById(`${widgetId}-selected`); const items = [...(list?.querySelectorAll('.tw-item.tw-selected') || [])].reverse(); items.forEach(item => { if (item.nextElementSibling) list.insertBefore(item.nextElementSibling, item); }); } function _twUpdateCounts(widgetId) { const sel = document.getElementById(`${widgetId}-selected`); const avl = document.getElementById(`${widgetId}-available`); const sc = document.getElementById(`${widgetId}-sel-count`); const ac = document.getElementById(`${widgetId}-avl-count`); if (sc) sc.textContent = sel?.children.length ?? 0; if (ac) ac.textContent = avl?.children.length ?? 0; } // ── Global helpers (called inline from onclick) ─────────────────────────────── window._twMoveSelected = _twMoveSelected; window._twMoveAll = _twMoveAll; window._twMoveUp = _twMoveUp; window._twMoveDown = _twMoveDown; async function saveRigFeatures() { const enabledDynamic = {}; const dynamicOrder = {}; const enabledFeatures = {}; // Static features (split, ptt, freq etc.) const featSel = document.getElementById('feat-transfer-selected'); const featAvl = document.getElementById('feat-transfer-available'); featSel?.querySelectorAll('.tw-item[data-feature-id]').forEach(item => { enabledFeatures[item.dataset.featureId] = true; }); featAvl?.querySelectorAll('.tw-item[data-feature-id]').forEach(item => { enabledFeatures[item.dataset.featureId] = false; }); // Buttons const btnSel = document.getElementById('btn-transfer-selected'); const btnAvl = document.getElementById('btn-transfer-available'); btnSel?.querySelectorAll('.tw-item[data-dynamic-id]').forEach((item, idx) => { enabledDynamic[item.dataset.dynamicId] = true; dynamicOrder[item.dataset.dynamicId] = idx; }); btnAvl?.querySelectorAll('.tw-item[data-dynamic-id]').forEach(item => { enabledDynamic[item.dataset.dynamicId] = false; }); // Sliders const sldSel = document.getElementById('sld-transfer-selected'); const sldAvl = document.getElementById('sld-transfer-available'); sldSel?.querySelectorAll('.tw-item[data-dynamic-id]').forEach((item, idx) => { enabledDynamic[item.dataset.dynamicId] = true; dynamicOrder[item.dataset.dynamicId] = idx; }); sldAvl?.querySelectorAll('.tw-item[data-dynamic-id]').forEach(item => { enabledDynamic[item.dataset.dynamicId] = false; }); try { const token = localStorage.getItem('token'); const r = await fetch('/api/rig/features', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': `Bearer ${token}` } : {}) }, body: JSON.stringify({ rigId: _rigFeaturesData?.rigId, enabledFeatures, enabledDynamic, dynamicOrder, }), }); const data = await r.json(); if (!data.ok) throw new Error(data.error || 'blad'); window.UI?.showToast(I18n.t('cfg_toast_config_saved'), 'success'); await loadRigFeatures(); } catch(e) { window.UI?.showToast('✗ ' + I18n.t('log_error_prefix') + e.message, 'error'); } } // ── FT8 Timer — admin per-user management ───────────────────────────────────── async function loadFt8Timers() { // Load the global timer const token = localStorage.getItem('token') || ''; const h = token ? {'Authorization': `Bearer ${token}`} : {}; try { const r = await fetch('/api/ft8timer/global', {headers: h}); const d = await r.json(); const el = document.getElementById('ft8t-global-dur'); if (el) el.value = d.duration_min || 6; const st = document.getElementById('ft8t-global-status'); if (st) st.textContent = I18n.t('adm_timer_active').replace('{min}', d.duration_min || 6); } catch(e) { console.warn('[ft8timer] load:', e); } } async function saveGlobalFt8Timer() { const dur = parseInt(document.getElementById('ft8t-global-dur')?.value || '6'); const token = localStorage.getItem('token') || ''; const h = {'Content-Type':'application/json', ...(token ? {'Authorization': `Bearer ${token}`} : {})}; try { const r = await fetch('/api/ft8timer/global', { method: 'POST', headers: h, body: JSON.stringify({ duration_min: dur }), }); const d = await r.json(); if (d.ok) { window.UI?.showToast(I18n.t('adm_timer_saved_toast').replace('{min}', dur)); const st = document.getElementById('ft8t-global-status'); if (st) st.textContent = I18n.t('adm_timer_active').replace('{min}', dur); } } catch(e) { window.UI?.showToast(I18n.t('adm_timer_save_error'), 'error'); } } // ── AdminBands — bands and modes ────────────────────────────────────────────── window.AdminBands = (() => { const token = () => localStorage.getItem('token') || ''; async function load() { await Promise.all([_loadBands(), _loadModes()]); } // ── BANDS ─────────────────────────────────────────────────────────────────── async function _loadBands() { const sel = document.getElementById('bands-transfer-selected'); const avl = document.getElementById('bands-transfer-available'); if (!sel || !avl) return; try { const r = await fetch('/api/config/bands', { headers: {'Authorization': `Bearer ${token()}`} }); const data = await r.json(); const enabled = data.enabledBands || []; const all = Object.keys(data.allBands || {}); sel.innerHTML = enabled.map(b => `
${b}
` ).join(''); avl.innerHTML = all.filter(b => !enabled.includes(b)).map(b => `
${b}
` ).join(''); _updateCount('bands-transfer'); _attachBandEvents(); } catch(e) { console.warn('[AdminBands] bands error:', e); } } async function save() { const sel = document.getElementById('bands-transfer-selected'); if (!sel) return; const enabled = [...sel.querySelectorAll('.tw-item')].map(el => el.dataset.band); try { const r = await fetch('/api/config/bands', { method: 'POST', headers: {'Content-Type':'application/json', 'Authorization': `Bearer ${token()}`}, body: JSON.stringify({ enabledBands: enabled }), }); const res = await r.json(); if (res.ok) window.UI?.showToast(I18n.t('cfg_toast_bands_saved')); else window.UI?.showToast('✗ ' + (res.error||I18n.t('profile_error_fallback')), 'error'); } catch(e) { window.UI?.showToast('✗ ' + e.message, 'error'); } } // ── MODES ─────────────────────────────────────────────────────────────────── let _allModes = []; let _modeFilters = {}; async function _loadModes() { const sel = document.getElementById('modes-transfer-selected'); const avl = document.getElementById('modes-transfer-available'); if (!sel || !avl) return; try { const r = await fetch('/api/config/modes', { headers: {'Authorization': `Bearer ${token()}`} }); const data = await r.json(); _allModes = data.allModes || []; _modeFilters = data.modeFilters || {}; const enabled = data.enabledModes || []; sel.innerHTML = enabled.map(m => `
${m}
` ).join(''); avl.innerHTML = _allModes.filter(m => !enabled.includes(m)).map(m => `
${m}
` ).join(''); _updateCount('modes-transfer'); _attachModeEvents(); _renderModeFilters(enabled); } catch(e) { console.warn('[AdminBands] modes error:', e); } } function _renderModeFilters(enabled) { const grid = document.getElementById('mode-filter-grid'); if (!grid) return; const FILTERS = ['1','2','3']; grid.innerHTML = enabled.map(m => `
${m}
`).join(''); } async function saveModes() { const sel = document.getElementById('modes-transfer-selected'); if (!sel) return; const enabled = [...sel.querySelectorAll('.tw-item')].map(el => el.dataset.mode); const filters = {}; document.querySelectorAll('[data-mode-filter]').forEach(el => { if (el.value) filters[el.dataset.modeFilter] = el.value; }); try { const r = await fetch('/api/config/modes', { method: 'POST', headers: {'Content-Type':'application/json', 'Authorization': `Bearer ${token()}`}, body: JSON.stringify({ enabledModes: enabled, modeFilters: filters }), }); const res = await r.json(); if (res.ok) window.UI?.showToast(I18n.t('cfg_toast_modes_saved')); else window.UI?.showToast('✗ ' + (res.error||I18n.t('profile_error_fallback')), 'error'); } catch(e) { window.UI?.showToast('✗ ' + e.message, 'error'); } } // ── Drag & drop and events ─────────────────────────────────────────────────── function _attachBandEvents() { _attachDnD('bands-transfer', 'band'); } function _attachModeEvents() { _attachDnD('modes-transfer', 'mode'); } function _attachDnD(widgetId, dataKey) { ['selected','available'].forEach(side => { const list = document.getElementById(`${widgetId}-${side}`); if (!list) return; // _loadBands/_loadModes are called EVERY time the CONFIGURATION tab // is opened, but these are FIXED DOM nodes (only their .innerHTML // gets replaced) - without this guard, addEventListener would stack // up on every visit (delegated on the parent, so it survives the // children being replaced), and click/dblclick would start // behaving randomly after a few visits (odd/even number of // listeners). Attaching once is enough. if (list.dataset.dndAttached === '1') return; list.dataset.dndAttached = '1'; const other = document.getElementById(`${widgetId}-${side==='selected'?'available':'selected'}`); // Highlight on click (ctrl/cmd = multi-select) - without this, the // "« Add"/"Remove »" buttons (which only move .tw-item.tw-selected, // see _twMoveSelected) had nothing to act on and were dead - only // double-click and dragging worked. _attachTransferEvents (used by // RADIO FEATURES) had this handler, this one (BANDS/MODES) didn't. list.addEventListener('click', e => { const item = e.target.closest('.tw-item'); if (!item) return; if (!e.ctrlKey && !e.metaKey) { list.querySelectorAll('.tw-item.tw-selected').forEach(i => i.classList.remove('tw-selected')); } item.classList.toggle('tw-selected'); }); list.addEventListener('dblclick', e => { const item = e.target.closest('.tw-item'); if (!item) return; item.dataset.side = other.dataset.side; other.appendChild(item); _updateCount(widgetId); if (dataKey === 'mode') { const enabled = [...document.getElementById(`${widgetId}-selected`).querySelectorAll('.tw-item')].map(el => el.dataset.mode); _renderModeFilters(enabled); } }); list.addEventListener('dragover', e => { e.preventDefault(); list.classList.add('tw-drag-over'); }); list.addEventListener('dragleave', () => list.classList.remove('tw-drag-over')); list.addEventListener('drop', e => { e.preventDefault(); list.classList.remove('tw-drag-over'); const id = e.dataTransfer.getData('tw-item-id'); const item = document.querySelector(`.tw-item[data-drag-id="${id}"]`); if (item) { item.dataset.side = list.dataset.side; list.appendChild(item); _updateCount(widgetId); } }); }); const widget = document.getElementById(widgetId); if (widget && widget.dataset.dndAttached !== '1') { widget.dataset.dndAttached = '1'; widget.addEventListener('dragstart', e => { const item = e.target.closest('.tw-item'); if (!item) return; const uid = Math.random().toString(36).slice(2); item.setAttribute('data-drag-id', uid); e.dataTransfer.setData('tw-item-id', uid); }); } } function _updateCount(widgetId) { const s = document.getElementById(`${widgetId}-selected`)?.children.length || 0; const a = document.getElementById(`${widgetId}-available`)?.children.length || 0; const sc = document.getElementById(`${widgetId}-sel-count`); const ac = document.getElementById(`${widgetId}-avl-count`); if (sc) sc.textContent = s; if (ac) ac.textContent = a; } return { load, save, saveModes }; })(); window._adminAttachTransferEvents = _attachTransferEvents; window._adminTwUpdateCounts = _twUpdateCounts; // ── Station locator (antenna position — base for rotator azimuth) ──────────── async function loadStationLocator() { const el = document.getElementById('cfg-station-locator'); if (!el) return; try { const token = localStorage.getItem('token') || ''; const r = await fetch('/api/config/station', { headers: token ? {'Authorization': `Bearer ${token}`} : {} }); const d = await r.json(); el.value = d.stationLocator || ''; } catch(e) {} } async function saveStationLocator() { const el = document.getElementById('cfg-station-locator'); const msg = document.getElementById('cfg-station-locator-msg'); if (!el) return; const loc = (el.value || '').trim().toUpperCase(); if (loc && !/^[A-R]{2}\d{2}([A-X]{2})?$/.test(loc)) { if (msg) { msg.textContent = I18n.t('cfg_bad_locator_format'); msg.style.color = 'var(--red)'; } return; } try { const token = localStorage.getItem('token') || ''; const r = await fetch('/api/config/station', { method: 'POST', headers: {'Content-Type':'application/json', ...(token ? {'Authorization': `Bearer ${token}`} : {})}, body: JSON.stringify({ stationLocator: loc }) }); const d = await r.json(); if (d.ok) { if (msg) { msg.textContent = I18n.t('cfg_saved_capital'); msg.style.color = 'var(--green)'; } setTimeout(() => { if (msg) msg.textContent = ''; }, 3000); } else { if (msg) { msg.textContent = '✗ ' + (d.error || I18n.t('profile_error_fallback')); msg.style.color = 'var(--red)'; } } } catch(e) { if (msg) { msg.textContent = '✗ ' + e.message; msg.style.color = 'var(--red)'; } } } // ── DeepCW — model management ───────────────────────────────────────────────── async function deepcwStatus() { const el = document.getElementById('deepcw-admin-status'); if (!el) return; try { const token = localStorage.getItem('token') || ''; const r = await fetch('/api/deepcw/engine_status', { headers: token ? {'Authorization': `Bearer ${token}`} : {} }); const d = await r.json(); if (d.error) { el.textContent = '✗ ' + d.error; el.style.color = 'var(--red)'; } else if (!d.hasModel) { el.textContent = I18n.t('cfg_deepcw_not_downloaded'); el.style.color = '#fa0'; } else if (!d.ready) { el.textContent = I18n.t('cfg_deepcw_loading').replace('{mb}', d.sizeMB); el.style.color = '#fa0'; } else { el.textContent = I18n.t('cfg_deepcw_ready').replace('{mb}', d.sizeMB); el.style.color = 'var(--green)'; } } catch(e) { el.textContent = '✗ ' + e.message; el.style.color = 'var(--red)'; } } async function deepcwDownload() { const bar = document.getElementById('deepcw-admin-bar'); const log = document.getElementById('deepcw-admin-log'); if (bar) bar.style.display = 'block'; if (log) log.textContent = I18n.t('cfg_deepcw_starting_download'); try { const token = localStorage.getItem('token') || ''; const r = await fetch('/api/deepcw/download', { method: 'POST', headers: token ? {'Authorization': `Bearer ${token}`} : {} }); const d = await r.json(); if (d.ok) { if (log) { log.textContent = I18n.t('cfg_deepcw_downloaded').replace('{mb}', (d.sizeBytes/1e6).toFixed(1)); log.style.color = 'var(--green)'; } deepcwStatus(); } else { if (log) { log.textContent = d.error ? ('✗ ' + d.error) : I18n.t('cfg_deepcw_download_error'); log.style.color = 'var(--red)'; } } } catch(e) { if (log) { log.textContent = '✗ ' + e.message; log.style.color = 'var(--red)'; } } } window.Admin = { loadUsers, showAddUser, editUser, saveUser, toggleActive, deleteUser, resetPwdDialog, closeModal, loadRotatorConfig, addRotator, removeRotator, saveRotatorConfig, testRotator, loadStationLocator, saveStationLocator, deepcwStatus, deepcwDownload, PERM_DEFS, _updatePermsByRole, loadRigFeatures, saveRigFeatures, loadFt8Timers, saveGlobalFt8Timer, }; })();