👤 admin user management — list / edit / delete accounts + reset passwords

The admin panel could mint invite codes and read stats but not manage the people.
Adds, all under the existing admin-gated /api/admin/ block (default-closed:
no session → 401, non-admin → 403):

- GET  /api/admin/users        — every account: id, email, admin flag, patch count, joined
- POST /api/admin/user/<id>    — rename / change email / grant-or-revoke admin
                                 (validated like signup: format + uniqueness)
- POST /api/admin/user/<id>/reset — set a fresh random temp password, returned ONCE
                                 (never stored plaintext or logged) to relay to the user
- DELETE /api/admin/user/<id>  — delete the account + its saved patches (feedback +
                                 used invite kept as history)

Self-lockout guards: you can't delete your own account or revoke your own admin.
Admin granted via the GODSTRUMENT_ADMIN env is marked env_admin and its toggle is
hidden (can't be removed in the db). auth.py migration-free — uses existing tables;
the is_admin column was already migrated.

Client: the ⚙-account admin panel now lists every user with inline edit (username +
email), 🔑 reset-pw (shows a copyable temp), ★ make/revoke admin, and 🗑 delete
(confirm); the panel widens for the table. Names/emails escaped on render.

Verified: auth.py --selftest (list/edit/reset/delete + endpoint guards); live curl
against the local dev db (rename, bad-email/dup rejected, admin toggle, self-guards
400, non-admin 403, delete cascades presets); and the UI end-to-end (rows render
with correct per-row actions, edit expands, reset shows a temp password). Console
clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-11 07:01:58 +10:00
parent 297ca8457f
commit 764e28b1c3
2 changed files with 218 additions and 5 deletions

141
auth.py
View File

@ -226,6 +226,89 @@ def submit_feedback(user_id: int, username: str, text: str, db: str = DEFAULT_DB
return True return True
def admin_list_users(db: str = DEFAULT_DB) -> list[dict]:
"""Every account with its id, admin flag, preset count and join date — for the
admin user-management panel. Read-only."""
con = _con(db)
try:
rows = con.execute(
"SELECT u.id, u.username, u.email, u.created, u.is_admin, "
"(SELECT COUNT(*) FROM presets p WHERE p.user_id = u.id) "
"FROM users u ORDER BY u.created DESC").fetchall()
finally:
con.close()
return [{"id": i, "username": un, "email": em,
"joined": time.strftime("%Y-%m-%d", time.localtime(c)),
"admin": bool(a or (ADMIN_EMAIL and em == ADMIN_EMAIL)),
"env_admin": bool(ADMIN_EMAIL and em == ADMIN_EMAIL), # admin via env — can't be toggled off in the db
"presets": pc}
for i, un, em, c, a, pc in rows]
def admin_update_user(user_id: int, db: str = DEFAULT_DB, username: str | None = None,
email: str | None = None, is_admin=None) -> tuple[bool, str]:
"""Edit an account (rename / change email / grant-or-revoke admin). Validates
format + uniqueness the same as signup. Only the fields passed are touched."""
con = _con(db)
try:
if not con.execute("SELECT 1 FROM users WHERE id=?", (user_id,)).fetchone():
return False, "no such user"
sets, vals = [], []
if username is not None:
username = username.strip()
if not USERNAME_RE.match(username):
return False, "username must be 2-24 letters, numbers, . _ or -"
if con.execute("SELECT 1 FROM users WHERE username=? COLLATE NOCASE AND id!=?",
(username, user_id)).fetchone():
return False, "username already taken"
sets.append("username=?"); vals.append(username)
if email is not None:
email = email.strip().lower()
if not EMAIL_RE.match(email):
return False, "invalid email"
if con.execute("SELECT 1 FROM users WHERE email=? AND id!=?",
(email, user_id)).fetchone():
return False, "email already registered"
sets.append("email=?"); vals.append(email)
if is_admin is not None:
sets.append("is_admin=?"); vals.append(1 if is_admin else 0)
if not sets:
return False, "nothing to change"
vals.append(user_id)
con.execute("UPDATE users SET " + ", ".join(sets) + " WHERE id=?", vals)
con.commit()
return True, "ok"
finally:
con.close()
def admin_reset_password(user_id: int, db: str = DEFAULT_DB) -> tuple[bool, str | None]:
"""Set a fresh random temporary password and return it ONCE (admin relays it;
it is never stored in plaintext or logged). For helping a locked-out friend."""
temp = secrets.token_urlsafe(9)
salt, ph = hash_pw(temp)
con = _con(db)
try:
cur = con.execute("UPDATE users SET pw_hash=?, salt=? WHERE id=?", (ph, salt, user_id))
con.commit()
return (True, temp) if cur.rowcount > 0 else (False, None)
finally:
con.close()
def admin_delete_user(user_id: int, db: str = DEFAULT_DB) -> bool:
"""Delete an account and its saved presets. Feedback rows are kept (they carry
the username as text) and the used invite stays used history is preserved."""
con = _con(db)
try:
con.execute("DELETE FROM presets WHERE user_id=?", (user_id,))
cur = con.execute("DELETE FROM users WHERE id=?", (user_id,))
con.commit()
return cur.rowcount > 0
finally:
con.close()
def admin_stats(db: str = DEFAULT_DB) -> dict: def admin_stats(db: str = DEFAULT_DB) -> dict:
con = _con(db) con = _con(db)
try: try:
@ -234,16 +317,13 @@ def admin_stats(db: str = DEFAULT_DB) -> dict:
"SELECT code FROM invites WHERE used_by IS NULL ORDER BY created DESC")] "SELECT code FROM invites WHERE used_by IS NULL ORDER BY created DESC")]
unused = len(open_codes) unused = len(open_codes)
presets = con.execute("SELECT COUNT(*) FROM presets").fetchone()[0] presets = con.execute("SELECT COUNT(*) FROM presets").fetchone()[0]
accounts = [{"username": u, "email": e,
"joined": time.strftime("%Y-%m-%d", time.localtime(c))}
for u, e, c in con.execute("SELECT username,email,created FROM users ORDER BY created DESC")]
fb = [{"username": u, "text": t, fb = [{"username": u, "text": t,
"when": time.strftime("%Y-%m-%d %H:%M", time.localtime(c))} "when": time.strftime("%Y-%m-%d %H:%M", time.localtime(c))}
for u, t, c in con.execute("SELECT username,text,created FROM feedback ORDER BY created DESC LIMIT 100")] for u, t, c in con.execute("SELECT username,text,created FROM feedback ORDER BY created DESC LIMIT 100")]
finally: finally:
con.close() con.close()
return {"users": users, "unused_codes": unused, "open_codes": open_codes, return {"users": users, "unused_codes": unused, "open_codes": open_codes,
"presets": presets, "accounts": accounts, "feedback": fb} "presets": presets, "accounts": admin_list_users(db), "feedback": fb}
# ---- presets (a full personal patch, stored as one JSON blob) --------------- # ---- presets (a full personal patch, stored as one JSON blob) ---------------
@ -352,8 +432,30 @@ def handle_api(method: str, path: str, body: bytes, cookie_header: str,
return 403, {"error": "admins only"}, None return 403, {"error": "admins only"}, None
if path == "/api/admin/stats" and method == "GET": if path == "/api/admin/stats" and method == "GET":
return 200, admin_stats(db), None return 200, admin_stats(db), None
if path == "/api/admin/users" and method == "GET":
return 200, {"users": admin_list_users(db)}, None
if path == "/api/admin/invite" and method == "POST": if path == "/api/admin/invite" and method == "POST":
return 200, {"code": mint_invite(db)}, None return 200, {"code": mint_invite(db)}, None
if path.startswith("/api/admin/user/"):
parts = path[len("/api/admin/user/"):].split("/")
try:
target = int(parts[0])
except (ValueError, IndexError):
return 400, {"error": "bad user id"}, None
action = parts[1] if len(parts) > 1 else ""
if action == "reset" and method == "POST":
ok, temp = admin_reset_password(target, db)
return (200, {"ok": True, "password": temp}, None) if ok else (404, {"error": "no such user"}, None)
if not action and method == "POST":
if target == uid and payload.get("admin") is False:
return 400, {"error": "you can't remove your own admin"}, None
ok, msg = admin_update_user(target, db, payload.get("username"),
payload.get("email"), payload.get("admin"))
return (200, {"ok": True}, None) if ok else (400, {"error": msg}, None)
if not action and method == "DELETE":
if target == uid:
return 400, {"error": "you can't delete your own admin account"}, None
return (200, {"ok": True}, None) if admin_delete_user(target, db) else (404, {"error": "no such user"}, None)
return 404, {"error": "not found"}, None return 404, {"error": "not found"}, None
if path == "/api/presets" and method == "GET": if path == "/api/presets" and method == "GET":
@ -431,6 +533,37 @@ def _selftest():
assert st == 200 and stats["users"] >= 1, "admin can read stats" assert st == 200 and stats["users"] >= 1, "admin can read stats"
assert any("one bug tho" in f["text"] for f in stats["feedback"]), "feedback shows in stats" assert any("one bug tho" in f["text"] for f in stats["feedback"]), "feedback shows in stats"
assert handle_api("POST", "/api/admin/invite", b"", cookie, db)[1].get("code"), "admin can mint codes" assert handle_api("POST", "/api/admin/invite", b"", cookie, db)[1].get("code"), "admin can mint codes"
# --- admin user management: list / edit / reset / delete --------------------
ulist = admin_list_users(db)
assert any(u["username"] == "Nova" and u["admin"] for u in ulist), "list shows the admin"
assert all("id" in u and "presets" in u for u in ulist), "list carries id + preset count"
okv, _, vid = signup("Victim", "victim@x.co", "temppass123", mint_invite(db), db)
assert okv and vid
assert admin_update_user(vid, db, username="Renamed")[0], "rename works"
assert admin_update_user(vid, db, email="new@x.co")[0], "email change works"
assert admin_update_user(vid, db, username="no spaces!")[0] is False, "bad username rejected"
assert admin_update_user(vid, db, username="Nova")[0] is False, "duplicate username rejected"
assert admin_update_user(vid, db, is_admin=True)[0] and user_info(vid, db)["admin"], "grant admin"
assert admin_update_user(vid, db, is_admin=False)[0] and not user_info(vid, db)["admin"], "revoke admin"
okr, temp = admin_reset_password(vid, db)
assert okr and temp and login("Renamed", temp, db) == vid, "reset password lets them log in"
assert login("Renamed", "temppass123", db) is None, "old password no longer works"
# endpoints — admin-gated + self-guards
assert handle_api("GET", "/api/admin/users", b"", cookie, db)[0] == 200, "admin lists users"
assert handle_api("GET", "/api/admin/users", b"", "", db)[0] == 401, "no session blocked"
assert handle_api("POST", f"/api/admin/user/{vid}/reset", b"", cookie, db)[1].get("password"), "reset endpoint returns a temp"
assert handle_api("POST", f"/api/admin/user/{uid}", json.dumps({"admin": False}).encode(), cookie, db)[0] == 400, "can't de-admin self"
assert handle_api("DELETE", f"/api/admin/user/{uid}", b"", cookie, db)[0] == 400, "can't delete self"
assert handle_api("POST", f"/api/admin/user/{vid}", json.dumps({"username": "Vic2"}).encode(), cookie, db)[0] == 200, "edit endpoint works"
# a non-admin can't reach any of it
_, _, ck2 = handle_api("POST", "/api/login", json.dumps({"identifier": "Mika", "password": "orbit12345"}).encode(), "", db)
mcookie = ck2.split(";")[0]
assert handle_api("GET", "/api/admin/users", b"", mcookie, db)[0] == 403, "non-admin blocked from users"
assert handle_api("DELETE", f"/api/admin/user/{vid}", b"", mcookie, db)[0] == 403, "non-admin blocked from delete"
# delete removes the user + its presets, leaves feedback/history
assert save_preset(vid, "gone", {"x": 1}, db) and list_presets(vid, db)
assert admin_delete_user(vid, db) and user_info(vid, db) is None, "delete removes the user"
assert not list_presets(vid, db), "delete cascades presets"
print("auth self-test: all checks passed.") print("auth self-test: all checks passed.")

View File

@ -347,6 +347,25 @@
padding: 4px 9px; user-select: text; -webkit-user-select: text; transition: background 0.15s; } padding: 4px 9px; user-select: text; -webkit-user-select: text; transition: background 0.15s; }
#account .codechip:hover { background: rgba(124,255,178,0.18); } #account .codechip:hover { background: rgba(124,255,178,0.18); }
#account .codechip.copied { color: #fff; border-color: #7cffb2; background: rgba(124,255,178,0.3); } #account .codechip.copied { color: #fff; border-color: #7cffb2; background: rgba(124,255,178,0.3); }
/* admin — user management */
#account.wide .box { width: 520px; }
#account .userlist { margin-top: 6px; }
#account .urow { padding: 8px 0; border-bottom: 1px solid rgba(120,150,200,0.12); }
#account .urow .uhead { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
#account .urow .uname { color: #eaf1ff; font-size: 13px; font-weight: 600; }
#account .urow .umeta { color: rgba(160,180,210,0.6); font-size: 10.5px; }
#account .urow .ubadge { font-size: 9.5px; letter-spacing: .5px; text-transform: uppercase; padding: 1px 6px; border-radius: 20px;
background: rgba(255,214,92,0.14); color: #ffd65c; border: 1px solid rgba(255,214,92,0.35); }
#account .urow .uact { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
#account .urow .ubtn { background: rgba(255,255,255,0.05); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.22);
border-radius: 7px; padding: 4px 9px; cursor: pointer; font-size: 10.5px; }
#account .urow .ubtn:hover { border-color: rgba(150,180,255,0.55); color: #eaf1ff; }
#account .urow .ubtn.danger:hover { border-color: rgba(255,120,120,0.6); color: #ffb3b3; }
#account .urow .uedit { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
#account .urow .uedit input { flex: 1 1 130px; min-width: 0; padding: 5px 8px; border-radius: 7px; font: inherit; font-size: 11px;
background: rgba(8,12,24,0.7); color: #eaf1ff; border: 1px solid rgba(120,150,200,0.25); }
#account .urow .umsg { font-size: 10.5px; margin-top: 4px; min-height: 12px; }
#account .urow .umsg .tmp { font-family: monospace; color: #7cffb2; user-select: text; }
/* ---- gated landing page (shown until you sign in) ---- */ /* ---- gated landing page (shown until you sign in) ---- */
#landing { #landing {
@ -8250,7 +8269,7 @@
const acct = document.getElementById("account"); const acct = document.getElementById("account");
const acctBox = acct.querySelector(".box"); const acctBox = acct.querySelector(".box");
const esc = (s) => (s || "").replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c])); const esc = (s) => (s || "").replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
const closeAccount = () => acct.classList.remove("open"); const closeAccount = () => acct.classList.remove("open", "wide");
acct.addEventListener("click", (e) => { if (e.target === acct) closeAccount(); }); acct.addEventListener("click", (e) => { if (e.target === acct) closeAccount(); });
function openAccount() { function openAccount() {
@ -8281,7 +8300,28 @@
const s = r.data, box = acctBox.querySelector("#adminbox"); const s = r.data, box = acctBox.querySelector("#adminbox");
const plural = (n, w) => n + " " + w + (n === 1 ? "" : "s"); const plural = (n, w) => n + " " + w + (n === 1 ? "" : "s");
box.style.display = "block"; box.style.display = "block";
acct.classList.add("wide");
const codes = s.open_codes || []; const codes = s.open_codes || [];
const me = user.username;
const userRows = (s.accounts || []).map(a => {
const isMe = a.username === me;
const canToggle = !a.env_admin && !(isMe && a.admin); // never strand yourself; env-admin can't toggle in db
return '<div class="urow" data-id="' + a.id + '">' +
'<div class="uhead"><span class="uname">@' + esc(a.username) + "</span>" +
(a.admin ? '<span class="ubadge">admin</span>' : "") +
'<span class="umeta">' + esc(a.email) + " · " + plural(a.presets, "patch") + " · joined " + esc(a.joined) + (isMe ? " · you" : "") + "</span></div>" +
'<div class="uact">' +
'<button class="ubtn" data-act="edit">✎ edit</button>' +
'<button class="ubtn" data-act="reset">🔑 reset pw</button>' +
(canToggle ? '<button class="ubtn" data-act="toggle">' + (a.admin ? "☆ revoke admin" : "★ make admin") + "</button>" : "") +
(isMe ? "" : '<button class="ubtn danger" data-act="delete">🗑 delete</button>') +
"</div>" +
'<div class="uedit" style="display:none">' +
'<input class="ename" value="' + esc(a.username) + '" placeholder="username" maxlength="24">' +
'<input class="eemail" value="' + esc(a.email) + '" placeholder="email">' +
'<button class="ubtn" data-act="save">save</button><button class="ubtn" data-act="cancel">cancel</button></div>' +
'<div class="umsg"></div></div>';
}).join("");
box.innerHTML = box.innerHTML =
"<h3>admin</h3>" + "<h3>admin</h3>" +
'<div class="sub">' + plural(s.users, "user") + " · " + plural(s.unused_codes, "code") + " left · " + plural(s.presets, "preset") + "</div>" + '<div class="sub">' + plural(s.users, "user") + " · " + plural(s.unused_codes, "code") + " left · " + plural(s.presets, "preset") + "</div>" +
@ -8290,6 +8330,8 @@
'<div class="codes" id="codes">' + '<div class="codes" id="codes">' +
(codes.length ? codes.map(c => '<span class="codechip">' + esc(c) + "</span>").join("") (codes.length ? codes.map(c => '<span class="codechip">' + esc(c) + "</span>").join("")
: '<span class="sub">none — issue one above</span>') + "</div>" + : '<span class="sub">none — issue one above</span>') + "</div>" +
'<div class="sub" style="margin-top:14px">users — click ✎ to edit, 🔑 for a temp password</div>' +
'<div class="userlist" id="userlist">' + (userRows || '<div class="sub">none</div>') + "</div>" +
'<div class="sub" style="margin-top:12px">feedback</div>' + '<div class="sub" style="margin-top:12px">feedback</div>' +
'<div class="fblist">' + (s.feedback.length '<div class="fblist">' + (s.feedback.length
? s.feedback.map(f => '<div class="fbitem"><b>@' + esc(f.username) + '</b> <span style="opacity:.55">' + esc(f.when) + "</span><br>" + esc(f.text) + "</div>").join("") ? s.feedback.map(f => '<div class="fbitem"><b>@' + esc(f.username) + '</b> <span style="opacity:.55">' + esc(f.when) + "</span><br>" + esc(f.text) + "</div>").join("")
@ -8316,6 +8358,44 @@
setTimeout(() => { chip.classList.remove("copied"); chip.textContent = c; }, 1100); setTimeout(() => { chip.classList.remove("copied"); chip.textContent = c; }, 1100);
}); });
}; };
const ulist = box.querySelector("#userlist");
if (ulist) ulist.onclick = async (e) => { // edit / reset / admin / delete a user
const btn = e.target.closest(".ubtn"); if (!btn) return;
const row = btn.closest(".urow"); if (!row) return;
const id = row.dataset.id, act = btn.dataset.act, msg = row.querySelector(".umsg");
msg.style.color = "rgba(160,180,210,0.7)";
const fail = (rr, def) => { msg.style.color = "#ff9ba0"; msg.textContent = (rr.data && rr.data.error) || def; };
if (act === "edit") { row.querySelector(".uedit").style.display = "flex"; return; }
if (act === "cancel") { row.querySelector(".uedit").style.display = "none"; msg.textContent = ""; return; }
if (act === "save") {
const rr = await api("/api/admin/user/" + id, "POST",
{ username: row.querySelector(".ename").value.trim(), email: row.querySelector(".eemail").value.trim() });
if (rr.ok) loadAdmin(); else fail(rr, "save failed");
return;
}
if (act === "toggle") {
const rr = await api("/api/admin/user/" + id, "POST", { admin: btn.textContent.indexOf("make") >= 0 });
if (rr.ok) loadAdmin(); else fail(rr, "failed");
return;
}
if (act === "reset") {
const rr = await api("/api/admin/user/" + id + "/reset", "POST");
if (rr.ok && rr.data.password) {
msg.style.color = "rgba(160,180,210,0.7)";
msg.innerHTML = 'temp password (give it to them, they should change it): <span class="tmp">' + esc(rr.data.password) + "</span>";
const tmp = msg.querySelector(".tmp");
tmp.style.cursor = "pointer"; tmp.title = "click to copy";
tmp.onclick = () => copyText(rr.data.password, () => { const p = tmp.textContent; tmp.textContent = "copied ✓"; setTimeout(() => tmp.textContent = p, 1100); });
} else fail(rr, "reset failed");
return;
}
if (act === "delete") {
if (!confirm("Delete " + row.querySelector(".uname").textContent + " and their saved patches? This cannot be undone.")) return;
const rr = await api("/api/admin/user/" + id, "DELETE");
if (rr.ok) loadAdmin(); else fail(rr, "delete failed");
return;
}
};
} }
// clipboard with a fallback for non-secure contexts // clipboard with a fallback for non-secure contexts
function copyText(text, onOk) { function copyText(text, onOk) {