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

269 lines
12 KiB
Bash

#!/bin/bash
# Rocket.Chat user management, via its REST API.
#
# Called with curl from the host rather than from inside the container: the
# Rocket.Chat image ships node but no curl or wget, and shelling out to node
# just to make an HTTP request would mean embedding JavaScript in bash for no
# benefit. The base URL is read from ROOT_URL in the deployed compose, which the
# APP_URL tag has already resolved to whatever this install actually serves on —
# https://host behind Traefik, http://ip:port on a LAN-only box.
#
# Authentication uses the admin seeded at install (CFG_ROCKETCHAT_ADMIN_*).
# Rocket.Chat has no local/socket admin path like Mattermost's, so there is no
# way round needing a real account here.
_rocketchatBaseUrl() {
local compose="${containers_dir}rocketchat/docker-compose.yml"
local url
url=$(runFileOp grep -oP '^\s*-\s*ROOT_URL=\K\S+' "$compose" 2>/dev/null | head -1)
url="${url%%#*}"
printf '%s' "${url%/}"
}
# Echoes "<userId> <authToken>" on success. Both are needed: Rocket.Chat wants
# them as separate X-User-Id / X-Auth-Token headers on every subsequent call.
_rocketchatLogin() {
local base="$1"
local user="${CFG_ROCKETCHAT_ADMIN_USERNAME:-admin}"
local pass="${CFG_ROCKETCHAT_ADMIN_PASSWORD_1}"
if [[ -z "$pass" || "$pass" == RANDOMIZEDPASSWORD* ]]; then
isError "No Rocket.Chat admin password in rocketchat.config — cannot authenticate."
return 1
fi
local res
res=$(curl -sS -m 20 -X POST "$base/api/v1/login" \
-H 'Content-Type: application/json' \
--data-binary "$(printf '{"user":%s,"password":%s}' \
"$(_rcJson "$user")" "$(_rcJson "$pass")")" 2>&1)
local id token
id=$(printf '%s' "$res" | python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('data',{}).get('userId',''))" 2>/dev/null)
token=$(printf '%s' "$res" | python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('data',{}).get('authToken',''))" 2>/dev/null)
if [[ -z "$id" || -z "$token" ]]; then
isError "Rocket.Chat admin login failed. Check CFG_ROCKETCHAT_ADMIN_USERNAME / _PASSWORD_1."
return 1
fi
printf '%s %s' "$id" "$token"
}
# Rocket.Chat enables a password policy by default that demands lower, upper,
# digit AND special, at 14+ characters. generateRandomPassword is alphanumeric,
# so a generated password is rejected by users.update with "does not meet the
# server's password policy" — note that users.create does NOT enforce it, which
# is why creating worked while resetting did not.
#
# Appending one character from each class guarantees compliance without
# weakening anything: the entropy of the generated part is untouched.
_rocketchatPassword() {
printf '%s%s' "$(generateRandomPassword)" 'aZ7#'
}
# JSON-encode a bash string so a password containing quotes or backslashes
# cannot break the request body.
_rcJson() {
printf '%s' "$1" | python3 -c "import sys,json;print(json.dumps(sys.stdin.read()))"
}
# _rocketchatApi <METHOD> <path> [json body]
_rocketchatApi() {
local method="$1" path="$2" body="$3"
local base; base=$(_rocketchatBaseUrl)
[[ -z "$base" ]] && { isError "Could not read Rocket.Chat's ROOT_URL from its compose file."; return 1; }
local creds; creds=$(_rocketchatLogin "$base") || return 1
local id="${creds%% *}" token="${creds##* }"
if [[ -n "$body" ]]; then
curl -sS -m 30 -X "$method" "$base$path" \
-H "X-User-Id: $id" -H "X-Auth-Token: $token" \
-H 'Content-Type: application/json' --data-binary "$body"
else
curl -sS -m 30 -X "$method" "$base$path" \
-H "X-User-Id: $id" -H "X-Auth-Token: $token"
fi
}
# Rocket.Chat answers 200 with {"success":false,"error":"..."} rather than an
# HTTP error, so success has to be read out of the body.
_rocketchatOk() {
printf '%s' "$1" | python3 -c "import sys,json
try: print('yes' if json.load(sys.stdin).get('success') else 'no')
except Exception: print('no')" 2>/dev/null
}
_rocketchatError() {
printf '%s' "$1" | python3 -c "import sys,json
try:
d=json.load(sys.stdin); print(d.get('error') or d.get('message') or 'unknown error')
except Exception: print('could not parse the API response')" 2>/dev/null
}
authAdapter_rocketchat_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=$(_rocketchatPassword)
local roles='["user"]'
[[ "$isAdmin" == "true" ]] && roles='["admin","user"]'
local body out
body=$(printf '{"email":%s,"name":%s,"password":%s,"username":%s,"roles":%s,"joinDefaultChannels":true,"requirePasswordChange":false,"verified":true}' \
"$(_rcJson "$email")" "$(_rcJson "$username")" "$(_rcJson "$password")" "$(_rcJson "$username")" "$roles")
out=$(_rocketchatApi POST /api/v1/users.create "$body") || return 1
[[ "$(_rocketchatOk "$out")" != "yes" ]] && { isError "Creating $email failed: $(_rocketchatError "$out")"; return 1; }
isSuccessful "Rocket.Chat user created — Email: $email — Username: $username — Password: $password"
}
# Resolve a username or email to Rocket.Chat's internal user id, which is what
# every mutating endpoint wants.
_rocketchatUserId() {
local who="$1" field="username"
[[ "$who" == *@* ]] && field="email"
local out
out=$(_rocketchatApi GET "/api/v1/users.info?${field}=${who}") || return 1
printf '%s' "$out" | python3 -c "import sys,json
try: print(json.load(sys.stdin).get('user',{}).get('_id',''))
except Exception: print('')" 2>/dev/null
}
_rocketchatUsernameOf() {
local who="$1" field="username"
[[ "$who" == *@* ]] && field="email"
local out
out=$(_rocketchatApi GET "/api/v1/users.info?${field}=${who}") || return 1
printf '%s' "$out" | python3 -c "import sys,json
try: print(json.load(sys.stdin).get('user',{}).get('username',''))
except Exception: print('')" 2>/dev/null
}
authAdapter_rocketchat_setPassword() {
local who="$1" password="$2"
[[ -z "$who" ]] && { isError "A username or email is required."; return 1; }
[[ -z "$password" ]] && password=$(_rocketchatPassword)
local uid; uid=$(_rocketchatUserId "$who") || return 1
[[ -z "$uid" ]] && { isError "No Rocket.Chat user '$who'."; return 1; }
local body out
body=$(printf '{"userId":%s,"data":{"password":%s,"requirePasswordChange":false}}' \
"$(_rcJson "$uid")" "$(_rcJson "$password")")
out=$(_rocketchatApi POST /api/v1/users.update "$body") || return 1
[[ "$(_rocketchatOk "$out")" != "yes" ]] && { isError "Resetting $who failed: $(_rocketchatError "$out")"; return 1; }
# If this is the account the tools authenticate as, the config has to follow
# or every later tool call fails to log in.
if [[ "$who" == "${CFG_ROCKETCHAT_ADMIN_USERNAME:-admin}" || "$who" == "${CFG_ROCKETCHAT_ADMIN_EMAIL:-}" ]]; then
authPersistCfg rocketchat ADMIN_PASSWORD "$password"
fi
isSuccessful "Rocket.Chat password set for $who — New password: $password"
}
authAdapter_rocketchat_listUsers() {
local out
out=$(_rocketchatApi GET '/api/v1/users.list?count=500') || return 1
[[ "$(_rocketchatOk "$out")" != "yes" ]] && { isError "Listing users failed: $(_rocketchatError "$out")"; return 1; }
local rendered
rendered=$(printf '%s' "$out" | python3 -c "
import sys, json
d = json.load(sys.stdin)
users = d.get('users', [])
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', '-')
# 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)
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 Rocket.Chat account(s)."
}
# Deactivate, not delete. users.delete purges the account and its messages with
# no undo; setting active=false revokes access and is reversible from the admin
# UI, which is the safer default behind a single button.
authAdapter_rocketchat_deleteUser() {
local who="$1"
[[ -z "$who" ]] && { isError "A username or email is required."; return 1; }
if [[ "$who" == "${CFG_ROCKETCHAT_ADMIN_USERNAME:-admin}" || "$who" == "${CFG_ROCKETCHAT_ADMIN_EMAIL:-}" ]]; then
isError "Refusing to deactivate '$who' — it is the admin these tools authenticate as."
return 1
fi
local uid; uid=$(_rocketchatUserId "$who") || return 1
[[ -z "$uid" ]] && { isError "No Rocket.Chat user '$who'."; return 1; }
local out
out=$(_rocketchatApi POST /api/v1/users.setActiveStatus "$(printf '{"userId":%s,"activeStatus":false}' "$(_rcJson "$uid")")") || return 1
[[ "$(_rocketchatOk "$out")" != "yes" ]] && { isError "Deactivating $who failed: $(_rocketchatError "$out")"; return 1; }
isSuccessful "Rocket.Chat user '$who' deactivated. Re-enable them from Admin → Users."
}
# The counterpart to deleteUser. Deactivation is only a safe default if undoing
# it is equally easy — otherwise the "reversible" claim is theoretical.
authAdapter_rocketchat_enableUser() {
local who="$1"
[[ -z "$who" ]] && { isError "A username or email is required."; return 1; }
local uid; uid=$(_rocketchatUserId "$who") || return 1
[[ -z "$uid" ]] && { isError "No Rocket.Chat user '$who'."; return 1; }
local out
out=$(_rocketchatApi POST /api/v1/users.setActiveStatus "$(printf '{"userId":%s,"activeStatus":true}' "$(_rcJson "$uid")")") || return 1
[[ "$(_rocketchatOk "$out")" != "yes" ]] && { isError "Enabling $who failed: $(_rocketchatError "$out")"; return 1; }
isSuccessful "Rocket.Chat user '$who' re-enabled."
}
authAdapter_rocketchat_setAdmin() {
local who="$1" isAdmin="$2"
[[ -z "$who" ]] && { isError "A username or email is required."; return 1; }
local target="false"; [[ "$isAdmin" == "true" ]] && target="true"
if [[ "$target" == "false" && ( "$who" == "${CFG_ROCKETCHAT_ADMIN_USERNAME:-admin}" || "$who" == "${CFG_ROCKETCHAT_ADMIN_EMAIL:-}" ) ]]; then
isError "Refusing to demote '$who' — it is the admin these tools authenticate as."
return 1
fi
# The endpoint takes roleId + username, and nothing else: passing roleName
# fails schema validation with "must NOT have additional properties", and
# roleId + userId is rejected for a missing username. For built-in roles the
# id and the name happen to be the same string ("admin").
#
# username is resolved from the account rather than assumed from the input,
# so passing an email works here too.
local username
username=$(_rocketchatUsernameOf "$who") || return 1
[[ -z "$username" ]] && { isError "No Rocket.Chat user '$who'."; return 1; }
local endpoint="/api/v1/roles.addUserToRole"
[[ "$target" == "false" ]] && endpoint="/api/v1/roles.removeUserFromRole"
local body
body=$(printf '{"roleId":"admin","username":%s}' "$(_rcJson "$username")")
local out
out=$(_rocketchatApi POST "$endpoint" "$body") || return 1
[[ "$(_rocketchatOk "$out")" != "yes" ]] && { isError "Changing admin status for $who failed: $(_rocketchatError "$out")"; return 1; }
isSuccessful "Rocket.Chat user '$who' admin → $target."
}