librelad 5b6ed924d2 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>
2026-08-18 23:23:12 +01:00

157 lines
6.4 KiB
Bash

#!/bin/bash
# Mattermost user management, via mmctl in local mode.
#
# mmctl is the current CLI — not the long-deprecated `mattermost` binary that
# used to ship alongside it. It lives in the image at /usr/local/bin/mmctl and
# is the only reason these tools are possible at all: the v11 image is
# distroless, with no shell, so every call has to be a direct exec of a binary
# with no pipes, redirects or shell built-ins available.
#
# --local talks to the server's unix socket instead of the REST API, which means
# no credentials to store, no token to expire, and it keeps working even when
# the admin account is locked out or the site URL is wrong.
_mmctl() {
runFileOp docker exec -i mattermost-service mmctl --local "$@" 2>&1
}
# mmctl exits non-zero on failure and writes the reason to stderr, which _mmctl
# folds into stdout.
#
# Its errors are multi-line — a summary ("Error: 1 error occurred:") followed by
# an indented bullet carrying the part that actually explains anything. Reporting
# only the first line threw the useful half away, so prefer the bullet when
# there is one.
_mmctlFailed() {
local out="$1" what="$2"
[[ "$out" != *"Error:"* ]] && return 1
local detail
detail=$(printf '%s' "$out" | sed -n 's/^[[:space:]]*\*[[:space:]]*//p' | head -1)
[[ -z "$detail" ]] && detail=$(printf '%s' "$out" | grep -m1 'Error:' | sed 's/.*Error: *//')
isError "$what failed: $detail"
return 0
}
authAdapter_mattermost_createUser() {
local email="$1" password="$2" username="$3" isAdmin="$4"
[[ -z "$email" ]] && { isError "An email address is required."; return 1; }
[[ -z "$username" ]] && username="${email%@*}"
[[ -z "$password" ]] && password=$(generateRandomPassword)
# Mattermost usernames are lowercase and restricted to letters, numbers and
# . - _ — sanitise rather than let the server reject the whole call.
username=$(printf '%s' "$username" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9._-' '-' | sed 's/^-*//; s/-*$//')
[[ -z "$username" ]] && username="user"
local out
out=$(_mmctl user create --email "$email" --username "$username" --password "$password")
_mmctlFailed "$out" "Creating $email" && return 1
if [[ "$isAdmin" == "true" ]]; then
local promote
promote=$(_mmctl roles system-admin "$email")
_mmctlFailed "$promote" "Granting system admin to $email" && return 1
fi
isSuccessful "Mattermost user created — Email: $email — Username: $username — Password: $password"
}
authAdapter_mattermost_setPassword() {
local email="$1" password="$2"
[[ -z "$email" ]] && { isError "An email address is required."; return 1; }
[[ -z "$password" ]] && password=$(generateRandomPassword)
local out
out=$(_mmctl user change-password "$email" --password "$password")
_mmctlFailed "$out" "Resetting $email" && return 1
# Keep the config in step if this is the account the WebUI card advertises.
[[ "$email" == "${CFG_MATTERMOST_ADMIN_EMAIL:-}" ]] && authPersistCfg mattermost ADMIN_PASSWORD "$password"
isSuccessful "Mattermost password set for $email — New password: $password"
}
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
# 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
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
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
# (permanent, purges content). This is the reversible one: it is what the
# product itself recommends, and a real delete is not undoable from a WebUI
# button click.
authAdapter_mattermost_deleteUser() {
local email="$1"
[[ -z "$email" ]] && { isError "An email address is required."; return 1; }
local out
out=$(_mmctl user deactivate "$email")
_mmctlFailed "$out" "Deactivating $email" && return 1
isSuccessful "Mattermost user '$email' deactivated. Re-enable them from the System Console if needed."
}
authAdapter_mattermost_setAdmin() {
local email="$1" isAdmin="$2"
[[ -z "$email" ]] && { isError "An email address is required."; return 1; }
# `roles system-admin` / `roles member`, NOT `user promote` / `user demote`:
# those two convert between guest and member accounts and have no bearing on
# administrator rights at all.
local out target="false"
if [[ "$isAdmin" == "true" ]]; then
target="true"
out=$(_mmctl roles system-admin "$email")
else
out=$(_mmctl roles member "$email")
fi
_mmctlFailed "$out" "Changing admin status for $email" && return 1
isSuccessful "Mattermost user '$email' system admin → $target."
}