/**
* 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 `
`;
}
// ── 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,
};
})();