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:
Gerard Hickey authored and GitHub committed 2026-09-13 12:57:01 -07:00
1 parent f4903b11f6
commit 19c3d4912a
4 files changed
+110 -5

No files matched your search

+10 -3
View File
@@ -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
+33 -2
View File
@@ -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;