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

8
API.md
View File

@@ -34,8 +34,10 @@ GET /api/results — leaderboard with totals, counts, averages, callers 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

View File

@@ -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);

View File

@@ -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);
});
}
}

View File

@@ -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<IResult> DeletePlayerAsync(Guid playerId)
public async Task<IResult> 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<IResult> ResetAsync()
public async Task<IResult> 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<IResult> FactoryResetAsync()
public async Task<IResult> 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<IResult?> 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.");
}
}

View File

@@ -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);
}
}

View File

@@ -11,6 +11,7 @@ Help a small Discord group (48 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

View File

@@ -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).

View File

@@ -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

View File

@@ -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

View File

@@ -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",

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);
}