Require admin password for destructive admin actions
This commit is contained in:
@@ -155,6 +155,7 @@ Nein. Vorschläge und Bewertungen sind schreibgeschützt. Wende dich bei Bedarf
|
||||
- Vorschläge löschen
|
||||
- Abstimmungsstatus einsehen (wer finalisiert hat)
|
||||
- Einen Spieler löschen (inklusive dessen Vorschläge und Stimmen)
|
||||
- Für Konto-Löschung, Zurücksetzen und Werkseinstellung das Admin-Passwort bestätigen
|
||||
- Die Datenbank auf Werkseinstellungen zurücksetzen
|
||||
- Zu vorherigen Phasen zurückkehren
|
||||
|
||||
|
||||
@@ -159,6 +159,7 @@ No. Suggestions and votes are read-only. Contact an admin for assistance.
|
||||
- Delete suggestions
|
||||
- View vote readiness (who has finalized)
|
||||
- Delete a player (removes their suggestions and votes)
|
||||
- Confirm admin password for account deletion, reset, and factory reset
|
||||
- Reset the database to factory defaults
|
||||
- Move backward to previous phases
|
||||
|
||||
|
||||
@@ -107,6 +107,12 @@
|
||||
"admin.resultsUpdated": "Results availability updated",
|
||||
"admin.reset": "Reset (keep players)",
|
||||
"admin.factoryReset": "Factory reset",
|
||||
"admin.resetConfirmTitle": "Reset round data?",
|
||||
"admin.resetConfirmBody": "This clears suggestions and votes while keeping accounts. Enter your admin password to continue.",
|
||||
"admin.factoryResetConfirmTitle": "Factory reset everything?",
|
||||
"admin.factoryResetConfirmBody": "This removes all players, suggestions, and votes. Enter your admin password to continue.",
|
||||
"admin.confirmPasswordLabel": "Admin password",
|
||||
"admin.confirmPasswordRequired": "Admin password is required.",
|
||||
"admin.resetDone": "Reset complete",
|
||||
"admin.factoryResetDone": "Factory reset complete",
|
||||
"admin.readyForResults": "Ready for results",
|
||||
@@ -269,6 +275,12 @@
|
||||
"admin.resultsUpdated": "Ergebnisfreigabe aktualisiert",
|
||||
"admin.reset": "Zurücksetzen (Spieler behalten)",
|
||||
"admin.factoryReset": "Werkseinstellung",
|
||||
"admin.resetConfirmTitle": "Rundendaten zurücksetzen?",
|
||||
"admin.resetConfirmBody": "Dadurch werden Vorschläge und Stimmen gelöscht, die Konten bleiben erhalten. Gib dein Admin-Passwort ein, um fortzufahren.",
|
||||
"admin.factoryResetConfirmTitle": "Alles auf Werkseinstellung setzen?",
|
||||
"admin.factoryResetConfirmBody": "Dadurch werden alle Spieler, Vorschläge und Stimmen gelöscht. Gib dein Admin-Passwort ein, um fortzufahren.",
|
||||
"admin.confirmPasswordLabel": "Admin-Passwort",
|
||||
"admin.confirmPasswordRequired": "Admin-Passwort ist erforderlich.",
|
||||
"admin.resetDone": "Zurücksetzen abgeschlossen",
|
||||
"admin.factoryResetDone": "Werkseinstellung abgeschlossen",
|
||||
"admin.readyForResults": "Bereit für Ergebnisse",
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user