diff --git a/code/html/src/index.css b/code/html/src/index.css index 34ea2d3b..640ac4c8 100644 --- a/code/html/src/index.css +++ b/code/html/src/index.css @@ -2,6 +2,35 @@ General -------------------------------------------------------------------------- */ +#layout.initial { + padding-left: 0; +} + +#layout.initial .content { + margin: 0 auto; +} + +#layout.initial .menu-link, +#layout.initial #menu { + display: none; +} + +#layout.initial .button-setup-password { + display: inline-block; +} + +#layout .button-setup-password { + display: none; +} + +#layout.initial .setup-password-initial { + display: block; +} + +#layout .setup-password-initial { + display: none; +} + #menu .pure-menu-heading { font-size: 100%; padding: .5em .5em; @@ -98,10 +127,6 @@ div.center { display: none; } -#password .content { - margin: 0 auto; -} - #layout .content { margin: 0; } @@ -229,6 +254,7 @@ label { .button-clear-messages, .button-clear-counts, .button-settings-factory, +.button-settings-password, .button-setup-password.button-setup-lenient { background: rgb(192, 0, 0); /* redish */ } diff --git a/code/html/src/index.html b/code/html/src/index.html index bf48f6b5..ea7562e4 100644 --- a/code/html/src/index.html +++ b/code/html/src/index.html @@ -19,14 +19,6 @@
-
-
- -
-
-
+ + diff --git a/code/html/src/index.mjs b/code/html/src/index.mjs index f61223c8..87be0607 100644 --- a/code/html/src/index.mjs +++ b/code/html/src/index.mjs @@ -7,9 +7,10 @@ window.addEventListener("error", (event) => { }); import { + onPanelTargetClick, pageReloadIn, randomString, - onPanelTargetClick, + showPanelByName, styleInject, } from './core.mjs'; @@ -19,10 +20,8 @@ import { askAndCall } from './question.mjs'; import { init as initSettings, - applySettings, + applySettingsFromForms, askSaveSettings, - getData, - setChangedElement, updateVariables, variableListeners, } from './settings.mjs'; @@ -143,16 +142,13 @@ function onMessage(value) { * @param {number} value */ function initWebMode(value) { - const initial = (1 === value); + const layout = /** @type {!HTMLElement} */ + (document.getElementById("layout")); + layout.style.display = "inherit"; - const layout = document.getElementById("layout") - if (layout) { - layout.style.display = (initial ? "none" : "inherit"); - } - - const password = document.getElementById("password"); - if (password) { - password.style.display = initial ? "inherit" : "none"; + if (1 === value) { + layout.classList.add("initial"); + showPanelByName("password"); } } @@ -326,9 +322,9 @@ function generatePasswordsForForm(form) { .map((x) => form.elements.namedItem(x)) .filter((x) => x instanceof HTMLInputElement) .forEach((elem) => { - setChangedElement(elem); elem.type = "text"; elem.value = value; + elem.dispatchEvent(new Event("change")); }); } @@ -341,14 +337,14 @@ function initSetupPassword(form) { elem.addEventListener("click", (event) => { event.preventDefault(); - const target = /** @type {HTMLInputElement} */ + const target = /** @type {!HTMLInputElement} */ (event.target); - const lenient = target.classList - .contains("button-setup-lenient"); + const strict = target.classList + .contains("button-setup-strict"); const forms = [form]; - if (validateFormsPasswords(forms, {strict: !lenient})) { - applySettings(getData(forms, {cleanup: false})); + if (validateFormsPasswords(forms, {strict})) { + applySettingsFromForms(forms); } }); }); @@ -433,11 +429,9 @@ function onJsonPayload(event) { } function init() { - // Initial page, when webMode only allows to change the password - const passwd = document.forms.namedItem("form-setup-password"); - if (passwd) { - initSetupPassword(passwd); - } + const password = /** @type {!HTMLFormElement} */ + (document.forms.namedItem("form-setup-password")); + initSetupPassword(password); document.querySelectorAll(".password-reveal") .forEach((elem) => { diff --git a/code/html/src/panel-admin.html b/code/html/src/panel-admin.html index 1378783c..6a88096f 100644 --- a/code/html/src/panel-admin.html +++ b/code/html/src/panel-admin.html @@ -19,26 +19,11 @@
- Access + Security
- - - +
- -
- - - -
- -
- - The administrator password is used to access this web interface (user 'admin'), but also to connect to the device when in AP mode or to flash a new firmware over-the-air (OTA).
- It must be 8..63 characters (numbers and letters and any of these special characters: _,.;:~!?@#$%^&*<>\|(){}[]) and have at least one lowercase and one uppercase or one number.
-
-
diff --git a/code/html/src/panel-password.html b/code/html/src/panel-password.html index dfd22288..771c0722 100644 --- a/code/html/src/panel-password.html +++ b/code/html/src/panel-password.html @@ -4,12 +4,15 @@
- -
+
Update the device password -

- Before using this device you are required to change the default password. This password will be used for: +

+ Before using this device you are required to change the default password. +

+ +

+ Password will be used for:

  • @@ -22,10 +25,10 @@
  • OTA - over-the-air updates
  • TELNET aka plain socket connection
-

+

Password must be 8…63 characters. Our password policy also requires:

-
    +
    • At least one special character: _,.;:~!?@#$%^&*<>\|(){}[])
    • At least one lowercase or uppercase letter
    • At least one number
    • @@ -44,8 +47,8 @@
- - + +
diff --git a/code/html/src/settings.mjs b/code/html/src/settings.mjs index 26163432..1314583f 100644 --- a/code/html/src/settings.mjs +++ b/code/html/src/settings.mjs @@ -1,6 +1,16 @@ import { notifyError } from './errors.mjs'; -import { pageReloadIn, count } from './core.mjs'; -import { send, sendAction, connectionUrls } from './connection.mjs'; +import { + count, + pageReloadIn, + showPanelByName, +} from './core.mjs'; + +import { + send, + sendAction, + connectionUrls, +} from './connection.mjs'; + import { validateForms } from './validate.mjs'; /** @@ -13,12 +23,17 @@ export function isChangedElement(elem) { /** * @param {Element} node */ -export function countChangedElements(node) { - const elems = /** @type {Array} */ - (Array.from(node.querySelectorAll( +export function getElements(node) { + return /** @type {Array} */( + Array.from(node.querySelectorAll( "input[data-changed],select[data-changed]"))); +} - return count(elems, isChangedElement); +/** + * @param {Element} node + */ +export function countChangedElements(node) { + return count(getElements(node), isChangedElement); } /** @@ -1271,18 +1286,24 @@ export function applySettings(settings) { send(JSON.stringify({settings})); } -export function applySettingsFromAllForms() { +/** @param {HTMLFormElement[]} forms */ +export function applySettingsFromForms(forms) { + applySettings(getData(forms)); + Settings.resetChanged(); + waitForSaved(); +} + +/** @param {Event} event */ +function applySettingsFromAllForms(event) { + event.preventDefault(); + const elems = /** @type {NodeListOf} */ (document.querySelectorAll("form.form-settings")); const forms = Array.from(elems); if (validateForms(forms)) { - applySettings(getData(forms)); - Settings.resetChanged(); - waitForSaved(); + applySettingsFromForms(forms); } - - return false; } /** @param {Event} event */ @@ -1373,10 +1394,7 @@ export function init() { ?.addEventListener("change", handleSettingsFile); document.querySelector(".button-save") - ?.addEventListener("click", (event) => { - event.preventDefault(); - applySettingsFromAllForms(); - }); + ?.addEventListener("click", applySettingsFromAllForms); document.querySelector(".button-settings-backup") ?.addEventListener("click", (event) => { @@ -1401,6 +1419,11 @@ export function init() { document.querySelector(".button-settings-factory") ?.addEventListener("click", resetToFactoryDefaults); + document.querySelector(".button-settings-password") + ?.addEventListener("click", () => { + showPanelByName("password"); + }); + document.querySelectorAll(".button-add-settings-group") .forEach((elem) => { elem.addEventListener("click", onGroupSettingsAddClick); diff --git a/code/html/src/validate.mjs b/code/html/src/validate.mjs index d6ab5748..7342a85d 100644 --- a/code/html/src/validate.mjs +++ b/code/html/src/validate.mjs @@ -1,10 +1,19 @@ -import { isChangedElement } from './settings.mjs'; +import { isChangedElement, getElements } from './settings.mjs'; + +// per. [RFC1035](https://datatracker.ietf.org/doc/html/rfc1035) +const INVALID_HOSTNAME = ` +Hostname cannot be empty and may only contain the ASCII letters ('A' through 'Z' and 'a' through 'z'), +the digits '0' through '9', and the hyphen ('-')! They can neither start or end with an hyphen.`; + +const INVALID_PASSWORD = "Invalid password!"; +const DIFFERENT_PASSWORD = "Passwords are different!"; +const EMPTY_PASSWORD = "Password cannot be empty!"; /** - * @param {string} password + * @param {string} value * @returns {boolean} */ -export function validatePassword(password) { +export function validatePassword(value) { // http://www.the-art-of-web.com/javascript/validate-password/ // at least one lowercase and one uppercase letter or number // at least eight characters (letters, numbers or special characters) @@ -14,89 +23,118 @@ export function validatePassword(password) { // https://github.com/xoseperez/espurna/issues/1151 const Pattern = /^(?=.*[A-Z\d])(?=.*[a-z])[\w~!@#$%^&*()<>,.?;:{}[\]\\|]{8,63}/; - return ((typeof password === "string") - && (password.length >= 8) - && Pattern.test(password)); + return ((typeof value === "string") + && (value.length >= 8) + && Pattern.test(value)); +} + +/** + * @typedef {{strict?: boolean}} ValidationOptions + */ + +/** + * @param {HTMLInputElement[]} pair + * @param {ValidationOptions} options + * @returns {boolean} + */ +function validatePasswords(pair, {strict = true} = {}) { + if (pair.length !== 2) { + alert(EMPTY_PASSWORD); + return false; + } + + if (pair.some((x) => !x.value.length)) { + alert(EMPTY_PASSWORD); + return false; + } + + if (pair[0].value !== pair[1].value) { + alert(DIFFERENT_PASSWORD); + return false; + } + + /** @param {HTMLInputElement} elem */ + function checkValidity(elem) { + if (!elem.checkValidity()) { + return false; + } + + return !strict || validatePassword(elem.value); + } + + if (pair.every(checkValidity)) { + return true; + } + + alert(INVALID_PASSWORD); + return false; + } /** * Try to validate 'adminPass{0,1}', searching the first form containing both. * In case it's default webMode, avoid checking things when both fields are empty (`required === false`) * @param {HTMLFormElement[]} forms - * @param {{required?: boolean, strict?: boolean}} options + * @param {ValidationOptions} options * @returns {boolean} */ -export function validateFormsPasswords(forms, {required = true, strict = true} = {}) { - const [first, second] = Array.from(forms) - .flatMap((x) => { - return [ - x.elements.namedItem("adminPass0"), - x.elements.namedItem("adminPass1"), - ]; - }) +export function validateFormsPasswords(forms, {strict = true} = {}) { + const pair = Array.from(forms) + .flatMap((x) => [ + x.elements.namedItem("adminPass0"), + x.elements.namedItem("adminPass1"), + ]) .filter((x) => x instanceof HTMLInputElement); - if (first && second) { - if (!required && !first.value.length && !second.value.length) { - return true; - } - - if (first.value !== second.value) { - alert("Passwords are different!"); - return false; - } - - const firstValid = first.checkValidity() - && (!strict || validatePassword(first.value)); - const secondValid = second.checkValidity() - && (!strict || validatePassword(second.value)); - - if (firstValid && secondValid) { - return true; - } - } - - alert(`Invalid password!`); - - return false; -} - -/** - * Same as above, but only applies to the general settings page. - * Find the first available form that contains 'hostname' input - * @param {HTMLFormElement[]} forms - */ -export function validateFormsHostname(forms) { - // per. [RFC1035](https://datatracker.ietf.org/doc/html/rfc1035) - // Hostname may contain: - // - the ASCII letters 'a' through 'z' (case-insensitive), - // - the digits '0' through '9', and the hyphen. - // Hostname labels cannot begin or end with a hyphen. - // No other symbols, punctuation characters, or blank spaces are permitted. - const [hostname] = Array.from(forms) - .flatMap(form => form.elements.namedItem("hostname")) - .filter((x) => x instanceof HTMLInputElement); - if (!hostname) { - return true; - } - - // Validation pattern is attached to the element itself, so just check that. - // (and, we also re-use the hostname for fallback SSID, thus limited to 1...32 chars instead of 1...63) - - const result = (hostname.value.length > 0) - && (!isChangedElement(hostname) || hostname.checkValidity()); - if (!result) { - alert(`Hostname cannot be empty and may only contain the ASCII letters ('A' through 'Z' and 'a' through 'z'), - the digits '0' through '9', and the hyphen ('-')! They can neither start or end with an hyphen.`); - } - - return result; + return validatePasswords(pair, {strict}); } /** * @param {HTMLFormElement[]} forms */ export function validateForms(forms) { - return validateFormsPasswords(forms, {strict: false}) - && validateFormsHostname(forms); + const elems = forms + .flatMap((form) => getElements(form)) + .filter(isChangedElement) + + if (!elems.length) { + return false; + } + + /** @type {HTMLInputElement[]} */ + const passwords = []; + + for (let elem of elems) { + switch (elem.name) { + case "hostname": + if (!elem.checkValidity()) { + alert(INVALID_HOSTNAME); + return false; + } + break; + + case "adminPass0": + case "adminPass1": + if (!(elem instanceof HTMLInputElement)) { + return false; + } + + passwords.push(elem); + break; + + default: + if (!elem.checkValidity()) { + return false; + } + break; + } + } + + if ((passwords.length > 0) + && (!validatePasswords(passwords, {strict: false}))) + { + return false; + } + + return true; }