// src/static/js/pages/wifi-ap.js — Wi-Fi Access Point leaf (spec §9.1). // Dedicated ap_config key · STA uplink untouched · self-contained Save/Apply (/api/network/ap/*). import { isAdvanced } from '../view-mode.js'; import { showFieldError, clearAllFieldErrors, setPageError } from '../validator.js'; import { icon } from '../icons.js'; import { showToast } from '../toast.js'; import { escapeHtml } from '../utils.js'; import { clearDirty } from '../page-dirty.js'; const _DEFAULTS = { ap_enabled: false, ap_ssid: '', ap_passphrase: '', ap_band: 'auto', ap_channel: 0, ap_hidden: false }; let _cfg = { ..._DEFAULTS }; // last loaded/saved config let _status = {}; // live status (ap_enabled, ap0_up, hostapd_running, clients, country_pending) let _countryPending = false; let _statusTimer = null; // v1.11.9 (review LOW concurrency): _save is async and bound to three buttons // (ap-save / ap-apply / ap-save-apply) — guard against a double-apply re-entry. let _saving = false; function _byteLen(s) { return new TextEncoder().encode(s || '').length; } function _badge(cls, text) { return `${escapeHtml(text)}`; } async function _api(path, opts) { const res = await fetch(path, opts); const body = await res.json().catch(() => ({})); if (!res.ok) throw new Error(body.error || `${path}: ${res.status}`); return body; } function _collect() { const g = id => document.getElementById(id); const out = { ap_enabled: g('ap_enabled')?.checked || false, ap_ssid: g('ap_ssid')?.value || '', }; // v1.11.9 (review HIGH security UX): the backend STRIPS ap_passphrase from GET status, // so the field renders blank. Only send ap_passphrase when the user actually typed a new // one — a BLANK field OMITS the key so the server-side RMW merge keeps the stored PSK. // (Sending ap_passphrase:"" would wipe the existing password.) const psk = g('ap_passphrase')?.value || ''; if (psk) out.ap_passphrase = psk; // Advanced-only fields are collected only when rendered. In User mode (no DOM) they are // omitted so the server-side config merge preserves the stored ap_band/ap_hidden. const band = g('ap_band'); if (band) out.ap_band = band.value; const hidden = g('ap_hidden'); if (hidden) out.ap_hidden = hidden.checked; return out; } function _validate() { clearAllFieldErrors(); const d = _collect(); const errs = []; if (d.ap_enabled) { if (!d.ap_ssid || _byteLen(d.ap_ssid) > 32) { showFieldError(document.getElementById('ap_ssid'), 'Network name must be 1-32 bytes.'); errs.push('ssid'); } // v1.11.9 (review HIGH security UX): a BLANK PSK field means "keep the existing // stored password" (_collect omits ap_passphrase entirely). Only validate the // 8-63 byte bound when the user actually typed a NEW passphrase. if ('ap_passphrase' in d) { const p = _byteLen(d.ap_passphrase); if (p < 8 || p > 63) { showFieldError(document.getElementById('ap_passphrase'), 'Password must be 8-63 bytes.'); errs.push('psk'); } } } setPageError('wifi-ap', errs.length > 0); return errs.length === 0; } // Live status panel (matches net-apply dash-card / net-badge design language). function _statusHtml() { const adv = isAdvanced(); if (_status.ap_enabled === undefined) { return `
Status ${_badge('na', 'loading…')}
`; } const running = _status.ap_enabled && _status.ap0_up && _status.hostapd_running; const apBadge = running ? _badge('ok', 'Running') : _badge('na', 'Off'); const rows = [ `
Access point${apBadge}
`, `
${icon('smartphone', { size: 14 })} Connected clients ${escapeHtml(String(_status.clients ?? 0))}
`, ]; if (adv) { const hostapd = _status.hostapd_running ? _badge('ok', 'active') : _badge('na', 'inactive'); rows.push(`
hostapd${hostapd}
`); } if (_status.country_pending) { rows.push(`
Regulatory domain ${_badge('warn', 'Reboot required')}
`); } const refresh = adv ? `` : ''; return `

${icon('activity', { size: 16 })} Status ${refresh}

${rows.join('')}
`; } function _render(container) { const a = _cfg, adv = isAdvanced(); container.innerHTML = `
${_countryPending ? `
${icon('alert-triangle', { size: 14 })} A country-code change is pending. Reboot the device before enabling the access point.
` : ''}
${adv ? `
` : ''}
${adv ? ` ` : ``}
${_statusHtml()}
`; } function _bind(container) { const t = container.querySelector('.input-password__toggle'); if (t) t.addEventListener('click', () => { const i = container.querySelector('#ap_passphrase'); if (!i) return; const show = i.type === 'password'; i.type = show ? 'text' : 'password'; t.innerHTML = icon(show ? 'eye-off' : 'eye', { size: 14 }); t.setAttribute('aria-label', show ? 'Hide password' : 'Show password'); }); container.querySelector('#ap-save')?.addEventListener('click', () => _save({ apply: false })); container.querySelector('#ap-apply')?.addEventListener('click', () => _save({ apply: true })); container.querySelector('#ap-save-apply')?.addEventListener('click', () => _save({ apply: true })); container.querySelector('#ap-refresh')?.addEventListener('click', () => _refreshStatus()); } async function _saveConfig({ toast = true } = {}) { if (!document.getElementById('ap_enabled')) { throw new Error('Wi-Fi AP page is not mounted.'); } if (!_validate()) { throw new Error('Check your input.'); } const d = _collect(); await _api('api/network/ap/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(d) }); _cfg = { ..._cfg, ...d }; clearDirty('wifi-ap'); if (toast) showToast('Access point settings saved.', 'success'); } async function _save({ apply }) { if (_saving) return; // in-flight guard: concurrent save/apply gestures → only one runs _saving = true; try { await _saveConfig({ toast: !apply }); if (apply) { const r = await _api('api/network/ap/apply', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dry_run: false }) }); if (r.state !== 'COMMITTED') showToast('Apply failed: ' + ((r.errors && r.errors[0]) || 'validation failed'), 'error'); else showToast('Access point settings applied.', 'success'); } await _refreshStatus(); } catch (e) { showToast('Failed — ' + e.message, 'error'); } finally { _saving = false; } } async function _refreshStatus() { try { const s = await _api('api/network/ap/status'); _status = s || {}; _countryPending = !!s.country_pending; if (s.config) _cfg = { ..._DEFAULTS, ...s.config }; const el = document.getElementById('ap-status'); if (el) { el.innerHTML = _statusHtml(); // only the status region was replaced — re-bind just its Refresh button (form handlers persist) el.querySelector('#ap-refresh')?.addEventListener('click', () => _refreshStatus()); } } catch (e) { /* fail-soft: status poll failures stay silent */ } } const wifiApPage = { render(container) { _render(container); }, mount(container) { _bind(container); // First entry: load saved config + live status, then re-render the form to reflect it. _refreshStatus().then(() => { _render(container); _bind(container); }); if (isAdvanced()) _statusTimer = setInterval(_refreshStatus, 10000); }, destroy() { if (_statusTimer) { clearInterval(_statusTimer); _statusTimer = null; } // v1.11.9 (review LOW correctness/honesty): reset live status so a revisit after an // API failure shows loading/empty, not a stale "Running" panel. _cfg is intentionally // NOT reset (resetting to defaults would flash a blank form before _refreshStatus). _status = {}; _countryPending = false; _saving = false; }, validate() { if (!document.getElementById('ap_enabled')) return []; return _validate() ? [] : [{ page: 'wifi-ap', message: 'Invalid Wi-Fi AP settings' }]; }, async saveSelfContained() { await _saveConfig({ toast: false }); }, }; export default wifiApPage;