mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-05-31 16:13:31 +00:00
fix(security): address CodeQL and coderabbit review findings
## Security fixes
### pages_v3.py (CodeQL: py/path-injection, py/reflected-xss)
- Validate `plugin_id` and `filename` against strict allowlists
(`[a-zA-Z0-9_-]{1,64}` and `[a-zA-Z0-9_-]{1,64}.html`) before any
path or script operations — satisfies CodeQL path-injection checks
- Error responses returned as `text/plain` with no user data in body
- HTML-meta-char escaping on PLUGIN_ID value in script tag (defence in depth)
### array-table.js (CodeQL: js/prototype-pollution)
- Guard `setNestedValue()` against `__proto__`, `prototype`, and
`constructor` keys; silently drops any write targeting those keys
### plugin-file-manager.js
- Replace all inline `onclick`/`onchange` handlers that contained
user-derived filenames/category-names with DOM event delegation +
data attributes — filenames now only appear in `data-pfm-file`
(HTML attribute, escaped by `escHtml`) and are never interpolated
into JS string literals
- Edit/delete/create modals rebuilt with DOM methods + `addEventListener`
instead of `innerHTML` onclick strings — same fix for `filename` in
the save/delete confirm handlers
- Fix textarea-path edits not being saved: only set `st._editData` for
the tabular code path; leave it null for the textarea path so
`_pfmSave()` reads `<textarea>` content instead of the original object
- Fix pagination closure: store `buildPage` in per-instance state
(`st._buildPage`); `window._pfmTablePage` dispatches to the correct
instance by fieldId — multiple instances no longer clobber each other
### time-picker.js
- Call `widget.validate(fieldId)` after `onClear()` to keep required-field
error state accurate when the field is cleared
### plugin_config.html
- Honor `x_widget` alias (underscore) alongside `x-widget` (hyphen) in
the new server-side array-table column rendering branches
- Same fix for the `has_file_manager_widget` suppression check
### widget-guide.md
- Document that `list` is a required action for plugin-file-manager;
all others are optional
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -47,7 +47,7 @@ Full inline file management UI for plugins that manage files via the `web_ui_act
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Not all 7 actions are required — omit any key to hide the corresponding UI element (e.g., no `create` = no New File button, no `toggle` = no enable/disable switch).
|
**`list` is required** — the widget calls it on render to populate the file grid; omitting it leaves the widget stuck in a loading state. All other actions are optional — omit any key to hide its UI element (e.g., no `create` = no New File button, no `toggle` = no enable/disable switch).
|
||||||
|
|
||||||
The edit view auto-detects whether file content is tabular (object-of-objects with uniform keys) and shows a paginated table editor with inline cells. Otherwise falls back to a JSON textarea.
|
The edit view auto-detects whether file content is tabular (object-of-objects with uniform keys) and shows a paginated table editor with inline cells. Otherwise falls back to a JSON textarea.
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Strict allowlists for URL-derived values used in path and script operations.
|
||||||
|
_SAFE_PLUGIN_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
||||||
|
_SAFE_WEB_UI_FILE_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.html$')
|
||||||
from src.web_interface.secret_helpers import mask_secret_fields
|
from src.web_interface.secret_helpers import mask_secret_fields
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -110,23 +114,35 @@ def serve_plugin_web_ui(plugin_id, filename):
|
|||||||
Wraps the fragment with a minimal HTML page that injects window.PLUGIN_ID
|
Wraps the fragment with a minimal HTML page that injects window.PLUGIN_ID
|
||||||
and loads Tailwind CSS so the fragment runs correctly in a sandboxed iframe.
|
and loads Tailwind CSS so the fragment runs correctly in a sandboxed iframe.
|
||||||
"""
|
"""
|
||||||
|
# Validate URL-derived values against strict allowlists before any path or
|
||||||
|
# script operations. This satisfies CodeQL py/path-injection and
|
||||||
|
# py/reflected-xss by ensuring neither value contains path separators,
|
||||||
|
# HTML/JS special chars, or other unexpected content.
|
||||||
|
if not _SAFE_PLUGIN_ID_RE.match(plugin_id):
|
||||||
|
return 'Invalid plugin ID', 400, {'Content-Type': 'text/plain'}
|
||||||
|
if not _SAFE_WEB_UI_FILE_RE.match(filename):
|
||||||
|
return 'Invalid filename', 400, {'Content-Type': 'text/plain'}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
|
_plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
|
||||||
_plugin_dir = (_plugins_base / plugin_id).resolve()
|
_plugin_dir = (_plugins_base / plugin_id).resolve()
|
||||||
# Path traversal guard — plugin_dir must be inside plugins base
|
# Path containment guard — plugin_dir must be inside plugins base
|
||||||
_plugin_dir.relative_to(_plugins_base)
|
_plugin_dir.relative_to(_plugins_base)
|
||||||
|
|
||||||
web_ui_path = (_plugin_dir / 'web_ui' / filename).resolve()
|
web_ui_path = (_plugin_dir / 'web_ui' / filename).resolve()
|
||||||
# Second guard — web_ui_path must stay inside web_ui/
|
# Second containment guard — must stay inside the plugin's web_ui dir
|
||||||
web_ui_path.relative_to(_plugin_dir / 'web_ui')
|
web_ui_path.relative_to(_plugin_dir / 'web_ui')
|
||||||
|
|
||||||
if not web_ui_path.exists():
|
if not web_ui_path.exists():
|
||||||
return f'web_ui file not found: {filename}', 404
|
return 'Not found', 404, {'Content-Type': 'text/plain'}
|
||||||
if web_ui_path.suffix.lower() != '.html':
|
|
||||||
return 'Only .html files may be served here', 403
|
|
||||||
|
|
||||||
fragment = web_ui_path.read_text(encoding='utf-8')
|
fragment = web_ui_path.read_text(encoding='utf-8')
|
||||||
|
|
||||||
|
# json.dumps produces a safe JSON string, but </script> inside it could
|
||||||
|
# break the enclosing script tag. Re-encode those bytes as Unicode
|
||||||
|
# escapes so the value is inert in an HTML context.
|
||||||
|
safe_plugin_id_js = json.dumps(plugin_id).replace('<', r'<').replace('>', r'>').replace('&', r'&')
|
||||||
|
|
||||||
page = (
|
page = (
|
||||||
'<!DOCTYPE html>\n'
|
'<!DOCTYPE html>\n'
|
||||||
'<html lang="en">\n'
|
'<html lang="en">\n'
|
||||||
@@ -134,8 +150,10 @@ def serve_plugin_web_ui(plugin_id, filename):
|
|||||||
'<meta charset="UTF-8">\n'
|
'<meta charset="UTF-8">\n'
|
||||||
'<meta name="viewport" content="width=device-width,initial-scale=1">\n'
|
'<meta name="viewport" content="width=device-width,initial-scale=1">\n'
|
||||||
'<script>\n'
|
'<script>\n'
|
||||||
# Inject plugin context before the fragment runs
|
# Inject plugin context before the fragment runs.
|
||||||
f' window.PLUGIN_ID = {json.dumps(plugin_id)};\n'
|
# plugin_id is validated to [a-zA-Z0-9_-] above, so this is safe,
|
||||||
|
# but we also Unicode-escape HTML meta-chars as defence in depth.
|
||||||
|
f' window.PLUGIN_ID = {safe_plugin_id_js};\n'
|
||||||
'</script>\n'
|
'</script>\n'
|
||||||
# Tailwind v2 CDN — same version used by the parent LEDMatrix UI
|
# Tailwind v2 CDN — same version used by the parent LEDMatrix UI
|
||||||
'<link rel="stylesheet" '
|
'<link rel="stylesheet" '
|
||||||
@@ -150,10 +168,10 @@ def serve_plugin_web_ui(plugin_id, filename):
|
|||||||
return page, 200, {'Content-Type': 'text/html; charset=utf-8'}
|
return page, 200, {'Content-Type': 'text/html; charset=utf-8'}
|
||||||
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return 'Forbidden', 403
|
return 'Forbidden', 403, {'Content-Type': 'text/plain'}
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.error('Error serving plugin web_ui %s/%s', plugin_id, filename, exc_info=True)
|
logger.error('Error serving plugin web_ui %s/%s', plugin_id, filename, exc_info=True)
|
||||||
return 'Error serving file', 500
|
return 'Error serving file', 500, {'Content-Type': 'text/plain'}
|
||||||
|
|
||||||
def _load_overview_partial():
|
def _load_overview_partial():
|
||||||
"""Load overview partial with system stats"""
|
"""Load overview partial with system stats"""
|
||||||
|
|||||||
@@ -111,16 +111,21 @@
|
|||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Keys that must never be assigned to prevent prototype pollution.
|
||||||
|
const _FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
|
||||||
|
|
||||||
function setNestedValue(obj, path, value) {
|
function setNestedValue(obj, path, value) {
|
||||||
const parts = path.split('.');
|
const parts = path.split('.');
|
||||||
let cur = obj;
|
let cur = obj;
|
||||||
for (let i = 0; i < parts.length - 1; i++) {
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
if (_FORBIDDEN_KEYS.has(parts[i])) return; // block prototype pollution
|
||||||
if (cur[parts[i]] === undefined || typeof cur[parts[i]] !== 'object') {
|
if (cur[parts[i]] === undefined || typeof cur[parts[i]] !== 'object') {
|
||||||
cur[parts[i]] = {};
|
cur[parts[i]] = {};
|
||||||
}
|
}
|
||||||
cur = cur[parts[i]];
|
cur = cur[parts[i]];
|
||||||
}
|
}
|
||||||
cur[parts[parts.length - 1]] = value;
|
const lastKey = parts[parts.length - 1];
|
||||||
|
if (!_FORBIDDEN_KEYS.has(lastKey)) cur[lastKey] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNestedValue(obj, path) {
|
function getNestedValue(obj, path) {
|
||||||
|
|||||||
@@ -239,6 +239,28 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove any existing delegated listener before re-render
|
||||||
|
if (st._gridClickHandler) grid.removeEventListener('click', st._gridClickHandler);
|
||||||
|
if (st._gridChangeHandler) grid.removeEventListener('change', st._gridChangeHandler);
|
||||||
|
|
||||||
|
// Event delegation: handles edit/delete/toggle via data attributes so
|
||||||
|
// filenames and category names are never interpolated into JS string literals.
|
||||||
|
st._gridClickHandler = function(e) {
|
||||||
|
const btn = e.target.closest('[data-pfm-action]');
|
||||||
|
if (!btn) return;
|
||||||
|
const action = btn.dataset.pfmAction;
|
||||||
|
const fId = btn.dataset.pfmField;
|
||||||
|
if (action === 'edit') window._pfmOpenEdit(fId, btn.dataset.pfmFile);
|
||||||
|
if (action === 'delete') window._pfmOpenDelete(fId, btn.dataset.pfmFile);
|
||||||
|
};
|
||||||
|
st._gridChangeHandler = function(e) {
|
||||||
|
const inp = e.target.closest('[data-pfm-action="toggle"]');
|
||||||
|
if (!inp) return;
|
||||||
|
window._pfmToggle(inp.dataset.pfmField, inp.dataset.pfmCategory, inp.checked);
|
||||||
|
};
|
||||||
|
grid.addEventListener('click', st._gridClickHandler);
|
||||||
|
grid.addEventListener('change', st._gridChangeHandler);
|
||||||
|
|
||||||
grid.innerHTML = st.files.map(f => `
|
grid.innerHTML = st.files.map(f => `
|
||||||
<div class="pfm-card${f.enabled === false ? ' disabled' : ''}" data-filename="${escHtml(f.filename)}" data-category="${escHtml(f.category_name)}">
|
<div class="pfm-card${f.enabled === false ? ' disabled' : ''}" data-filename="${escHtml(f.filename)}" data-category="${escHtml(f.category_name)}">
|
||||||
<div class="pfm-card-top">
|
<div class="pfm-card-top">
|
||||||
@@ -246,7 +268,8 @@
|
|||||||
${st.actions.toggle ? `
|
${st.actions.toggle ? `
|
||||||
<label class="pfm-toggle-cb" title="${f.enabled !== false ? 'Click to disable' : 'Click to enable'}">
|
<label class="pfm-toggle-cb" title="${f.enabled !== false ? 'Click to disable' : 'Click to enable'}">
|
||||||
<input type="checkbox" ${f.enabled !== false ? 'checked' : ''}
|
<input type="checkbox" ${f.enabled !== false ? 'checked' : ''}
|
||||||
onchange="window._pfmToggle('${fieldId}','${escHtml(f.category_name)}',this.checked)">
|
data-pfm-action="toggle" data-pfm-field="${escHtml(fieldId)}"
|
||||||
|
data-pfm-category="${escHtml(f.category_name)}">
|
||||||
<span class="pfm-toggle-slider"></span>
|
<span class="pfm-toggle-slider"></span>
|
||||||
</label>` : ''}
|
</label>` : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -260,12 +283,14 @@
|
|||||||
<div class="pfm-card-actions">
|
<div class="pfm-card-actions">
|
||||||
${st.actions.get && st.actions.save ? `
|
${st.actions.get && st.actions.save ? `
|
||||||
<button class="pfm-btn pfm-btn-primary"
|
<button class="pfm-btn pfm-btn-primary"
|
||||||
onclick="window._pfmOpenEdit('${fieldId}','${escHtml(f.filename)}')">
|
data-pfm-action="edit" data-pfm-field="${escHtml(fieldId)}"
|
||||||
|
data-pfm-file="${escHtml(f.filename)}">
|
||||||
<i class="fas fa-edit"></i> Edit
|
<i class="fas fa-edit"></i> Edit
|
||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
${st.actions.delete ? `
|
${st.actions.delete ? `
|
||||||
<button class="pfm-btn pfm-btn-danger pfm-btn-sm"
|
<button class="pfm-btn pfm-btn-danger pfm-btn-sm"
|
||||||
onclick="window._pfmOpenDelete('${fieldId}','${escHtml(f.filename)}')">
|
data-pfm-action="delete" data-pfm-field="${escHtml(fieldId)}"
|
||||||
|
data-pfm-file="${escHtml(f.filename)}">
|
||||||
<i class="fas fa-trash"></i>
|
<i class="fas fa-trash"></i>
|
||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -277,27 +302,30 @@
|
|||||||
window._pfmOpenEdit = async function (fieldId, filename) {
|
window._pfmOpenEdit = async function (fieldId, filename) {
|
||||||
const st = getState(fieldId);
|
const st = getState(fieldId);
|
||||||
const overlay = createOverlay(fieldId);
|
const overlay = createOverlay(fieldId);
|
||||||
overlay.innerHTML = `
|
// Build modal using DOM methods so filename never enters a JS string literal.
|
||||||
<div class="pfm-modal">
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'pfm-modal';
|
||||||
|
modal.innerHTML = `
|
||||||
<div class="pfm-modal-header">
|
<div class="pfm-modal-header">
|
||||||
<span class="pfm-modal-title"><i class="fas fa-edit mr-2"></i>${escHtml(filename)}</span>
|
<span class="pfm-modal-title"><i class="fas fa-edit mr-2"></i>${escHtml(filename)}</span>
|
||||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm"
|
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_modal_close">
|
||||||
onclick="window._pfmCloseModal('${fieldId}')">
|
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="pfm-modal-body" id="${fieldId}_edit_body">
|
<div class="pfm-modal-body" id="${escHtml(fieldId)}_edit_body">
|
||||||
<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>
|
<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pfm-modal-footer">
|
<div class="pfm-modal-footer">
|
||||||
<button class="pfm-btn pfm-btn-secondary"
|
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_modal_cancel">Cancel</button>
|
||||||
onclick="window._pfmCloseModal('${fieldId}')">Cancel</button>
|
<button class="pfm-btn pfm-btn-primary" id="${escHtml(fieldId)}_save_btn">
|
||||||
<button class="pfm-btn pfm-btn-primary" id="${fieldId}_save_btn"
|
|
||||||
onclick="window._pfmSave('${fieldId}','${escHtml(filename)}')">
|
|
||||||
<i class="fas fa-save mr-1"></i>Save
|
<i class="fas fa-save mr-1"></i>Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
|
overlay.appendChild(modal);
|
||||||
|
// Bind events after DOM insertion — filename captured in closure, not in HTML.
|
||||||
|
modal.querySelector(`#${CSS.escape(fieldId)}_modal_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||||
|
modal.querySelector(`#${CSS.escape(fieldId)}_modal_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||||
|
modal.querySelector(`#${CSS.escape(fieldId)}_save_btn`).addEventListener('click', () => window._pfmSave(fieldId, filename));
|
||||||
|
|
||||||
const data = await callAction(st.pluginId, st.actions.get, { filename }).catch(() => null);
|
const data = await callAction(st.pluginId, st.actions.get, { filename }).catch(() => null);
|
||||||
const body = document.getElementById(`${fieldId}_edit_body`);
|
const body = document.getElementById(`${fieldId}_edit_body`);
|
||||||
@@ -307,18 +335,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const content = data.content || data.data || {};
|
const content = data.content || data.data || {};
|
||||||
st._editData = content;
|
|
||||||
st._editFilename = filename;
|
st._editFilename = filename;
|
||||||
|
|
||||||
if (isTabular(content)) {
|
if (isTabular(content)) {
|
||||||
|
// Table path: track cell edits live in _editData
|
||||||
|
st._editData = content;
|
||||||
renderEntryTable(fieldId, body, content);
|
renderEntryTable(fieldId, body, content);
|
||||||
} else {
|
} else {
|
||||||
// Fallback: JSON textarea
|
// Textarea path: _editData stays null; save() reads from the <textarea>
|
||||||
|
st._editData = null;
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<textarea id="${fieldId}_json_ta" rows="20"
|
<textarea id="${escHtml(fieldId)}_json_ta" rows="20"
|
||||||
style="width:100%;font-family:monospace;font-size:.75rem;border:1px solid #d1d5db;border-radius:.375rem;padding:.5rem;"
|
style="width:100%;font-family:monospace;font-size:.75rem;border:1px solid #d1d5db;border-radius:.375rem;padding:.5rem;"
|
||||||
>${escHtml(JSON.stringify(content, null, 2))}</textarea>
|
>${escHtml(JSON.stringify(content, null, 2))}</textarea>
|
||||||
<div id="${fieldId}_json_err" style="color:#dc2626;font-size:.75rem;margin-top:.25rem;"></div>`;
|
<div id="${escHtml(fieldId)}_json_err" style="color:#dc2626;font-size:.75rem;margin-top:.25rem;"></div>`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -401,13 +431,22 @@
|
|||||||
st._tableCols = cols;
|
st._tableCols = cols;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store buildPage in per-instance state so multiple instances don't
|
||||||
|
// clobber each other's pagination via a shared global.
|
||||||
|
st._buildPage = buildPage;
|
||||||
buildPage(st._tablePage || 1);
|
buildPage(st._tablePage || 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global dispatcher — resolves the per-instance buildPage from state so
|
||||||
|
// multiple plugin-file-manager instances don't clobber each other.
|
||||||
window._pfmTablePage = function (fId, p) {
|
window._pfmTablePage = function (fId, p) {
|
||||||
const s = getState(fId);
|
const s = getState(fId);
|
||||||
const totalP = Math.ceil(s._tableEntries.length / s.entriesPerPage);
|
if (s._buildPage) {
|
||||||
buildPage(Math.max(1, Math.min(p, totalP)));
|
const total = s._tableEntries ? s._tableEntries.length : 0;
|
||||||
};
|
const totalP = Math.ceil(total / s.entriesPerPage) || 1;
|
||||||
|
s._buildPage(Math.max(1, Math.min(p, totalP)));
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
window._pfmCellEdit = function (fieldId, day, col, value) {
|
window._pfmCellEdit = function (fieldId, day, col, value) {
|
||||||
const st = getState(fieldId);
|
const st = getState(fieldId);
|
||||||
@@ -454,12 +493,13 @@
|
|||||||
|
|
||||||
window._pfmOpenDelete = function (fieldId, filename) {
|
window._pfmOpenDelete = function (fieldId, filename) {
|
||||||
const overlay = createOverlay(fieldId);
|
const overlay = createOverlay(fieldId);
|
||||||
overlay.innerHTML = `
|
const modal = document.createElement('div');
|
||||||
<div class="pfm-modal" style="max-width:28rem;">
|
modal.className = 'pfm-modal';
|
||||||
|
modal.style.maxWidth = '28rem';
|
||||||
|
modal.innerHTML = `
|
||||||
<div class="pfm-modal-header">
|
<div class="pfm-modal-header">
|
||||||
<span class="pfm-modal-title"><i class="fas fa-trash mr-2"></i>Delete File</span>
|
<span class="pfm-modal-title"><i class="fas fa-trash mr-2"></i>Delete File</span>
|
||||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm"
|
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_del_close">
|
||||||
onclick="window._pfmCloseModal('${fieldId}')">
|
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -470,14 +510,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pfm-modal-footer">
|
<div class="pfm-modal-footer">
|
||||||
<button class="pfm-btn pfm-btn-secondary"
|
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_del_cancel">Cancel</button>
|
||||||
onclick="window._pfmCloseModal('${fieldId}')">Cancel</button>
|
<button class="pfm-btn pfm-btn-danger" id="${escHtml(fieldId)}_del_confirm">
|
||||||
<button class="pfm-btn pfm-btn-danger"
|
|
||||||
onclick="window._pfmConfirmDelete('${fieldId}','${escHtml(filename)}')">
|
|
||||||
<i class="fas fa-trash mr-1"></i>Delete
|
<i class="fas fa-trash mr-1"></i>Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
|
overlay.appendChild(modal);
|
||||||
|
modal.querySelector(`#${CSS.escape(fieldId)}_del_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||||
|
modal.querySelector(`#${CSS.escape(fieldId)}_del_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||||
|
modal.querySelector(`#${CSS.escape(fieldId)}_del_confirm`).addEventListener('click', () => window._pfmConfirmDelete(fieldId, filename));
|
||||||
};
|
};
|
||||||
|
|
||||||
window._pfmConfirmDelete = async function (fieldId, filename) {
|
window._pfmConfirmDelete = async function (fieldId, filename) {
|
||||||
@@ -499,35 +540,38 @@
|
|||||||
const st = getState(fieldId);
|
const st = getState(fieldId);
|
||||||
const fields = st.createFields;
|
const fields = st.createFields;
|
||||||
const overlay = createOverlay(fieldId);
|
const overlay = createOverlay(fieldId);
|
||||||
overlay.innerHTML = `
|
const modal = document.createElement('div');
|
||||||
<div class="pfm-modal" style="max-width:32rem;">
|
modal.className = 'pfm-modal';
|
||||||
|
modal.style.maxWidth = '32rem';
|
||||||
|
modal.innerHTML = `
|
||||||
<div class="pfm-modal-header">
|
<div class="pfm-modal-header">
|
||||||
<span class="pfm-modal-title"><i class="fas fa-plus-circle mr-2"></i>Create New File</span>
|
<span class="pfm-modal-title"><i class="fas fa-plus-circle mr-2"></i>Create New File</span>
|
||||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm"
|
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_cre_close">
|
||||||
onclick="window._pfmCloseModal('${fieldId}')">
|
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="pfm-modal-body">
|
<div class="pfm-modal-body">
|
||||||
<div id="${fieldId}_create_err" class="pfm-field-error" style="margin-bottom:.5rem;"></div>
|
<div id="${escHtml(fieldId)}_create_err" class="pfm-field-error" style="margin-bottom:.5rem;"></div>
|
||||||
${fields.map(f => `
|
${fields.map(f => `
|
||||||
<div class="pfm-field">
|
<div class="pfm-field">
|
||||||
<label for="${fieldId}_cf_${escHtml(f.key)}">${escHtml(f.label || f.key)}</label>
|
<label for="${escHtml(fieldId)}_cf_${escHtml(f.key)}">${escHtml(f.label || f.key)}</label>
|
||||||
<input type="text" id="${fieldId}_cf_${escHtml(f.key)}"
|
<input type="text" id="${escHtml(fieldId)}_cf_${escHtml(f.key)}"
|
||||||
placeholder="${escHtml(f.placeholder || '')}"
|
placeholder="${escHtml(f.placeholder || '')}"
|
||||||
${f.pattern ? `pattern="${escHtml(f.pattern)}"` : ''}>
|
${f.pattern ? `pattern="${escHtml(f.pattern)}"` : ''}>
|
||||||
${f.hint ? `<div class="pfm-field-hint">${escHtml(f.hint)}</div>` : ''}
|
${f.hint ? `<div class="pfm-field-hint">${escHtml(f.hint)}</div>` : ''}
|
||||||
</div>`).join('')}
|
</div>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div class="pfm-modal-footer">
|
<div class="pfm-modal-footer">
|
||||||
<button class="pfm-btn pfm-btn-secondary"
|
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_cre_cancel">Cancel</button>
|
||||||
onclick="window._pfmCloseModal('${fieldId}')">Cancel</button>
|
<button class="pfm-btn pfm-btn-create" id="${escHtml(fieldId)}_create_btn">
|
||||||
<button class="pfm-btn pfm-btn-create" id="${fieldId}_create_btn"
|
|
||||||
onclick="window._pfmConfirmCreate('${fieldId}')">
|
|
||||||
<i class="fas fa-plus mr-1"></i>Create
|
<i class="fas fa-plus mr-1"></i>Create
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
overlay.appendChild(modal);
|
||||||
|
modal.querySelector(`#${CSS.escape(fieldId)}_cre_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||||
|
modal.querySelector(`#${CSS.escape(fieldId)}_cre_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||||
|
modal.querySelector(`#${CSS.escape(fieldId)}_create_btn`).addEventListener('click', () => window._pfmConfirmCreate(fieldId));
|
||||||
};
|
};
|
||||||
|
|
||||||
window._pfmConfirmCreate = async function (fieldId) {
|
window._pfmConfirmCreate = async function (fieldId) {
|
||||||
|
|||||||
@@ -148,6 +148,7 @@
|
|||||||
onClear: function(fieldId) {
|
onClear: function(fieldId) {
|
||||||
const widget = window.LEDMatrixWidgets.get('time-picker');
|
const widget = window.LEDMatrixWidgets.get('time-picker');
|
||||||
widget.setValue(fieldId, '');
|
widget.setValue(fieldId, '');
|
||||||
|
widget.validate(fieldId); // refresh required/error state
|
||||||
triggerChange(fieldId, '');
|
triggerChange(fieldId, '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -526,7 +526,7 @@
|
|||||||
{% for col_name in display_columns %}
|
{% for col_name in display_columns %}
|
||||||
{% set col_def = item_properties.get(col_name, {}) %}
|
{% set col_def = item_properties.get(col_name, {}) %}
|
||||||
{% set col_type = col_def.get('type', 'string') %}
|
{% set col_type = col_def.get('type', 'string') %}
|
||||||
{% set col_xwidget = col_def.get('x-widget', '') %}
|
{% set col_xwidget = col_def.get('x-widget') or col_def.get('x_widget', '') %}
|
||||||
{% set col_enum = col_def.get('enum', []) %}
|
{% set col_enum = col_def.get('enum', []) %}
|
||||||
{% set col_value = item.get(col_name, col_def.get('default', '')) %}
|
{% set col_value = item.get(col_name, col_def.get('default', '')) %}
|
||||||
{% if col_xwidget == 'date-picker' %}{% set td_min_w = '140px' %}
|
{% if col_xwidget == 'date-picker' %}{% set td_min_w = '140px' %}
|
||||||
@@ -1033,7 +1033,7 @@
|
|||||||
or if every action is marked ui_hidden in the manifest. #}
|
or if every action is marked ui_hidden in the manifest. #}
|
||||||
{% set has_file_manager_widget = namespace(value=false) %}
|
{% set has_file_manager_widget = namespace(value=false) %}
|
||||||
{% for _fk, _fp in schema.get('properties', {}).items() %}
|
{% for _fk, _fp in schema.get('properties', {}).items() %}
|
||||||
{% if _fp.get('x-widget') in ('json-file-manager', 'plugin-file-manager') %}
|
{% if (_fp.get('x-widget') or _fp.get('x_widget')) in ('json-file-manager', 'plugin-file-manager') %}
|
||||||
{% set has_file_manager_widget.value = true %}
|
{% set has_file_manager_widget.value = true %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
Reference in New Issue
Block a user