Require admin password for destructive admin actions

This commit is contained in:
2026-02-08 15:05:10 +01:00
parent 96a47020d8
commit e666e7c603
13 changed files with 197 additions and 43 deletions

View File

@@ -56,12 +56,18 @@ 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: (password) =>
request("/api/admin/reset", { method: "POST", body: { password } }),
factoryReset: (password) =>
request("/api/admin/factory-reset", { method: "POST", body: { password } }),
grantJoker: (playerId) => request("/api/admin/joker", { method: "POST", body: { playerId } }),
setPlayerPhase: (playerId, phase) =>
request("/api/admin/player-phase", { method: "POST", body: { playerId, phase } }),
deletePlayer: (playerId) => request(`/api/admin/players/${playerId}`, { method: "DELETE" }),
deletePlayer: (playerId, password) =>
request(`/api/admin/players/${playerId}`, {
method: "DELETE",
body: { password },
}),
linkSuggestions: (sourceSuggestionId, targetSuggestionId) =>
request("/api/admin/link-suggestions", { method: "POST", body: { sourceSuggestionId, targetSuggestionId } }),
unlinkSuggestions: (suggestionId) =>

View File

@@ -8,14 +8,23 @@ import {
renderPhasePill,
} from "./ui.js";
async function adminAction(fn, successMessage, runSerializedRefresh) {
try {
await fn();
toast(successMessage);
await runSerializedRefresh();
} catch (err) {
toast(err.message, true);
}
function openAdminPasswordModal({ title, body, confirmLabel, onConfirm }) {
openConfirmModal({
title,
body,
confirmLabel,
confirmClass: "danger",
requirePassword: true,
passwordLabel: t("admin.confirmPasswordLabel"),
onConfirm: async (close, payload) => {
const password = (payload?.password || "").trim();
if (!password) {
toast(t("admin.confirmPasswordRequired"), true);
return;
}
await onConfirm(password, close);
},
});
}
function setupAdminPanelToggle() {
@@ -32,16 +41,40 @@ function setupAdminPanelToggle() {
}
function setupResetButtons(runSerializedRefresh) {
$("reset").addEventListener("click", () =>
adminAction(adminApi.reset, t("admin.resetDone"), runSerializedRefresh),
);
$("factory-reset").addEventListener("click", () =>
adminAction(
adminApi.factoryReset,
t("admin.factoryResetDone"),
runSerializedRefresh,
),
);
$("reset").addEventListener("click", () => {
openAdminPasswordModal({
title: t("admin.resetConfirmTitle"),
body: t("admin.resetConfirmBody"),
confirmLabel: t("admin.reset"),
onConfirm: async (password, close) => {
try {
await adminApi.reset(password);
toast(t("admin.resetDone"));
close();
await runSerializedRefresh();
} catch (err) {
toast(err.message, true);
}
},
});
});
$("factory-reset").addEventListener("click", () => {
openAdminPasswordModal({
title: t("admin.factoryResetConfirmTitle"),
body: t("admin.factoryResetConfirmBody"),
confirmLabel: t("admin.factoryReset"),
onConfirm: async (password, close) => {
try {
await adminApi.factoryReset(password);
toast(t("admin.factoryResetDone"));
close();
await runSerializedRefresh();
} catch (err) {
toast(err.message, true);
}
},
});
});
}
function setupResultsToggle(runSerializedRefresh) {
@@ -145,13 +178,13 @@ function setupPlayerTableActions(runSerializedRefresh) {
} else if (deleteBtn) {
const playerId = deleteBtn.dataset.deletePlayer;
const name = deleteBtn.dataset.name || "";
openConfirmModal({
openAdminPasswordModal({
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

@@ -29,6 +29,9 @@ export function openConfirmModal({
body,
confirmLabel,
cancelLabel = t("modal.cancel"),
confirmClass = null,
requirePassword = false,
passwordLabel = t("auth.password"),
onConfirm,
}) {
const overlay = document.createElement("div");
@@ -48,7 +51,9 @@ export function openConfirmModal({
const actions = document.createElement("div");
actions.className = "stack horizontal";
const confirmBtn = document.createElement("button");
if (confirmClass) confirmBtn.className = confirmClass;
confirmBtn.textContent = confirmLabel ?? t("modal.confirm");
confirmBtn.disabled = requirePassword;
actions.append(confirmBtn);
if (cancelLabel !== null && cancelLabel !== undefined) {
const cancelBtn = document.createElement("button");
@@ -58,7 +63,24 @@ export function openConfirmModal({
actions.append(cancelBtn);
cancelBtn.addEventListener("click", close);
}
panel.querySelector(".edit-body")?.appendChild(actions);
const bodyContainer = panel.querySelector(".edit-body");
let passwordInput = null;
if (requirePassword && bodyContainer) {
const field = document.createElement("label");
field.className = "stack";
const label = document.createElement("span");
label.className = "label";
label.textContent = passwordLabel;
passwordInput = document.createElement("input");
passwordInput.type = "password";
passwordInput.autocomplete = "current-password";
field.append(label, passwordInput);
bodyContainer.appendChild(field);
passwordInput.addEventListener("input", () => {
confirmBtn.disabled = !(passwordInput.value || "").trim();
});
}
bodyContainer?.appendChild(actions);
overlay.addEventListener("click", (e) => {
if (
@@ -70,7 +92,7 @@ export function openConfirmModal({
});
confirmBtn.addEventListener("click", async () => {
try {
await onConfirm?.(close);
await onConfirm?.(close, { password: passwordInput?.value ?? "" });
} catch (err) {
toast(err.message, true);
}