mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 23:05:10 +00:00
* feat(web): Schedule and General become ES-module pages (stage 3) Schedule and General follow stage 2 (#727): no inline scripts or inline handlers in either partial. Their code moves to static/v3/js/pages/schedule.js and pages/general.js, started per swap-in by the page registry. - Schedule: both pickers are drawn from the saved config carried as JSON in data-* attributes. The forms' hx-on save handlers become one htmx:afterRequest listener on the page; the forms are marked data-reports-result, which app.js now treats like an hx-on after-request handler, so a save still shows one notification. - General: the timezone picker reads data-timezone. The Security section's forms and buttons are delegated data-actions; requests go through core/api.js, so the login redirect is quiet, and a change made just before a swap is still reported. - handleScheduleResponse, handleDimScheduleResponse and webLogin stay as deprecated aliases through window.LEDMatrix. - New DOM suites test_schedule_page.js and test_general_page.js; the web login unit suite imports the module; test_es_modules.py pins the pages, the aliases, and the schedule config's round trip through its attribute. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): no unused catch bindings or computed writes in the stage 3 modules Codacy flagged two unused catch variables and dynamic-key writes in pages/schedule.js and boot.js. The schedule config is read with getAttribute, and the default days and the webLogin alias object are built with Object.fromEntries. No behaviour change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
263 lines
11 KiB
JavaScript
263 lines
11 KiB
JavaScript
/*
|
|
* pages/general.js -- the General tab (templates/v3/partials/general.html).
|
|
*
|
|
* The settings form stays plain htmx (hx-post to /api/v3/config/main; its
|
|
* hx-on calls the shared showSaveResult). This module:
|
|
* - draws the timezone-selector widget into #timezone_container, with the
|
|
* saved zone from its data-timezone attribute
|
|
* - runs the optional Security section (web login, #683): three forms and
|
|
* two kinds of button, all marked data-action and handled by one
|
|
* delegated submit and one delegated click listener on the page root, so
|
|
* a token row added later needs no listener of its own
|
|
*
|
|
* Login changes are writes: they are not cancelled when the page is swapped
|
|
* away, so the server always finishes and the result is still reported, but
|
|
* nothing is drawn into a page that has gone. Token names reach the page as
|
|
* text.
|
|
*
|
|
* window.webLogin (setPassword, disable, createToken, copyToken, revoke) is a
|
|
* deprecated alias made in core/boot.js; it forwards to the webLogin export
|
|
* at the bottom of this file.
|
|
*/
|
|
|
|
const API = '/api/v3/auth';
|
|
const RETRY_MS = 50;
|
|
const MAX_TRIES = 200;
|
|
|
|
// The mounted page, for the deprecated alias (boot.js).
|
|
let active = null;
|
|
|
|
function windowOf(ctx) {
|
|
return ctx.root.ownerDocument.defaultView;
|
|
}
|
|
|
|
function find(ctx, id) {
|
|
return ctx.root.querySelector('#' + id);
|
|
}
|
|
|
|
function field(form, name) {
|
|
const el = form.querySelector('[name="' + name + '"]');
|
|
return el ? el.value : '';
|
|
}
|
|
|
|
/**
|
|
* A login request. Resolves to {ok, d}: d is the JSON body, also for a
|
|
* refused request (its message is shown). A network failure rejects; the
|
|
* optional login's own "sign in again" answer resolves to null, since the
|
|
* page is already on its way to the login form.
|
|
*/
|
|
function send(ctx, method, url, body) {
|
|
return ctx.api.request(method, url, body === undefined ? {} : { json: body }).then(function(d) {
|
|
return { ok: true, d: d };
|
|
}, function(err) {
|
|
if (err && err.loginRequired) return null;
|
|
// Only an HTTP answer is a refusal; anything else is a failure.
|
|
if (!err || err.name !== 'ApiError' || err.network || !err.status) throw err;
|
|
return { ok: false, d: err.body && typeof err.body === 'object' ? err.body : {} };
|
|
});
|
|
}
|
|
|
|
function failed(ctx) {
|
|
return function(err) { ctx.notify('Request failed: ' + ((err && err.message) || err), 'error'); };
|
|
}
|
|
|
|
function reloadSection(ctx) {
|
|
if (ctx.signal.aborted) return; // already swapped for a fresh copy
|
|
const win = windowOf(ctx);
|
|
if (win.htmx) {
|
|
win.htmx.ajax('GET', '/v3/partials/general', { target: '#general-content', swap: 'innerHTML' });
|
|
} else {
|
|
win.location.reload();
|
|
}
|
|
}
|
|
|
|
function tokenRow(doc, record) {
|
|
const row = doc.createElement('div');
|
|
row.className = 'flex flex-wrap items-center justify-between gap-2 border border-gray-200 rounded-md px-4 py-2';
|
|
row.dataset.tokenId = record.id;
|
|
const text = doc.createElement('div');
|
|
text.className = 'text-sm';
|
|
[['font-semibold text-gray-900', record.name],
|
|
['font-mono text-gray-600 ml-2', record.prefix + '…'],
|
|
['text-gray-600 ml-2', 'created just now']].forEach(function(part) {
|
|
const span = doc.createElement('span');
|
|
span.className = part[0];
|
|
span.textContent = part[1];
|
|
text.appendChild(span);
|
|
});
|
|
const button = doc.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'text-sm text-red-600 hover:underline';
|
|
button.dataset.action = 'revoke-token';
|
|
button.dataset.tokenName = record.name;
|
|
button.textContent = 'Revoke';
|
|
row.appendChild(text);
|
|
row.appendChild(button);
|
|
return row;
|
|
}
|
|
|
|
/** The password form: set (or change) the password, then redraw the section. */
|
|
export function setPassword(ctx, form) {
|
|
const next = field(form, 'new_password');
|
|
if (next !== field(form, 'confirm_password')) {
|
|
ctx.notify('The two new passwords do not match.', 'error');
|
|
return Promise.resolve(false);
|
|
}
|
|
const body = { new_password: next };
|
|
if (form.querySelector('[name="current_password"]')) body.current_password = field(form, 'current_password');
|
|
return send(ctx, 'POST', API + '/password', body).then(function(res) {
|
|
if (!res) return false;
|
|
ctx.notify(res.d.message || (res.ok ? 'Saved' : 'Could not save the password'), res.ok ? 'success' : 'error');
|
|
if (res.ok) reloadSection(ctx);
|
|
return res.ok;
|
|
}).catch(failed(ctx));
|
|
}
|
|
|
|
/** The "Turn login off" form. */
|
|
export function disableLogin(ctx, form) {
|
|
if (!windowOf(ctx).confirm('Turn login off? Anyone on your network will be able to open this page.')) {
|
|
return Promise.resolve(false);
|
|
}
|
|
return send(ctx, 'POST', API + '/disable', { current_password: field(form, 'current_password') }).then(function(res) {
|
|
if (!res) return false;
|
|
ctx.notify(res.d.message || (res.ok ? 'Login is off' : 'Could not turn login off'), res.ok ? 'success' : 'error');
|
|
if (res.ok) reloadSection(ctx);
|
|
return res.ok;
|
|
}).catch(failed(ctx));
|
|
}
|
|
|
|
/** The "Create token" form: add the row and show the token once. */
|
|
export function createToken(ctx, form) {
|
|
return send(ctx, 'POST', API + '/tokens', { name: field(form, 'name') }).then(function(res) {
|
|
if (!res) return false;
|
|
if (!res.ok) {
|
|
ctx.notify(res.d.message || 'Could not create the token', 'error');
|
|
return false;
|
|
}
|
|
const data = res.d.data || {};
|
|
// app.js marks a form dirty on input and clears the mark only after
|
|
// an htmx save; this one posts with fetch, so clear it here or a
|
|
// reload asks "Leave site?" about a saved token.
|
|
form.reset();
|
|
form.removeAttribute('data-dirty');
|
|
if (!ctx.signal.aborted) {
|
|
const list = find(ctx, 'web-login-tokens');
|
|
if (list && data.record) {
|
|
const empty = list.querySelector('[data-empty]');
|
|
if (empty) empty.remove();
|
|
list.appendChild(tokenRow(ctx.root.ownerDocument, data.record));
|
|
}
|
|
const value = find(ctx, 'web-login-new-token-value');
|
|
const box = find(ctx, 'web-login-new-token');
|
|
if (value) value.textContent = data.token;
|
|
if (box) box.classList.remove('hidden');
|
|
}
|
|
ctx.notify(res.d.message || 'Token created', 'success');
|
|
return true;
|
|
}).catch(failed(ctx));
|
|
}
|
|
|
|
/** The Copy button next to a new token. */
|
|
export function copyToken(ctx) {
|
|
const win = windowOf(ctx);
|
|
const box = find(ctx, 'web-login-new-token-value');
|
|
if (!box) return;
|
|
if (win.navigator.clipboard && win.isSecureContext) {
|
|
win.navigator.clipboard.writeText(box.textContent).then(function() { ctx.notify('Token copied', 'success'); });
|
|
return;
|
|
}
|
|
// Plain http on a LAN is not a secure context: select it instead.
|
|
const range = ctx.root.ownerDocument.createRange();
|
|
range.selectNodeContents(box);
|
|
const selection = win.getSelection();
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
ctx.notify('Selected: press Ctrl+C (or Cmd+C) to copy.', 'info');
|
|
}
|
|
|
|
/** A token row's Revoke button. */
|
|
export function revokeToken(ctx, id, name) {
|
|
if (!windowOf(ctx).confirm('Revoke the token "' + name + '"? Anything using it stops working.')) {
|
|
return Promise.resolve(false);
|
|
}
|
|
return send(ctx, 'DELETE', API + '/tokens/' + encodeURIComponent(id)).then(function(res) {
|
|
if (!res) return false;
|
|
ctx.notify(res.d.message || (res.ok ? 'Token revoked' : 'Could not revoke the token'), res.ok ? 'success' : 'error');
|
|
if (!res.ok) return false;
|
|
ctx.root.querySelectorAll('#web-login-tokens [data-token-id]').forEach(function(row) {
|
|
if (row.dataset.tokenId === id) row.remove();
|
|
});
|
|
return true;
|
|
}).catch(failed(ctx));
|
|
}
|
|
|
|
const FORM_ACTIONS = { 'set-password': setPassword, 'disable-login': disableLogin, 'create-token': createToken };
|
|
|
|
function drawTimezone(root, ctx) {
|
|
const win = root.ownerDocument.defaultView;
|
|
let tries = 0;
|
|
function attempt() {
|
|
ctx.state.timer = null;
|
|
if (ctx.signal.aborted) return;
|
|
const container = root.querySelector('#timezone_container');
|
|
if (!container) return;
|
|
const widget = win.LEDMatrixWidgets && win.LEDMatrixWidgets.get('timezone-selector');
|
|
if (!widget) {
|
|
if (++tries < MAX_TRIES) ctx.state.timer = win.setTimeout(attempt, RETRY_MS);
|
|
else win.console.error('[General] timezone-selector widget not available');
|
|
return;
|
|
}
|
|
// Only render if container is empty (not already rendered)
|
|
if (container.children.length > 0) return;
|
|
widget.render(container, {
|
|
'x-options': { showOffset: true, placeholder: 'Select your timezone...' },
|
|
}, container.dataset.timezone || 'America/Chicago', {
|
|
fieldId: 'timezone',
|
|
name: 'timezone',
|
|
});
|
|
}
|
|
attempt();
|
|
}
|
|
|
|
export function init(root, ctx) {
|
|
const on = { signal: ctx.signal };
|
|
root.addEventListener('submit', function(event) {
|
|
const form = event.target;
|
|
const action = form && form.dataset ? FORM_ACTIONS[form.dataset.action] : null;
|
|
if (!action) return; // the settings form: htmx posts it
|
|
event.preventDefault();
|
|
action(ctx, form);
|
|
}, on);
|
|
root.addEventListener('click', function(event) {
|
|
const button = event.target.closest('button[data-action]');
|
|
if (!button || !root.contains(button)) return;
|
|
if (button.dataset.action === 'copy-token') {
|
|
copyToken(ctx);
|
|
} else if (button.dataset.action === 'revoke-token') {
|
|
const row = button.closest('[data-token-id]');
|
|
if (row) revokeToken(ctx, row.dataset.tokenId, button.dataset.tokenName);
|
|
}
|
|
}, on);
|
|
drawTimezone(root, ctx);
|
|
active = ctx;
|
|
}
|
|
|
|
export function destroy(root, ctx) {
|
|
if (ctx.state.timer) {
|
|
root.ownerDocument.defaultView.clearTimeout(ctx.state.timer);
|
|
ctx.state.timer = null;
|
|
}
|
|
if (active === ctx) active = null;
|
|
}
|
|
|
|
// ── the old global, kept as a deprecated alias (boot.js) ─────────────────────
|
|
// window.webLogin's methods took the form (or the token's id and name) and
|
|
// acted on the General tab on screen.
|
|
export const webLogin = Object.freeze({
|
|
setPassword: function(form) { return active ? setPassword(active, form) : undefined; },
|
|
disable: function(form) { return active ? disableLogin(active, form) : undefined; },
|
|
createToken: function(form) { return active ? createToken(active, form) : undefined; },
|
|
copyToken: function() { return active ? copyToken(active) : undefined; },
|
|
revoke: function(id, name) { return active ? revokeToken(active, id, name) : undefined; },
|
|
});
|