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.
248 lines
14 KiB
248 lines
14 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, client_details)
|
|
let _countryPending = false;
|
|
let _statusTimer = null;
|
|
// Fix #3b: track whether the backend has a stored PSK (from status.has_passphrase).
|
|
// null = not yet loaded (status not fetched); true/false = known.
|
|
// Used in _validate() to reject blank PSK when AP is being enabled on a fresh device.
|
|
let _hasPassphrase = 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>`; }
|
|
function _fmtDbm(v) { return Number.isFinite(v) ? `${v} dBm` : 'Unknown'; }
|
|
function _fmtIdle(v) { return Number.isFinite(v) ? `${v} ms` : 'Unknown'; }
|
|
|
|
function _clientDetailsHtml() {
|
|
const clients = Array.isArray(_status.client_details) ? _status.client_details : [];
|
|
if (!clients.length) return '';
|
|
const rows = clients.map(c => `
|
|
<tr>
|
|
<td class="ap-client-table__mono">${escapeHtml(c.mac || 'unknown')}</td>
|
|
<td class="ap-client-table__mono">${escapeHtml(c.ip || 'IP pending')}</td>
|
|
<td>${escapeHtml(_fmtDbm(c.signal_dbm))}</td>
|
|
<td>${escapeHtml(_fmtIdle(c.inactive_ms))}</td>
|
|
</tr>`).join('');
|
|
return `
|
|
<div class="data-table-wrapper ap-client-table">
|
|
<table class="data-table">
|
|
<thead><tr><th>MAC</th><th>IP</th><th>Signal</th><th>Idle</th></tr></thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>
|
|
</div>`;
|
|
}
|
|
|
|
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.
|
|
// Fix #3b: on a fresh device (_hasPassphrase=false) a blank PSK has nothing to
|
|
// "keep" — the server-side RMW would store an empty PSK and fail apply-time
|
|
// validation. Reject proactively so the error is shown client-side immediately.
|
|
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');
|
|
}
|
|
} else if (_hasPassphrase === false) {
|
|
// Blank PSK + no stored PSK: require the user to set one now.
|
|
showFieldError(document.getElementById('ap_passphrase'), 'Wi-Fi AP requires a passphrase (8–63 characters).'); 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 })} Associated 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('')}${_clientDetailsHtml()}</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 & Apply</button>`
|
|
: `<button class="btn btn--primary" id="ap-save-apply" type="button">${icon('check-circle-2', { size: 14 })} Save & 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;
|
|
// Fix #3b: capture has_passphrase (boolean from backend, null = not yet known).
|
|
if (typeof s.has_passphrase === 'boolean') _hasPassphrase = s.has_passphrase;
|
|
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;
|
|
_hasPassphrase = null; // Fix #3b: reset so next mount re-fetches from backend
|
|
},
|
|
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;
|
|
|