👤 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:
parent
297ca8457f
commit
764e28b1c3
141
auth.py
141
auth.py
@ -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.")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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 => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
const esc = (s) => (s || "").replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user