diff --git a/auth.py b/auth.py index 317dcab..8c41bbf 100644 --- a/auth.py +++ b/auth.py @@ -226,6 +226,89 @@ def submit_feedback(user_id: int, username: str, text: str, db: str = DEFAULT_DB 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: con = _con(db) 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")] unused = len(open_codes) 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, "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")] finally: con.close() 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) --------------- @@ -352,8 +432,30 @@ def handle_api(method: str, path: str, body: bytes, cookie_header: str, return 403, {"error": "admins only"}, None if path == "/api/admin/stats" and method == "GET": 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": 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 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 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" + # --- 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.") diff --git a/viz/index.html b/viz/index.html index f16ee20..fd97594 100644 --- a/viz/index.html +++ b/viz/index.html @@ -347,6 +347,25 @@ 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.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) ---- */ #landing { @@ -8250,7 +8269,7 @@ const acct = document.getElementById("account"); const acctBox = acct.querySelector(".box"); 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(); }); function openAccount() { @@ -8281,7 +8300,28 @@ const s = r.data, box = acctBox.querySelector("#adminbox"); const plural = (n, w) => n + " " + w + (n === 1 ? "" : "s"); box.style.display = "block"; + acct.classList.add("wide"); 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 '