admin: click-to-copy invite codes + list open (unclaimed) codes

- auth.py admin_stats now returns open_codes (all unclaimed invite codes,
  newest first); unused_codes derived from it.
- account panel: renders every open code as a click-to-copy chip ("copied ✓"
  flash); "issue new code" prepends the new code and auto-copies it. Chips are
  user-selectable (the body-wide user-select:none no longer traps them).
  copyText uses navigator.clipboard with an execCommand fallback for
  non-secure contexts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-06 12:21:48 +10:00
parent ed5bfa6122
commit a476b13317
2 changed files with 50 additions and 5 deletions

View File

@ -230,7 +230,9 @@ def admin_stats(db: str = DEFAULT_DB) -> dict:
con = _con(db)
try:
users = con.execute("SELECT COUNT(*) FROM users").fetchone()[0]
unused = con.execute("SELECT COUNT(*) FROM invites WHERE used_by IS NULL").fetchone()[0]
open_codes = [c for (c,) in con.execute(
"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))}
@ -240,8 +242,8 @@ def admin_stats(db: str = DEFAULT_DB) -> dict:
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, "presets": presets,
"accounts": accounts, "feedback": fb}
return {"users": users, "unused_codes": unused, "open_codes": open_codes,
"presets": presets, "accounts": accounts, "feedback": fb}
# ---- presets (a full personal patch, stored as one JSON blob) ---------------

View File

@ -285,6 +285,12 @@
#account .ghost { background: rgba(255,255,255,0.05); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.2);
border-radius: 7px; padding: 6px 10px; cursor: pointer; font-size: 11px; }
#account .code { font-family: monospace; color: #7cffb2; margin-left: 8px; font-size: 12px; }
#account .codes { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 2px; }
#account .codechip { font-family: monospace; color: #7cffb2; font-size: 12px; cursor: pointer;
background: rgba(124,255,178,0.08); border: 1px solid rgba(124,255,178,0.3); border-radius: 6px;
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); }
/* ---- gated landing page (shown until you sign in) ---- */
#landing {
@ -5791,19 +5797,56 @@
const s = r.data, box = acctBox.querySelector("#adminbox");
const plural = (n, w) => n + " " + w + (n === 1 ? "" : "s");
box.style.display = "block";
const codes = s.open_codes || [];
box.innerHTML =
"<h3>admin</h3>" +
'<div class="sub">' + plural(s.users, "user") + " · " + plural(s.unused_codes, "code") + " left · " + plural(s.presets, "preset") + "</div>" +
'<button class="ghost" id="newcode">issue new code</button><span class="code" id="codeout"></span>' +
'<button class="ghost" id="newcode">issue new code</button>' +
'<div class="sub" style="margin-top:10px">open invite codes — click any to copy</div>' +
'<div class="codes" id="codes">' +
(codes.length ? codes.map(c => '<span class="codechip">' + esc(c) + "</span>").join("")
: '<span class="sub">none — issue one above</span>') + "</div>" +
'<div class="sub" style="margin-top:12px">feedback</div>' +
'<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("")
: '<div class="sub">none yet</div>') + "</div>";
const wireChip = (chip) => {
chip.onclick = () => copyText(chip.textContent, () => {
const prev = chip.textContent;
chip.classList.add("copied"); chip.textContent = "copied ✓";
setTimeout(() => { chip.classList.remove("copied"); chip.textContent = prev; }, 1100);
});
};
box.querySelectorAll(".codechip").forEach(wireChip);
box.querySelector("#newcode").onclick = async () => {
const cr = await api("/api/admin/invite", "POST");
box.querySelector("#codeout").textContent = cr.ok ? cr.data.code : "failed";
if (!cr.ok) return;
const holder = box.querySelector("#codes");
if (holder.querySelector(".sub")) holder.innerHTML = ""; // clear the "none" placeholder
const chip = document.createElement("span");
chip.className = "codechip"; chip.textContent = cr.data.code;
holder.insertBefore(chip, holder.firstChild);
wireChip(chip);
copyText(cr.data.code, () => { // fresh code copies itself
chip.classList.add("copied"); const c = cr.data.code; chip.textContent = "copied ✓";
setTimeout(() => { chip.classList.remove("copied"); chip.textContent = c; }, 1100);
});
};
}
// clipboard with a fallback for non-secure contexts
function copyText(text, onOk) {
const done = () => { if (onOk) onOk(); };
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done).catch(() => fallbackCopy(text, done));
} else fallbackCopy(text, done);
}
function fallbackCopy(text, done) {
const ta = document.createElement("textarea");
ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0";
document.body.appendChild(ta); ta.select();
try { document.execCommand("copy"); } catch (e) {}
document.body.removeChild(ta); done();
}
loginForm.addEventListener("submit", async (e) => {
e.preventDefault(); clearErr();