/* SPDX-License-Identifier: GPL-3.0-only */ /* Offline browser UI response helpers for public and session-authenticated HTTPS routes. */ #include "web_ui.h" #include #include #include #include "web_assets_data.h" #define WEB_UI_DOCUMENT_CACHE_CONTROL "no-store" #define WEB_UI_AUTHORED_ASSET_CACHE_CONTROL "private, max-age=300" #define WEB_UI_ASSET_CACHE_CONTROL "private, max-age=604800" static const char s_index_html[] = "\n" "\n" "\n" "\n" "\n" "\n" "ESP32 Serial Console\n" "\n" "\n" "\n" "\n" "\n" "\n" "\n" "\n" "
\n" "
\n" "
" "

ESP32 Serial Console

Secure local serial workspace

\n" "
\n" "
@" "Loading…" "Account
\n" "Connecting\n" "\n" "\n" "
\n" "
\n" "
\n" "
\n" "
Broker role" "Observer
\n" "
Broker client" "
\n" "
Active writer" "None
\n" "
Broker clients" "
\n" "
Wi-Fi" "Loading…
\n" "
Serial" "Loading…
\n" "
\n" "
\n" "
\n" "\n" "\n" "\n" "
\n" "

" "Observer mode — terminal input is disabled.

\n" "

" "Requesting a one-time connection ticket…

\n" "

\n" "
\n" "
\n" "
\n" "
" "Live serial stream" "
\n" "Binary, unmodified device output
\n" "
\n" "\n" "
\n" "\n" "\n" "
\n" "\n" "\n"; #define WEB_UI_LOGIN_PREFIX \ "\n" \ "\n" \ "\n" \ "\n" \ "\n" \ "\n" \ "Sign in · ESP32 Serial Console\n" \ "\n" \ "\n" \ "\n" \ "\n" \ "
\n" \ "
ESP32 Serial Console" \ "Secure local serial workspace
\n" \ "
\n" \ "

Device access

\n" \ "

Sign in

\n" \ "

Use your local device account to continue.

\n" #define WEB_UI_LOGIN_FORM \ "
\n" \ "\n" \ "\n" \ "\n" \ "
\n" \ "
\n" \ "

Credentials are sent only to this device over HTTPS.

\n" \ "
\n" \ "\n" \ "\n" static const char s_login_html[] = WEB_UI_LOGIN_PREFIX WEB_UI_LOGIN_FORM; static const char s_login_error_html[] = WEB_UI_LOGIN_PREFIX "
Sign-in failed." "The username or password was not accepted. Try again.
\n" WEB_UI_LOGIN_FORM; #undef WEB_UI_LOGIN_FORM #undef WEB_UI_LOGIN_PREFIX static const char s_app_js[] = "(() => {\n" "'use strict';\n" "const element = (id) => document.getElementById(id);\n" "const connectionStatus = element('connection-status');\n" "const accountUsernameField = element('account-username');\n" "const accountRoleField = element('account-role');\n" "const logoutButton = element('logout');\n" "const settingsOpen = element('settings-open');\n" "const settingsBackdrop = element('settings-backdrop');\n" "const settingsClose = element('settings-close');\n" "const settingsMessage = element('settings-message');\n" "const settingsAdminShell = element('settings-admin-shell');\n" "const serialSettingsForm = element('serial-settings-form');\n" "const serialBaud = element('serial-baud');\n" "const serialData = element('serial-data');\n" "const serialParity = element('serial-parity');\n" "const serialStopBits = element('serial-stop-bits');\n" "const serialFlow = element('serial-flow');\n" "const serialDtr = element('serial-dtr');\n" "const serialRts = element('serial-rts');\n" "const userCreateForm = element('user-create-form');\n" "const userCreateName = element('user-create-name');\n" "const userCreateRole = element('user-create-role');\n" "const userCreatePasswordMode = element('user-create-password-mode');\n" "const userCreatePassword = element('user-create-password');\n" "const userEditForm = element('user-edit-form');\n" "const userSelect = element('user-select');\n" "const userRole = element('user-role');\n" "const userIdentity = element('user-identity');\n" "const userPassword = element('user-password');\n" "const userKey = element('user-key');\n" "const userKeys = element('user-keys');\n" "const generatedPasswordBox = element('generated-password-box');\n" "const generatedPassword = element('generated-password');\n" "const wifiProfileForm = element('wifi-profile-form');\n" "const wifiProfileSlot = element('wifi-profile-slot');\n" "const wifiProfileSsid = element('wifi-profile-ssid');\n" "const wifiProfilePriority = element('wifi-profile-priority');\n" "const wifiProfileSecurity = element('wifi-profile-security');\n" "const wifiProfileStatus = element('wifi-profile-status');\n" "const wifiProfileSecret = element('wifi-profile-secret');\n" "const wifiProfileSecretState = element('wifi-profile-secret-state');\n" "const wifiProfileToggle = element('wifi-profile-toggle');\n" "const wifiApForm = element('wifi-ap-form');\n" "const wifiApPolicy = element('wifi-ap-policy');\n" "const wifiApSsid = element('wifi-ap-ssid');\n" "const wifiApChannel = element('wifi-ap-channel');\n" "const wifiApSecret = element('wifi-ap-secret');\n" "const wifiApSecretState = element('wifi-ap-secret-state');\n" "const displayForm = element('display-form');\n" "const displayDim = element('display-dim');\n" "const displayOff = element('display-off');\n" "const displayState = element('display-state');\n" "const roleStatus = element('role-status');\n" "const clientIdField = element('client-id');\n" "const writerIdField = element('writer-id');\n" "const brokerClientsField = element('broker-clients');\n" "const writerCard = element('writer-card');\n" "const clientsCard = element('clients-card');\n" "const wifiCard = element('wifi-card');\n" "const serialCard = element('serial-card');\n" "const quickPopover = element('quick-popover');\n" "const popoverTitle = element('popover-title');\n" "const popoverContent = element('popover-content');\n" "const popoverClose = element('popover-close');\n" "const wifiSummary = element('wifi-summary');\n" "const serialSummary = element('serial-summary');\n" "const inputState = element('input-state');\n" "const connectionDetail = element('connection-detail');\n" "const sessionNotice = element('session-notice');\n" "const requestControl = element('request-control');\n" "const releaseControl = element('release-control');\n" "const connectionToggle = element('connection-toggle');\n" "const terminalTitle = element('terminal-title');\n" "const terminalDetail = element('terminal-detail');\n" "const terminalModes = element('terminal-modes');\n" "const serialModeButton = element('serial-mode');\n" "const adminModeButton = element('admin-mode');\n" "const serialTerminalHost = element('serial-terminal');\n" "const adminTerminalHost = element('admin-terminal');\n" "const terminalOptions = {\n" " allowProposedApi: false, convertEol: false, cursorBlink: true, disableStdin: true,\n" " fontFamily: '\"SFMono-Regular\",Consolas,\"Liberation Mono\",monospace',\n" " fontSize: 14, scrollback: 5000, theme: {\n" " background: '#080c12', foreground: '#dce7f5', cursor: '#55c2ff',\n" " selectionBackground: '#285173', black: '#18202b', red: '#ff6b7a',\n" " green: '#52d68b', yellow: '#ffc857', blue: '#55aaff',\n" " magenta: '#c792ea', cyan: '#55d6be', white: '#e8eef8'\n" " }\n" "};\n" "const serialTerminal = new Terminal(terminalOptions);\n" "const adminTerminal = new Terminal({...terminalOptions, disableStdin: false});\n" "const serialFitAddon = new FitAddon.FitAddon();\n" "const adminFitAddon = new FitAddon.FitAddon();\n" "serialTerminal.loadAddon(serialFitAddon);\n" "adminTerminal.loadAddon(adminFitAddon);\n" "serialTerminal.open(serialTerminalHost);\n" "adminTerminal.open(adminTerminalHost);\n" "const encoder = new TextEncoder();\n" "let socket = null;\n" "let adminSocket = null;\n" "let adminTicketAbort = null;\n" "let adminConnectionGeneration = 0;\n" "let terminalMode = 'serial';\n" "let sessionAbort = null;\n" "let ticketAbort = null;\n" "let reconnectTimer = null;\n" "let reconnectDelay = 1000;\n" "let reconnectEnabled = true;\n" "let connectionGeneration = 0;\n" "let brokerRole = 'observer';\n" "let accountUsername = null;\n" "let accountRole = null;\n" "let csrfToken = null;\n" "let sessionReady = false;\n" "let startupInFlight = false;\n" "let authRedirecting = false;\n" "let clientId = null;\n" "let writerId = 0;\n" "let unloading = false;\n" "let fitFrame = 0;\n" "let serialLastFitWidth = 0;\n" "let serialLastFitHeight = 0;\n" "let adminLastFitWidth = 0;\n" "let adminLastFitHeight = 0;\n" "let statusInFlight = false;\n" "let statusTimer = null;\n" "let popoverTrigger = null;\n" "let popoverGeneration = 0;\n" "let usersState = null;\n" "let wifiConfigState = null;\n" "const setBadge = (target, text, tone) => {\n" " target.textContent = text;\n" " target.dataset.tone = tone;\n" "};\n" "const validId = (value) => Number.isSafeInteger(value) && value >= 0;\n" "const displayId = (value, noneText) => validId(value) && value !== 0 ? String(value) : noneText;\n" "const socketOpen = () => socket !== null && socket.readyState === WebSocket.OPEN;\n" "const adminSocketOpen = () => adminSocket !== null && adminSocket.readyState === WebSocket.OPEN;\n" "const updateControls = () => {\n" " const writer = brokerRole === 'writer';\n" " serialTerminal.options.disableStdin = !writer;\n" " requestControl.disabled = !socketOpen() || writer;\n" " releaseControl.disabled = !socketOpen() || !writer;\n" " const connectionActive = reconnectEnabled || socket !== null || ticketAbort !== null || reconnectTimer !== null;\n" " connectionToggle.disabled = unloading || authRedirecting || startupInFlight;\n" " logoutButton.disabled = unloading || authRedirecting || !sessionReady;\n" " connectionToggle.textContent = connectionActive ? 'Disconnect serial' : 'Connect serial';\n" " connectionToggle.classList.toggle('danger', connectionActive);\n" " inputState.dataset.enabled = writer ? 'true' : 'false';\n" " inputState.textContent = writer\n" " ? 'Writer mode — terminal input is enabled.'\n" " : 'Observer mode — terminal input is disabled.';\n" " setBadge(roleStatus, writer ? 'Writer' : 'Observer', writer ? 'good' : 'warn');\n" "};\n" "const setBrokerRole = (nextRole) => {\n" " brokerRole = nextRole === 'writer' ? 'writer' : 'observer';\n" " updateControls();\n" "};\n" "const setConnection = (text, tone, detail) => {\n" " setBadge(connectionStatus, text, tone);\n" " connectionDetail.textContent = detail;\n" " updateControls();\n" "};\n" "const setSessionNotice = (message) => {\n" " const visible = typeof message === 'string' && message.length > 0;\n" " sessionNotice.textContent = visible ? message : '';\n" " sessionNotice.dataset.visible = visible ? 'true' : 'false';\n" "};\n" "const setTerminalMode = (nextMode) => {\n" " const admin = nextMode === 'admin' && accountRole === 'admin';\n" " terminalMode = admin ? 'admin' : 'serial';\n" " serialTerminalHost.hidden = admin;\n" " adminTerminalHost.hidden = !admin;\n" " serialModeButton.setAttribute('aria-selected', admin ? 'false' : 'true');\n" " adminModeButton.setAttribute('aria-selected', admin ? 'true' : 'false');\n" " terminalTitle.textContent = admin ? 'Administrative shell' : 'Live serial stream';\n" " terminalDetail.textContent = admin\n" " ? 'Canonical device administration · serial connection and writer lease remain active'\n" " : 'Binary, unmodified device output';\n" " if (admin) {\n" " adminLastFitWidth = 0; adminLastFitHeight = 0;\n" " if (!adminSocketOpen() && adminTicketAbort === null) connectAdmin();\n" " adminTerminal.focus();\n" " } else {\n" " serialLastFitWidth = 0; serialLastFitHeight = 0;\n" " serialTerminal.focus();\n" " }\n" " scheduleFit();\n" "};\n" "const clearReconnectTimer = () => {\n" " if (reconnectTimer !== null) {\n" " window.clearTimeout(reconnectTimer);\n" " reconnectTimer = null;\n" " }\n" "};\n" "const stopAuthenticatedActivity = () => {\n" " reconnectEnabled = false;\n" " sessionReady = false;\n" " ++connectionGeneration;\n" " ++adminConnectionGeneration;\n" " clearReconnectTimer();\n" " if (statusTimer !== null) { window.clearInterval(statusTimer); statusTimer = null; }\n" " if (sessionAbort !== null) { sessionAbort.abort(); sessionAbort = null; }\n" " if (ticketAbort !== null) { ticketAbort.abort(); ticketAbort = null; }\n" " if (adminTicketAbort !== null) { adminTicketAbort.abort(); adminTicketAbort = null; }\n" " if (socket !== null) { const previous = socket; socket = null; previous.close(); }\n" " if (adminSocket !== null) { const previous = adminSocket; adminSocket = null; previous.close(); }\n" " clientId = null;\n" " clientIdField.textContent = '—';\n" " usersState = null;\n" " wifiConfigState = null;\n" " clearGeneratedPassword();\n" " setBrokerRole('observer');\n" " updateControls();\n" "};\n" "const navigateToLogin = () => {\n" " if (authRedirecting || unloading) return;\n" " authRedirecting = true;\n" " stopAuthenticatedActivity();\n" " csrfToken = null;\n" " accountUsername = null;\n" " accountRole = null;\n" " window.location.replace('/login');\n" "};\n" "const scheduleReconnect = () => {\n" " if (unloading || authRedirecting || !sessionReady || !reconnectEnabled || reconnectTimer !== null) return;\n" " const delay = reconnectDelay;\n" " reconnectDelay = Math.min(reconnectDelay * 2, 10000);\n" " setConnection('Disconnected', 'bad', `Reconnecting in ${Math.ceil(delay / 1000)} second(s)…`);\n" " reconnectTimer = window.setTimeout(() => {\n" " reconnectTimer = null;\n" " connect();\n" " }, delay);\n" "};\n" "const acceptBrokerMessage = (message) => {\n" " if (message === null || typeof message !== 'object') return;\n" " if (message.type !== 'hello' && message.type !== 'writer') return;\n" " if (message.role !== 'writer' && message.role !== 'observer') return;\n" " if (!validId(message.writerId)) return;\n" " if (message.type === 'hello') {\n" " if (!validId(message.clientId) || message.clientId === 0) return;\n" " clientId = message.clientId;\n" " clientIdField.textContent = String(clientId);\n" " reconnectDelay = 1000;\n" " }\n" " writerId = message.writerId;\n" " writerIdField.textContent = displayId(writerId, 'None');\n" " setBrokerRole(message.role);\n" "};\n" "const handleSocketMessage = (event) => {\n" " if (typeof event.data === 'string') {\n" " try {\n" " acceptBrokerMessage(JSON.parse(event.data));\n" " } catch (_) {\n" " setConnection('Protocol error', 'bad', 'The device sent an invalid control message.');\n" " }\n" " return;\n" " }\n" " if (event.data instanceof ArrayBuffer) {\n" " serialTerminal.write(new Uint8Array(event.data));\n" " }\n" "};\n" "async function requestTicket(signal) {\n" " if (!sessionReady || typeof csrfToken !== 'string') throw new Error('session unavailable');\n" " const response = await fetch('/api/ws-ticket', {\n" " method: 'POST', credentials: 'same-origin', cache: 'no-store', signal,\n" " headers: {'X-CSRF-Token': csrfToken}\n" " });\n" " if (response.status === 401) {\n" " navigateToLogin();\n" " throw new Error('session expired');\n" " }\n" " if (!response.ok) throw new Error('ticket request failed');\n" " const payload = await response.json();\n" " if (payload === null || typeof payload !== 'object' ||\n" " typeof payload.ticket !== 'string' || !/^[A-Za-z0-9_-]{32}$/.test(payload.ticket)) {\n" " throw new Error('invalid ticket response');\n" " }\n" " const ticket = payload.ticket;\n" " payload.ticket = '';\n" " return ticket;\n" "}\n" "async function connect() {\n" " if (unloading || authRedirecting || !sessionReady || !reconnectEnabled) return;\n" " clearReconnectTimer();\n" " const generation = ++connectionGeneration;\n" " if (ticketAbort !== null) ticketAbort.abort();\n" " ticketAbort = new AbortController();\n" " if (socket !== null) {\n" " const previous = socket;\n" " socket = null;\n" " previous.close();\n" " }\n" " clientId = null;\n" " clientIdField.textContent = '—';\n" " setBrokerRole('observer');\n" " setConnection('Connecting', 'warn', 'Requesting a one-time connection ticket…');\n" " try {\n" " const ticket = await requestTicket(ticketAbort.signal);\n" " if (unloading || generation !== connectionGeneration) return;\n" " ticketAbort = null;\n" " const url = new URL('/ws/serial', window.location.origin);\n" " url.protocol = 'wss:';\n" " url.searchParams.set('ticket', ticket);\n" " const nextSocket = new WebSocket(url.toString());\n" " url.search = '';\n" " nextSocket.binaryType = 'arraybuffer';\n" " socket = nextSocket;\n" " nextSocket.addEventListener('open', () => {\n" " if (socket !== nextSocket) return;\n" " setConnection('Connected', 'good', 'Connected; waiting for broker role information.');\n" " });\n" " nextSocket.addEventListener('message', (event) => {\n" " if (socket === nextSocket) handleSocketMessage(event);\n" " });\n" " nextSocket.addEventListener('error', () => {\n" " if (socket === nextSocket) {\n" " setConnection('Connection error', 'bad', 'The WebSocket connection failed.');\n" " }\n" " });\n" " nextSocket.addEventListener('close', () => {\n" " if (socket !== nextSocket) return;\n" " socket = null;\n" " clientId = null;\n" " clientIdField.textContent = '—';\n" " setBrokerRole('observer');\n" " scheduleReconnect();\n" " });\n" " } catch (error) {\n" " if (generation !== connectionGeneration || unloading || authRedirecting || error.name === 'AbortError') return;\n" " ticketAbort = null;\n" " scheduleReconnect();\n" " }\n" "}\n" "async function requestAdminTicket(signal) {\n" " if (!sessionReady || accountRole !== 'admin' || typeof csrfToken !== 'string') throw new Error('admin session unavailable');\n" " const response = await fetch('/api/admin/ws-ticket', {\n" " method: 'POST', credentials: 'same-origin', cache: 'no-store', signal,\n" " headers: {'X-CSRF-Token': csrfToken}\n" " });\n" " if (response.status === 401) { navigateToLogin(); throw new Error('session expired'); }\n" " if (response.status === 403) throw new Error('administrator access required');\n" " if (!response.ok) throw new Error('admin ticket request failed');\n" " const payload = await response.json();\n" " if (payload === null || typeof payload !== 'object' ||\n" " typeof payload.ticket !== 'string' || !/^[A-Za-z0-9_-]{32}$/.test(payload.ticket)) {\n" " throw new Error('invalid admin ticket response');\n" " }\n" " const ticket = payload.ticket;\n" " payload.ticket = '';\n" " return ticket;\n" "}\n" "async function connectAdmin() {\n" " if (unloading || authRedirecting || !sessionReady || accountRole !== 'admin' ||\n" " adminSocket !== null || adminTicketAbort !== null) return;\n" " const generation = ++adminConnectionGeneration;\n" " const controller = new AbortController();\n" " adminTicketAbort = controller;\n" " adminTerminal.write('\\r\\n[Opening administrative shell…]\\r\\n');\n" " try {\n" " const ticket = await requestAdminTicket(controller.signal);\n" " if (unloading || authRedirecting || generation !== adminConnectionGeneration) return;\n" " adminTicketAbort = null;\n" " const url = new URL('/ws/admin', window.location.origin);\n" " url.protocol = 'wss:';\n" " url.searchParams.set('ticket', ticket);\n" " const nextSocket = new WebSocket(url.toString());\n" " url.search = '';\n" " nextSocket.binaryType = 'arraybuffer';\n" " adminSocket = nextSocket;\n" " nextSocket.addEventListener('open', () => {\n" " if (adminSocket === nextSocket && terminalMode === 'admin') adminTerminal.focus();\n" " });\n" " nextSocket.addEventListener('message', (event) => {\n" " if (adminSocket === nextSocket && event.data instanceof ArrayBuffer) {\n" " adminTerminal.write(new Uint8Array(event.data));\n" " }\n" " });\n" " nextSocket.addEventListener('error', () => {\n" " if (adminSocket === nextSocket) adminTerminal.write('\\r\\n[Administrative shell connection error.]\\r\\n');\n" " });\n" " nextSocket.addEventListener('close', () => {\n" " if (adminSocket !== nextSocket) return;\n" " adminSocket = null;\n" " adminTerminal.write('\\r\\n[Administrative shell closed. Select Admin shell to reconnect.]\\r\\n');\n" " });\n" " } catch (error) {\n" " if (generation !== adminConnectionGeneration || unloading || authRedirecting || error.name === 'AbortError') return;\n" " adminTicketAbort = null;\n" " adminTerminal.write(`\\r\\n[Could not open administrative shell: ${error.message}]\\r\\n`);\n" " }\n" "}\n" "async function adminRequest(path, method = 'GET', fields = null) {\n" " if (!sessionReady || accountRole !== 'admin') throw new Error('administrator access required');\n" " const options = {method, credentials: 'same-origin', cache: 'no-store', headers: {}};\n" " if (method === 'POST') {\n" " if (typeof csrfToken !== 'string') throw new Error('session unavailable');\n" " options.headers['X-CSRF-Token'] = csrfToken;\n" " options.headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';\n" " options.body = new URLSearchParams(fields);\n" " }\n" " const response = await fetch(path, options);\n" " if (response.status === 401) { navigateToLogin(); throw new Error('session expired'); }\n" " if (response.status === 403) throw new Error('administrator access required');\n" " if (!response.ok) {\n" " const contentType = response.headers.get('content-type') || '';\n" " let message = 'operation failed'; let code = null;\n" " if (contentType.includes('application/json')) {\n" " try {\n" " const payload = await response.json();\n" " if (typeof payload?.message === 'string' && payload.message.length > 0) message = payload.message;\n" " if (typeof payload?.error === 'string' && /^[a-z_]+$/.test(payload.error)) code = payload.error;\n" " } catch (_) {}\n" " } else {\n" " const text = (await response.text()).trim(); if (text.length > 0) message = text;\n" " }\n" " const error = new Error(message); error.status = response.status; error.code = code;\n" " throw error;\n" " }\n" " return response.headers.get('content-type')?.includes('application/json') ? response.json() : null;\n" "}\n" "const clearGeneratedPassword = () => {\n" " generatedPassword.textContent = '';\n" " generatedPasswordBox.hidden = true;\n" "};\n" "const showGeneratedPassword = (password) => {\n" " clearGeneratedPassword();\n" " generatedPassword.textContent = password;\n" " generatedPasswordBox.hidden = false;\n" "};\n" "const selectedUser = () => {\n" " if (!usersState || !Array.isArray(usersState.users)) return null;\n" " return usersState.users.find((user) => String(user.id) === userSelect.value) || null;\n" "};\n" "const renderSelectedUser = () => {\n" " const user = selectedUser();\n" " const available = user !== null;\n" " userRole.disabled = !available;\n" " userPassword.disabled = !available;\n" " userKey.disabled = !available;\n" " for (const id of ['user-role-apply','user-password-set','user-password-generate','user-key-add','user-key-clear','user-delete']) {\n" " element(id).disabled = !available;\n" " }\n" " userKeys.replaceChildren();\n" " if (!available) {\n" " userIdentity.textContent = '—';\n" " return;\n" " }\n" " userRole.value = user.role;\n" " userIdentity.textContent = `ID ${user.id} · auth generation ${user.auth_generation}`;\n" " element('user-password-generate').disabled = user.username === accountUsername;\n" " const hasKeys = Array.isArray(user.keys) && user.keys.length > 0;\n" " element('user-key-clear').disabled = !hasKeys;\n" " if (!hasKeys) {\n" " const empty = document.createElement('p');\n" " empty.className = 'client-meta';\n" " empty.textContent = 'No authorized SSH public keys.';\n" " userKeys.append(empty);\n" " return;\n" " }\n" " for (const key of user.keys) {\n" " const row = document.createElement('div'); row.className = 'key-row';\n" " const copy = document.createElement('div');\n" " const title = document.createElement('strong'); title.textContent = `${key.type} · slot ${key.index}`;\n" " const fingerprint = document.createElement('div'); fingerprint.className = 'client-meta'; fingerprint.textContent = key.fingerprint;\n" " copy.append(title, fingerprint);\n" " const remove = makeButton('Remove', async () => {\n" " if (!window.confirm(`Remove SSH key slot ${key.index} from ${user.username}?`)) return;\n" " await runSettingsAction(() => userOperation('key-delete', {key_index: String(key.index), confirm: 'true'}, user));\n" " });\n" " row.append(copy, remove); userKeys.append(row);\n" " }\n" "};\n" "async function loadUsers(preferredUserId = null) {\n" " const payload = await adminRequest('/api/admin/users');\n" " if (!payload || !Number.isSafeInteger(payload.generation) || payload.generation < 1 || !Array.isArray(payload.users)) {\n" " throw new Error('invalid user response');\n" " }\n" " const previous = preferredUserId !== null ? String(preferredUserId) : userSelect.value;\n" " usersState = payload;\n" " userSelect.replaceChildren();\n" " for (const user of payload.users) {\n" " if (!validId(user.id) || typeof user.username !== 'string' || (user.role !== 'user' && user.role !== 'admin')) continue;\n" " const option = document.createElement('option'); option.value = String(user.id); option.textContent = `${user.username} (${user.role})`; userSelect.append(option);\n" " }\n" " if ([...userSelect.options].some((option) => option.value === previous)) userSelect.value = previous;\n" " renderSelectedUser();\n" " return payload;\n" "}\n" "const printableAscii = (value, minimum, maximum) => value.length >= minimum && value.length <= maximum &&\n" " [...value].every((character) => character.codePointAt(0) >= 0x20 && character.codePointAt(0) <= 0x7e);\n" "async function userOperation(operation, extra = {}, target = selectedUser()) {\n" " if (!usersState) throw new Error('User list is not loaded.');\n" " const fields = {operation, expected_generation: String(usersState.generation), ...extra};\n" " if (operation !== 'create') {\n" " if (!target) throw new Error('Select an account first.');\n" " fields.expected_user_id = String(target.id);\n" " fields.username = target.username;\n" " }\n" " const clearSubmittedValues = () => {\n" " if ('password' in fields) fields.password = '';\n" " if ('password' in extra) extra.password = '';\n" " userPassword.value = '';\n" " userCreatePassword.value = '';\n" " userKey.value = '';\n" " };\n" " settingsMessage.textContent = `User ${operation} in progress…`;\n" " let result;\n" " try {\n" " result = await adminRequest('/api/admin/users', 'POST', fields);\n" " } catch (error) {\n" " clearSubmittedValues();\n" " clearGeneratedPassword();\n" " if (error.code === 'stale') {\n" " try {\n" " await loadUsers(target?.id ?? null);\n" " } catch (_) {\n" " usersState = null;\n" " userSelect.replaceChildren();\n" " renderSelectedUser();\n" " throw new Error('User database changed and could not be reloaded. Close Settings and try again.');\n" " }\n" " throw new Error('User database changed. The account form was reloaded; re-enter any password and try again.');\n" " }\n" " throw error;\n" " } finally {\n" " clearSubmittedValues();\n" " }\n" " if (typeof result?.generated_password === 'string' && result.generated_password.length > 0) {\n" " showGeneratedPassword(result.generated_password);\n" " result.generated_password = '';\n" " }\n" " const preferred = validId(result?.user_id) ? result.user_id : target?.id ?? null;\n" " await loadUsers(preferred);\n" " settingsMessage.textContent = result?.revocation_complete === false\n" " ? `User ${operation} committed; a transport session will close on its next authorization check.`\n" " : `User ${operation} completed.`;\n" "}\n" "const bytesToBase64 = (bytes) => {\n" " let binary = '';\n" " for (const byte of bytes) binary += String.fromCharCode(byte);\n" " return btoa(binary);\n" "};\n" "const ssidToBase64 = (value) => {\n" " const bytes = encoder.encode(value);\n" " if (bytes.length < 1 || bytes.length > 32) throw new Error('SSID must encode to 1–32 bytes.');\n" " return bytesToBase64(bytes);\n" "};\n" "const ssidFromBase64 = (value) => {\n" " const binary = atob(value);\n" " const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));\n" " return new TextDecoder().decode(bytes);\n" "};\n" "const ssidFieldValue = (input) => input.value === input.dataset.originalValue\n" " ? input.dataset.originalBase64\n" " : ssidToBase64(input.value);\n" "const selectedWifiProfile = () => {\n" " if (!wifiConfigState || !Array.isArray(wifiConfigState.profiles)) return null;\n" " return wifiConfigState.profiles.find((profile) => String(profile.slot) === wifiProfileSlot.value) || null;\n" "};\n" "const fillWifiProfile = () => {\n" " const profile = selectedWifiProfile();\n" " if (!profile) return;\n" " const ssid = ssidFromBase64(profile.ssid_b64);\n" " wifiProfileSsid.value = ssid;\n" " wifiProfileSsid.dataset.originalValue = ssid;\n" " wifiProfileSsid.dataset.originalBase64 = profile.ssid_b64;\n" " wifiProfilePriority.value = String(profile.priority);\n" " wifiProfileSecurity.value = profile.security;\n" " wifiProfileStatus.textContent = `${profile.configured ? 'configured' : 'empty'} · ${profile.enabled ? 'enabled' : 'disabled'}`;\n" " wifiProfileSecretState.textContent = profile.secret_set ? 'A secret is set.' : 'No secret is set.';\n" " wifiProfileToggle.textContent = profile.enabled ? 'Disable' : 'Enable';\n" " wifiProfileToggle.disabled = !profile.enabled && !(profile.configured && profile.secret_set);\n" " element('wifi-profile-secret-set').disabled = !profile.configured;\n" " wifiProfileSecret.value = '';\n" "};\n" "const fillWifiAp = () => {\n" " if (!wifiConfigState?.ap) return;\n" " const ssid = ssidFromBase64(wifiConfigState.ap.ssid_b64);\n" " wifiApSsid.value = ssid;\n" " wifiApSsid.dataset.originalValue = ssid;\n" " wifiApSsid.dataset.originalBase64 = wifiConfigState.ap.ssid_b64;\n" " wifiApPolicy.value = wifiConfigState.ap.policy;\n" " wifiApChannel.value = String(wifiConfigState.ap.channel);\n" " wifiApSecretState.textContent = wifiConfigState.ap.secret_set ? 'A secret is set.' : 'No secret is set.';\n" " wifiApSecret.value = '';\n" "};\n" "async function loadWifiConfig() {\n" " const payload = await adminRequest('/api/admin/wifi-config');\n" " if (!payload || !Number.isSafeInteger(payload.generation) || payload.generation < 1 ||\n" " !payload.ap || !Array.isArray(payload.profiles) || payload.profiles.length !== 4) {\n" " throw new Error('invalid Wi-Fi configuration response');\n" " }\n" " wifiConfigState = payload;\n" " fillWifiProfile();\n" " fillWifiAp();\n" " return payload;\n" "}\n" "async function wifiConfigOperation(operation, extra = {}) {\n" " if (!wifiConfigState) throw new Error('Wi-Fi configuration is not loaded.');\n" " const fields = {operation, expected_generation: String(wifiConfigState.generation), ...extra};\n" " settingsMessage.textContent = `Wi-Fi ${operation} in progress…`;\n" " try {\n" " await adminRequest('/api/admin/wifi-config', 'POST', fields);\n" " wifiProfileSecret.value = '';\n" " wifiApSecret.value = '';\n" " await loadWifiConfig();\n" " settingsMessage.textContent = `Wi-Fi ${operation} completed.`;\n" " window.setTimeout(pollStatus, 300);\n" " } catch (error) {\n" " wifiProfileSecret.value = '';\n" " wifiApSecret.value = '';\n" " if (error.code === 'stale') {\n" " try { await loadWifiConfig(); } catch (_) {}\n" " throw new Error('Wi-Fi configuration changed. The form was reloaded; re-enter any secret and try again.');\n" " }\n" " throw error;\n" " }\n" "}\n" "const fillSerialSettings = (serial) => {\n" " serialBaud.value = String(serial.baud); serialData.value = serial.data;\n" " serialParity.value = serial.parity; serialStopBits.value = serial.stop;\n" " serialFlow.value = serial.flow; serialDtr.value = serial.dtr; serialRts.value = String(serial.rts);\n" "};\n" "async function loadSerialSettings() {\n" " const serial = await adminRequest('/api/admin/serial');\n" " fillSerialSettings(serial);\n" " return serial;\n" "}\n" "const fillDisplaySettings = (display) => {\n" " displayDim.value = String(display.dim_seconds);\n" " displayOff.value = String(display.off_seconds);\n" " displayState.textContent = display.service_available\n" " ? `${display.initialized ? 'OLED initialized' : 'OLED unavailable'} · bus ${display.bus_ready ? 'ready' : 'unavailable'} · address 0x${Number(display.address).toString(16).padStart(2, '0')} · contrast ${display.contrast}`\n" " : 'Local OLED status unavailable.';\n" "};\n" "async function loadDisplaySettings() {\n" " const display = await adminRequest('/api/admin/display');\n" " fillDisplaySettings(display);\n" " return display;\n" "}\n" "async function displayOperation(operation) {\n" " const fields = {operation};\n" " if (operation === 'apply') {\n" " if (!displayForm.reportValidity()) throw new Error('Enter display timeouts from 0 to 86400 seconds.');\n" " const dim = Number(displayDim.value);\n" " const off = Number(displayOff.value);\n" " if (!Number.isInteger(dim) || !Number.isInteger(off) || dim < 0 || dim > 86400 || off < 0 || off > 86400) {\n" " throw new Error('Display timeouts must be whole numbers from 0 to 86400.');\n" " }\n" " if (dim !== 0 && off !== 0 && off <= dim) throw new Error('Display off time must be later than dim time when both are enabled.');\n" " fields.dim_seconds = String(dim);\n" " fields.off_seconds = String(off);\n" " }\n" " if (operation === 'reset') {\n" " if (!window.confirm('Reset display aging settings to defaults and persist them?')) return;\n" " fields.confirm = 'true';\n" " }\n" " settingsMessage.textContent = `Display ${operation} in progress…`;\n" " await adminRequest('/api/admin/display', 'POST', fields);\n" " await loadDisplaySettings();\n" " settingsMessage.textContent = `Display ${operation} completed.`;\n" "}\n" "async function serialOperation(operation) {\n" " if (operation === 'reset') {\n" " if (!window.confirm('Reset serial settings to defaults and persist them?')) return;\n" " }\n" " if (['apply','load','defaults','reset','stop'].includes(operation)) {\n" " const current = await adminRequest('/api/admin/serial');\n" " if ((current.rx_pending > 0 || current.tx_pending > 0) &&\n" " !window.confirm(`Serial has ${current.rx_pending} RX and ${current.tx_pending} TX bytes pending. Continue with ${operation}?`)) throw new Error('Serial operation cancelled.');\n" " }\n" " const fields = {operation};\n" " if (operation === 'apply') Object.assign(fields, {\n" " baud: serialBaud.value, data: serialData.value, parity: serialParity.value,\n" " stop: serialStopBits.value, flow: serialFlow.value, dtr: serialDtr.value, rts: serialRts.value\n" " });\n" " if (operation === 'reset') fields.confirm = 'true';\n" " settingsMessage.textContent = `Serial ${operation} in progress…`;\n" " await adminRequest('/api/admin/serial', 'POST', fields);\n" " await loadSerialSettings();\n" " settingsMessage.textContent = `Serial ${operation} completed.`;\n" " pollStatus();\n" "}\n" "async function wifiOperation(operation) {\n" " settingsMessage.textContent = `Wi-Fi ${operation} requested…`;\n" " await adminRequest('/api/admin/wifi', 'POST', {operation});\n" " settingsMessage.textContent = `Wi-Fi ${operation} queued.`;\n" " window.setTimeout(pollStatus, 300);\n" "}\n" "const makeButton = (label, action, primary = false) => {\n" " const button = document.createElement('button');\n" " button.type = 'button'; button.className = primary ? 'button primary' : 'button';\n" " button.textContent = label; button.addEventListener('click', action); return button;\n" "};\n" "const positionPopover = (trigger) => {\n" " const rect = trigger.getBoundingClientRect();\n" " const width = Math.min(520, window.innerWidth * .92);\n" " quickPopover.style.left = `${Math.max(12, Math.min(rect.left, window.innerWidth - width - 12))}px`;\n" " const below = rect.bottom + 8;\n" " quickPopover.style.top = `${below + Math.min(420, window.innerHeight * .72) < window.innerHeight ? below : Math.max(12, rect.top - Math.min(420, window.innerHeight * .72) - 8)}px`;\n" "};\n" "const closePopover = (restoreFocus = false) => {\n" " ++popoverGeneration; quickPopover.hidden = true; popoverContent.replaceChildren();\n" " const previous = popoverTrigger; popoverTrigger = null;\n" " if (restoreFocus && previous !== null) previous.focus();\n" "};\n" "const clientRow = (client, action = null) => {\n" " const row = document.createElement('div'); row.className = 'client-row';\n" " const copy = document.createElement('div');\n" " const title = document.createElement('strong'); title.textContent = `${client.name || client.type} · ID ${client.id}`;\n" " const meta = document.createElement('div'); meta.className = 'client-meta';\n" " meta.textContent = `${client.type} · ${client.writer ? 'writer' : 'observer'} · ${client.output_pending}/4096 queued · ${client.output_dropped} dropped`;\n" " copy.append(title, meta); row.append(copy); if (action !== null) row.append(action); return row;\n" "};\n" "async function renderPopover(kind, generation) {\n" " if (kind === 'serial') {\n" " const serial = await adminRequest('/api/admin/serial'); if (generation !== popoverGeneration) return;\n" " const summary = document.createElement('p'); summary.className = 'client-meta';\n" " summary.textContent = `${serial.running ? 'Running' : 'Stopped'} · ${serial.baud} baud · ${serial.data}${serial.parity[0].toUpperCase()}${serial.stop} · ${serial.flow} · DTR ${serial.dtr}`;\n" " const actions = document.createElement('div'); actions.className = 'quick-actions';\n" " actions.append(makeButton(serial.running ? 'Stop' : 'Start', async () => { try { await serialOperation(serial.running ? 'stop' : 'start'); closePopover(); } catch (error) { settingsMessage.textContent = error.message; } }),\n" " makeButton('Save', async () => { try { await serialOperation('save'); closePopover(); } catch (error) { settingsMessage.textContent = error.message; } }),\n" " makeButton('Open settings', () => { closePopover(); openSettings(); }, true));\n" " popoverContent.replaceChildren(summary, actions); return;\n" " }\n" " if (kind === 'wifi') {\n" " const summary = document.createElement('p'); summary.className = 'client-meta'; summary.textContent = wifiSummary.textContent;\n" " const actions = document.createElement('div'); actions.className = 'quick-actions';\n" " for (const [label, operation] of [['Reconnect','reconnect'],['Next profile','next-profile'],['Start','start'],['Stop','stop']]) {\n" " actions.append(makeButton(label, async () => { try { await wifiOperation(operation); closePopover(); } catch (error) { settingsMessage.textContent = error.message; } }, operation === 'reconnect'));\n" " }\n" " actions.append(makeButton('Open settings', () => { closePopover(); openSettings(); }));\n" " popoverContent.replaceChildren(summary, actions); return;\n" " }\n" " const payload = await adminRequest('/api/admin/broker-clients'); if (generation !== popoverGeneration) return;\n" " const list = document.createElement('div'); list.className = 'popover-list';\n" " if (payload.clients.length === 0) { const empty = document.createElement('p'); empty.className = 'client-meta'; empty.textContent = 'No broker clients are connected.'; list.append(empty); }\n" " for (const client of payload.clients) {\n" " let action = null;\n" " if (kind === 'writer' && !client.writer) action = makeButton('Assign', async () => {\n" " if (!window.confirm(`Transfer the writer lease to ${client.name || client.type} (ID ${client.id})?`)) return;\n" " try { await adminRequest('/api/admin/broker-writer', 'POST', {expected: String(payload.writer), target: String(client.id), confirm: 'true'}); await openPopover('writer', popoverTrigger); pollStatus(); }\n" " catch (error) { const message = document.createElement('p'); message.className = 'session-notice'; message.dataset.visible = 'true'; message.textContent = error.message; popoverContent.prepend(message); }\n" " }, true);\n" " list.append(clientRow(client, action));\n" " }\n" " popoverContent.replaceChildren(list);\n" "}\n" "async function openPopover(kind, trigger) {\n" " if (accountRole !== 'admin' || trigger === null) return;\n" " popoverTrigger = trigger; const generation = ++popoverGeneration;\n" " popoverTitle.textContent = kind === 'serial' ? 'Serial quick settings' : kind === 'wifi' ? 'Wi-Fi quick settings' : kind === 'writer' ? 'Transfer writer lease' : 'Broker clients';\n" " const loading = document.createElement('p'); loading.className = 'client-meta'; loading.textContent = 'Loading…';\n" " popoverContent.replaceChildren(loading); quickPopover.hidden = false; positionPopover(trigger);\n" " try { await renderPopover(kind, generation); }\n" " catch (error) { if (generation === popoverGeneration) { loading.textContent = error.message; popoverContent.replaceChildren(loading); } }\n" "}\n" "const closeSettings = (restoreFocus = true) => {\n" " settingsBackdrop.hidden = true;\n" " userCreatePassword.value = ''; userPassword.value = ''; userKey.value = '';\n" " wifiProfileSecret.value = ''; wifiApSecret.value = '';\n" " clearGeneratedPassword();\n" " if (restoreFocus) settingsOpen.focus();\n" "};\n" "async function openSettings() {\n" " if (accountRole !== 'admin') return;\n" " closePopover();\n" " settingsMessage.textContent = 'Loading settings…';\n" " settingsBackdrop.hidden = false;\n" " try {\n" " await loadSerialSettings();\n" " await loadUsers();\n" " await loadWifiConfig();\n" " await loadDisplaySettings();\n" " settingsMessage.textContent = '';\n" " } catch (error) {\n" " settingsMessage.textContent = error.message;\n" " }\n" " settingsClose.focus();\n" "}\n" "serialTerminal.onData((data) => {\n" " if (brokerRole !== 'writer' || !socketOpen()) return;\n" " const bytes = encoder.encode(data);\n" " for (let offset = 0; offset < bytes.length; offset += 1024) {\n" " socket.send(bytes.subarray(offset, Math.min(offset + 1024, bytes.length)));\n" " }\n" "});\n" "adminTerminal.onData((data) => {\n" " if (!adminSocketOpen()) return;\n" " const bytes = encoder.encode(data);\n" " for (let offset = 0; offset < bytes.length; offset += 1024) {\n" " adminSocket.send(bytes.subarray(offset, Math.min(offset + 1024, bytes.length)));\n" " }\n" "});\n" "serialModeButton.addEventListener('click', () => setTerminalMode('serial'));\n" "adminModeButton.addEventListener('click', () => setTerminalMode('admin'));\n" "for (const [card, kind] of [[serialCard,'serial'],[wifiCard,'wifi'],[clientsCard,'clients'],[writerCard,'writer']]) {\n" " card.addEventListener('pointerenter', () => openPopover(kind, card));\n" " card.addEventListener('focus', () => openPopover(kind, card));\n" " card.addEventListener('click', () => openPopover(kind, card));\n" " card.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); openPopover(kind, card); } });\n" "}\n" "popoverClose.addEventListener('click', () => closePopover(true));\n" "document.addEventListener('pointerdown', (event) => {\n" " if (!quickPopover.hidden && !quickPopover.contains(event.target) && event.target !== popoverTrigger && !popoverTrigger?.contains(event.target)) closePopover();\n" "});\n" "document.addEventListener('keydown', (event) => {\n" " if (event.key !== 'Escape') return;\n" " if (!quickPopover.hidden) closePopover(true);\n" " else if (!settingsBackdrop.hidden) closeSettings();\n" "});\n" "settingsOpen.addEventListener('click', openSettings);\n" "settingsClose.addEventListener('click', () => closeSettings());\n" "settingsBackdrop.addEventListener('pointerdown', (event) => { if (event.target === settingsBackdrop) closeSettings(); });\n" "settingsAdminShell.addEventListener('click', () => { closeSettings(false); setTerminalMode('admin'); });\n" "serialSettingsForm.addEventListener('submit', (event) => event.preventDefault());\n" "const runSettingsAction = async (action, secretScope = null) => {\n" " try { await action(); }\n" " catch (error) { if (secretScope === 'user') clearGeneratedPassword(); settingsMessage.textContent = error.message; }\n" " finally {\n" " if (secretScope === 'user') { userPassword.value = ''; userCreatePassword.value = ''; userKey.value = ''; }\n" " if (secretScope === 'wifi') { wifiProfileSecret.value = ''; wifiApSecret.value = ''; }\n" " }\n" "};\n" "const updateCreatePasswordMode = () => {\n" " const generated = userCreatePasswordMode.value === 'generated';\n" " userCreatePassword.disabled = generated;\n" " userCreatePassword.required = !generated;\n" " if (generated) userCreatePassword.value = '';\n" "};\n" "userCreatePasswordMode.addEventListener('change', updateCreatePasswordMode);\n" "updateCreatePasswordMode();\n" "userSelect.addEventListener('change', renderSelectedUser);\n" "userCreateForm.addEventListener('submit', (event) => event.preventDefault());\n" "userEditForm.addEventListener('submit', (event) => event.preventDefault());\n" "element('user-create').addEventListener('click', () => runSettingsAction(async () => {\n" " updateCreatePasswordMode();\n" " if (!userCreateForm.reportValidity()) return;\n" " const generated = userCreatePasswordMode.value === 'generated';\n" " if (!generated && !printableAscii(userCreatePassword.value, 12, 64)) throw new Error('Password must be 12–64 printable ASCII characters.');\n" " await userOperation('create', {\n" " username: userCreateName.value,\n" " role: userCreateRole.value,\n" " password_mode: generated ? 'generated' : 'provided',\n" " ...(generated ? {} : {password: userCreatePassword.value})\n" " }, null);\n" " userCreateName.value = '';\n" "}, 'user'));\n" "element('user-role-apply').addEventListener('click', () => runSettingsAction(async () => {\n" " const user = selectedUser(); if (!user) return;\n" " if (!window.confirm(`Change ${user.username} from ${user.role} to ${userRole.value}?`)) return;\n" " await userOperation('set-role', {role: userRole.value, confirm: 'true'}, user);\n" "}, 'user'));\n" "element('user-password-set').addEventListener('click', () => runSettingsAction(async () => {\n" " const user = selectedUser(); if (!user) return;\n" " if (!userPassword.reportValidity() || !printableAscii(userPassword.value, 12, 64)) throw new Error('Password must be 12–64 printable ASCII characters.');\n" " if (!window.confirm(`Replace the password for ${user.username}? Active sessions for that account will be revoked.`)) return;\n" " await userOperation('set-password', {password_mode: 'provided', password: userPassword.value}, user);\n" "}, 'user'));\n" "element('user-password-generate').addEventListener('click', () => runSettingsAction(async () => {\n" " const user = selectedUser(); if (!user) return;\n" " if (user.username === accountUsername) throw new Error('Generating a replacement password for the current administrator is disabled. Set one explicitly instead.');\n" " if (!window.confirm(`Generate and install a new password for ${user.username}? It will be shown only once.`)) return;\n" " await userOperation('set-password', {password_mode: 'generated'}, user);\n" "}, 'user'));\n" "element('user-key-add').addEventListener('click', () => runSettingsAction(async () => {\n" " const user = selectedUser(); if (!user) return;\n" " const parts = userKey.value.trim().split(/\\s+/);\n" " if (parts.length < 2 || !['ssh-ed25519','ecdsa-sha2-nistp256'].includes(parts[0]) || !/^[A-Za-z0-9+/]+={0,2}$/.test(parts[1])) {\n" " throw new Error('Enter an OpenSSH Ed25519 or ECDSA P-256 public key.');\n" " }\n" " await userOperation('key-add', {key_type: parts[0], key_base64: parts[1]}, user);\n" "}, 'user'));\n" "element('user-key-clear').addEventListener('click', () => runSettingsAction(async () => {\n" " const user = selectedUser(); if (!user || !Array.isArray(user.keys) || user.keys.length === 0) return;\n" " if (!window.confirm(`Remove all SSH public keys from ${user.username}?`)) return;\n" " await userOperation('key-clear', {confirm: 'true'}, user);\n" "}, 'user'));\n" "element('user-delete').addEventListener('click', () => runSettingsAction(async () => {\n" " const user = selectedUser(); if (!user) return;\n" " if (!window.confirm(`Permanently delete account ${user.username}?`)) return;\n" " await userOperation('delete', {confirm: 'true'}, user);\n" "}, 'user'));\n" "element('generated-password-clear').addEventListener('click', clearGeneratedPassword);\n" "element('generated-password-copy').addEventListener('click', () => runSettingsAction(async () => {\n" " if (generatedPassword.textContent.length === 0) return;\n" " await navigator.clipboard.writeText(generatedPassword.textContent);\n" " settingsMessage.textContent = 'Generated password copied. Clear it after storing it safely.';\n" "}));\n" "wifiProfileForm.addEventListener('submit', (event) => event.preventDefault());\n" "wifiApForm.addEventListener('submit', (event) => event.preventDefault());\n" "wifiProfileSlot.addEventListener('change', fillWifiProfile);\n" "element('wifi-profile-apply').addEventListener('click', () => runSettingsAction(() => wifiConfigOperation('profile-apply', {\n" " slot: wifiProfileSlot.value,\n" " ssid_b64: ssidFieldValue(wifiProfileSsid),\n" " priority: wifiProfilePriority.value,\n" " security: wifiProfileSecurity.value\n" "}), 'wifi'));\n" "wifiProfileToggle.addEventListener('click', () => runSettingsAction(() => {\n" " const profile = selectedWifiProfile(); if (!profile) throw new Error('Select a profile first.');\n" " return wifiConfigOperation(profile.enabled ? 'profile-disable' : 'profile-enable', {slot: String(profile.slot)});\n" "}, 'wifi'));\n" "element('wifi-profile-secret-set').addEventListener('click', () => runSettingsAction(() => {\n" " const profile = selectedWifiProfile(); if (!profile) throw new Error('Select a profile first.');\n" " if (!wifiProfileSecret.reportValidity() || !printableAscii(wifiProfileSecret.value, 8, 63)) throw new Error('Wi-Fi secret must be 8–63 printable ASCII characters.');\n" " return wifiConfigOperation('profile-secret', {slot: String(profile.slot), secret: wifiProfileSecret.value});\n" "}, 'wifi'));\n" "element('wifi-profile-delete').addEventListener('click', () => runSettingsAction(() => {\n" " const profile = selectedWifiProfile(); if (!profile) throw new Error('Select a profile first.');\n" " if (!window.confirm(`Delete Wi-Fi profile slot ${profile.slot}, including its stored secret?`)) return Promise.resolve();\n" " return wifiConfigOperation('profile-delete', {slot: String(profile.slot), confirm: 'true'});\n" "}, 'wifi'));\n" "element('wifi-ap-apply').addEventListener('click', () => runSettingsAction(() => wifiConfigOperation('ap-apply', {\n" " policy: wifiApPolicy.value,\n" " ssid_b64: ssidFieldValue(wifiApSsid),\n" " channel: wifiApChannel.value\n" "}), 'wifi'));\n" "element('wifi-ap-secret-set').addEventListener('click', () => runSettingsAction(() => {\n" " if (!wifiApSecret.reportValidity() || !printableAscii(wifiApSecret.value, 8, 63)) throw new Error('AP secret must be 8–63 printable ASCII characters.');\n" " return wifiConfigOperation('ap-secret', {secret: wifiApSecret.value});\n" "}, 'wifi'));\n" "element('wifi-config-save').addEventListener('click', () => runSettingsAction(() => wifiConfigOperation('save'), 'wifi'));\n" "displayForm.addEventListener('submit', (event) => event.preventDefault());\n" "element('display-apply').addEventListener('click', () => runSettingsAction(() => displayOperation('apply')));\n" "for (const operation of ['save','load','defaults','reset']) element(`display-${operation}`).addEventListener('click', () => runSettingsAction(() => displayOperation(operation)));\n" "element('serial-apply').addEventListener('click', () => runSettingsAction(() => serialOperation('apply')));\n" "for (const operation of ['save','start','stop','load','defaults','reset']) element(`serial-${operation}`).addEventListener('click', () => runSettingsAction(() => serialOperation(operation)));\n" "for (const button of document.querySelectorAll('[data-wifi-action]')) button.addEventListener('click', () => runSettingsAction(() => wifiOperation(button.dataset.wifiAction)));\n" "requestControl.addEventListener('click', () => {\n" " if (brokerRole !== 'writer' && socketOpen()) socket.send('request-writer');\n" "});\n" "releaseControl.addEventListener('click', () => {\n" " if (brokerRole === 'writer' && socketOpen()) socket.send('release-writer');\n" "});\n" "logoutButton.addEventListener('click', async () => {\n" " if (unloading || authRedirecting || !sessionReady || typeof csrfToken !== 'string') return;\n" " const token = csrfToken;\n" " setSessionNotice('');\n" " authRedirecting = true;\n" " stopAuthenticatedActivity();\n" " try {\n" " const response = await fetch('/logout', {\n" " method: 'POST', credentials: 'same-origin', cache: 'no-store',\n" " headers: {'X-CSRF-Token': token}\n" " });\n" " if (!response.ok) throw new Error('logout request failed');\n" " csrfToken = null;\n" " accountUsername = null;\n" " accountRole = null;\n" " window.location.assign('/login');\n" " } catch (_) {\n" " authRedirecting = false;\n" " csrfToken = null;\n" " accountUsername = null;\n" " accountRole = null;\n" " accountUsernameField.textContent = 'Restoring…';\n" " accountRoleField.textContent = 'Session';\n" " setSessionNotice('Logout failed; session remains active.');\n" " startAuthenticatedUi();\n" " }\n" "});\n" "connectionToggle.addEventListener('click', () => {\n" " const connectionActive = reconnectEnabled || socket !== null || ticketAbort !== null || reconnectTimer !== null;\n" " if (!connectionActive) {\n" " reconnectEnabled = true;\n" " reconnectDelay = 1000;\n" " if (!sessionReady) { startAuthenticatedUi(); return; }\n" " connect();\n" " return;\n" " }\n" " reconnectEnabled = false;\n" " ++connectionGeneration;\n" " clearReconnectTimer();\n" " if (ticketAbort !== null) { ticketAbort.abort(); ticketAbort = null; }\n" " if (socket !== null) { const previous = socket; socket = null; previous.close(); }\n" " clientId = null;\n" " clientIdField.textContent = '—';\n" " setBrokerRole('observer');\n" " setConnection('Disconnected', 'warn', 'Disconnected by user. Automatic reconnect is paused.');\n" "});\n" "const fitTerminal = () => {\n" " fitFrame = 0;\n" " const admin = terminalMode === 'admin';\n" " const host = admin ? adminTerminalHost : serialTerminalHost;\n" " const instance = admin ? adminTerminal : serialTerminal;\n" " const addon = admin ? adminFitAddon : serialFitAddon;\n" " const bounds = host.getBoundingClientRect();\n" " const width = Math.floor(bounds.width);\n" " const height = Math.floor(bounds.height);\n" " const unchanged = admin\n" " ? width === adminLastFitWidth && height === adminLastFitHeight\n" " : width === serialLastFitWidth && height === serialLastFitHeight;\n" " if (width < 1 || height < 1 || unchanged) return;\n" " if (admin) { adminLastFitWidth = width; adminLastFitHeight = height; }\n" " else { serialLastFitWidth = width; serialLastFitHeight = height; }\n" " try {\n" " const dimensions = addon.proposeDimensions();\n" " if (dimensions && dimensions.cols > 0 && dimensions.rows > 0 &&\n" " (dimensions.cols !== instance.cols || dimensions.rows !== instance.rows)) {\n" " instance.resize(dimensions.cols, dimensions.rows);\n" " }\n" " } catch (_) {}\n" "};\n" "const scheduleFit = () => {\n" " if (fitFrame === 0) fitFrame = window.requestAnimationFrame(fitTerminal);\n" "};\n" "const resizeObserver = 'ResizeObserver' in window ? new ResizeObserver(scheduleFit) : null;\n" "if (resizeObserver !== null) { resizeObserver.observe(serialTerminalHost); resizeObserver.observe(adminTerminalHost); }\n" "window.addEventListener('resize', scheduleFit);\n" "const textValue = (value, fallback) => typeof value === 'string' && value.length > 0 ? value : fallback;\n" "const updateStatus = (status) => {\n" " const wifi = status !== null && typeof status === 'object' ? status.wifi : null;\n" " if (wifi && wifi.available) {\n" " const parts = [textValue(wifi.state, 'unknown')];\n" " if (typeof wifi.sta_ipv4 === 'string' && wifi.sta_ipv4 !== '0.0.0.0') parts.push(wifi.sta_ipv4);\n" " if (Number.isFinite(wifi.rssi)) parts.push(`${wifi.rssi} dBm`);\n" " if (Number.isFinite(wifi.channel) && wifi.channel > 0) parts.push(`channel ${wifi.channel}`);\n" " if (wifi.ap_running && Number.isFinite(wifi.ap_clients)) parts.push(`AP clients ${wifi.ap_clients}`);\n" " wifiSummary.textContent = parts.join(' · ');\n" " } else {\n" " wifiSummary.textContent = 'Unavailable';\n" " }\n" " const serial = status !== null && typeof status === 'object' ? status.serial : null;\n" " if (serial && typeof serial === 'object') {\n" " const parts = [serial.running ? 'Running' : 'Stopped'];\n" " if (serial.config_available) {\n" " if (Number.isFinite(serial.baud)) parts.push(`${serial.baud} baud`);\n" " parts.push(`${textValue(serial.data_bits, '?')} data`);\n" " parts.push(`${textValue(serial.parity, '?')} parity`);\n" " parts.push(`${textValue(serial.stop_bits, '?')} stop`);\n" " parts.push(`${textValue(serial.flow, '?')} flow`);\n" " }\n" " serialSummary.textContent = parts.join(' · ');\n" " } else {\n" " serialSummary.textContent = 'Unavailable';\n" " }\n" " const broker = status !== null && typeof status === 'object' ? status.broker : null;\n" " if (broker && broker.available) {\n" " brokerClientsField.textContent = validId(broker.clients) ? String(broker.clients) : '—';\n" " if (validId(broker.writer)) {\n" " writerId = broker.writer;\n" " writerIdField.textContent = displayId(writerId, 'None');\n" " }\n" " } else {\n" " brokerClientsField.textContent = '—';\n" " }\n" "};\n" "async function pollStatus() {\n" " if (unloading || statusInFlight) return;\n" " statusInFlight = true;\n" " try {\n" " const response = await fetch('/api/status', {credentials: 'same-origin', cache: 'no-store'});\n" " if (response.status === 401) { navigateToLogin(); return; }\n" " if (!response.ok) throw new Error('status request failed');\n" " updateStatus(await response.json());\n" " } catch (_) {\n" " wifiSummary.textContent = 'Unavailable';\n" " serialSummary.textContent = 'Unavailable';\n" " brokerClientsField.textContent = '—';\n" " } finally {\n" " statusInFlight = false;\n" " }\n" "}\n" "async function loadSession() {\n" " const controller = new AbortController();\n" " sessionAbort = controller;\n" " try {\n" " const response = await fetch('/api/session', {\n" " method: 'GET', credentials: 'same-origin', cache: 'no-store', signal: controller.signal\n" " });\n" " if (response.status === 401) {\n" " navigateToLogin();\n" " throw new Error('session expired');\n" " }\n" " if (!response.ok) throw new Error('session request failed');\n" " const payload = await response.json();\n" " if (payload === null || typeof payload !== 'object' ||\n" " typeof payload.username !== 'string' || payload.username.length === 0 ||\n" " (payload.role !== 'user' && payload.role !== 'admin') ||\n" " typeof payload.csrf !== 'string' || payload.csrf.length === 0) {\n" " throw new Error('invalid session response');\n" " }\n" " accountUsername = payload.username;\n" " accountRole = payload.role;\n" " csrfToken = payload.csrf;\n" " payload.csrf = '';\n" " accountUsernameField.textContent = accountUsername;\n" " accountRoleField.textContent = accountRole === 'admin' ? 'Administrator' : 'User';\n" " const administrator = accountRole === 'admin';\n" " terminalModes.hidden = !administrator;\n" " settingsOpen.hidden = !administrator;\n" " for (const card of [serialCard,wifiCard,clientsCard,writerCard]) {\n" " card.classList.toggle('interactive', administrator); card.tabIndex = administrator ? 0 : -1;\n" " }\n" " sessionReady = true;\n" " if (accountRole !== 'admin') setTerminalMode('serial');\n" " else if (terminalMode === 'admin') connectAdmin();\n" " } finally {\n" " if (sessionAbort === controller) sessionAbort = null;\n" " }\n" "}\n" "async function startAuthenticatedUi() {\n" " if (unloading || authRedirecting || startupInFlight || sessionReady) return;\n" " startupInFlight = true;\n" " reconnectEnabled = true;\n" " setConnection('Connecting', 'warn', 'Loading account session…');\n" " try {\n" " await loadSession();\n" " if (unloading || authRedirecting || !sessionReady) return;\n" " scheduleFit();\n" " pollStatus();\n" " statusTimer = window.setInterval(pollStatus, 5000);\n" " connect();\n" " } catch (error) {\n" " if (unloading || authRedirecting || error.name === 'AbortError') return;\n" " csrfToken = null;\n" " accountUsername = null;\n" " accountRole = null;\n" " sessionReady = false;\n" " reconnectEnabled = false;\n" " accountUsernameField.textContent = 'Unavailable';\n" " accountRoleField.textContent = 'Session';\n" " setConnection('Session unavailable', 'bad', 'Unable to load the account session. Select Connect to try again.');\n" " } finally {\n" " startupInFlight = false;\n" " updateControls();\n" " }\n" "}\n" "const shutdown = () => {\n" " if (unloading) return;\n" " unloading = true;\n" " ++connectionGeneration;\n" " ++adminConnectionGeneration;\n" " clearReconnectTimer();\n" " if (statusTimer !== null) window.clearInterval(statusTimer);\n" " if (sessionAbort !== null) sessionAbort.abort();\n" " if (fitFrame !== 0) window.cancelAnimationFrame(fitFrame);\n" " if (resizeObserver !== null) resizeObserver.disconnect();\n" " window.removeEventListener('resize', scheduleFit);\n" " if (ticketAbort !== null) ticketAbort.abort();\n" " if (adminTicketAbort !== null) adminTicketAbort.abort();\n" " if (socket !== null) socket.close();\n" " if (adminSocket !== null) adminSocket.close();\n" " socket = null;\n" " adminSocket = null;\n" " csrfToken = null;\n" " accountUsername = null;\n" " accountRole = null;\n" "};\n" "window.addEventListener('pagehide', shutdown);\n" "window.addEventListener('pageshow', (event) => {\n" " if (!event.persisted) return;\n" " unloading = false; authRedirecting = false; startupInFlight = false; sessionReady = false;\n" " statusTimer = null; sessionAbort = null; ticketAbort = null; adminTicketAbort = null;\n" " socket = null; adminSocket = null; reconnectEnabled = true;\n" " if (resizeObserver !== null) { resizeObserver.observe(serialTerminalHost); resizeObserver.observe(adminTerminalHost); }\n" " window.addEventListener('resize', scheduleFit); scheduleFit();\n" " startAuthenticatedUi();\n" "});\n" "updateControls();\n" "scheduleFit();\n" "startAuthenticatedUi();\n" "})();\n"; typedef enum { WEB_UI_CSP_NONE = 0, WEB_UI_CSP_APP, WEB_UI_CSP_LOGIN, } web_ui_csp_policy_t; typedef struct { const char *content_type; const char *cache_control; const char *content_encoding; const uint8_t *data; size_t length; web_ui_csp_policy_t csp_policy; } web_ui_response_t; static esp_err_t describe_resource(web_ui_resource_t resource, web_ui_response_t *response) { response->cache_control = WEB_UI_ASSET_CACHE_CONTROL; response->content_encoding = NULL; response->csp_policy = WEB_UI_CSP_NONE; switch (resource) { case WEB_UI_RESOURCE_INDEX: response->content_type = "text/html; charset=utf-8"; response->cache_control = WEB_UI_DOCUMENT_CACHE_CONTROL; response->data = (const uint8_t *)s_index_html; response->length = sizeof(s_index_html) - 1U; response->csp_policy = WEB_UI_CSP_APP; return ESP_OK; case WEB_UI_RESOURCE_LOGIN: response->content_type = "text/html; charset=utf-8"; response->cache_control = WEB_UI_DOCUMENT_CACHE_CONTROL; response->data = (const uint8_t *)s_login_html; response->length = sizeof(s_login_html) - 1U; response->csp_policy = WEB_UI_CSP_LOGIN; return ESP_OK; case WEB_UI_RESOURCE_LOGIN_ERROR: response->content_type = "text/html; charset=utf-8"; response->cache_control = WEB_UI_DOCUMENT_CACHE_CONTROL; response->data = (const uint8_t *)s_login_error_html; response->length = sizeof(s_login_error_html) - 1U; response->csp_policy = WEB_UI_CSP_LOGIN; return ESP_OK; case WEB_UI_RESOURCE_XTERM_JS: response->content_type = "text/javascript; charset=utf-8"; response->content_encoding = "gzip"; response->data = web_asset_xterm_js_gz; response->length = web_asset_xterm_js_gz_size; return ESP_OK; case WEB_UI_RESOURCE_XTERM_CSS: response->content_type = "text/css; charset=utf-8"; response->content_encoding = "gzip"; response->data = web_asset_xterm_css_gz; response->length = web_asset_xterm_css_gz_size; return ESP_OK; case WEB_UI_RESOURCE_ADDON_FIT_JS: response->content_type = "text/javascript; charset=utf-8"; response->content_encoding = "gzip"; response->data = web_asset_addon_fit_js_gz; response->length = web_asset_addon_fit_js_gz_size; return ESP_OK; case WEB_UI_RESOURCE_APP_JS: response->content_type = "text/javascript; charset=utf-8"; response->cache_control = WEB_UI_AUTHORED_ASSET_CACHE_CONTROL; response->data = (const uint8_t *)s_app_js; response->length = sizeof(s_app_js) - 1U; return ESP_OK; case WEB_UI_RESOURCE_LOGO_PNG: response->content_type = "image/png"; response->data = web_asset_logo_png; response->length = web_asset_logo_png_size; return ESP_OK; default: return ESP_ERR_INVALID_ARG; } } static esp_err_t set_response_headers(httpd_req_t *request, const web_ui_response_t *response) { esp_err_t result = httpd_resp_set_type(request, response->content_type); if (result == ESP_OK) { result = httpd_resp_set_hdr(request, "Cache-Control", response->cache_control); } if (result == ESP_OK) { result = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff"); } if (result == ESP_OK) { result = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer"); } if (result == ESP_OK && response->content_encoding != NULL) { result = httpd_resp_set_hdr(request, "Content-Encoding", response->content_encoding); } if (result == ESP_OK && response->csp_policy == WEB_UI_CSP_APP) { result = httpd_resp_set_hdr( request, "Content-Security-Policy", "default-src 'none'; script-src 'self' 'sha256-5ukY3vEyRwFowsj4k3O4ilN8ezlXoZu1w90PPHg0YVE='; " "script-src-elem 'self' 'sha256-5ukY3vEyRwFowsj4k3O4ilN8ezlXoZu1w90PPHg0YVE='; " "style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; " "base-uri 'none'; form-action 'none'; " "frame-ancestors 'none'"); } if (result == ESP_OK && response->csp_policy == WEB_UI_CSP_LOGIN) { result = httpd_resp_set_hdr( request, "Content-Security-Policy", "default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; " "img-src 'self' data:; base-uri 'none'; form-action 'self'; " "frame-ancestors 'none'"); } return result; } esp_err_t web_ui_send_response(httpd_req_t *request, web_ui_resource_t resource) { if (request == NULL) { return ESP_ERR_INVALID_ARG; } web_ui_response_t response = {0}; esp_err_t result = describe_resource(resource, &response); if (result == ESP_OK) { result = set_response_headers(request, &response); } if (result == ESP_OK) { result = httpd_resp_send(request, (const char *)response.data, (ssize_t)response.length); } return result; }