Implement admin back-pass flow and guarded admin actions

This commit is contained in:
2026-02-08 14:20:38 +01:00
parent 4ee327fb4e
commit 5595bfd3b1
25 changed files with 572 additions and 109 deletions

View File

@@ -15,6 +15,27 @@ function displayPlayerStatus(player) {
return phase;
}
function renderStatusSelect(player) {
const statusText = displayPlayerStatus(player);
const safeStatusText = escapeHtml(statusText);
const playerId = escapeHtml(player.playerId);
if (player.phase === "Vote") {
return `
<select class="admin-status-select" data-player-phase="${playerId}" data-current-phase="Vote">
<option value="Vote" selected>${safeStatusText}</option>
<option value="Suggest">${escapeHtml(t("admin.statusSuggesting"))}</option>
</select>
`;
}
return `
<select class="admin-status-select" disabled data-player-phase="${playerId}" data-current-phase="${escapeHtml(player.phase)}">
<option value="${escapeHtml(player.phase)}" selected>${safeStatusText}</option>
</select>
`;
}
export function renderAdminVoteStatus() {
if (!state.me?.isAdmin) return;
const statusBadge = $("admin-ready-status");
@@ -24,14 +45,13 @@ export function renderAdminVoteStatus() {
table.innerHTML = "";
state.adminVoteStatus.voters.forEach((v) => {
const tr = document.createElement("tr");
const statusText = displayPlayerStatus(v);
const gamesTooltip = escapeHtml((v.suggestionTitles || []).join(", "));
const nameText = escapeHtml(truncate(v.name, 28));
const userText = escapeHtml(truncate(v.username, 24));
tr.innerHTML = `
<td title="${escapeHtml(v.name)}">${nameText}</td>
<td class="muted small" title="${escapeHtml(v.username)}">${userText}</td>
<td>${statusText}</td>
<td>${renderStatusSelect(v)}</td>
<td title="${gamesTooltip}">${v.suggestionCount ?? 0}</td>
<td><button class="chip" data-grant-joker="${v.playerId}" type="button">${v.hasJoker ? "🎟" : t("admin.grantJokerChip")}</button></td>
<td><button class="chip danger-chip" data-delete-player="${v.playerId}" data-name="${v.name}" type="button">✕</button></td>

View File

@@ -56,10 +56,11 @@ export const api = {
export const adminApi = {
setResultsOpen: (resultsOpen) => request("/api/admin/results", { method: "POST", body: { resultsOpen } }),
voteStatus: () => request("/api/admin/vote-status"),
reset: () => request("/api/admin/reset", { method: "POST" }),
factoryReset: () => request("/api/admin/factory-reset", { method: "POST" }),
reset: (adminPassword) => request("/api/admin/reset", { method: "POST", body: { adminPassword } }),
factoryReset: (adminPassword) => request("/api/admin/factory-reset", { method: "POST", body: { adminPassword } }),
grantJoker: (playerId) => request("/api/admin/joker", { method: "POST", body: { playerId } }),
deletePlayer: (playerId) => request(`/api/admin/players/${playerId}`, { method: "DELETE" }),
deletePlayer: (playerId, adminPassword) => request(`/api/admin/players/${playerId}`, { method: "DELETE", body: { adminPassword } }),
setPlayerPhase: (playerId, phase) => request(`/api/admin/players/${playerId}/phase`, { method: "POST", body: { phase } }),
linkSuggestions: (sourceSuggestionId, targetSuggestionId) =>
request("/api/admin/link-suggestions", { method: "POST", body: { sourceSuggestionId, targetSuggestionId } }),
unlinkSuggestions: (suggestionId) =>

View File

@@ -3,7 +3,7 @@ import { t } from "./i18n.js";
import { state } from "./state.js";
import { $, toast } from "./dom.js";
import {
openConfirmModal,
openPasswordConfirmModal,
openResultsRelockModal,
renderPhasePill,
} from "./ui.js";
@@ -13,8 +13,10 @@ async function adminAction(fn, successMessage, runSerializedRefresh) {
await fn();
toast(successMessage);
await runSerializedRefresh();
return true;
} catch (err) {
toast(err.message, true);
return false;
}
}
@@ -32,24 +34,56 @@ function setupAdminPanelToggle() {
}
function setupResetButtons(runSerializedRefresh) {
const askPasswordThenRun = ({
title,
body,
confirmLabel,
action,
done,
}) => {
openPasswordConfirmModal({
title,
body,
confirmLabel,
onConfirm: async (password, close) => {
const success = await adminAction(
() => action(password),
done,
runSerializedRefresh,
);
if (success) close();
},
});
};
$("reset").addEventListener("click", () =>
adminAction(adminApi.reset, t("admin.resetDone"), runSerializedRefresh),
askPasswordThenRun({
title: t("admin.reset"),
body: t("admin.resetConfirmBody"),
confirmLabel: t("admin.reset"),
action: (password) => adminApi.reset(password),
done: t("admin.resetDone"),
}),
);
$("factory-reset").addEventListener("click", () =>
adminAction(
adminApi.factoryReset,
t("admin.factoryResetDone"),
runSerializedRefresh,
),
askPasswordThenRun({
title: t("admin.factoryReset"),
body: t("admin.factoryResetConfirmBody"),
confirmLabel: t("admin.factoryReset"),
action: (password) => adminApi.factoryReset(password),
done: t("admin.factoryResetDone"),
}),
);
}
function setupResultsToggle(runSerializedRefresh) {
const resultsToggle = $("results-open");
const resultsToggle = $("results-open-toggle");
if (!resultsToggle) return;
resultsToggle.addEventListener("change", async (e) => {
const desired = !!e.target.checked;
resultsToggle.addEventListener("click", async () => {
const desired = !state.resultsOpen;
resultsToggle.disabled = true;
try {
const resp = await adminApi.setResultsOpen(desired);
const wasResultsOpen = state.resultsOpen;
@@ -62,8 +96,9 @@ function setupResultsToggle(runSerializedRefresh) {
toast(t("admin.resultsUpdated"));
await runSerializedRefresh();
} catch (err) {
e.target.checked = !desired;
toast(err.message, true);
} finally {
resultsToggle.disabled = false;
}
});
}
@@ -92,6 +127,45 @@ function setupPlayerTableActions(runSerializedRefresh) {
const playerTable = $("admin-player-table");
if (!playerTable) return;
const syncSelectFocusState = () => {
state.adminStatusMenuOpen = !!playerTable.querySelector(
".admin-status-select:focus",
);
};
playerTable.addEventListener("focusin", (e) => {
if (e.target.closest(".admin-status-select")) {
state.adminStatusMenuOpen = true;
}
});
playerTable.addEventListener("focusout", () => {
window.setTimeout(syncSelectFocusState, 0);
});
playerTable.addEventListener("change", async (e) => {
const statusSelect = e.target.closest(".admin-status-select");
if (!statusSelect || statusSelect.disabled) return;
const playerId = statusSelect.dataset.playerPhase;
const currentPhase = statusSelect.dataset.currentPhase;
const desiredPhase = statusSelect.value;
if (!playerId || !desiredPhase || desiredPhase === currentPhase) return;
statusSelect.disabled = true;
try {
await adminApi.setPlayerPhase(playerId, desiredPhase);
toast(t("admin.statusUpdated"));
await runSerializedRefresh();
} catch (err) {
statusSelect.value = currentPhase ?? statusSelect.value;
toast(err.message, true);
} finally {
statusSelect.disabled = false;
state.adminStatusMenuOpen = false;
}
});
playerTable.addEventListener("click", async (e) => {
const grantBtn = e.target.closest("[data-grant-joker]");
const deleteBtn = e.target.closest("[data-delete-player]");
@@ -107,13 +181,13 @@ function setupPlayerTableActions(runSerializedRefresh) {
} else if (deleteBtn) {
const playerId = deleteBtn.dataset.deletePlayer;
const name = deleteBtn.dataset.name || "";
openConfirmModal({
openPasswordConfirmModal({
title: t("admin.deleteTitle"),
body: t("admin.deleteBody", { name }),
confirmLabel: t("admin.deleteConfirm"),
onConfirm: async (close) => {
onConfirm: async (password, close) => {
try {
await adminApi.deletePlayer(playerId);
await adminApi.deletePlayer(playerId, password);
toast(t("admin.deleteDone"));
close();
await runSerializedRefresh();

View File

@@ -1,6 +1,11 @@
import { api } from "./api.js";
import { t } from "./i18n.js";
import { state, clearUserState, setSavedUsername } from "./state.js";
import {
state,
clearUserState,
clearSavedUsername,
setSavedUsername,
} from "./state.js";
import { $, toast } from "./dom.js";
import {
handleAuthError,
@@ -139,24 +144,38 @@ function setupLogoutHandler() {
const logoutBtn = $("logout");
if (!logoutBtn) return;
const clearAuthFormFields = () => {
[
"login-username",
"login-password",
"register-username",
"register-password",
"register-displayName",
"register-adminkey",
].forEach((id) => {
const input = $(id);
if (input) input.value = "";
});
["login-consent", "register-consent"].forEach((id) => {
const box = $(id);
if (box) box.checked = false;
});
};
logoutBtn.addEventListener("click", async (e) => {
e.preventDefault();
const lastUser = state.me?.username;
try {
await api.logout();
} catch (err) {
toast(err.message, true);
}
clearUserState();
clearSavedUsername();
state.isAuthenticated = false;
setAuthMode("login");
setAuthUI(false);
if (lastUser) {
setSavedUsername(lastUser);
const loginUser = $("login-username");
if (loginUser) loginUser.value = lastUser;
const loginPass = $("login-password");
if (loginPass) loginPass.value = "";
}
clearAuthFormFields();
});
}

View File

@@ -34,7 +34,15 @@ export async function loadSuggestionsData() {
const latest = await api.allSuggestions();
const latestSig = signatureSuggestions(latest);
const changed = latestSig !== state.allSuggestionsSig;
if (changed && state.phase === "Vote" && state.allSuggestionsSig) {
const canCompareWithDisplayedVoteList =
state.phase === "Vote" &&
state.votesRendered &&
!!state.displayedVoteSuggestionsSig;
if (
changed &&
canCompareWithDisplayedVoteList &&
latestSig !== state.displayedVoteSuggestionsSig
) {
const added = latest
.filter((s) => !prevById[s.id])
.map((s) => s.name);

View File

@@ -80,6 +80,84 @@ export function openConfirmModal({
document.body.appendChild(overlay);
}
export function openPasswordConfirmModal({
title,
body,
confirmLabel,
cancelLabel = t("modal.cancel"),
onConfirm,
}) {
const overlay = document.createElement("div");
overlay.className = "edit-modal";
const panel = document.createElement("div");
panel.className = "edit-panel";
panel.innerHTML = `
<div class="edit-header">
<h3>${title}</h3>
<button class="lightbox-close" aria-label="${t("modal.close")}">x</button>
</div>
<div class="edit-body">
<p>${body}</p>
</div>
`;
const close = () => overlay.remove();
const bodyWrap = panel.querySelector(".edit-body");
const fieldWrap = document.createElement("label");
fieldWrap.className = "stack";
fieldWrap.innerHTML = `
<span class="label">${t("admin.passwordLabel")}</span>
<input type="password" autocomplete="current-password" />
`;
bodyWrap?.appendChild(fieldWrap);
const passwordInput = fieldWrap.querySelector("input");
const actions = document.createElement("div");
actions.className = "stack horizontal";
const confirmBtn = document.createElement("button");
confirmBtn.className = "danger";
confirmBtn.textContent = confirmLabel ?? t("modal.confirm");
actions.append(confirmBtn);
if (cancelLabel !== null && cancelLabel !== undefined) {
const cancelBtn = document.createElement("button");
cancelBtn.className = "ghost";
cancelBtn.type = "button";
cancelBtn.textContent = cancelLabel;
actions.append(cancelBtn);
cancelBtn.addEventListener("click", close);
}
bodyWrap?.appendChild(actions);
overlay.addEventListener("click", (e) => {
if (
e.target.classList.contains("edit-modal") ||
e.target.classList.contains("lightbox-close")
) {
close();
}
});
confirmBtn.addEventListener("click", async () => {
const password = passwordInput?.value ?? "";
if (!password.trim()) {
toast(t("admin.passwordRequired"), true);
passwordInput?.focus();
return;
}
try {
await onConfirm?.(password, close);
} catch (err) {
toast(err.message, true);
}
});
overlay.appendChild(panel);
document.body.appendChild(overlay);
passwordInput?.focus();
}
export function openResultsRelockModal() {
openConfirmModal({
title: t("results.relockedTitle"),

View File

@@ -11,10 +11,12 @@ export const state = {
mySuggestions: [],
allSuggestions: [],
allSuggestionsSig: null,
displayedVoteSuggestionsSig: null,
myVotes: [],
results: [],
votesRendered: false,
adminVoteStatus: null,
adminStatusMenuOpen: false,
};
export function clearUserState() {
@@ -27,9 +29,13 @@ export function clearUserState() {
state.counts = null;
state.mySuggestions = [];
state.allSuggestions = [];
state.allSuggestionsSig = null;
state.displayedVoteSuggestionsSig = null;
state.myVotes = [];
state.results = [];
state.votesRendered = false;
state.adminVoteStatus = null;
state.adminStatusMenuOpen = false;
const adminCard = document.getElementById("admin-card");
if (adminCard) adminCard.classList.add("hidden");
}
@@ -38,3 +44,5 @@ export const getSavedUsername = () =>
localStorage.getItem("last_username") || "";
export const setSavedUsername = (name) =>
localStorage.setItem("last_username", name);
export const clearSavedUsername = () =>
localStorage.removeItem("last_username");

View File

@@ -27,6 +27,7 @@ import { renderResults } from "./results-ui.js";
import {
openConfirmModal,
openLightbox,
openPasswordConfirmModal,
openResultsRelockModal,
openSuggestionsChangedModal,
} from "./modals-ui.js";
@@ -64,6 +65,7 @@ export {
openConfirmModal,
openLightbox,
openNewSuggestionModal,
openPasswordConfirmModal,
openResultsRelockModal,
openSuggestionsChangedModal,
renderAllSuggestions,

View File

@@ -42,6 +42,7 @@ export function renderVotes() {
li.querySelector(".card-body").appendChild(footer);
list.appendChild(li);
});
state.displayedVoteSuggestionsSig = state.allSuggestionsSig;
updatePhaseNav();
updateMissingBadgeFromDom();
list.scrollTop = prevScroll;
@@ -202,9 +203,11 @@ export function updatePhaseNav() {
showNav("nav-suggest", phase === "Suggest");
showNav("nav-vote", phase === "Vote");
const playerCanMoveBackToSuggest =
!isAdmin && phase === "Vote" && state.hasJoker;
const jokerBtn = $("open-joker-modal");
if (jokerBtn) {
const showJoker = phase === "Vote" && state.hasJoker;
const showJoker = false;
jokerBtn.classList.toggle("hidden", !showJoker);
jokerBtn.disabled = !showJoker;
}
@@ -242,11 +245,14 @@ export function updatePhaseNav() {
renderAdminLinker();
updateMissingBadgeFromDom();
const backButtons = ["nav-vote-prev"];
backButtons.forEach((id) => {
const btn = $(id);
if (btn) btn.classList.toggle("hidden", !isAdmin);
});
const votePrev = $("nav-vote-prev");
if (votePrev) {
const canUseBack = isAdmin || playerCanMoveBackToSuggest;
votePrev.classList.toggle("hidden", !canUseBack);
votePrev.textContent = playerCanMoveBackToSuggest
? t("nav.backToSuggestOnce")
: t("nav.prev");
}
const suggestNext = $("nav-suggest-next");
if (suggestNext) {
@@ -268,8 +274,10 @@ export function updatePhaseNav() {
: t("nav.next");
}
const adminResultsToggle = $("results-open");
const adminResultsToggle = $("results-open-toggle");
if (adminResultsToggle) {
adminResultsToggle.checked = !!state.resultsOpen;
adminResultsToggle.textContent = state.resultsOpen
? t("admin.resultsOpenButtonDisable")
: t("admin.resultsOpenButtonEnable");
}
}