New Addons -> Web UI: a small Node/Express app (webui/) installed as a systemd service running as $KIOSK_USER, giving a browser-based editor for Sites & Page Timing, Display & Interaction, and Password Protection & Lockout - the three Core Settings menus that are pure config.json read/write with no privileged system mutation involved. Runs with no sudo at all, since config.json is already owned by $KIOSK_USER. webui/lib/config.js re-implements lib/config.sh's exact schema and merge-on-save contract in JS (kiosk-app/main.js already reads the same config.json directly in JS, so this isn't a new pattern), so it can never silently clobber fields it doesn't track - the same bug previously fixed in lib/config.sh's own history. No login of its own by design: Authelia runs elsewhere, and the expectation is a reverse proxy (e.g. Caddy) with Authelia forward-auth in front of it, the same way other self-hosted apps get protected - Authelia integration is explicitly out of scope for this repo. Deliberately narrow scope for this first pass: WiFi, Timezone, Power/Display/Quiet Hours, Complete Uninstall, every other addon, and everything in Advanced remain terminal-only, since a network-facing process shouldn't be handed sudo-level system mutation without a lot more thought than this pass gives it. Wired into Complete Uninstall (webui_do_uninstall) and Clone Settings (addon-presence detection) the same way every other addon is. This is the single-kiosk piece of the web-based GUI this repo's "Modular Management" notes have mentioned for a while - a central multi-kiosk fleet dashboard is an intentional follow-up. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VfsFSoRqfbRG7XAg5RoE7e
265 lines
10 KiB
JavaScript
265 lines
10 KiB
JavaScript
'use strict';
|
|
|
|
// webui/public/app.js - vanilla JS, no framework/build step. Every
|
|
// user-controlled value (site URL/name/username) is set via .value or
|
|
// .textContent, never innerHTML, so nothing typed into a site name or
|
|
// URL can execute as markup - the one new attack surface a browser-based
|
|
// config UI has that the terminal menus never did.
|
|
|
|
const msgEl = document.getElementById('msg');
|
|
let currentConfig = null;
|
|
|
|
function showMessage(text, isError) {
|
|
msgEl.textContent = text;
|
|
msgEl.hidden = false;
|
|
msgEl.className = 'banner ' + (isError ? 'error' : 'success');
|
|
clearTimeout(showMessage._t);
|
|
showMessage._t = setTimeout(() => { msgEl.hidden = true; }, 5000);
|
|
}
|
|
|
|
async function apiGet() {
|
|
const res = await fetch('/api/config');
|
|
if (!res.ok) throw new Error('Failed to load configuration');
|
|
return res.json();
|
|
}
|
|
|
|
async function apiPut(patch) {
|
|
const res = await fetch('/api/config', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(patch),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(data.error || 'Save failed');
|
|
return data;
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------- */
|
|
/* Sites & Page Timing */
|
|
/* ---------------------------------------------------------------------- */
|
|
|
|
const sitesList = document.getElementById('sites-list');
|
|
const siteRowTemplate = document.getElementById('site-row-template');
|
|
|
|
function renderSites(tabs) {
|
|
sitesList.textContent = '';
|
|
tabs.forEach((tab, idx) => sitesList.appendChild(buildSiteRow(tab, idx)));
|
|
if (tabs.length === 0) {
|
|
const p = document.createElement('p');
|
|
p.className = 'hint';
|
|
p.textContent = 'No pages configured yet.';
|
|
sitesList.appendChild(p);
|
|
}
|
|
}
|
|
|
|
function buildSiteRow(tab, idx) {
|
|
const node = siteRowTemplate.content.firstElementChild.cloneNode(true);
|
|
node.dataset.index = String(idx);
|
|
|
|
node.querySelector('.site-url').value = tab.url || '';
|
|
node.querySelector('.site-name').value = tab.name || '';
|
|
node.querySelector('.site-duration').value = tab.duration ?? 180;
|
|
|
|
const authEnable = node.querySelector('.site-auth-enable');
|
|
const authUser = node.querySelector('.site-auth-username');
|
|
const authState = node.querySelector('.auth-state');
|
|
const hasAuth = !!(tab.username || tab.hasPassword);
|
|
authEnable.checked = hasAuth;
|
|
authUser.value = tab.username || '';
|
|
authState.textContent = hasAuth ? '(enabled)' : '(disabled)';
|
|
|
|
node.querySelector('.remove-site').addEventListener('click', () => {
|
|
node.remove();
|
|
if (!sitesList.querySelector('.site-row')) renderSites([]);
|
|
});
|
|
|
|
return node;
|
|
}
|
|
|
|
document.getElementById('add-site').addEventListener('click', () => {
|
|
if (sitesList.querySelector('.hint')) sitesList.textContent = '';
|
|
sitesList.appendChild(buildSiteRow({ url: '', name: '', duration: 180, username: '', hasPassword: false }, sitesList.children.length));
|
|
updateHomeTabOptions(collectTabs());
|
|
});
|
|
document.getElementById('save-sites').addEventListener('click', saveSites);
|
|
|
|
function collectTabs() {
|
|
return Array.from(sitesList.querySelectorAll('.site-row')).map((row) => {
|
|
const tab = {
|
|
url: row.querySelector('.site-url').value.trim(),
|
|
name: row.querySelector('.site-name').value.trim(),
|
|
duration: parseInt(row.querySelector('.site-duration').value, 10),
|
|
};
|
|
const authEnabled = row.querySelector('.site-auth-enable').checked;
|
|
if (authEnabled) {
|
|
tab.username = row.querySelector('.site-auth-username').value;
|
|
const newPass = row.querySelector('.site-auth-password').value;
|
|
if (newPass) tab.password = newPass;
|
|
// else: omit `password` entirely - server keeps the existing one.
|
|
} else {
|
|
tab.username = '';
|
|
tab.password = '';
|
|
}
|
|
return tab;
|
|
});
|
|
}
|
|
|
|
// A "Save" per row would be simpler individually, but sites.sh's own
|
|
// save_config always rewrites the whole tabs array too - this mirrors
|
|
// that, saving all sites (and the home-page selection they feed into)
|
|
// together whenever anything in the Sites section changes.
|
|
async function saveSites() {
|
|
const tabs = collectTabs();
|
|
if (tabs.some((t) => !t.url)) {
|
|
showMessage('Every site needs a URL', true);
|
|
return;
|
|
}
|
|
try {
|
|
currentConfig = await apiPut({ tabs });
|
|
showMessage('Sites saved');
|
|
renderSites(currentConfig.tabs);
|
|
populateAll(currentConfig);
|
|
} catch (e) {
|
|
showMessage(e.message, true);
|
|
}
|
|
}
|
|
|
|
sitesList.addEventListener('change', () => { updateHomeTabOptions(collectTabs()); });
|
|
|
|
/* ---------------------------------------------------------------------- */
|
|
/* Display & Interaction */
|
|
/* ---------------------------------------------------------------------- */
|
|
|
|
const displayForm = document.getElementById('display-form');
|
|
const homeTabSelect = document.getElementById('home-tab-select');
|
|
|
|
function updateHomeTabOptions(tabs) {
|
|
const previous = homeTabSelect.value;
|
|
homeTabSelect.textContent = '';
|
|
const disabledOpt = document.createElement('option');
|
|
disabledOpt.value = '-1';
|
|
disabledOpt.textContent = 'Disabled';
|
|
homeTabSelect.appendChild(disabledOpt);
|
|
tabs.forEach((tab, idx) => {
|
|
const opt = document.createElement('option');
|
|
opt.value = String(idx);
|
|
opt.textContent = tab.name || tab.url || `Page ${idx + 1}`;
|
|
homeTabSelect.appendChild(opt);
|
|
});
|
|
const stillValid = Array.from(homeTabSelect.options).some((o) => o.value === previous);
|
|
homeTabSelect.value = stillValid ? previous : '-1';
|
|
}
|
|
|
|
displayForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const fd = new FormData(displayForm);
|
|
const patch = {
|
|
swipeMode: fd.get('swipeMode'),
|
|
allowNavigation: fd.get('allowNavigation'),
|
|
enablePauseButton: fd.get('enablePauseButton') === 'on',
|
|
enableKeyboardButton: fd.get('enableKeyboardButton') === 'on',
|
|
enableNavButton: fd.get('enableNavButton') === 'on',
|
|
homeTabIndex: parseInt(fd.get('homeTabIndex'), 10),
|
|
inactivityTimeoutMinutes: parseInt(fd.get('inactivityTimeoutMinutes'), 10),
|
|
};
|
|
try {
|
|
currentConfig = await apiPut(patch);
|
|
showMessage('Display & Interaction saved');
|
|
populateAll(currentConfig);
|
|
} catch (err) {
|
|
showMessage(err.message, true);
|
|
}
|
|
});
|
|
|
|
/* ---------------------------------------------------------------------- */
|
|
/* Password Protection & Lockout */
|
|
/* ---------------------------------------------------------------------- */
|
|
|
|
const lockoutForm = document.getElementById('lockout-form');
|
|
const lockoutEnabled = document.getElementById('lockout-enabled');
|
|
const lockoutFields = document.getElementById('lockout-fields');
|
|
const dailyLockEnabled = document.getElementById('daily-lock-enabled');
|
|
const lockoutAtTime = document.getElementById('lockout-at-time');
|
|
const passwordLabel = document.getElementById('password-label');
|
|
|
|
function refreshLockoutFieldVisibility() {
|
|
lockoutFields.hidden = !lockoutEnabled.checked;
|
|
}
|
|
lockoutEnabled.addEventListener('change', refreshLockoutFieldVisibility);
|
|
|
|
dailyLockEnabled.addEventListener('change', () => {
|
|
lockoutAtTime.disabled = !dailyLockEnabled.checked;
|
|
if (!dailyLockEnabled.checked) lockoutAtTime.value = '';
|
|
});
|
|
|
|
lockoutForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
const fd = new FormData(lockoutForm);
|
|
const enable = fd.get('enablePasswordProtection') === 'on';
|
|
const newPassword = fd.get('newLockoutPassword') || '';
|
|
const confirmPassword = document.getElementById('lockout-password-confirm').value;
|
|
|
|
if (newPassword && newPassword !== confirmPassword) {
|
|
showMessage("Passwords don't match", true);
|
|
return;
|
|
}
|
|
if (enable && !newPassword && !(currentConfig && currentConfig.hasLockoutPassword)) {
|
|
showMessage('Set a lockout password before enabling password protection', true);
|
|
return;
|
|
}
|
|
|
|
const patch = { enablePasswordProtection: enable };
|
|
if (enable) {
|
|
if (newPassword) patch.newLockoutPassword = newPassword;
|
|
patch.lockoutTimeoutMinutes = parseInt(fd.get('lockoutTimeoutMinutes'), 10);
|
|
patch.lockoutAtTime = dailyLockEnabled.checked ? fd.get('lockoutAtTime') : '';
|
|
patch.requirePasswordOnBoot = fd.get('requirePasswordOnBoot') === 'on';
|
|
}
|
|
|
|
try {
|
|
currentConfig = await apiPut(patch);
|
|
showMessage('Password Protection & Lockout saved');
|
|
populateAll(currentConfig);
|
|
lockoutForm.querySelector('[name=newLockoutPassword]').value = '';
|
|
document.getElementById('lockout-password-confirm').value = '';
|
|
} catch (err) {
|
|
showMessage(err.message, true);
|
|
}
|
|
});
|
|
|
|
/* ---------------------------------------------------------------------- */
|
|
/* Populate forms from a config snapshot */
|
|
/* ---------------------------------------------------------------------- */
|
|
|
|
function populateAll(config) {
|
|
displayForm.elements.swipeMode.value = config.swipeMode;
|
|
displayForm.elements.allowNavigation.value = config.allowNavigation;
|
|
displayForm.elements.enablePauseButton.checked = !!config.enablePauseButton;
|
|
displayForm.elements.enableKeyboardButton.checked = !!config.enableKeyboardButton;
|
|
displayForm.elements.enableNavButton.checked = !!config.enableNavButton;
|
|
updateHomeTabOptions(config.tabs);
|
|
homeTabSelect.value = String(config.homeTabIndex);
|
|
displayForm.elements.inactivityTimeoutMinutes.value = Math.round(config.inactivityTimeout / 60);
|
|
|
|
lockoutEnabled.checked = !!config.enablePasswordProtection;
|
|
passwordLabel.textContent = config.hasLockoutPassword ? 'New password (leave blank to keep the current one)' : 'Set lockout password';
|
|
lockoutForm.elements.lockoutTimeoutMinutes.value = config.lockoutTimeout;
|
|
dailyLockEnabled.checked = !!config.lockoutAtTime;
|
|
lockoutAtTime.disabled = !config.lockoutAtTime;
|
|
lockoutAtTime.value = config.lockoutAtTime || '';
|
|
lockoutForm.elements.requirePasswordOnBoot.checked = !!config.requirePasswordOnBoot;
|
|
refreshLockoutFieldVisibility();
|
|
}
|
|
|
|
async function init() {
|
|
try {
|
|
currentConfig = await apiGet();
|
|
renderSites(currentConfig.tabs);
|
|
populateAll(currentConfig);
|
|
} catch (e) {
|
|
showMessage(e.message, true);
|
|
}
|
|
}
|
|
|
|
init();
|