Admin interface
This commit is contained in:
+105
-1
@@ -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;
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
<button type="button" data-section="seasons-section" aria-controls="seasons-section" aria-expanded="false">Seasons</button>
|
||||
<button type="button" data-section="chat-section" aria-controls="chat-section" aria-expanded="false">Chat</button>
|
||||
<button type="button" data-section="alerts-section" aria-controls="alerts-section" aria-expanded="false">Alerts</button>
|
||||
<button type="button" data-section="admin-section" aria-controls="admin-section" aria-expanded="false" hidden>Admin</button>
|
||||
<button type="button" data-section="stats-section" aria-controls="stats-section" aria-expanded="false" hidden>Final stats</button>
|
||||
<button type="button" data-section="rules" aria-controls="rules" aria-expanded="false">Rules</button>
|
||||
<button type="button" data-section="share-controls" aria-controls="share-controls" aria-expanded="false" hidden>Share</button>
|
||||
@@ -61,6 +62,29 @@
|
||||
<p class="welcome-hint">Choose a section above to join in, compare scores, or say hello.</p>
|
||||
</div>
|
||||
|
||||
<section id="admin-section" data-panel hidden aria-labelledby="admin-heading">
|
||||
<header class="panel-header"><div class="header-topline"><p class="eyebrow">KEEP THE EXPERIMENT WELCOMING</p><span class="site-badge">Administrator</span></div>
|
||||
<h2 id="admin-heading">Control room.</h2>
|
||||
<p class="panel-description">Manage player access and keep the conversation on track. Every action is recorded.</p>
|
||||
</header>
|
||||
<div class="panel-body">
|
||||
<div class="form-actions"><button id="admin-refresh" type="button">Refresh dashboard</button></div>
|
||||
<p id="admin-status" role="status" class="fine-print"></p>
|
||||
<p id="admin-error" role="status" class="error"></p>
|
||||
<div id="admin-overview" class="score-grid"></div>
|
||||
<h3 class="subheading">Players</h3>
|
||||
<label class="field" for="admin-player-filter"><span>Filter by player name</span><input id="admin-player-filter" type="search" placeholder="Find a player" autocomplete="off"></label>
|
||||
<p class="fine-print">Role changes and bans revoke the player's sessions. You cannot remove your own access or the last active administrator. Delete account removes credentials and anonymizes the player; historical scores stay.</p>
|
||||
<ul id="admin-players" class="admin-list" aria-label="Player administration"></ul>
|
||||
<h3 class="subheading">Recent chat</h3>
|
||||
<p class="fine-print">Latest 100 visible messages, including automatic announcements. Removal is permanent; it does not undo presses or milestone unlocks.</p>
|
||||
<ul id="admin-messages" class="admin-list" aria-label="Chat moderation"></ul>
|
||||
<h3 class="subheading">Recent admin actions</h3>
|
||||
<p class="fine-print">Most recent 50 actions. IDs are internal account/message identifiers; no passwords or tokens are recorded.</p>
|
||||
<div class="table-scroll"><table id="admin-audit" aria-label="Administrator audit log"></table></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="seasons-section" data-panel hidden aria-labelledby="seasons-heading">
|
||||
<header class="panel-header"><p class="eyebrow">A NEW CHAPTER, FOUR TIMES A YEAR</p>
|
||||
<h2 id="seasons-heading">The seasonal leaderboard.</h2>
|
||||
|
||||
@@ -321,6 +321,17 @@ button:focus-visible, a:focus-visible, input:focus-visible, summary:focus-visibl
|
||||
.error { color: var(--red); }
|
||||
.panel-body > .error { color: var(--red); }
|
||||
.error:empty { display: none; }
|
||||
.admin-list { list-style: none; display: grid; gap: 0.75rem; padding: 0; max-height: 520px; overflow-y: auto; }
|
||||
.admin-card { background: var(--base); border: 1px solid var(--surface); border-radius: 12px; padding: 1rem; min-width: 0; overflow-wrap: anywhere; }
|
||||
.admin-card > strong { color: var(--lavender); font-size: 0.9rem; }
|
||||
.admin-card > small { display: block; margin-top: 0.3rem; }
|
||||
.admin-card > p { font-size: 0.85rem; }
|
||||
.admin-card .form-actions button { flex: 0 1 auto; font-size: 0.75rem; }
|
||||
button.danger-button:not(#the-button) { color: var(--red); border-color: #f38ba840; background: #f38ba808; }
|
||||
button.danger-button:not(#the-button):hover { background: #f38ba820; }
|
||||
#admin-overview { margin-top: 1rem; }
|
||||
#admin-overview .metric-card strong { font-size: 1.2rem; }
|
||||
#admin-audit td { max-width: 160px; overflow-wrap: anywhere; }
|
||||
#alerts-form { display: grid; justify-content: stretch; gap: 1rem; max-width: 460px; }
|
||||
.alert-option { display: flex; align-items: center; gap: 0.65rem; color: var(--text); font-size: 0.85rem; cursor: pointer; }
|
||||
.alert-option input { width: 18px; height: 18px; min-height: 0; padding: 0; margin: 0; accent-color: var(--mauve); flex-shrink: 0; }
|
||||
|
||||
Reference in New Issue
Block a user