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; namespace GameList.Contracts;
public record SetNameRequest(string Name); 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 VoteRequest(int SuggestionId, int Score);
public record ResultsOpenRequest(bool ResultsOpen); public record ResultsOpenRequest(bool ResultsOpen);
public record VoteFinalizeRequest(bool Final); 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 LinkSuggestionsRequest(int SourceSuggestionId, int TargetSuggestionId);
public record UnlinkSuggestionsRequest(int SuggestionId); public record UnlinkSuggestionsRequest(int SuggestionId);
public record GrantJokerRequest(Guid PlayerId); 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 var voters = await db.Players
.AsNoTracking() .AsNoTracking()
.Where(p => p.CurrentPhase == Phase.Vote || p.Suggestions.Any())
.OrderBy(p => p.DisplayName ?? p.Username) .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(); .ToListAsync();
var waiting = voters.Where(v => !v.Finalized).Select(v => v.Name).ToList(); 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 }); 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) => admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) =>
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);

View File

@@ -204,17 +204,38 @@ function setupHandlers() {
}); });
} }
const grantJokerBtn = $("grant-joker"); const playerTable = $("admin-player-table");
if (grantJokerBtn) { if (playerTable) {
grantJokerBtn.addEventListener("click", async () => { playerTable.addEventListener("click", async (e) => {
const playerId = $("joker-player")?.value; const grantBtn = e.target.closest("[data-grant-joker]");
if (!playerId) return toast(t("admin.jokerSelectFirst"), true); const deleteBtn = e.target.closest("[data-delete-player]");
try { if (grantBtn) {
await adminApi.grantJoker(playerId); const playerId = grantBtn.dataset.grantJoker;
toast(t("admin.jokerGranted")); try {
await refreshPhaseData(); await adminApi.grantJoker(playerId);
} catch (err) { toast(t("admin.jokerGranted"));
toast(err.message, true); await refreshPhaseData();
} 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> <h3 data-i18n="admin.title">Admin</h3>
<button id="admin-close" class="ghost"></button> <button id="admin-close" class="ghost"></button>
</div> </div>
<div class="stack" id="admin-vote-status"> <div class="stack" id="admin-players">
<div class="badge" id="admin-ready-status"></div> <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> </div>
<label class="stack toggle-row"> <label class="stack toggle-row">
<input type="checkbox" id="results-open" /> <input type="checkbox" id="results-open" />
<span data-i18n="admin.resultsOpenToggle">Allow results phase</span> <span data-i18n="admin.resultsOpenToggle">Allow results phase</span>
</label> </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"> <div class="stack hidden" id="admin-linker">
<h4 data-i18n="admin.linkTitle">Link games</h4> <h4 data-i18n="admin.linkTitle">Link games</h4>
<label class="stack"> <label class="stack">

View File

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

View File

@@ -110,12 +110,20 @@ const translations = {
"admin.factoryResetDone": "Factory reset complete", "admin.factoryResetDone": "Factory reset complete",
"admin.readyForResults": "Ready for results", "admin.readyForResults": "Ready for results",
"admin.waitingForPlayers": "Waiting for players: {names}", "admin.waitingForPlayers": "Waiting for players: {names}",
"admin.jokerTitle": "Jokers", "admin.playerName": "Name",
"admin.jokerSelect": "Player", "admin.playerUsername": "Username",
"admin.jokerGive": "Grant joker", "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.jokerGranted": "Joker granted",
"admin.jokerSelectFirst": "Pick a player first.",
"admin.jokerPlaceholder": "Pick a player",
"admin.linkTitle": "Link games", "admin.linkTitle": "Link games",
"admin.linkSource": "Game to link", "admin.linkSource": "Game to link",
"admin.linkTarget": "Link to (parent)", "admin.linkTarget": "Link to (parent)",
@@ -261,12 +269,20 @@ const translations = {
"admin.factoryResetDone": "Werkseinstellung abgeschlossen", "admin.factoryResetDone": "Werkseinstellung abgeschlossen",
"admin.readyForResults": "Bereit für Ergebnisse", "admin.readyForResults": "Bereit für Ergebnisse",
"admin.waitingForPlayers": "Warten auf: {names}", "admin.waitingForPlayers": "Warten auf: {names}",
"admin.jokerTitle": "Joker", "admin.playerName": "Name",
"admin.jokerSelect": "Spieler", "admin.playerUsername": "Benutzername",
"admin.jokerGive": "Joker vergeben", "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.jokerGranted": "Joker vergeben",
"admin.jokerSelectFirst": "Wähle zuerst einen Spieler.",
"admin.jokerPlaceholder": "Spieler wählen",
"admin.linkTitle": "Spiele verknüpfen", "admin.linkTitle": "Spiele verknüpfen",
"admin.linkSource": "Spiel verknüpfen", "admin.linkSource": "Spiel verknüpfen",
"admin.linkTarget": "Verknüpfen mit", "admin.linkTarget": "Verknüpfen mit",

View File

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