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>
127 lines
5.0 KiB
Bash
127 lines
5.0 KiB
Bash
#!/bin/bash
|
|
|
|
# Stoat user management.
|
|
#
|
|
# Deliberately the thinnest set of the four, because Stoat gives the least to
|
|
# work with. Its service containers are distroless (no shell), it has no admin
|
|
# CLI, and its admin HTTP surface is not a stable documented API — so the only
|
|
# durable handles are the public auth endpoint and the database itself.
|
|
#
|
|
# What that rules out, and why:
|
|
#
|
|
# Password reset — Stoat hashes with argon2 through its own authifier layer.
|
|
# Reimplementing that in bash means matching its parameters exactly, and
|
|
# getting them subtly wrong writes a hash nothing can verify, silently locking
|
|
# the account out with no error at the time. Users reset their own password
|
|
# through the app; that path is not worth counterfeiting from a button.
|
|
#
|
|
# Role/permission editing — Stoat's permissions are per-server bitfields held
|
|
# on server_members, not a global admin flag. There is no single "make admin"
|
|
# to toggle; it is a server-by-server concept the app models properly and a
|
|
# button would misrepresent.
|
|
#
|
|
# What is left is genuinely useful and genuinely safe: see who exists, and turn
|
|
# an account off or back on.
|
|
|
|
_stoatMongo() {
|
|
runFileOp docker exec -i stoat-database mongosh revolt --quiet --eval "$1" 2>&1
|
|
}
|
|
|
|
_stoatMongoFailed() {
|
|
local out="$1" what="$2"
|
|
# mongosh reports failures as a thrown error rather than a non-zero exit in
|
|
# --eval mode, so match on the text.
|
|
if [[ "$out" == *MongoServerError* || "$out" == *ReferenceError* || "$out" == *TypeError* ]]; then
|
|
isError "$what failed: $(printf '%s' "$out" | head -2 | tr '\n' ' ')"
|
|
return 0
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
# Accounts and profiles are separate collections sharing an _id: `accounts` holds
|
|
# the login (email, disabled flag), `users` the profile (username, discriminator).
|
|
# Neither alone is a useful view, so join them.
|
|
authAdapter_stoat_listUsers() {
|
|
local out
|
|
out=$(_stoatMongo '
|
|
const users = db.users.find({}, {username:1, discriminator:1, display_name:1}).toArray();
|
|
const byId = {};
|
|
db.accounts.find({}, {email:1, disabled:1}).toArray().forEach(a => byId[a._id] = a);
|
|
users.forEach(u => {
|
|
const a = byId[u._id] || {};
|
|
const handle = u.username + (u.discriminator ? "#" + u.discriminator : "");
|
|
const state = a.disabled ? " (disabled)" : "";
|
|
// 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);
|
|
')
|
|
_stoatMongoFailed "$out" "Listing users" && return 1
|
|
|
|
local line total=0
|
|
while IFS= read -r line; do
|
|
case "$line" in
|
|
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"
|
|
|
|
if [[ "$total" == "0" ]]; then
|
|
isNotice "No Stoat accounts yet — the first person to register becomes the instance owner."
|
|
return 0
|
|
fi
|
|
isSuccessful "$total Stoat account(s)."
|
|
}
|
|
|
|
# Disabling is Stoat's own reversible state, not a hand-rolled hack: the account
|
|
# stays intact and its messages stay readable, the holder just cannot log in.
|
|
_stoatSetDisabled() {
|
|
local who="$1" disabled="$2" verb="$3"
|
|
[[ -z "$who" ]] && { isError "A username or email is required."; return 1; }
|
|
|
|
local out
|
|
out=$(_stoatMongoWho "$who" "$disabled")
|
|
_stoatMongoFailed "$out" "$verb $who" && return 1
|
|
[[ "$out" == *LP_MISSING* ]] && { isError "No Stoat account matching '$who'."; return 1; }
|
|
[[ "$out" != *LP_OK* ]] && { isError "$verb $who failed: $out"; return 1; }
|
|
return 0
|
|
}
|
|
|
|
# Kept separate so the account lookup (by email on `accounts`, or by username on
|
|
# `users`) lives in one place.
|
|
_stoatMongoWho() {
|
|
local who="$1" disabled="$2"
|
|
_stoatMongo "
|
|
const who = $(printf '%s' "$who" | python3 -c 'import sys,json;print(json.dumps(sys.stdin.read()))');
|
|
let acct = null;
|
|
if (who.includes('@')) {
|
|
acct = db.accounts.findOne({email: who});
|
|
} else {
|
|
const u = db.users.findOne({username: who});
|
|
if (u) acct = db.accounts.findOne({_id: u._id});
|
|
}
|
|
if (!acct) { print('LP_MISSING'); } else {
|
|
db.accounts.updateOne({_id: acct._id}, {\$set: {disabled: $disabled}});
|
|
print('LP_OK');
|
|
}
|
|
"
|
|
}
|
|
|
|
authAdapter_stoat_deleteUser() {
|
|
local who="$1"
|
|
_stoatSetDisabled "$who" "true" "Disabling" || return 1
|
|
isSuccessful "Stoat account '$who' disabled — they can no longer sign in. Re-enable it with the Enable tool."
|
|
isNotice "Their existing sessions are not revoked by this; restart the app to force everyone to re-authenticate."
|
|
}
|
|
|
|
authAdapter_stoat_enableUser() {
|
|
local who="$1"
|
|
_stoatSetDisabled "$who" "false" "Enabling" || return 1
|
|
isSuccessful "Stoat account '$who' re-enabled."
|
|
}
|