fix(chat tools): wire list_users into the WebUI user-list modal

The Tools tab has an interactive modal: when a list_users task completes it
parses the task log for EZ_USER lines and renders one row per account with
reset / promote / delete buttons. All four new apps failed its contract in every
respect, so running List Users produced log text and nothing else.

- The marker is EZ_USER, tab-separated as email, username, roles. Matrix and
  Stoat emitted LP_USER in a different field order; Mattermost and Rocket.Chat
  emitted no marker at all.
- Matrix and Stoat then consumed their own marker lines in the formatting loop
  and printed only the pretty version, so nothing reached the log to parse.
- The row buttons look up tools by id: reset_password, set_admin, delete_user.
  The deactivate tools were named deactivate_user / disable_user, so no delete
  button rendered.
- Prefill only fills a field named email or username. Rocket.Chat's and Stoat's
  identifier field was called user, so a row action would have opened with an
  empty box.
- '-' placeholders are truthy, so the modal's `email || username` fallback
  picked '-' over the real username for accounts without an email (rocket.cat).
  The EZ_USER line now carries an empty string; '-' stays in the readable line.

Mattermost's listing is rebuilt on `mmctl --json`, which carries roles and
delete_at. The text listing has neither, and there is no --system-admin filter
on user list, so every account was reported as a plain user. Two parsing notes
that cost time: mmctl prints status lines both before and after the JSON, so it
needs raw_decode rather than json.loads; and --per-page above 200 makes it emit
a warning line ahead of the payload.

The modal's delete button also stops asserting "Delete user" over whatever the
tool actually does — it takes its label and icon from the tool, because most of
these deactivate and Matrix cannot delete at all.

Verified by replaying the modal's own parser over real tool output: 2 rows for
Matrix, 4 for Mattermost, 3 for Rocket.Chat, with admin and deactivated states
resolving correctly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-18 23:23:12 +01:00
parent 77b50e5226
commit 5b6ed924d2
20 changed files with 120 additions and 47 deletions

View File

@ -98,6 +98,10 @@ class ToolsManager {
_openUserListModal(appName, users) {
const tools = (window.toolsCatalog?.apps?.[appName]?.tools) || [];
const resetTool = tools.find(t => t.id === 'reset_password');
// Its id is delete_user by convention, but what it actually does is the
// app's business — most deactivate rather than delete, and Matrix cannot
// delete at all. The row button therefore takes its label and icon from the
// tool itself instead of asserting "Delete user" over the top of it.
const deleteTool = tools.find(t => t.id === 'delete_user');
const adminTool = tools.find(t => t.id === 'set_admin');
const appLabel = (window.getAppDisplayName ? window.getAppDisplayName(appName) : appName);
@ -121,7 +125,7 @@ class ToolsManager {
<div class="user-row-actions">
${resetTool ? `<button type="button" class="user-row-btn" data-act="reset" data-idx="${idx}" title="Reset password">🔑</button>` : ''}
${adminTool ? `<button type="button" class="user-row-btn" data-act="admin" data-idx="${idx}" title="${isAdmin ? 'Demote from admin' : 'Promote to admin'}">${isAdmin ? '👤' : '👑'}</button>` : ''}
${deleteTool ? `<button type="button" class="user-row-btn danger" data-act="delete" data-idx="${idx}" title="Delete user">🗑</button>` : ''}
${deleteTool ? `<button type="button" class="user-row-btn danger" data-act="delete" data-idx="${idx}" title="${escapeHtml(deleteTool.label || 'Delete user')}">${escapeHtml(deleteTool.icon || '🗑')}</button>` : ''}
</div>
</div>`;
}).join('')

View File

@ -224,7 +224,10 @@ for u in res.get('users', []):
flags = []
if u.get('admin'): flags.append('admin')
if u.get('deactivated'): flags.append('deactivated')
print('LP_USER\t' + u['name'] + '\t' + (u.get('displayname') or '-') + '\t' + (','.join(flags) or 'user'))
# EZ_USER<TAB>identifier<TAB>display<TAB>roles — the exact shape the WebUI's
# user-list modal parses. The first column is what a row action gets
# prefilled with, so it must be the Matrix ID, not the display name.
print('EZ_USER\t' + u['name'] + '\t' + (u.get('displayname') or '') + '\t' + (','.join(flags) or 'user'))
print('LP_TOTAL:' + str(res.get('total', 0)))
" 2>&1)
@ -233,7 +236,12 @@ print('LP_TOTAL:' + str(res.get('total', 0)))
local line total=0
while IFS= read -r line; do
case "$line" in
LP_USER*) IFS=$'\t' read -r _ uid name flags <<< "$line"
EZ_USER*) IFS=$'\t' read -r _ uid name flags <<< "$line"
# Re-emit the raw marker line as well as the readable one:
# the WebUI modal reads the task log looking for EZ_USER,
# so consuming it here and printing only the pretty version
# left the modal with nothing to parse.
printf '%s\n' "$line"
printf ' %-34s %-20s %s\n' "$uid" "$name" "$flags" ;;
LP_TOTAL:*) total="${line#LP_TOTAL:}" ;;
esac

View File

@ -86,7 +86,7 @@
]
},
{
"id": "deactivate_user",
"id": "delete_user",
"category": "users",
"label": "Deactivate User Account",
"description": "Matrix has no delete. This revokes access and erases the profile, and the user ID stays permanently taken.",

View File

@ -2,7 +2,7 @@
# Named deactivate rather than delete on purpose — Matrix has no delete, and
# calling it one would misrepresent what the button does.
appMatrixDeactivateUser() {
appMatrixDeleteUser() {
local args="$1"
authAdapterCall matrix deleteUser "$(authToolArg "$args" username)"
}

View File

@ -73,19 +73,54 @@ authAdapter_mattermost_setPassword() {
}
authAdapter_mattermost_listUsers() {
# --json rather than the human format: it carries `roles` and `delete_at`,
# which the plain listing does not. There is no --system-admin filter on
# `user list` (only --inactive), so parsing the text output left every
# account looking like a plain user.
local out
out=$(_mmctl user list --per-page 500)
# 200 is mmctl's maximum; asking for more makes it print a warning line
# BEFORE the JSON, which then fails to parse.
out=$(_mmctl --json user list --per-page 200)
_mmctlFailed "$out" "Listing users" && return 1
# `user list` prints "id: username (email)" per line, plus a trailing count.
local line count=0
local rendered
rendered=$(printf '%s' "$out" | python3 -c "
import sys, json
# _mmctl folds stderr into stdout, and mmctl prints status lines such as
# 'There are 4 users on local instance' AFTER the JSON as well as warnings
# before it. raw_decode stops at the end of the first complete JSON value and
# ignores whatever trails it, which plain json.loads will not do.
raw = sys.stdin.read()
start = min([i for i in (raw.find('['), raw.find('{')) if i >= 0], default=-1)
users = []
if start >= 0:
try:
users, _ = json.JSONDecoder().raw_decode(raw[start:])
except Exception:
users = []
if isinstance(users, dict):
users = users.get('users', [])
for u in users:
roles = 'admin' if 'system_admin' in (u.get('roles') or '') else 'user'
if u.get('delete_at'):
roles += ',deactivated'
email = u.get('email') or '-'
name = u.get('username') or '-'
# EZ_USER<TAB>email<TAB>username<TAB>roles is what the WebUI user-list modal
# parses; the aligned line after it is for whoever reads the log.
print('EZ_USER\\t%s\\t%s\\t%s' % (email, name, roles))
print(' %-30s %-22s %s' % (email, name, roles))
print('LP_TOTAL:%d' % len(users))
" 2>/dev/null)
local line total=0
while IFS= read -r line; do
[[ "$line" =~ ^[a-z0-9]+:\ ]] || continue
local rest="${line#*: }"
printf ' %s\n' "$rest"
((count++))
done <<< "$out"
isSuccessful "$count Mattermost account(s)."
case "$line" in
LP_TOTAL:*) total="${line#LP_TOTAL:}" ;;
*) [[ -n "$line" ]] && printf '%s\n' "$line" ;;
esac
done <<< "$rendered"
isSuccessful "$total Mattermost account(s)."
}
# Mattermost distinguishes deactivate (reversible, frees nothing) from delete

View File

@ -85,7 +85,7 @@
]
},
{
"id": "deactivate_user",
"id": "delete_user",
"category": "users",
"label": "Deactivate User Account",
"description": "Revoke access without deleting content. Reversible from the System Console.",

View File

@ -1,6 +1,6 @@
#!/bin/bash
appMattermostDeactivateUser() {
appMattermostDeleteUser() {
local args="$1"
authAdapterCall mattermost deleteUser "$(authToolArg "$args" email)"
}

View File

@ -180,7 +180,12 @@ for u in users:
roles = ','.join(u.get('roles') or []) or 'user'
state = '' if u.get('active', True) else ' (deactivated)'
email = (u.get('emails') or [{}])[0].get('address', '-')
print(' %-22s %-30s %s%s' % (u.get('username', '-'), email, roles, state))
# EZ_USER<TAB>email<TAB>username<TAB>roles drives the WebUI's user-list
# modal; the aligned line below it is what a human reads in the log.
# Empty, not '-': the modal falls back to the username with
# \`email || username\`, and a '-' placeholder is truthy so it would win.
print('EZ_USER\t%s\t%s\t%s' % ('' if email == '-' else email, u.get('username', ''), roles + state))
print(' %-30s %-22s %s%s' % (email, u.get('username', '-'), roles, state))
print('LP_TOTAL:%d' % d.get('total', len(users)))
" 2>/dev/null)

View File

@ -50,7 +50,7 @@
"icon": "🔑",
"fields": [
{
"name": "user",
"name": "username",
"label": "Username or email",
"type": "text",
"required": true
@ -71,7 +71,7 @@
"icon": "👑",
"fields": [
{
"name": "user",
"name": "username",
"label": "Username or email",
"type": "text",
"required": true
@ -85,7 +85,7 @@
]
},
{
"id": "deactivate_user",
"id": "delete_user",
"category": "users",
"label": "Deactivate User Account",
"description": "Revoke access without deleting messages. Reversible from Admin → Users.",
@ -94,7 +94,7 @@
"confirm": "The user will be signed out and unable to log in.",
"fields": [
{
"name": "user",
"name": "username",
"label": "Username or email",
"type": "text",
"required": true
@ -109,7 +109,7 @@
"icon": "✅",
"fields": [
{
"name": "user",
"name": "username",
"label": "Username or email",
"type": "text",
"required": true

View File

@ -1,6 +1,6 @@
#!/bin/bash
appRocketchatDeactivateUser() {
appRocketchatDeleteUser() {
local args="$1"
authAdapterCall rocketchat deleteUser "$(authToolArg "$args" user)"
authAdapterCall rocketchat deleteUser "$(authToolArg "$args" username)"
}

View File

@ -2,5 +2,5 @@
appRocketchatEnableUser() {
local args="$1"
authAdapterCall rocketchat enableUser "$(authToolArg "$args" user)"
authAdapterCall rocketchat enableUser "$(authToolArg "$args" username)"
}

View File

@ -3,6 +3,6 @@
appRocketchatResetPassword() {
local args="$1"
authAdapterCall rocketchat setPassword \
"$(authToolArg "$args" user)" \
"$(authToolArg "$args" username)" \
"$(authToolArg "$args" password)"
}

View File

@ -3,6 +3,6 @@
appRocketchatSetAdmin() {
local args="$1"
authAdapterCall rocketchat setAdmin \
"$(authToolArg "$args" user)" \
"$(authToolArg "$args" username)" \
"$(authToolArg "$args" admin)"
}

View File

@ -51,7 +51,9 @@ users.forEach(u => {
const a = byId[u._id] || {};
const handle = u.username + (u.discriminator ? "#" + u.discriminator : "");
const state = a.disabled ? " (disabled)" : "";
print("LP_USER\t" + handle + "\t" + (a.email || "-") + "\t" + (u.display_name || "-") + state);
// EZ_USER<TAB>identifier<TAB>display<TAB>roles, as the WebUI modal expects.
// Email first where there is one: it is the field a row action prefills.
print("EZ_USER\t" + (a.email || handle) + "\t" + handle + "\t" + (a.disabled ? "disabled" : "user"));
});
print("LP_TOTAL:" + users.length);
')
@ -60,8 +62,11 @@ print("LP_TOTAL:" + users.length);
local line total=0
while IFS= read -r line; do
case "$line" in
LP_USER*) IFS=$'\t' read -r _ handle email display <<< "$line"
printf ' %-24s %-30s %s\n' "$handle" "$email" "$display" ;;
EZ_USER*) IFS=$'\t' read -r _ ident handle state <<< "$line"
# The marker line has to reach the task log for the WebUI
# user-list modal to build its rows from.
printf '%s\n' "$line"
printf ' %-30s %-24s %s\n' "$ident" "$handle" "$state" ;;
LP_TOTAL:*) total="${line#LP_TOTAL:}" ;;
esac
done <<< "$out"

View File

@ -187,6 +187,25 @@ EOF
isSuccessful "Generated secrets.env"
}
# Own the LibrePortal-written config files (Caddyfile, Revolt.toml, .env.web,
# stoat.json, secrets.env, livekit.yml, the compose + app config) as the docker
# install user, so the containers can read their bind-mount sources.
#
# Top level ONLY. This used to be `chown -R "$app_dir"`, which walked into
# data/db, data/minio and friends — content created by the containers and owned
# by THEIR uids (mongo's, minio's; under rootless those are subuids the docker
# install user has no authority over). Every reinstall therefore printed a screen
# of "Operation not permitted" plus "Permission denied" on the 0700 dirs it
# couldn't even enter, and then failed the step outright — an ✗ Error on a
# healthy install, which is the kind of noise that teaches you to skip error
# lines. Those files must keep their container ownership anyway: chowning mongo's
# data away from mongo is what would actually break stoat.
_stoatOwnConfigFiles() {
local app_dir="$1"
runFileOp find "$app_dir" -maxdepth 1 -type f \
-exec chown "$docker_install_user":"$docker_install_user" {} +
}
stoat_install_post_compose()
{
local app_name="$1"
@ -267,8 +286,8 @@ webhook:
EOF
checkSuccess "Writing livekit.yml"
runFileOp chown -R "$docker_install_user":"$docker_install_user" "$app_dir"
checkSuccess "Setting ownership on the $app_name install directory"
_stoatOwnConfigFiles "$app_dir"
checkSuccess "Setting ownership on the $app_name config files"
}
stoat_install_post_start()
@ -294,7 +313,7 @@ stoat_install_post_start()
local video_enabled=""
[[ "$CFG_STOAT_ENABLE_VIDEO" != "false" ]] && video_enabled="true"
_stoatWriteUrlFiles "$app_dir" "$base" "$video_enabled" "$CFG_STOAT_RABBITMQ_PASSWORD_1"
runFileOp chown -R "$docker_install_user":"$docker_install_user" "$app_dir"
_stoatOwnConfigFiles "$app_dir"
isSuccessful "Public URL settled as $base (was ${current:-unset})"
# The web client compiles VITE_* at container start, so it has to come back

View File

@ -9,7 +9,7 @@
"fields": []
},
{
"id": "disable_user",
"id": "delete_user",
"category": "users",
"label": "Disable User Account",
"description": "Block sign-in without deleting the account or its messages. Reversible.",
@ -18,7 +18,7 @@
"confirm": "The user will not be able to sign in again until re-enabled.",
"fields": [
{
"name": "user",
"name": "username",
"label": "Username or email",
"type": "text",
"required": true
@ -33,7 +33,7 @@
"icon": "✅",
"fields": [
{
"name": "user",
"name": "username",
"label": "Username or email",
"type": "text",
"required": true

View File

@ -1,6 +1,6 @@
#!/bin/bash
appStoatDisableUser() {
appStoatDeleteUser() {
local args="$1"
authAdapterCall stoat deleteUser "$(authToolArg "$args" user)"
authAdapterCall stoat deleteUser "$(authToolArg "$args" username)"
}

View File

@ -2,5 +2,5 @@
appStoatEnableUser() {
local args="$1"
authAdapterCall stoat enableUser "$(authToolArg "$args" user)"
authAdapterCall stoat enableUser "$(authToolArg "$args" username)"
}

View File

@ -33,7 +33,7 @@ function checkSuccess()
local _where="${BASH_SOURCE[1]##*/}:${BASH_LINENO[0]}"
local _stamp; _stamp="$(date '+%F %T' 2>/dev/null || echo now)"
printf '%s\t[exit %s]\t%s\t(%s)\n' "$_stamp" "$rc" "$msg" "$_where" \
| runInstallWrite -a "$logs_dir/error_report.log" 2>/dev/null || true
| runInstallWrite -a "${logs_dir%/}/error_report.log" 2>/dev/null || true
if [ -f "$logs_dir/$docker_log_file" ]; then
isError " $msg (exit $rc, $_where)" | runInstallWrite -a "$logs_dir/$docker_log_file" >/dev/null 2>&1 || true
fi
@ -42,7 +42,7 @@ function checkSuccess()
# doesn't abort the whole run and we surface EVERY issue in one pass. Turn
# CFG_REQUIREMENT_CONTINUE_ON_ERROR off for strict abort once things are clean.
if [[ "${CFG_REQUIREMENT_CONTINUE_ON_ERROR:-true}" == "true" ]]; then
isNotice "continue-on-error: logged to $logs_dir/error_report.log — continuing."
isNotice "continue-on-error: logged to ${logs_dir%/}/error_report.log — continuing."
return 0
fi

View File

@ -175,9 +175,7 @@ declare -gA LP_FN_MAP=(
[authAdapter_matrix_setAdmin]="matrix/scripts/matrix_auth.sh"
[authAdapter_matrix_setPassword]="matrix/scripts/matrix_auth.sh"
[authAdapter_mattermost_createUser]="mattermost/scripts/mattermost_auth.sh"
[authAdapter_mattermost_deleteUser]="mattermost/scripts/mattermost_auth.sh"
[authAdapter_mattermost_listUsers]="mattermost/scripts/mattermost_auth.sh"
[authAdapter_mattermost_setAdmin]="mattermost/scripts/mattermost_auth.sh"
[authAdapter_mattermost_setPassword]="mattermost/scripts/mattermost_auth.sh"
[authAdapter_nextcloud_createUser]="nextcloud/scripts/nextcloud_auth.sh"
[authAdapter_nextcloud_deleteUser]="nextcloud/scripts/nextcloud_auth.sh"
@ -955,6 +953,7 @@ declare -gA LP_FN_MAP=(
[_stoatMongo]="stoat/scripts/stoat_auth.sh"
[_stoatMongoFailed]="stoat/scripts/stoat_auth.sh"
[_stoatMongoWho]="stoat/scripts/stoat_auth.sh"
[_stoatOwnConfigFiles]="stoat/scripts/stoat_install_hooks.sh"
[_stoatSetDisabled]="stoat/scripts/stoat_auth.sh"
[_stoatWriteSecrets]="stoat/scripts/stoat_install_hooks.sh"
[_stoatWriteUrlFiles]="stoat/scripts/stoat_install_hooks.sh"
@ -1299,9 +1298,7 @@ declare -gA LP_FN_ROOT=(
[authAdapter_matrix_setAdmin]="containers"
[authAdapter_matrix_setPassword]="containers"
[authAdapter_mattermost_createUser]="containers"
[authAdapter_mattermost_deleteUser]="containers"
[authAdapter_mattermost_listUsers]="containers"
[authAdapter_mattermost_setAdmin]="containers"
[authAdapter_mattermost_setPassword]="containers"
[authAdapter_nextcloud_createUser]="containers"
[authAdapter_nextcloud_deleteUser]="containers"
@ -2079,6 +2076,7 @@ declare -gA LP_FN_ROOT=(
[_stoatMongo]="containers"
[_stoatMongoFailed]="containers"
[_stoatMongoWho]="containers"
[_stoatOwnConfigFiles]="containers"
[_stoatSetDisabled]="containers"
[_stoatWriteSecrets]="containers"
[_stoatWriteUrlFiles]="containers"
@ -2458,9 +2456,7 @@ authAdapter_matrix_listUsers() { unset -f authAdapter_matrix_listUsers; __lpAuto
authAdapter_matrix_setAdmin() { unset -f authAdapter_matrix_setAdmin; __lpAutoload "${install_containers_dir}matrix/scripts/matrix_auth.sh"; authAdapter_matrix_setAdmin "$@"; }
authAdapter_matrix_setPassword() { unset -f authAdapter_matrix_setPassword; __lpAutoload "${install_containers_dir}matrix/scripts/matrix_auth.sh"; authAdapter_matrix_setPassword "$@"; }
authAdapter_mattermost_createUser() { unset -f authAdapter_mattermost_createUser; __lpAutoload "${install_containers_dir}mattermost/scripts/mattermost_auth.sh"; authAdapter_mattermost_createUser "$@"; }
authAdapter_mattermost_deleteUser() { unset -f authAdapter_mattermost_deleteUser; __lpAutoload "${install_containers_dir}mattermost/scripts/mattermost_auth.sh"; authAdapter_mattermost_deleteUser "$@"; }
authAdapter_mattermost_listUsers() { unset -f authAdapter_mattermost_listUsers; __lpAutoload "${install_containers_dir}mattermost/scripts/mattermost_auth.sh"; authAdapter_mattermost_listUsers "$@"; }
authAdapter_mattermost_setAdmin() { unset -f authAdapter_mattermost_setAdmin; __lpAutoload "${install_containers_dir}mattermost/scripts/mattermost_auth.sh"; authAdapter_mattermost_setAdmin "$@"; }
authAdapter_mattermost_setPassword() { unset -f authAdapter_mattermost_setPassword; __lpAutoload "${install_containers_dir}mattermost/scripts/mattermost_auth.sh"; authAdapter_mattermost_setPassword "$@"; }
authAdapter_nextcloud_createUser() { unset -f authAdapter_nextcloud_createUser; __lpAutoload "${install_containers_dir}nextcloud/scripts/nextcloud_auth.sh"; authAdapter_nextcloud_createUser "$@"; }
authAdapter_nextcloud_deleteUser() { unset -f authAdapter_nextcloud_deleteUser; __lpAutoload "${install_containers_dir}nextcloud/scripts/nextcloud_auth.sh"; authAdapter_nextcloud_deleteUser "$@"; }
@ -3238,6 +3234,7 @@ stoat_install_pre() { unset -f stoat_install_pre; __lpAutoload "${install_contai
_stoatMongo() { unset -f _stoatMongo; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_auth.sh"; _stoatMongo "$@"; }
_stoatMongoFailed() { unset -f _stoatMongoFailed; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_auth.sh"; _stoatMongoFailed "$@"; }
_stoatMongoWho() { unset -f _stoatMongoWho; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_auth.sh"; _stoatMongoWho "$@"; }
_stoatOwnConfigFiles() { unset -f _stoatOwnConfigFiles; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; _stoatOwnConfigFiles "$@"; }
_stoatSetDisabled() { unset -f _stoatSetDisabled; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_auth.sh"; _stoatSetDisabled "$@"; }
_stoatWriteSecrets() { unset -f _stoatWriteSecrets; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; _stoatWriteSecrets "$@"; }
_stoatWriteUrlFiles() { unset -f _stoatWriteUrlFiles; __lpAutoload "${install_containers_dir}stoat/scripts/stoat_install_hooks.sh"; _stoatWriteUrlFiles "$@"; }