mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-10-23 06:12:19 +00:00
feat(web_viewer): mask plugin password fields in settings (#273)
Add a password type to settings_schema so plugin secrets render as masked inputs and never reach the browser. Stored values stay in config.ini; an empty box on save leaves an existing secret unchanged.
This commit is contained in:
1 parent
f4903b11f6
commit
19c3d4912a
4 files changed
+110
-5
No files matched your search
@@ -19,7 +19,7 @@ Schema field format (a list of these dicts on ``settings_schema``)::
|
||||
{
|
||||
"key": "poll_interval", # config key within the section
|
||||
"label": "Poll interval", # human label
|
||||
"type": "int", # bool|int|float|str|enum|list
|
||||
"type": "int", # bool|int|float|str|enum|list|password
|
||||
"options": [{"value": "...", "label": "..."}], # required for enum
|
||||
"min": 1000, "max": None, # numeric bounds (int/float)
|
||||
"default": 60000,
|
||||
@@ -43,7 +43,7 @@ from typing import Any, Optional
|
||||
# so a plugin's on/off state displays correctly before the first canonical save.
|
||||
from modules.config_schema import LEGACY_ENABLED_ALIASES as _ENABLED_LEGACY_ALIASES
|
||||
|
||||
VALID_TYPES = {"bool", "int", "float", "str", "enum", "list"}
|
||||
VALID_TYPES = {"bool", "int", "float", "str", "enum", "list", "password"}
|
||||
|
||||
# Truthy/falsey string forms accepted for bool fields (configparser-compatible).
|
||||
_TRUE = {"1", "true", "yes", "on"}
|
||||
@@ -122,7 +122,8 @@ def validate_field(field: dict, raw: Any) -> tuple[bool, Any, Optional[str]]:
|
||||
return False, None, f"{label} contains an invalid value: {item}"
|
||||
return True, items, None
|
||||
|
||||
# str (default)
|
||||
# str / password (password is a str stored in plaintext in config.ini,
|
||||
# masked only in the web UI — same validation as str)
|
||||
s = str(raw)
|
||||
pattern = field.get("pattern")
|
||||
if pattern and not re.fullmatch(pattern, s):
|
||||
@@ -330,6 +331,12 @@ def _assemble_entry(
|
||||
continue
|
||||
resolved = dict(field)
|
||||
resolved["value"] = _read_typed(config, section, field)
|
||||
if field.get("type") == "password":
|
||||
# Never send the plaintext secret to the browser; the UI shows a
|
||||
# placeholder when has_value is true and only submits a new value
|
||||
# if the user actually types one.
|
||||
resolved["has_value"] = bool(resolved["value"])
|
||||
resolved["value"] = ""
|
||||
fields.append(resolved)
|
||||
|
||||
# Every command can restrict which channels it responds in
|
||||
|
||||
@@ -600,6 +600,36 @@ function renderField(field, overrideValue) {
|
||||
wrapper.appendChild(group);
|
||||
if (field.help) wrapper.appendChild(helpText(field.help));
|
||||
return { wrapper, input };
|
||||
} else if (field.type === 'password') {
|
||||
const group = document.createElement('div');
|
||||
group.className = 'input-group input-group-sm has-validation';
|
||||
input = document.createElement('input');
|
||||
input.className = 'form-control';
|
||||
input.type = 'password';
|
||||
input.autocomplete = 'new-password';
|
||||
if (field.pattern) input.pattern = field.pattern;
|
||||
input.value = (value !== undefined && value !== null) ? value : '';
|
||||
input.placeholder = field.has_value ? '(saved — enter new value to change)' : '';
|
||||
group.appendChild(input);
|
||||
const toggleBtn = document.createElement('button');
|
||||
toggleBtn.type = 'button';
|
||||
toggleBtn.className = 'btn btn-outline-secondary';
|
||||
const toggleIcon = document.createElement('i');
|
||||
toggleIcon.className = 'fas fa-eye';
|
||||
toggleBtn.appendChild(toggleIcon);
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
const showing = input.type === 'text';
|
||||
input.type = showing ? 'password' : 'text';
|
||||
toggleIcon.classList.toggle('fa-eye', showing);
|
||||
toggleIcon.classList.toggle('fa-eye-slash', !showing);
|
||||
});
|
||||
group.appendChild(toggleBtn);
|
||||
const fb = document.createElement('div');
|
||||
fb.className = 'invalid-feedback';
|
||||
group.appendChild(fb);
|
||||
wrapper.appendChild(group);
|
||||
if (field.help) wrapper.appendChild(helpText(field.help));
|
||||
return { wrapper, input };
|
||||
} else {
|
||||
input = document.createElement('input');
|
||||
input.className = 'form-control form-control-sm';
|
||||
@@ -638,7 +668,7 @@ function fieldColClass(field) {
|
||||
if (field.type === 'int' || field.type === 'float' || field.type === 'enum') {
|
||||
return 'col-6 col-md-4 col-xl-3';
|
||||
}
|
||||
return 'col-12 col-sm-6 col-xl-4'; // str / list — medium
|
||||
return 'col-12 col-sm-6 col-xl-4'; // str / list / password — medium
|
||||
}
|
||||
|
||||
// Badge marking a field that reads/writes a shared section (not the plugin's own).
|
||||
@@ -870,6 +900,7 @@ function validateClient(field, raw) {
|
||||
const empty = (raw === '' || raw === null || raw === undefined);
|
||||
if (field.type === 'bool') return null;
|
||||
if (empty) {
|
||||
if (field.type === 'password' && field.has_value) return null; // leave unchanged
|
||||
return field.required ? `${field.label} is required` : null;
|
||||
}
|
||||
if (field.type === 'int' || field.type === 'float') {
|
||||
@@ -883,7 +914,7 @@ function validateClient(field, raw) {
|
||||
const allowed = (field.options || []).map(o => String(o.value));
|
||||
if (!allowed.includes(String(raw))) return `${field.label} is invalid`;
|
||||
}
|
||||
if (field.type === 'str' && field.pattern) {
|
||||
if ((field.type === 'str' || field.type === 'password') && field.pattern) {
|
||||
if (!new RegExp('^(?:' + field.pattern + ')$').test(raw)) return `${field.label} has an invalid format`;
|
||||
}
|
||||
return null;
|
||||
|
||||
Reference in new issue
Block a user