let token = ''; let roles = []; let users = []; let apis = []; let selectedUser = ''; let selectedRole = ''; let selectedApi = -1; let currentFilePath = '/'; let filesLoaded = false; let uiBusyCount = 0; let serverBusyCount = 0; let currentUserRoles = []; const el = id => document.getElementById(id); const hasCurrentRole = role => currentUserRoles.includes(role); function setProjectName(projectName) { if (!projectName) return; document.title = projectName; el('appTitle').textContent = projectName; } async function loadAppInfo() { try { const response = await fetch('/api/app-info'); const json = await response.json(); setProjectName(json.projectName || ''); } catch (error) {} } function setActivityBusy(kind, on) { if (kind == 'server') { serverBusyCount += on ? 1 : -1; if (serverBusyCount < 0) serverBusyCount = 0; el('serverBusyChip').classList.toggle('hidden', serverBusyCount == 0); return; } uiBusyCount += on ? 1 : -1; if (uiBusyCount < 0) uiBusyCount = 0; el('uiBusyChip').classList.toggle('hidden', uiBusyCount == 0); } async function tracked(fn, kind = 'ui') { setActivityBusy(kind, true); try { return await fn(); } finally { setActivityBusy(kind, false); } } async function apiFetch(url, options, activity = 'ui') { return await tracked(() => fetch(url, options), activity); } async function req(method, url, body, activity = 'ui') { const options = {method, headers: {'Content-Type': 'application/json'}}; if (token) options.headers.Authorization = 'Bearer ' + token; if (body !== undefined && method != 'GET') options.body = JSON.stringify(body); const response = await apiFetch(url, options, activity); return await response.text(); } async function serverReq(method, url, body) { return await req(method, url, body, 'server'); } window.App = { apiFetch, req, serverReq, setActivityBusy, token: () => token, roles: () => [...currentUserRoles] }; const topLevelTabs = [ {id: 'dashboard', role: 'WebUIConnect'}, {id: 'access', role: 'AccessAdmin'}, {id: 'network', role: 'NetworkAdmin'}, {id: 'ops', role: 'SystemAdmin'} ]; function visibleTopLevelTabs() { return topLevelTabs.filter(tab => hasCurrentRole(tab.role)).map(tab => tab.id); } function applyRoleVisibility() { const visible = visibleTopLevelTabs(); topLevelTabs.forEach(tab => { const allowed = visible.includes(tab.id); document.querySelectorAll('[data-tab="' + tab.id + '"]').forEach(button => button.classList.toggle('hidden', !allowed)); const panel = el(tab.id); if (panel) panel.classList.toggle('hidden', !allowed); }); const topTabs = document.querySelector('#app > .tabs'); if (topTabs) topTabs.classList.toggle('hidden', visible.length <= 1); if (visible.length) showTab(visible[0]); return visible; } function showTab(id) { if (!visibleTopLevelTabs().includes(id)) return; document.querySelectorAll('.tab[data-tab]').forEach(button => button.classList.toggle('active', button.dataset.tab == id)); document.querySelectorAll('.panel').forEach(panel => panel.classList.toggle('active', panel.id == id)); if (id == 'network') loadNetworkSettings(); if (id == 'ops' && !filesLoaded) loadFiles('/'); } function showAccessTab(id) { document.querySelectorAll('.subtab').forEach(button => button.classList.toggle('active', button.dataset.subtab == id)); document.querySelectorAll('.subpanel').forEach(panel => panel.classList.toggle('active', panel.id == id)); if (id == 'users') showUserOverview(); if (id == 'roles') showRoleOverview(); if (id == 'apis') showApiOverview(); } async function doLogin() { const text = await req('POST', '/api/login', {username: el('user').value, password: el('pass').value}); el('loginOut').textContent = text; const json = JSON.parse(text); if (!json.success) return; token = json.token; currentUserRoles = (json.roles || '').split('|').filter(Boolean); el('loginPanel').classList.add('hidden'); el('app').classList.remove('hidden'); el('logoutBtn').classList.remove('hidden'); window.dispatchEvent(new CustomEvent('app:login', {detail: {token, roles: currentUserRoles}})); const visible = applyRoleVisibility(); if (hasCurrentRole('SystemAdmin')) loadSettings(); if (hasCurrentRole('AccessAdmin')) loadAccess(); if (hasCurrentRole('NetworkAdmin')) loadNetworkSettings(); if (visible.includes('ops')) loadFiles('/'); } async function doLogout() { if (token) { try { await req('POST', '/api/logout'); } catch (error) {} } token = ''; currentUserRoles = []; roles = []; users = []; apis = []; selectedUser = ''; selectedRole = ''; selectedApi = -1; filesLoaded = false; currentFilePath = '/'; el('app').classList.add('hidden'); el('loginPanel').classList.remove('hidden'); el('logoutBtn').classList.add('hidden'); el('pass').value = ''; el('loginOut').textContent = ''; window.dispatchEvent(new CustomEvent('app:logout')); } function esc(value) { return String(value).replace(/[&<>"']/g, char => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[char])); } function roleNames() { return roles.map(role => role.name); } function userHasRole(user, role) { return (user.roles || '').split('|').includes(role); } function roleChecks(id, selected) { el(id).innerHTML = roleNames().map(role => '').join(''); } function selectedRoles() { return [...el('userRoles').querySelectorAll('input:checked')].map(input => input.value); } function showUserOverview() { el('usersOverview').classList.remove('hidden'); el('userDetail').classList.add('hidden'); } function showRoleOverview() { el('rolesOverview').classList.remove('hidden'); el('roleDetail').classList.add('hidden'); } function showApiOverview() { el('apisOverview').classList.remove('hidden'); el('apiDetail').classList.add('hidden'); } function renderRoles() { el('rolesOut').innerHTML = roles.map((role, index) => '').join(''); } function renderRoleUsage(role) { const roleUsers = users.filter(user => userHasRole(user, role.name)).sort((a, b) => a.username.localeCompare(b.username)); const roleApis = apis.filter(api => api.role == role.name).sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method)); const usersHtml = roleUsers.length ? roleUsers.map(user => '
' + esc(user.username) + '' + (user.active ? 'Active' : 'Inactive') + '
').join('') : '
No users
'; const apisHtml = roleApis.length ? roleApis.map(api => '
' + esc(api.method) + '' + esc(api.path) + '
').join('') : '
No API endpoints
'; el('roleUsage').innerHTML = '

Users

' + usersHtml + '

API endpoints

' + apisHtml + '
'; } async function loadRoles() { const json = JSON.parse(await req('GET', '/api/roles')); if (!json.success) return; roles = json.roles; renderRoles(); roleOptions(); if (selectedUser && !el('userDetail').classList.contains('hidden')) selectUser(selectedUser); } function newRoleRecord() { selectedRole = ''; el('rolesOverview').classList.add('hidden'); el('roleDetail').classList.remove('hidden'); el('roleTitle').textContent = 'New role'; el('roleName').value = ''; el('roleName').readOnly = false; el('roleMeta').innerHTML = ''; el('roleUsage').innerHTML = ''; el('roleSave').classList.remove('hidden'); el('roleDelete').classList.add('hidden'); el('roleMsg').textContent = ''; } function selectRole(index) { const role = roles[index]; if (!role) return; selectedRole = role.name; el('rolesOverview').classList.add('hidden'); el('roleDetail').classList.remove('hidden'); el('roleTitle').textContent = role.name; el('roleName').value = role.name; el('roleName').readOnly = true; el('roleMeta').innerHTML = '' + (role.system ? 'System role' : 'Custom role') + ''; renderRoleUsage(role); el('roleSave').classList.add('hidden'); el('roleDelete').classList.toggle('hidden', role.system); el('roleMsg').textContent = ''; } async function addRole() { const text = await req('POST', '/api/roles', {role: el('roleName').value}); el('roleMsg').textContent = text; await loadAccess(); showRoleOverview(); } async function deleteSelectedRole() { if (!selectedRole) return; const text = await req('DELETE', '/api/roles', {role: selectedRole}); el('roleMsg').textContent = text; selectedRole = ''; await loadAccess(); showRoleOverview(); } function renderUsers() { el('userList').innerHTML = users.map(user => '').join(''); } async function loadUsers() { const json = JSON.parse(await req('GET', '/api/users')); if (!json.success) return; users = json.users; renderUsers(); if (selectedUser && !el('userDetail').classList.contains('hidden')) selectUser(selectedUser); } function newUserRecord() { selectedUser = ''; el('usersOverview').classList.add('hidden'); el('userDetail').classList.remove('hidden'); el('userTitle').textContent = 'New user'; el('userName').value = ''; el('userName').readOnly = false; el('userPass').value = ''; el('userActive').checked = true; roleChecks('userRoles', []); el('usersOut').textContent = ''; } function selectUser(name) { selectedUser = name; const user = users.find(item => item.username == name); if (!user) return; el('usersOverview').classList.add('hidden'); el('userDetail').classList.remove('hidden'); el('userTitle').textContent = name; el('userName').value = name; el('userName').readOnly = true; el('userPass').value = ''; el('userActive').checked = user.active; roleChecks('userRoles', (user.roles || '').split('|')); el('usersOut').textContent = ''; } async function saveSelectedUser() { const text = await req('POST', '/api/users', {username: el('userName').value, password: el('userPass').value, active: el('userActive').checked, roles: selectedRoles()}); el('usersOut').textContent = text; selectedUser = el('userName').value; await loadUsers(); showUserOverview(); } function roleOptions() { const options = '' + roleNames().map(role => '').join(''); el('apiRole').innerHTML = options; } function renderApis() { const groups = new Map(); apis.forEach((api, index) => { if (!groups.has(api.path)) groups.set(api.path, []); groups.get(api.path).push({...api, index}); }); const methodOrder = {GET: 1, POST: 2, PUT: 3, PATCH: 4, DELETE: 5}; el('apiList').innerHTML = [...groups.entries()] .sort(([a], [b]) => a.localeCompare(b)) .map(([path, items]) => { items.sort((a, b) => (methodOrder[a.method] || 99) - (methodOrder[b.method] || 99) || a.method.localeCompare(b.method)); const verbs = items.map(api => '').join(''); return '
' + esc(path) + '
' + verbs + '
'; }) .join(''); } async function loadApis() { const json = JSON.parse(await req('GET', '/api/apis')); if (!json.success) return; apis = json.apis; renderApis(); if (selectedApi >= 0 && !el('apiDetail').classList.contains('hidden')) selectApi(selectedApi); } function selectApi(index) { selectedApi = index; const api = apis[index]; if (!api) return; el('apisOverview').classList.add('hidden'); el('apiDetail').classList.remove('hidden'); el('apiTitle').textContent = api.method + ' ' + api.path; el('apiPath').value = api.path; el('apiMethod').value = api.method; roleOptions(); el('apiRole').value = api.role; el('apisOut').textContent = ''; el('apiTestOut').textContent = ''; } async function saveSelectedApi() { if (selectedApi < 0) return; const text = await req('POST', '/api/apis', {path: el('apiPath').value, method: el('apiMethod').value, role: el('apiRole').value}); el('apisOut').textContent = text; await loadApis(); } function clearApiTestBody() { el('apiTestBody').value = ''; } async function testSelectedApi() { if (selectedApi < 0) return; const api = apis[selectedApi]; const body = el('apiTestBody').value.trim(); let payload; if (body) { try { payload = JSON.parse(body); } catch (error) { el('apiTestOut').textContent = 'Invalid JSON body'; return; } } el('apiTestOut').textContent = await req(api.method, api.path, payload); } async function loadAccess() { await loadRoles(); await loadUsers(); await loadApis(); } function fileName(path) { const index = String(path).lastIndexOf('/'); return index >= 0 ? path.substring(index + 1) : path; } function fileIcon(file) { if (file.directory) return ''; return ''; } function fileSize(file) { if (file.directory) return 'Folder'; return file.size + ' B'; } function parentPath(path) { if (!path || path == '/') return '/'; const end = path.lastIndexOf('/'); return end <= 0 ? '/' : path.substring(0, end); } function renderFiles(files) { el('fileList').classList.add('fileList'); files.sort((a, b) => Number(b.directory) - Number(a.directory) || a.path.localeCompare(b.path)); const rows = currentFilePath == '/' ? [] : [{name: '..', path: parentPath(currentFilePath), directory: true, parent: true}]; rows.push(...files); el('fileList').innerHTML = rows.map(file => '
' + fileIcon(file) + '
' + esc(file.name || file.path) + '
' + (file.parent ? '' : fileSize(file)) + '
').join(''); } async function loadFiles(path = currentFilePath) { const text = await req('GET', '/api/files?path=' + encodeURIComponent(path)); el('filesOut').textContent = ''; try { const json = JSON.parse(text); if (json.success) { currentFilePath = json.path || path || '/'; filesLoaded = true; el('filesPath').textContent = currentFilePath; renderFiles(json.files || []); if (!(json.files || []).length) el('filesOut').textContent = 'No files found'; } else { el('filesOut').textContent = text; } } catch (error) { el('filesOut').textContent = text; } } async function downloadFile(path) { const response = await apiFetch('/api/files/download?path=' + encodeURIComponent(path), {headers: {Authorization: 'Bearer ' + token}}); if (!response.ok) { el('filesOut').textContent = await response.text(); return; } const blob = await response.blob(); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = fileName(path) || 'download'; document.body.appendChild(link); link.click(); URL.revokeObjectURL(link.href); link.remove(); } async function downloadConfig() { const response = await apiFetch('/api/config/export', {headers: {Authorization: 'Bearer ' + token}}); if (!response.ok) { el('maintOut').textContent = await response.text(); return; } const blob = await response.blob(); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = 'config-backup.json'; document.body.appendChild(link); link.click(); URL.revokeObjectURL(link.href); link.remove(); el('maintOut').textContent = 'Configuration backup downloaded'; } async function restoreConfig() { if (!el('configFile').files.length) { el('maintOut').textContent = 'Select a configuration JSON file first'; return; } if (!confirm('Restore configuration from this file? WiFi settings will not change.')) return; setBusy('restoreConfigBtn', true, 'Restoring'); try { await tracked(async () => { const body = await el('configFile').files[0].text(); const response = await fetch('/api/config/import', {method: 'POST', headers: {'Content-Type': 'application/json', Authorization: 'Bearer ' + token}, body}); el('maintOut').textContent = await response.text(); if (response.ok) { loadSettings(); loadAccess(); } }); } catch (error) { el('maintOut').textContent = 'Restore failed: ' + error.message; } finally { setBusy('restoreConfigBtn', false, 'Restore config'); } } async function loadCert() { const text = await req('GET', '/api/cert'); try { const json = JSON.parse(text); el('certOut').textContent = json.success ? (json.certificate || 'No certificate stored') : text; } catch (error) { el('certOut').textContent = text; } } async function saveCertFile() { if (!el('certFile').files.length) { el('certOut').textContent = 'Select a PEM/CER/CRT file first'; return; } setBusy('certSaveBtn', true, 'Saving'); try { await tracked(async () => { const certificate = await el('certFile').files[0].text(); el('certOut').textContent = await req('POST', '/api/cert', {certificate}); }); } catch (error) { el('certOut').textContent = 'Certificate save failed: ' + error.message; } finally { setBusy('certSaveBtn', false, 'Save certificate'); } } async function factoryResetDevice() { if (!confirm('Factory reset this device and restart?')) return; el('maintOut').textContent = await req('POST', '/api/factory-reset'); } async function restartDevice() { if (!confirm('Restart this device now?')) return; setBusy('restartDevice', true, 'Restarting'); try { el('maintOut').textContent = await req('POST', '/api/restart'); } finally { setBusy('restartDevice', false, 'Restart'); } } async function loadLogs() { const text = await req('GET', '/api/logs'); try { const json = JSON.parse(text); el('logsOut').textContent = json.success ? (json.logs || '') : text; } catch (error) { el('logsOut').textContent = text; } } async function clearLogs() { const text = await req('POST', '/api/logs/clear'); el('logsOut').textContent = text; try { if (JSON.parse(text).success) el('logsOut').textContent = 'Logs cleared'; } catch (error) {} } function syncNetworkMode() { const staticMode = el('netDhcp').value == 'false'; ['netIp', 'netGateway', 'netSubnet', 'netDns1', 'netDns2'].forEach(id => el(id).disabled = !staticMode); } async function loadSettings() { const text = await req('GET', '/api/settings'); el('settingsOut').textContent = text; const json = JSON.parse(text); if (!json.success) return; const settings = json.settings; setProjectName(settings.projectName); el('logLevel').value = settings.logLevel; el('logMax').value = settings.maxLogBytes; el('upd').value = settings.updateUrl; window.dispatchEvent(new CustomEvent('app:settings-loaded', {detail: {settings}})); } async function loadNetworkSettings() { const text = await req('GET', '/api/network'); el('networkOut').textContent = text; const json = JSON.parse(text); if (!json.success) return; const settings = json.settings; setProjectName(settings.projectName); el('netHost').value = settings.hostname || ''; el('netHost').placeholder = settings.defaultHostname || 'Default host name'; el('netDhcp').value = String(settings.dhcp !== false); el('netIp').value = settings.ip || ''; el('netGateway').value = settings.gateway || ''; el('netSubnet').value = settings.subnet || ''; el('netDns1').value = settings.dns1 || ''; el('netDns2').value = settings.dns2 || ''; el('netCurrent').textContent = 'Current IP: ' + (settings.currentIp || 'not connected'); syncNetworkMode(); } async function saveSettings() { el('settingsOut').textContent = await req('POST', '/api/settings', {logLevel: +el('logLevel').value, maxLogBytes: +el('logMax').value, updateUrl: el('upd').value}); } async function saveNetwork() { setBusy('networkSaveBtn', true, 'Saving'); try { const body = {hostname: el('netHost').value, dhcp: el('netDhcp').value == 'true', ip: el('netIp').value, gateway: el('netGateway').value, subnet: el('netSubnet').value, dns1: el('netDns1').value, dns2: el('netDns2').value}; const text = await req('POST', '/api/network', body); el('networkOut').textContent = text + '\nNetwork changes apply on the next WiFi reconnect or reboot.'; if (JSON.parse(text).success) await loadNetworkSettings(); } catch (error) { el('networkOut').textContent = 'Network save failed: ' + error.message; } finally { setBusy('networkSaveBtn', false, 'Save network'); } } function setBusy(id, busy, label) { const button = el(id); if (!button) return; button.disabled = busy; button.innerHTML = busy ? '' + label : label; } async function uploadPkg() { if (!el('pkg').files.length) { el('fwOut').textContent = 'Select an update package first'; return; } setBusy('uploadPkgBtn', true, 'Uploading'); el('fwOut').textContent = 'Uploading update package...'; try { const formData = new FormData(); formData.append('package', el('pkg').files[0]); const response = await apiFetch('/api/update', {method: 'POST', headers: {Authorization: 'Bearer ' + token}, body: formData}); el('fwOut').textContent = await response.text(); } catch (error) { el('fwOut').textContent = 'Upload failed: ' + error.message; } finally { setBusy('uploadPkgBtn', false, 'Upload package'); } } async function checkOta() { el('fwOut').textContent = await req('POST', '/api/ota/check'); } async function runOta() { el('fwOut').textContent = await req('POST', '/api/ota/run'); } function bindEvents() { el('loginBtn').addEventListener('click', doLogin); el('logoutBtn').addEventListener('click', doLogout); document.querySelectorAll('.tab[data-tab]').forEach(button => button.addEventListener('click', () => showTab(button.dataset.tab))); document.querySelectorAll('.subtab').forEach(button => button.addEventListener('click', () => showAccessTab(button.dataset.subtab))); el('userNew').addEventListener('click', newUserRecord); el('userBack').addEventListener('click', showUserOverview); el('userSave').addEventListener('click', saveSelectedUser); el('roleNew').addEventListener('click', newRoleRecord); el('roleBack').addEventListener('click', showRoleOverview); el('roleSave').addEventListener('click', addRole); el('roleDelete').addEventListener('click', deleteSelectedRole); el('apiBack').addEventListener('click', showApiOverview); el('apiSave').addEventListener('click', saveSelectedApi); el('apiTest').addEventListener('click', testSelectedApi); el('apiTestClear').addEventListener('click', clearApiTestBody); el('rolesOut').addEventListener('click', event => { const button = event.target.closest('[data-role-index]'); if (button) selectRole(+button.dataset.roleIndex); }); el('userList').addEventListener('click', event => { const button = event.target.closest('[data-user]'); if (button) selectUser(button.dataset.user); }); el('apiList').addEventListener('click', event => { const button = event.target.closest('[data-api-index]'); if (button) selectApi(+button.dataset.apiIndex); }); el('netDhcp').addEventListener('change', syncNetworkMode); el('networkSaveBtn').addEventListener('click', saveNetwork); el('certSaveBtn').addEventListener('click', saveCertFile); el('certLoad').addEventListener('click', loadCert); el('settingsSave').addEventListener('click', saveSettings); el('uploadPkgBtn').addEventListener('click', uploadPkg); el('otaCheck').addEventListener('click', checkOta); el('otaRun').addEventListener('click', runOta); el('configDownload').addEventListener('click', downloadConfig); el('restoreConfigBtn').addEventListener('click', restoreConfig); el('restartDevice').addEventListener('click', restartDevice); el('factoryReset').addEventListener('click', factoryResetDevice); el('fileList').addEventListener('click', event => { const button = event.target.closest('[data-download]'); if (button) { downloadFile(button.dataset.download); return; } const directory = event.target.closest('.fileRow[data-path]'); if (directory) loadFiles(directory.dataset.path); }); el('logsLoad').addEventListener('click', loadLogs); el('logsClear').addEventListener('click', clearLogs); } loadAppInfo(); bindEvents();