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>
269 lines
12 KiB
Bash
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."
|
|
}
|