Rework admin player table and account deletion

This commit is contained in:
2026-02-05 13:19:23 +01:00
parent 19b5dc2491
commit 69d31b4113
7 changed files with 147 additions and 70 deletions

View File

@@ -1,3 +1,5 @@
using GameList.Domain;
namespace GameList.Contracts;
public record SetNameRequest(string Name);
@@ -6,7 +8,8 @@ public record SuggestionDto(int Id, string Name, string? Genre, string? Descript
public record VoteRequest(int SuggestionId, int Score);
public record ResultsOpenRequest(bool ResultsOpen);
public record VoteFinalizeRequest(bool Final);
public record VoteStatusDto(Guid PlayerId, string Name, bool Finalized, bool HasJoker);
public record VoteStatusDto(Guid PlayerId, string Name, string Username, Phase Phase, bool Finalized, bool HasJoker);
public record LinkSuggestionsRequest(int SourceSuggestionId, int TargetSuggestionId);
public record UnlinkSuggestionsRequest(int SuggestionId);
public record GrantJokerRequest(Guid PlayerId);
public record DeletePlayerRequest(Guid PlayerId);

View File

@@ -42,9 +42,13 @@ public static class AdminEndpoints
var voters = await db.Players
.AsNoTracking()
.Where(p => p.CurrentPhase == Phase.Vote || p.Suggestions.Any())
.OrderBy(p => p.DisplayName ?? p.Username)
.Select(p => new VoteStatusDto(p.Id, p.DisplayName ?? p.Username, p.VotesFinal, p.HasJoker))
.Select(p => new VoteStatusDto(p.Id,
p.DisplayName ?? p.Username,
p.Username,
p.CurrentPhase,
p.VotesFinal,
p.HasJoker))
.ToListAsync();
var waiting = voters.Where(v => !v.Finalized).Select(v => v.Name).ToList();
@@ -69,6 +73,41 @@ public static class AdminEndpoints
return Results.Ok(new { player.Id, player.HasJoker });
});
admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, HttpContext ctx, AppDbContext db, IConfiguration config) =>
{
if (!await EndpointHelpers.IsAdmin(ctx, db, config)) return Results.Unauthorized();
var player = await db.Players
.Include(p => p.Suggestions)
.FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null) return Results.NotFound(new { error = "Player not found." });
await using var tx = await db.Database.BeginTransactionAsync();
// Remove votes cast by the player
await db.Votes.Where(v => v.PlayerId == playerId).ExecuteDeleteAsync();
// Collect suggestions authored by the player
var suggestionIds = player.Suggestions.Select(s => s.Id).ToList();
if (suggestionIds.Count > 0)
{
// Break links pointing to these suggestions
await db.Suggestions
.Where(s => s.ParentSuggestionId != null && suggestionIds.Contains(s.ParentSuggestionId.Value))
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null));
// Remove votes for these suggestions to avoid orphaned rows
await db.Votes.Where(v => suggestionIds.Contains(v.SuggestionId)).ExecuteDeleteAsync();
}
// Delete player (cascades suggestions)
db.Players.Remove(player);
await db.SaveChangesAsync();
await tx.CommitAsync();
return Results.Ok(new { DeletedPlayerId = playerId });
});
admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) =>
{
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);

View File

@@ -204,11 +204,13 @@ function setupHandlers() {
});
}
const grantJokerBtn = $("grant-joker");
if (grantJokerBtn) {
grantJokerBtn.addEventListener("click", async () => {
const playerId = $("joker-player")?.value;
if (!playerId) return toast(t("admin.jokerSelectFirst"), true);
const playerTable = $("admin-player-table");
if (playerTable) {
playerTable.addEventListener("click", async (e) => {
const grantBtn = e.target.closest("[data-grant-joker]");
const deleteBtn = e.target.closest("[data-delete-player]");
if (grantBtn) {
const playerId = grantBtn.dataset.grantJoker;
try {
await adminApi.grantJoker(playerId);
toast(t("admin.jokerGranted"));
@@ -216,6 +218,25 @@ function setupHandlers() {
} catch (err) {
toast(err.message, true);
}
} else if (deleteBtn) {
const playerId = deleteBtn.dataset.deletePlayer;
const name = deleteBtn.dataset.name || "";
openConfirmModal({
title: t("admin.deleteTitle"),
body: t("admin.deleteBody", { name }),
confirmLabel: t("admin.deleteConfirm"),
onConfirm: async (close) => {
try {
await adminApi.deletePlayer(playerId);
toast(t("admin.deleteDone"));
close();
await refreshPhaseData();
} catch (err) {
toast(err.message, true);
}
},
});
}
});
}
}

View File

@@ -144,22 +144,27 @@
<h3 data-i18n="admin.title">Admin</h3>
<button id="admin-close" class="ghost"></button>
</div>
<div class="stack" id="admin-vote-status">
<div class="stack" id="admin-players">
<div class="badge" id="admin-ready-status"></div>
<ul class="status-list" id="admin-voter-list"></ul>
<div class="table-scroll">
<table class="compact" id="admin-player-table">
<thead>
<tr>
<th data-i18n="admin.playerName">Name</th>
<th data-i18n="admin.playerUsername">Username</th>
<th data-i18n="admin.playerStatus">Status</th>
<th data-i18n="admin.playerJoker">Joker</th>
<th data-i18n="admin.playerDelete">Delete</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<label class="stack toggle-row">
<input type="checkbox" id="results-open" />
<span data-i18n="admin.resultsOpenToggle">Allow results phase</span>
</label>
<div class="stack hidden" id="admin-joker">
<h4 data-i18n="admin.jokerTitle">Jokers</h4>
<label class="stack">
<span class="label" data-i18n="admin.jokerSelect">Player</span>
<select id="joker-player"></select>
</label>
<button id="grant-joker" class="secondary" type="button" data-i18n="admin.jokerGive">Grant joker</button>
</div>
<div class="stack hidden" id="admin-linker">
<h4 data-i18n="admin.linkTitle">Link games</h4>
<label class="stack">

View File

@@ -57,6 +57,7 @@ export const adminApi = {
reset: () => request("/api/admin/reset", { method: "POST" }),
factoryReset: () => request("/api/admin/factory-reset", { method: "POST" }),
grantJoker: (playerId) => request("/api/admin/joker", { method: "POST", body: { playerId } }),
deletePlayer: (playerId) => request(`/api/admin/players/${playerId}`, { method: "DELETE" }),
linkSuggestions: (sourceSuggestionId, targetSuggestionId) =>
request("/api/admin/link-suggestions", { method: "POST", body: { sourceSuggestionId, targetSuggestionId } }),
unlinkSuggestions: (suggestionId) =>

View File

@@ -110,12 +110,20 @@ const translations = {
"admin.factoryResetDone": "Factory reset complete",
"admin.readyForResults": "Ready for results",
"admin.waitingForPlayers": "Waiting for players: {names}",
"admin.jokerTitle": "Jokers",
"admin.jokerSelect": "Player",
"admin.jokerGive": "Grant joker",
"admin.playerName": "Name",
"admin.playerUsername": "Username",
"admin.playerStatus": "Status",
"admin.playerJoker": "Joker",
"admin.playerDelete": "Delete",
"admin.grantJokerChip": "Grant",
"admin.statusSuggesting": "Suggesting",
"admin.statusVoting": "Voting",
"admin.statusFinished": "Finished",
"admin.deleteTitle": "Delete account?",
"admin.deleteBody": "Delete player \"{name}\" and all their games and votes? This cannot be undone.",
"admin.deleteConfirm": "Delete",
"admin.deleteDone": "Player deleted",
"admin.jokerGranted": "Joker granted",
"admin.jokerSelectFirst": "Pick a player first.",
"admin.jokerPlaceholder": "Pick a player",
"admin.linkTitle": "Link games",
"admin.linkSource": "Game to link",
"admin.linkTarget": "Link to (parent)",
@@ -261,12 +269,20 @@ const translations = {
"admin.factoryResetDone": "Werkseinstellung abgeschlossen",
"admin.readyForResults": "Bereit für Ergebnisse",
"admin.waitingForPlayers": "Warten auf: {names}",
"admin.jokerTitle": "Joker",
"admin.jokerSelect": "Spieler",
"admin.jokerGive": "Joker vergeben",
"admin.playerName": "Name",
"admin.playerUsername": "Benutzername",
"admin.playerStatus": "Status",
"admin.playerJoker": "Joker",
"admin.playerDelete": "Löschen",
"admin.grantJokerChip": "Joker",
"admin.statusSuggesting": "Vorschlagen",
"admin.statusVoting": "Bewerten",
"admin.statusFinished": "Fertig",
"admin.deleteTitle": "Konto löschen?",
"admin.deleteBody": "Spieler \"{name}\" samt Spielen und Stimmen löschen? Dies kann nicht rückgängig gemacht werden.",
"admin.deleteConfirm": "Löschen",
"admin.deleteDone": "Spieler gelöscht",
"admin.jokerGranted": "Joker vergeben",
"admin.jokerSelectFirst": "Wähle zuerst einen Spieler.",
"admin.jokerPlaceholder": "Spieler wählen",
"admin.linkTitle": "Spiele verknüpfen",
"admin.linkSource": "Spiel verknüpfen",
"admin.linkTarget": "Verknüpfen mit",

View File

@@ -9,6 +9,10 @@ const sortByName = (items) =>
(items ?? [])
.slice()
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
const truncate = (text, max) => {
if (!text) return "";
return text.length > max ? `${text.slice(0, max - 1)}` : text;
};
export function setAuthUI(isAuthed) {
const main = document.querySelector("main");
@@ -715,20 +719,22 @@ function updateMissingBadgeFromDom() {
function renderAdminVoteStatus() {
if (!state.me?.isAdmin) return;
const list = $("admin-voter-list");
const status = $("admin-ready-status");
const jokerWrap = $("admin-joker");
const jokerSelect = $("joker-player");
if (!state.adminVoteStatus || !list || !status) return;
const statusBadge = $("admin-ready-status");
const table = $("admin-player-table")?.querySelector("tbody");
if (!state.adminVoteStatus || !statusBadge || !table) return;
list.innerHTML = "";
table.innerHTML = "";
state.adminVoteStatus.voters.forEach((v) => {
const li = document.createElement("li");
const name = v.name?.length > 24 ? `${v.name.slice(0, 21)}` : v.name;
const jokerMark = v.hasJoker ? " 🎟" : "";
li.textContent = `${name}${jokerMark}${v.finalized ? "✅" : "⏳"}`;
li.title = v.name;
list.appendChild(li);
const tr = document.createElement("tr");
const statusText = displayPlayerStatus(v);
tr.innerHTML = `
<td title="${v.name}">${truncate(v.name, 28)}</td>
<td class="muted small" title="${v.username}">${truncate(v.username, 24)}</td>
<td>${statusText}</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>
`;
table.appendChild(tr);
});
const waiting = state.adminVoteStatus.waiting;
@@ -736,33 +742,19 @@ function renderAdminVoteStatus() {
const waitingDisplay = waiting.map((name) =>
name?.length > 24 ? `${name.slice(0, 21)}` : name,
);
status.textContent = ready
statusBadge.textContent = ready
? t("admin.readyForResults")
: t("admin.waitingForPlayers", { names: waitingDisplay.join(", ") });
status.className = ready ? "badge" : "badge warning";
statusBadge.className = ready ? "badge" : "badge warning";
}
if (jokerWrap) jokerWrap.classList.toggle("hidden", state.phase !== "Vote");
if (jokerSelect && state.phase === "Vote") {
const previous = jokerSelect.value;
jokerSelect.innerHTML = "";
const placeholder = document.createElement("option");
placeholder.value = "";
placeholder.disabled = true;
placeholder.selected = true;
placeholder.textContent = t("admin.jokerPlaceholder");
jokerSelect.appendChild(placeholder);
state.adminVoteStatus.voters.forEach((v) => {
const opt = document.createElement("option");
opt.value = v.playerId;
opt.textContent = v.hasJoker ? `${v.name} — 🎟` : v.name;
jokerSelect.appendChild(opt);
});
if (previous && Array.from(jokerSelect.options).some((o) => o.value === previous)) {
jokerSelect.value = previous;
}
}
function displayPlayerStatus(player) {
if (!player) return "";
const phase = player.phase;
if (phase === "Suggest") return t("admin.statusSuggesting");
if (phase === "Vote") return player.finalized ? t("admin.statusFinished") : t("admin.statusVoting");
if (phase === "Results") return t("admin.statusFinished");
return phase;
}
function renderAdminLinker() {