// 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 `