mirror of
https://github.com/xoseperez/espurna.git
synced 2026-03-06 16:27:12 +01:00
webui: password webmode as a panel
This commit is contained in:
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -19,14 +19,6 @@
|
||||
<div id="error-notification" class="pure-g content">
|
||||
</div>
|
||||
|
||||
<div id="password" class="webmode">
|
||||
<div class="content">
|
||||
<object type="text/html"
|
||||
data="./panel-password.html"
|
||||
inline inline-raw="true"></object>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="layout" class="webmode">
|
||||
<div class="menu-link">
|
||||
<span></span>
|
||||
@@ -78,6 +70,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<object type="text/html"
|
||||
data="./panel-password.html"
|
||||
inline inline-raw="true"></object>
|
||||
|
||||
<object type="text/html"
|
||||
data="./panel-admin.html"
|
||||
inline inline-raw="true"></object>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -19,26 +19,11 @@
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Access</legend>
|
||||
<legend>Security</legend>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label>Admin password</label>
|
||||
<input name="adminPass0" placeholder="New password" data-settings-real-name="adminPass" minlength="8" maxlength="63" type="password" data-action="reboot" autocomplete="new-password" spellcheck="false">
|
||||
<span class="no-select password-reveal"></span>
|
||||
<button type="button" class="pure-button button-settings-password pure-input-1">Change Admin Password</button>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<label>Repeat password</label>
|
||||
<input name="adminPass1" placeholder="Repeat password" data-settings-real-name="adminPass" minlength="8" maxlength="63" type="password" data-action="reboot" autocomplete="new-password" spellcheck="false">
|
||||
<span class="no-select password-reveal"></span>
|
||||
</div>
|
||||
|
||||
<div class="pure-control-group">
|
||||
<span class="pure-form-message">
|
||||
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).<br>
|
||||
It must be <strong>8..63 characters</strong> (numbers and letters and any of these special characters: _,.;:~!?@#$%^&*<>\|(){}[]) and have at least <strong>one lowercase</strong> and <strong>one uppercase</strong> or <strong>one number</strong>.</span>
|
||||
</div>
|
||||
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
|
||||
@@ -4,12 +4,15 @@
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<form id="form-setup-password" class="pure-form pure-form-aligned">
|
||||
<form id="form-setup-password" class="pure-form pure-form-aligned form-settings">
|
||||
<fieldset>
|
||||
<legend>Update the device password</legend>
|
||||
<p>
|
||||
Before using this device you are required to change the default password. This password will be used for:
|
||||
<p class="setup-password-initial">
|
||||
Before using this device you are required to change the default password.
|
||||
</p>
|
||||
|
||||
</p>
|
||||
Password will be used for:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
@@ -22,10 +25,10 @@
|
||||
<li class="module-ota"><strong>OTA</strong> - over-the-air updates</li>
|
||||
<li class="module-telnet"><strong>TELNET</strong> aka plain socket connection</li>
|
||||
</ul>
|
||||
<p>
|
||||
<p class="setup-password-initial">
|
||||
Password <strong>must</strong> be <strong>8…63 characters</strong>. Our password policy also requires:
|
||||
</p>
|
||||
<ul>
|
||||
<ul class="setup-password-initial">
|
||||
<li>At least one <strong>special character</strong>: <code>_,.;:~!?@#$%^&*<>\|(){}[])</code></li>
|
||||
<li>At least one <strong>lowercase</strong> or <strong>uppercase</strong> letter</li>
|
||||
<li>At least one <strong>number</strong></li>
|
||||
@@ -44,8 +47,8 @@
|
||||
</div>
|
||||
|
||||
<button class="pure-input-1 pure-button button-generate-password" type="button" title="Generate password based on password policy">Generate</button>
|
||||
<button class="pure-input-1 pure-button button-setup-password" type="button" title="Save new password">Save</button>
|
||||
<button class="pure-input-1 pure-button button-setup-password button-setup-lenient" type="button" title="Save new password">Save without checking password policy</button>
|
||||
<button class="pure-input-1 pure-button button-setup-password button-setup-strict" type="button" title="Save new password">Save</button>
|
||||
<button class="pure-input-1 pure-button button-setup-password" type="button" title="Save new password">Save without checking password policy</button>
|
||||
</fieldset>
|
||||
|
||||
</form>
|
||||
|
||||
@@ -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<InputOrSelect>} */
|
||||
(Array.from(node.querySelectorAll(
|
||||
export function getElements(node) {
|
||||
return /** @type {Array<InputOrSelect>} */(
|
||||
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<HTMLFormElement>} */
|
||||
(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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user