Admin interface

This commit is contained in:
2026-10-22 11:26:42 -04:00
parent 2a2d976013
commit 3b81cae024
6 changed files with 583 additions and 9 deletions
+105 -1
View File
@@ -12,6 +12,9 @@ let currentShareText = "";
let selectedSection = null;
let chatSignature = null;
let chatLoading = false;
let isAdmin = false;
let adminSnapshot = null;
let adminBusy = false;
const PRESENCE_KEY = "thebutton-share-presence";
let sharePresence = localStorage.getItem(PRESENCE_KEY) !== "false";
let presenceTab = sessionStorage.getItem("thebutton-presence-tab");
@@ -208,9 +211,11 @@ window.addEventListener("storage", (event) => {
}
if (event.key === PRESENCE_KEY || event.key === TOKEN_KEY) {
sharePresence = localStorage.getItem(PRESENCE_KEY) !== "false";
if (event.key === TOKEN_KEY) isAdmin = false;
token = localStorage.getItem(TOKEN_KEY);
updateButton();
syncPresence();
if (event.key === TOKEN_KEY && token) loadMe();
}
});
@@ -221,6 +226,7 @@ function renderSections() {
"seasons-section": true,
"chat-section": true,
"alerts-section": true,
"admin-section": !!token && isAdmin,
"stats-section": statsLoaded,
rules: true,
"share-controls": !!currentShareText,
@@ -242,6 +248,7 @@ function renderSections() {
$("chat-login").hidden = !!token;
$("section-hint").hidden = selectedSection !== null;
$("presence-opt-in").checked = sharePresence;
if (!token || !isAdmin) clearAdminData();
}
$("section-nav").addEventListener("click", (event) => {
@@ -253,6 +260,7 @@ $("section-nav").addEventListener("click", (event) => {
if (selectedSection === "leaderboard-section") loadLeaderboard();
if (selectedSection === "chat-section") loadChat();
if (selectedSection === "seasons-section") loadSeasons();
if (selectedSection === "admin-section") loadAdmin();
syncPresence();
});
@@ -487,8 +495,11 @@ function renderAchievements(achievements) {
async function loadMe() {
if (!token) return;
const requestedToken = token;
try {
const me = await api("GET", "/api/me");
if (token !== requestedToken) return;
isAdmin = me.is_admin === true;
$("my-name").textContent = me.name;
$("my-points").textContent = me.total_points.toLocaleString();
$("my-presses").textContent = me.press_count;
@@ -531,12 +542,105 @@ async function loadMe() {
} catch (e) {
if (e.status === 401) {
token = null;
isAdmin = false;
localStorage.removeItem(TOKEN_KEY);
updateButton();
}
}
}
function clearAdminData() {
adminSnapshot = null;
for (const id of ["admin-overview", "admin-players", "admin-messages", "admin-audit"]) $(id).replaceChildren();
$("admin-status").textContent = "";
$("admin-error").textContent = "";
$("admin-player-filter").value = "";
}
function adminButton(text, action, target, value, extra = {}) {
const button = el("button", text, action === "delete_player" || action === "delete_message" ? "danger-button" : "");
button.type = "button";
button.disabled = adminBusy;
button.addEventListener("click", async () => {
const confirmation = action === "delete_player"
? `Delete account ${extra.name}? Login credentials will be removed, messages anonymized, and historical scores kept as Deleted player. This cannot be undone.`
: action === "delete_message" ? "Permanently remove this message from chat?"
: `${text} for ${extra.name}? Existing sessions will be revoked and the player must log in again.`;
if (adminBusy || !window.confirm(confirmation)) return;
adminBusy = true;
$("admin-error").textContent = "";
document.querySelectorAll("#admin-section button").forEach((b) => { b.disabled = true; });
try {
await api("POST", "/api/admin/action", { action, target_id: String(target),
...(value === undefined ? {} : { value }), ...(extra.at === undefined ? {} : { at: extra.at }) });
$("admin-status").textContent = "Action completed.";
chatSignature = null;
await loadAdmin();
await loadMe();
} catch (error) {
$("admin-error").textContent = error.message;
if (error.status === 401 || error.status === 403) { isAdmin = false; renderSections(); }
} finally {
adminBusy = false;
document.querySelectorAll("#admin-section button").forEach((b) => { b.disabled = false; });
renderAdminPlayers();
}
});
return button;
}
function renderAdminPlayers() {
if (!adminSnapshot) return;
const query = $("admin-player-filter").value.toLowerCase();
const players = adminSnapshot.players.filter((p) => p.name.toLowerCase().includes(query));
$("admin-players").replaceChildren(...players.map((player) => {
const item = el("li", undefined, "admin-card");
item.append(el("strong", player.name), el("p", `${player.is_admin ? "Admin" : "Player"} / ${player.is_banned ? "Banned" : "Active"} / ${player.total_points} points / ${player.press_count} presses / ${player.sessions} sessions`, "fine-print"));
const actions = el("div", undefined, "form-actions");
if (player.id === adminSnapshot.actor_id) {
actions.append(el("span", "Your account (protected)", "site-badge"));
} else {
actions.append(adminButton(player.is_admin ? "Remove admin" : "Make admin", "set_admin", player.id, !player.is_admin, { name: player.name }),
adminButton(player.is_banned ? "Unban" : "Ban", "set_banned", player.id, !player.is_banned, { name: player.name }),
adminButton("Delete account", "delete_player", player.id, undefined, { name: player.name }));
}
item.append(actions);
return item;
}));
}
async function loadAdmin() {
if (!token || !isAdmin) return;
const requestedToken = token;
try {
const data = await api("GET", "/api/admin");
if (token !== requestedToken || !isAdmin) return;
adminSnapshot = data;
$("admin-overview").replaceChildren(...[
[data.season.name, data.game.ended ? "Season finished" : "Season active"],
[data.players.length.toLocaleString(), "Player accounts"],
[data.game.total_presses.toLocaleString(), "Season presses"],
[data.game.stored_chat_messages.toLocaleString(), "Stored chat rows"],
].map(([value, label]) => { const card = el("div", undefined, "metric-card"); card.append(el("span", label), el("strong", value)); return card; }));
renderAdminPlayers();
$("admin-messages").replaceChildren(...data.messages.slice().reverse().map((message) => {
const item = el("li", undefined, "admin-card");
item.append(el("strong", message.name), el("small", `${new Date(message.at).toLocaleString()} / ${message.kind}`, "fine-print"),
el("p", message.message), adminButton("Remove message", "delete_message", message.id, undefined, { at: message.at }));
return item;
}));
fillTable($("admin-audit"), ["When", "Actor", "Action", "Target"], data.audit.map((entry) => [
new Date(entry.at).toLocaleString(), entry.actor_id, entry.action, entry.target_id,
]));
$("admin-status").textContent = `Last refreshed ${new Date().toLocaleTimeString()}. Refresh manually to keep controls stable.`;
} catch (error) {
$("admin-error").textContent = error.message;
if (error.status === 401 || error.status === 403) { isAdmin = false; renderSections(); }
}
}
$("admin-refresh").addEventListener("click", () => { if (!adminBusy) loadAdmin(); });
$("admin-player-filter").addEventListener("input", renderAdminPlayers);
function seasonDuration(ms) {
const days = Math.floor(ms / 86400000);
return `${days ? `${days}d ` : ""}${formatMs(ms % 86400000).slice(0, -3)}`;
@@ -602,7 +706,7 @@ async function loadChat() {
chatLoading = true;
try {
const { messages } = await api("GET", "/api/chat");
const signature = messages.map((message) => message.id).join(",");
const signature = JSON.stringify(messages);
const list = $("chat-messages");
if (signature !== chatSignature) {
const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 40;