From e666e7c603b24e99ee27c0b1b37cc05d6898efe2 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sun, 8 Feb 2026 15:05:10 +0100 Subject: [PATCH] Require admin password for destructive admin actions --- API.md | 8 +-- Contracts/Dtos.cs | 2 + Endpoints/AdminEndpoints.cs | 27 +++++++++-- Endpoints/AdminWorkflowService.cs | 33 +++++++++++-- GameList.Tests/AdminTests.cs | 36 ++++++++++++-- SPEC.md | 1 + TESTS.md | 6 +-- wwwroot/data/i18n/faq/de.md | 1 + wwwroot/data/i18n/faq/en.md | 1 + wwwroot/data/i18n/translations.json | 12 +++++ wwwroot/js/api.js | 12 +++-- wwwroot/js/app-admin-handlers.js | 75 +++++++++++++++++++++-------- wwwroot/js/modals-ui.js | 26 +++++++++- 13 files changed, 197 insertions(+), 43 deletions(-) diff --git a/API.md b/API.md index e3b99c0..71e8045 100644 --- a/API.md +++ b/API.md @@ -34,8 +34,10 @@ GET /api/results — leaderboard with totals, counts, averages, caller’s vote, ## Admin (requires authenticated admin user) POST /api/admin/results — `{ resultsOpen: bool }` locks/unlocks results and aligns player phases GET /api/admin/vote-status — readiness overview (who finalized) +POST /api/admin/joker — `{ playerId }` grants a vote-phase joker to the target player +POST /api/admin/player-phase — `{ playerId, phase }`; currently supports Vote→Suggest transitions only +DELETE /api/admin/players/{playerId} — `{ password }`; deletes player account plus their suggestions/votes POST /api/admin/link-suggestions — `{ sourceSuggestionId, targetSuggestionId }`; merges vote groups during Vote, clears votes in the linked group, unfinalizes **all** players POST /api/admin/unlink-suggestions — `{ suggestionId }`; breaks links, clears votes for that group, unfinalizes **all** players -POST /api/admin/player-phase — `{ playerId, phase }`; currently supports Vote→Suggest transitions only -POST /api/admin/reset — clear suggestions/votes; keep players; reset phases/vote-final flags -POST /api/admin/factory-reset — wipe players, suggestions, votes, state +POST /api/admin/reset — `{ password }`; clear suggestions/votes, keep players, reset phases/vote-final flags +POST /api/admin/factory-reset — `{ password }`; wipe players, suggestions, votes, state diff --git a/Contracts/Dtos.cs b/Contracts/Dtos.cs index ba166ce..ffdbd82 100644 --- a/Contracts/Dtos.cs +++ b/Contracts/Dtos.cs @@ -21,3 +21,5 @@ public record UnlinkSuggestionsRequest(int SuggestionId); public record GrantJokerRequest(Guid PlayerId); public record SetPlayerPhaseRequest(Guid PlayerId, Phase Phase); + +public record AdminPasswordRequest(string Password); diff --git a/Endpoints/AdminEndpoints.cs b/Endpoints/AdminEndpoints.cs index 0138e97..e58d6f8 100644 --- a/Endpoints/AdminEndpoints.cs +++ b/Endpoints/AdminEndpoints.cs @@ -19,7 +19,14 @@ public static class AdminEndpoints admin.MapPost("/player-phase", async ([FromBody] SetPlayerPhaseRequest request, AdminWorkflowService service) => await service.SetPlayerPhaseAsync(request.PlayerId, request.Phase)); - admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, AdminWorkflowService service) => await service.DeletePlayerAsync(playerId)); + admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, [FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) => + { + var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); + if (player is null) + return EndpointHelpers.UnauthorizedError(); + + return await service.DeletePlayerAsync(playerId, player.Id, request.Password); + }); admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) => { @@ -39,9 +46,23 @@ public static class AdminEndpoints return await service.UnlinkSuggestionsAsync(player.Id, request.SuggestionId); }); - admin.MapPost("/reset", async (AdminWorkflowService service) => await service.ResetAsync()); + admin.MapPost("/reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) => + { + var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); + if (player is null) + return EndpointHelpers.UnauthorizedError(); - admin.MapPost("/factory-reset", async (AdminWorkflowService service) => await service.FactoryResetAsync()); + return await service.ResetAsync(player.Id, request.Password); + }); + + admin.MapPost("/factory-reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) => + { + var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); + if (player is null) + return EndpointHelpers.UnauthorizedError(); + + return await service.FactoryResetAsync(player.Id, request.Password); + }); } } diff --git a/Endpoints/AdminWorkflowService.cs b/Endpoints/AdminWorkflowService.cs index 3c48a7c..43ace57 100644 --- a/Endpoints/AdminWorkflowService.cs +++ b/Endpoints/AdminWorkflowService.cs @@ -1,6 +1,7 @@ using GameList.Contracts; using GameList.Data; using GameList.Domain; +using GameList.Infrastructure; using Microsoft.EntityFrameworkCore; namespace GameList.Endpoints; @@ -86,8 +87,12 @@ internal sealed class AdminWorkflowService(AppDbContext db) return Results.Ok(new AdminSetPlayerPhaseResponse(player.Id, player.CurrentPhase, player.VotesFinal)); } - public async Task DeletePlayerAsync(Guid playerId) + public async Task DeletePlayerAsync(Guid playerId, Guid adminPlayerId, string password) { + var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password); + if (passwordError is not null) + return passwordError; + var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId); if (player is null) return EndpointHelpers.NotFoundError("Player not found."); @@ -203,8 +208,12 @@ internal sealed class AdminWorkflowService(AppDbContext db) return Results.Ok(new AdminUnlinkSuggestionsResponse(groupIds, await db.Players.CountAsync())); } - public async Task ResetAsync() + public async Task ResetAsync(Guid adminPlayerId, string password) { + var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password); + if (passwordError is not null) + return passwordError; + await using var tx = await db.Database.BeginTransactionAsync(); await db.Votes.ExecuteDeleteAsync(); @@ -220,8 +229,12 @@ internal sealed class AdminWorkflowService(AppDbContext db) return Results.Ok(new AdminResetStateResponse(Phase.Suggest, state.ResultsOpen, state.UpdatedAt)); } - public async Task FactoryResetAsync() + public async Task FactoryResetAsync(Guid adminPlayerId, string password) { + var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password); + if (passwordError is not null) + return passwordError; + await using var tx = await db.Database.BeginTransactionAsync(); await db.Votes.ExecuteDeleteAsync(); @@ -237,4 +250,18 @@ internal sealed class AdminWorkflowService(AppDbContext db) return Results.Ok(new AdminResetStateResponse(Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt)); } + + private async Task ValidateAdminPasswordAsync(Guid adminPlayerId, string password) + { + if (string.IsNullOrWhiteSpace(password)) + return EndpointHelpers.BadRequestError("Admin password is required."); + + var admin = await db.Players.AsNoTracking().FirstOrDefaultAsync(p => p.Id == adminPlayerId && p.IsAdmin); + if (admin is null) + return EndpointHelpers.UnauthorizedError(); + + return PasswordHasher.Verify(password, admin.PasswordHash, admin.PasswordSalt) + ? null + : EndpointHelpers.BadRequestError("Invalid admin password."); + } } diff --git a/GameList.Tests/AdminTests.cs b/GameList.Tests/AdminTests.cs index 31ead46..ea3113e 100644 --- a/GameList.Tests/AdminTests.cs +++ b/GameList.Tests/AdminTests.cs @@ -9,6 +9,8 @@ namespace GameList.Tests; public class AdminTests { + private const string AdminPassword = "Pass123!"; + [Fact] public async Task Admin_vote_status_marks_ready_when_all_finalized() { @@ -134,7 +136,10 @@ public class AdminTests Score = 8 }); - var resp = await admin.DeleteAsync($"/api/admin/players/{await player.GetProfileIdAsync()}"); + var resp = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{await player.GetProfileIdAsync()}") + { + Content = JsonContent.Create(new { password = AdminPassword }) + }); resp.EnsureSuccessStatusCode(); await factory.WithDbContextAsync(db => @@ -246,7 +251,7 @@ public class AdminTests await player.RegisterAsync("player"); await player.CreateSuggestionAsync("Keep"); - var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { }); + var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { password = AdminPassword }); reset.EnsureSuccessStatusCode(); await factory.WithDbContextAsync(db => @@ -266,7 +271,7 @@ public class AdminTests } }); - var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { }); + var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { password = AdminPassword }); factoryReset.EnsureSuccessStatusCode(); await factory.WithDbContextAsync(db => @@ -514,7 +519,7 @@ public class AdminTests await db.SaveChangesAsync(); }); - var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { }); + var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { password = AdminPassword }); reset.EnsureSuccessStatusCode(); await factory.WithDbContextAsync(async db => @@ -526,7 +531,7 @@ public class AdminTests Assert.False(state.ResultsOpen); }); - var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { }); + var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { password = AdminPassword }); factoryReset.EnsureSuccessStatusCode(); await factory.WithDbContextAsync(async db => { @@ -534,4 +539,25 @@ public class AdminTests Assert.False(state.ResultsOpen); }); } + + [Fact] + public async Task Destructive_admin_actions_require_valid_admin_password() + { + await using var factory = new TestWebApplicationFactory(); + var admin = factory.CreateClientWithCookies(); + await admin.RegisterAsync("admin", admin: true); + var player = factory.CreateClientWithCookies(); + await player.RegisterAsync("target"); + + var resetWrongPassword = await admin.PostAsJsonAsync("/api/admin/reset", new { password = "wrong" }); + Assert.Equal(HttpStatusCode.BadRequest, resetWrongPassword.StatusCode); + + var playerId = await factory.WithDbContextAsync(async db => await db.Players.Where(p => p.Username == "target").Select(p => p.Id).SingleAsync()); + + var deleteWrongPassword = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{playerId}") + { + Content = JsonContent.Create(new { password = "wrong" }) + }); + Assert.Equal(HttpStatusCode.BadRequest, deleteWrongPassword.StatusCode); + } } diff --git a/SPEC.md b/SPEC.md index 1d2b9a7..727564b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -11,6 +11,7 @@ Help a small Discord group (4–8 players) pick a co-op game via phased flow: - Username/password login (cookie auth) - Admins flagged via admin key at registration - Logout returns to the login form and clears all auth form fields +- Destructive admin actions (player delete, reset, factory reset) require admin password confirmation - Per-user phase tracking; admins can move themselves backward, everyone can move forward (subject to admin “results open” toggle and Suggest→Vote requiring at least one own suggestion) ## Suggest Phase diff --git a/TESTS.md b/TESTS.md index 83dc015..ed1cb3e 100644 --- a/TESTS.md +++ b/TESTS.md @@ -69,11 +69,11 @@ stateDiagram-v2 - GET /admin/vote-status returns list ordered by display/username with suggestion counts, finalized flag, joker flag; ready/waiting derived correctly. - POST /admin/joker grants joker only when target in Vote; resets VotesFinal for target. - POST /admin/player-phase allows Vote->Suggest transitions only; rejects other targets/current phases; clears target VotesFinal. -- DELETE /admin/players/{id}: removes player, cascades suggestions, breaks links to their suggestions, deletes related votes, wrapped in transaction. +- DELETE /admin/players/{id}: requires valid admin password; removes player, cascades suggestions, breaks links to their suggestions, deletes related votes, wrapped in transaction. - POST /admin/link-suggestions: only in Vote; errors on same ids/already linked/not found; re-parents groups correctly; deletes votes for affected group and unfinalizes affected players. - POST /admin/unlink-suggestions: only in Vote; clears parents for group, deletes votes in group, unfinalizes affected players; no-op safe when missing. -- POST /admin/reset: wipes suggestions/votes, resets phases to Suggest, clears votesFinal/hasJoker, closes results, updates timestamp. -- POST /admin/factory-reset: wipes all players/suggestions/votes/state; reseeds AppState with defaults; transactional. +- POST /admin/reset: requires valid admin password; wipes suggestions/votes, resets phases to Suggest, clears votesFinal/hasJoker, closes results, updates timestamp. +- POST /admin/factory-reset: requires valid admin password; wipes all players/suggestions/votes/state; reseeds AppState with defaults; transactional. ### 7) Infrastructure/Helpers - PasswordHasher: hash+verify roundtrip, rejects empty password, constant-time compare (FixedTimeEquals usage). diff --git a/wwwroot/data/i18n/faq/de.md b/wwwroot/data/i18n/faq/de.md index d3aa6b5..d407c6a 100644 --- a/wwwroot/data/i18n/faq/de.md +++ b/wwwroot/data/i18n/faq/de.md @@ -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 diff --git a/wwwroot/data/i18n/faq/en.md b/wwwroot/data/i18n/faq/en.md index bfa9bbd..f3a9206 100644 --- a/wwwroot/data/i18n/faq/en.md +++ b/wwwroot/data/i18n/faq/en.md @@ -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 diff --git a/wwwroot/data/i18n/translations.json b/wwwroot/data/i18n/translations.json index 21608b8..f03a55c 100644 --- a/wwwroot/data/i18n/translations.json +++ b/wwwroot/data/i18n/translations.json @@ -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", diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index a71d4e1..bbf39f9 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -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) => diff --git a/wwwroot/js/app-admin-handlers.js b/wwwroot/js/app-admin-handlers.js index a43f023..cfdaed9 100644 --- a/wwwroot/js/app-admin-handlers.js +++ b/wwwroot/js/app-admin-handlers.js @@ -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(); diff --git a/wwwroot/js/modals-ui.js b/wwwroot/js/modals-ui.js index 738bfd9..e8558ab 100644 --- a/wwwroot/js/modals-ui.js +++ b/wwwroot/js/modals-ui.js @@ -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); }