You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

215 lines
12 KiB

// 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 `<span class="net-badge net-badge--${cls}">${escapeHtml(text)}</span>`; }
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 `<div class="dash-card"><div class="dash-row"><span class="dash-row__lbl">Status</span>
<span class="dash-row__val">${_badge('na', 'loading…')}</span></div></div>`;
}
const running = _status.ap_enabled && _status.ap0_up && _status.hostapd_running;
const apBadge = running ? _badge('ok', 'Running') : _badge('na', 'Off');
const rows = [
`<div class="dash-row"><span class="dash-row__lbl">Access point</span><span class="dash-row__val">${apBadge}</span></div>`,
`<div class="dash-row"><span class="dash-row__lbl">${icon('smartphone', { size: 14 })} Connected clients</span>
<span class="dash-row__val">${escapeHtml(String(_status.clients ?? 0))}</span></div>`,
];
if (adv) {
const hostapd = _status.hostapd_running ? _badge('ok', 'active') : _badge('na', 'inactive');
rows.push(`<div class="dash-row"><span class="dash-row__lbl">hostapd</span><span class="dash-row__val">${hostapd}</span></div>`);
}
if (_status.country_pending) {
rows.push(`<div class="dash-row"><span class="dash-row__lbl">Regulatory domain</span>
<span class="dash-row__val">${_badge('warn', 'Reboot required')}</span></div>`);
}
const refresh = adv
? `<button class="btn btn--ghost btn--sm" id="ap-refresh" type="button">${icon('refresh-cw', { size: 14 })} Refresh</button>` : '';
return `<div class="dash-card"><h3 class="dash-card__title">${icon('activity', { size: 16 })} Status ${refresh}</h3>${rows.join('')}</div>`;
}
function _render(container) {
const a = _cfg, adv = isAdvanced();
container.innerHTML = `
<div class="page-header"><h1 class="page-header__title">${icon('radio-tower', { size: 28 })} Wi-Fi Access Point</h1>
<p class="page-header__desc">Broadcast this device as a Wi-Fi access point for local provisioning. Your <strong>STA uplink stays connected</strong> clients can only reach the configurator.</p></div>
<div class="card">
${_countryPending ? `<div class="info-banner info-banner--warning">${icon('alert-triangle', { size: 14 })}
A country-code change is pending. Reboot the device before enabling the access point.</div>` : ''}
<label class="radio-label"><input type="checkbox" id="ap_enabled" ${a.ap_enabled ? 'checked' : ''} ${_countryPending ? 'disabled' : ''}> Enable access point</label>
<div class="form-group"><label class="form-label" for="ap_ssid">Network name (SSID)</label>
<input class="form-input" id="ap_ssid" maxlength="32" value="${escapeHtml(a.ap_ssid)}" placeholder="e.g. Site-AP"></div>
<div class="form-group"><label class="form-label" for="ap_passphrase">Password ${icon('lock', { size: 12 })} (WPA2, 8-63 characters)</label>
<div class="input-password"><input class="form-input" type="password" id="ap_passphrase" value="" placeholder="leave blank to keep current password" autocomplete="new-password">
<button class="input-password__toggle" type="button" aria-label="Show password">${icon('eye', { size: 14 })}</button></div></div>
${adv ? `
<div class="form-row">
<div class="form-group"><label class="form-label" for="ap_band">Band</label>
<select class="form-select" id="ap_band">
<option value="auto" ${a.ap_band === 'auto' ? 'selected' : ''}>Auto (follow STA channel, recommended)</option>
<option value="2g" ${a.ap_band === '2g' ? 'selected' : ''}>2.4 GHz (experimental)</option>
<option value="5g" ${a.ap_band === '5g' ? 'selected' : ''}>5 GHz (experimental)</option></select></div>
<div class="form-group"><label class="radio-label"><input type="checkbox" id="ap_hidden" ${a.ap_hidden ? 'checked' : ''}> Hide SSID (do not broadcast)</label></div>
</div>` : ''}
<div class="dash-actions">
${adv
? `<button class="btn btn--outline" id="ap-save" type="button">${icon('save', { size: 14 })} Save</button>
<button class="btn btn--primary" id="ap-apply" type="button">${icon('check-circle-2', { size: 14 })} Save &amp; Apply</button>`
: `<button class="btn btn--primary" id="ap-save-apply" type="button">${icon('check-circle-2', { size: 14 })} Save &amp; Apply</button>`}
</div>
</div>
<div id="ap-status" aria-live="polite">${_statusHtml()}</div>`;
}
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;