diff --git a/static/js/manage_users.js b/static/js/manage_users.js
new file mode 100644
index 0000000..9a2203f
--- /dev/null
+++ b/static/js/manage_users.js
@@ -0,0 +1,53 @@
+// Populate the one shared edit modal from the #users-data JSON island.
+//
+// This page used to render a full 2KB edit modal per user — ~210KB of duplicated markup at 100
+// accounts, all of it for one dialog you can only have open once. The rows now carry an id and the
+// data comes from a single JSON blob, which is ~120 bytes per user.
+function _euUsers() {
+ var el = document.getElementById('users-data');
+ if (!el) return [];
+ try {
+ return JSON.parse(el.textContent) || [];
+ } catch (e) {
+ return []; // a malformed island must not take the page down with it
+ }
+}
+
+window.openEditUser = function (id) {
+ var u = null, all = _euUsers();
+ for (var i = 0; i < all.length; i++) {
+ if (all[i].id === id) { u = all[i]; break; }
+ }
+ if (!u) return;
+
+ // Coerce the id to a number before it reaches the form action. It is always an integer from our
+ // own database, but it arrives here as text read out of the DOM, and a form action is a URL sink
+ // — CodeQL flags that flow (js/xss-through-dom) and is right to. parseInt both proves the value
+ // cannot carry meta-characters and rejects a tampered island outright.
+ var uid = parseInt(u.id, 10);
+ if (!(uid > 0)) return;
+
+ var form = document.getElementById('edit-user-form');
+ form.setAttribute('action', (window.MOUNT || '') + '/users/' + uid + '/edit');
+
+ document.getElementById('eu-name').textContent = u.username; // textContent: never HTML
+ document.getElementById('eu-display').value = u.display_name || '';
+ document.getElementById('eu-email').value = u.email || '';
+ document.getElementById('eu-password').value = ''; // never prefill a password
+
+ var groups = u.groups || [];
+ document.querySelectorAll('.eu-group').forEach(function (cb) {
+ cb.checked = groups.indexOf(parseInt(cb.value, 10)) !== -1;
+ });
+
+ document.getElementById('eu-superadmin').checked = !!u.is_superadmin;
+ document.getElementById('eu-active').checked = !!u.is_active;
+
+ // The 2FA reset only makes sense for someone who has it on; it starts unchecked every time so a
+ // previous user's toggle can never carry over into the next one you open.
+ var tfa = document.getElementById('eu-2fa-block');
+ document.getElementById('eu-reset2fa').checked = false;
+ tfa.style.display = u.totp_enabled ? '' : 'none';
+
+ new bootstrap.Modal(document.getElementById('editUserModal')).show();
+};
diff --git a/templates/manage_users.html b/templates/manage_users.html
index 3df27c3..e1d246b 100644
--- a/templates/manage_users.html
+++ b/templates/manage_users.html
@@ -55,8 +55,8 @@
User Management
{{ user.last_login|datetime if user.last_login else 'Never' }}
-
@@ -148,29 +148,44 @@
Add User
-
-{% for user in users %}
-
+{# One row of JSON per user instead of one 2KB edit modal per user: at 100 accounts that was
+ ~210KB of duplicated markup in the page. The single modal below is populated from this. It lives
+ INSIDE #users-list so an ajax refresh after a save keeps it current. #}
+
+
+
+{# The single edit modal. Outside #users-list on purpose: an ajax refresh replaces that region, and
+ replacing a modal while it is open leaves Bootstrap's backdrop behind. #}
+
-
@@ -193,9 +207,8 @@
Edit User: {{ user.username
-
-
+
+
Ignores groups entirely — full control of everything. Leave off for a standard user.
@@ -205,27 +218,24 @@
Edit User: {{ user.username
-
-
+
+
Uncheck to block this user from logging in, without deleting the account.
- {% if user.totp_enabled %}
-
-
+
+
-
-
+
+
Use this if they've lost their authenticator. They can re-enable it from their Account page.
- {% endif %}
-{% endfor %}
-
+
+
{% endblock %}
diff --git a/tests/smoke_test.py b/tests/smoke_test.py
index 3f853ba..2b80ed9 100644
--- a/tests/smoke_test.py
+++ b/tests/smoke_test.py
@@ -1919,6 +1919,38 @@ def _banner_tag(html):
finally:
_am._cron_restart_pending.pop(gs_id, None)
+ # ── The users page renders ONE edit modal, not one per user ───────────────────────────────────
+ # It used to emit a full 2KB modal per row — 670KB of HTML at 100 accounts, all of it for a
+ # dialog you can only have open once. The rows now carry an id and the data comes from a single
+ # JSON island. These assert the shape holds AND that the data is actually right, because a
+ # smaller page that opens the wrong user's details would be a much worse bug than a big one.
+ import json as _json_u
+ _uh = c.get("/users").get_data(as_text=True)
+ check("users page: exactly one edit modal, however many accounts exist",
+ _uh.count('id="editUserModal"') == 1 and 'id="editUserModal-' not in _uh,
+ "found %d" % _uh.count('id="editUserModal'))
+ _isl = _re_ab.search(r'',
+ _uh, _re_ab.S)
+ check("users page: the JSON island is present", _isl is not None)
+ if _isl:
+ _rows = _json_u.loads(_isl.group(1)) # must be VALID json, not just present
+ with app.app_context():
+ # Materialise inside the context: .groups is a lazy relationship and reading it after
+ # the context closes raises DetachedInstanceError.
+ _want = {u.username: (bool(u.is_superadmin), bool(u.is_active),
+ sorted(g.id for g in u.groups)) for u in User.query.all()}
+ check("users page: one island entry per account", len(_rows) == len(_want),
+ "%d rows vs %d users" % (len(_rows), len(_want)))
+ _bad = [r["username"] for r in _rows
+ if (r["is_superadmin"], r["is_active"], sorted(r["groups"])) != _want[r["username"]]]
+ check("users page: each entry matches that account's real flags and groups", not _bad,
+ "wrong: %s" % _bad[:3])
+ check("users page: every row's Edit button opens the shared modal by id",
+ _uh.count('data-action="openEditUser"') == len(_rows),
+ "%d buttons for %d users" % (_uh.count('data-action="openEditUser"'), len(_rows)))
+ check("users page: no password is ever put in the island",
+ not any("password" in r for r in _rows))
+
# ── Bearer API tokens: the other way into every route ─────────────────────────────────────────
# A token authenticates AS its owner and inherits exactly that user's RBAC, and app.py exempts
# Bearer requests from CSRF — so this is a full authentication path that had no test at all.
@@ -2020,6 +2052,12 @@ def _bearer(tok):
check("custom command: a missing command is refused, not an exception",
_crcc(_adm, None, _gs) is False)
+except Exception:
+ # A crash part-way through otherwise just prints fewer checks and still reads as green-ish.
+ # That has hidden three separate mistakes while writing these; a crash is a FAILURE.
+ import traceback as _tb
+ _tb.print_exc()
+ results.append((False, "suite crashed before finishing — see the traceback above", ""))
finally:
passed = sum(1 for ok, _, _ in results if ok)
for ok, name, detail in results:
diff --git a/tests/template_actions_test.py b/tests/template_actions_test.py
index 95f6004..3eecd2d 100644
--- a/tests/template_actions_test.py
+++ b/tests/template_actions_test.py
@@ -46,7 +46,12 @@ def check(cond, name, detail=""):
# fine and looks harmless. Static analysers read the TEMPLATE, though, and to a JS parser "{#" is
# a private-field sigil: CodeQL raised two js/syntax-error alerts on exactly this. A whole file
# that fails to parse is a file nothing is checking, which is the real cost.
-_INLINE_SCRIPT = re.compile(r"", re.S)
+# Only script elements a JS parser actually reads: no src=, and either no type or a JS one.
+# A ", re.S)
_jinja_in_js = []
for _name, _src in srcs.items():
if not _name.endswith(".html"):