From 5ec18d20eabf01df375a49c52d79fce9fbaab0f7 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sun, 8 Feb 2026 14:43:26 +0100 Subject: [PATCH] Revert "Implement admin back-pass flow and guarded admin actions" This reverts commit 5595bfd3b1d3a71fb524d6968f67ea5a706c9d0d. --- API.md | 10 ++- Contracts/Dtos.cs | 4 -- Endpoints/AdminEndpoints.cs | 36 +--------- Endpoints/AdminWorkflowService.cs | 73 ++------------------ Endpoints/StateWorkflowService.cs | 25 ++----- GameList.Tests/AdminTests.cs | 94 +++---------------------- GameList.Tests/StateTests.cs | 26 ------- README.md | 2 - SPEC.md | 3 - TESTS.md | 15 ++-- wwwroot/app.js | 4 +- wwwroot/css/admin.css | 5 -- wwwroot/data/i18n/faq/de.md | 26 ++++--- wwwroot/data/i18n/faq/en.md | 27 ++++---- wwwroot/data/i18n/translations.json | 30 +++----- wwwroot/index.html | 7 +- wwwroot/js/admin-ui.js | 24 +------ wwwroot/js/api.js | 7 +- wwwroot/js/app-admin-handlers.js | 102 ++++------------------------ wwwroot/js/app-auth-handlers.js | 37 +++------- wwwroot/js/data.js | 10 +-- wwwroot/js/modals-ui.js | 78 --------------------- wwwroot/js/state.js | 8 --- wwwroot/js/ui.js | 2 - wwwroot/js/votes-ui.js | 24 +++---- 25 files changed, 108 insertions(+), 571 deletions(-) diff --git a/API.md b/API.md index fe635de..242d5a4 100644 --- a/API.md +++ b/API.md @@ -14,7 +14,7 @@ GET /api/me — id, displayName, username, isAdmin, currentPhase, votesFinal ## Player (requires auth) POST /api/me/phase/next — advance caller to next phase (Suggest→Vote requires at least one own suggestion; Vote→Results is gated by resultsOpen) -POST /api/me/phase/prev — move caller backward (admin: Results→Vote→Suggest, player: Vote→Suggest only when granted a one-time back pass) +POST /api/me/phase/prev — admin-only move caller backward (Results→Vote→Suggest) ## Suggestions (requires auth + phase gating) GET /api/suggestions/mine — own suggestions (Suggest phase) @@ -32,11 +32,9 @@ POST /api/votes/finalize — `{ final: bool }` toggles caller’s finalized stat GET /api/results — leaderboard with totals, counts, averages, caller’s vote, media/links, link metadata ## Admin (requires authenticated admin user) -POST /api/admin/results — `{ resultsOpen: bool }` locks/unlocks results and aligns player phases (closing results moves players with suggestions to `Vote`, players without suggestions to `Suggest`) +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/players/{playerId}/phase — `{ phase: "Suggest" }`; move a player from `Vote` back to `Suggest` -DELETE /api/admin/players/{playerId} — `{ adminPassword: string }`; delete a player and all related data 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/reset — `{ adminPassword: string }`; clear suggestions/votes; keep players; reset phases/vote-final flags -POST /api/admin/factory-reset — `{ adminPassword: string }`; wipe players, suggestions, votes, state +POST /api/admin/reset — clear suggestions/votes; keep players; reset phases/vote-final flags +POST /api/admin/factory-reset — wipe players, suggestions, votes, state diff --git a/Contracts/Dtos.cs b/Contracts/Dtos.cs index 82cddd1..d9baa5f 100644 --- a/Contracts/Dtos.cs +++ b/Contracts/Dtos.cs @@ -19,7 +19,3 @@ public record LinkSuggestionsRequest(int SourceSuggestionId, int TargetSuggestio public record UnlinkSuggestionsRequest(int SuggestionId); public record GrantJokerRequest(Guid PlayerId); - -public record AdminPasswordRequest(string AdminPassword); - -public record SetPlayerPhaseRequest(Phase Phase); diff --git a/Endpoints/AdminEndpoints.cs b/Endpoints/AdminEndpoints.cs index ece6deb..d19fb77 100644 --- a/Endpoints/AdminEndpoints.cs +++ b/Endpoints/AdminEndpoints.cs @@ -17,23 +17,7 @@ public static class AdminEndpoints admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, AdminWorkflowService service) => await service.GrantJokerAsync(request.PlayerId)); - admin.MapPost("/players/{playerId:guid}/phase", async (Guid playerId, [FromBody] SetPlayerPhaseRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) => - { - var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); - if (player is null) - return EndpointHelpers.UnauthorizedError(); - - return await service.SetPlayerPhaseAsync(playerId, request.Phase); - }); - - 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.AdminPassword); - }); + admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, AdminWorkflowService service) => await service.DeletePlayerAsync(playerId)); admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) => { @@ -53,23 +37,9 @@ public static class AdminEndpoints return await service.UnlinkSuggestionsAsync(player.Id, request.SuggestionId); }); - 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("/reset", async (AdminWorkflowService service) => await service.ResetAsync()); - return await service.ResetAsync(player.Id, request.AdminPassword); - }); - - 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.AdminPassword); - }); + admin.MapPost("/factory-reset", async (AdminWorkflowService service) => await service.FactoryResetAsync()); } } diff --git a/Endpoints/AdminWorkflowService.cs b/Endpoints/AdminWorkflowService.cs index be88903..973bb09 100644 --- a/Endpoints/AdminWorkflowService.cs +++ b/Endpoints/AdminWorkflowService.cs @@ -1,7 +1,6 @@ using GameList.Contracts; using GameList.Data; using GameList.Domain; -using GameList.Infrastructure; using Microsoft.EntityFrameworkCore; namespace GameList.Endpoints; @@ -22,21 +21,7 @@ internal sealed class AdminWorkflowService(AppDbContext db) } else { - var playersWithSuggestions = await db.Suggestions.Select(s => s.PlayerId).Distinct().ToListAsync(); - if (playersWithSuggestions.Count == 0) - { - await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest).SetProperty(x => x.VotesFinal, false)); - } - else - { - await db.Players - .Where(p => playersWithSuggestions.Contains(p.Id)) - .ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote).SetProperty(x => x.VotesFinal, false)); - - await db.Players - .Where(p => !playersWithSuggestions.Contains(p.Id)) - .ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest).SetProperty(x => x.VotesFinal, false)); - } + await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote).SetProperty(x => x.VotesFinal, false)); } await db.SaveChangesAsync(); @@ -54,32 +39,11 @@ internal sealed class AdminWorkflowService(AppDbContext db) .Select(p => new VoteStatusDto(p.Id, p.DisplayName ?? p.Username, p.Username, p.CurrentPhase, p.VotesFinal, p.HasJoker, p.Suggestions.Count, p.Suggestions.Select(s => s.Name).ToList())) .ToListAsync(); - var waiting = voters.Where(v => v.Phase == Phase.Vote && !v.Finalized).Select(v => v.Name).ToList(); + var waiting = voters.Where(v => !v.Finalized).Select(v => v.Name).ToList(); var ready = waiting.Count == 0; return Results.Ok(new VoteStatusResponse(voters, ready, waiting)); } - public async Task SetPlayerPhaseAsync(Guid playerId, Phase phase) - { - if (phase != Phase.Suggest) - return EndpointHelpers.BadRequestError("Players can only be moved back to the Suggest phase."); - - var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId); - if (player is null) - return EndpointHelpers.NotFoundError("Player not found."); - - var current = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); - if (current != Phase.Vote) - return EndpointHelpers.BadRequestError("Player must currently be in the Vote phase to move back."); - - player.CurrentPhase = Phase.Suggest; - player.VotesFinal = false; - await db.SaveChangesAsync(); - - var state = await db.AppState.AsNoTracking().SingleAsync(); - return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, state.ResultsOpen)); - } - public async Task GrantJokerAsync(Guid playerId) { var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId); @@ -97,12 +61,8 @@ internal sealed class AdminWorkflowService(AppDbContext db) return Results.Ok(new AdminGrantJokerResponse(player.Id, player.HasJoker)); } - public async Task DeletePlayerAsync(Guid playerId, Guid adminPlayerId, string? adminPassword) + public async Task DeletePlayerAsync(Guid playerId) { - var passwordCheck = await ValidateAdminPasswordAsync(adminPlayerId, adminPassword); - if (!passwordCheck.IsValid) - return passwordCheck.Error!; - var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId); if (player is null) return EndpointHelpers.NotFoundError("Player not found."); @@ -218,12 +178,8 @@ internal sealed class AdminWorkflowService(AppDbContext db) return Results.Ok(new AdminUnlinkSuggestionsResponse(groupIds, await db.Players.CountAsync())); } - public async Task ResetAsync(Guid adminPlayerId, string? adminPassword) + public async Task ResetAsync() { - var passwordCheck = await ValidateAdminPasswordAsync(adminPlayerId, adminPassword); - if (!passwordCheck.IsValid) - return passwordCheck.Error!; - await using var tx = await db.Database.BeginTransactionAsync(); await db.Votes.ExecuteDeleteAsync(); @@ -239,12 +195,8 @@ internal sealed class AdminWorkflowService(AppDbContext db) return Results.Ok(new AdminResetStateResponse(Phase.Suggest, state.ResultsOpen, state.UpdatedAt)); } - public async Task FactoryResetAsync(Guid adminPlayerId, string? adminPassword) + public async Task FactoryResetAsync() { - var passwordCheck = await ValidateAdminPasswordAsync(adminPlayerId, adminPassword); - if (!passwordCheck.IsValid) - return passwordCheck.Error!; - await using var tx = await db.Database.BeginTransactionAsync(); await db.Votes.ExecuteDeleteAsync(); @@ -260,19 +212,4 @@ internal sealed class AdminWorkflowService(AppDbContext db) return Results.Ok(new AdminResetStateResponse(Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt)); } - - private async Task<(bool IsValid, IResult? Error)> ValidateAdminPasswordAsync(Guid adminPlayerId, string? adminPassword) - { - if (string.IsNullOrEmpty(adminPassword)) - return (false, EndpointHelpers.BadRequestError("Admin password is required.")); - - var admin = await db.Players.AsNoTracking().FirstOrDefaultAsync(p => p.Id == adminPlayerId); - if (admin is null) - return (false, EndpointHelpers.UnauthorizedError()); - - if (!PasswordHasher.Verify(adminPassword, admin.PasswordHash, admin.PasswordSalt)) - return (false, EndpointHelpers.UnauthorizedError("Invalid admin password.")); - - return (true, null); - } } diff --git a/Endpoints/StateWorkflowService.cs b/Endpoints/StateWorkflowService.cs index 99ea8d2..8e11142 100644 --- a/Endpoints/StateWorkflowService.cs +++ b/Endpoints/StateWorkflowService.cs @@ -72,32 +72,15 @@ internal sealed class StateWorkflowService(AppDbContext db) public async Task PrevPhaseAsync(Player player) { - var appState = await db.AppState.SingleAsync(); - var shouldSave = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen); - if (!player.IsAdmin) - { - if (player.CurrentPhase != Phase.Vote) - return EndpointHelpers.BadRequestError("You can only move back from the Vote phase."); + return EndpointHelpers.BadRequestError("Only admins can move backward."); - if (!player.HasJoker) - return EndpointHelpers.BadRequestError("Only admins can move backward."); - - player.CurrentPhase = Phase.Suggest; - player.VotesFinal = false; - player.HasJoker = false; - shouldSave = true; - await db.SaveChangesAsync(); - return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen)); - } + var appState = await db.AppState.SingleAsync(); + _ = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen); player.CurrentPhase = PrevPhase(player.CurrentPhase); player.VotesFinal = false; - shouldSave = true; - - if (shouldSave) - await db.SaveChangesAsync(); - + await db.SaveChangesAsync(); return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen)); } diff --git a/GameList.Tests/AdminTests.cs b/GameList.Tests/AdminTests.cs index 8d912d0..a407b58 100644 --- a/GameList.Tests/AdminTests.cs +++ b/GameList.Tests/AdminTests.cs @@ -77,13 +77,7 @@ public class AdminTests Score = 8 }); - var resp = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{await player.GetProfileIdAsync()}") - { - Content = JsonContent.Create(new - { - AdminPassword = "Pass123!" - }) - }); + var resp = await admin.DeleteAsync($"/api/admin/players/{await player.GetProfileIdAsync()}"); resp.EnsureSuccessStatusCode(); await factory.WithDbContextAsync(db => @@ -195,10 +189,7 @@ public class AdminTests await player.RegisterAsync("player"); await player.CreateSuggestionAsync("Keep"); - var reset = await admin.PostAsJsonAsync("/api/admin/reset", new - { - AdminPassword = "Pass123!" - }); + var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { }); reset.EnsureSuccessStatusCode(); await factory.WithDbContextAsync(db => @@ -218,10 +209,7 @@ public class AdminTests } }); - var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new - { - AdminPassword = "Pass123!" - }); + var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { }); factoryReset.EnsureSuccessStatusCode(); await factory.WithDbContextAsync(db => @@ -241,26 +229,21 @@ public class AdminTests } [Fact] - public async Task Admin_results_closing_moves_only_players_with_suggestions_back_to_vote() + public async Task Admin_results_closing_moves_back_to_vote_and_clears_finalize() { await using var factory = new TestWebApplicationFactory(); var admin = factory.CreateClientWithCookies(); await admin.RegisterAsync("admin", admin: true); var player = factory.CreateClientWithCookies(); await player.RegisterAsync("player"); - var fresh = factory.CreateClientWithCookies(); - await fresh.RegisterAsync("fresh"); - await player.CreateSuggestionAsync("Player game"); var open = await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true }); open.EnsureSuccessStatusCode(); await factory.WithDbContextAsync(async db => { - var p = await db.Players.SingleAsync(x => x.Username == "player"); - var freshPlayer = await db.Players.SingleAsync(x => x.Username == "fresh"); + var p = await db.Players.FirstAsync(x => !x.IsAdmin); p.VotesFinal = true; - freshPlayer.VotesFinal = true; var state = await db.AppState.SingleAsync(); state.UpdatedAt = DateTimeOffset.UnixEpoch; await db.SaveChangesAsync(); @@ -271,12 +254,9 @@ public class AdminTests await factory.WithDbContextAsync(async db => { - var p = await db.Players.SingleAsync(x => x.Username == "player"); - var freshPlayer = await db.Players.SingleAsync(x => x.Username == "fresh"); + var p = await db.Players.FirstAsync(x => !x.IsAdmin); Assert.Equal(Phase.Vote, p.CurrentPhase); Assert.False(p.VotesFinal); - Assert.Equal(Phase.Suggest, freshPlayer.CurrentPhase); - Assert.False(freshPlayer.VotesFinal); var state = await db.AppState.AsNoTracking().SingleAsync(); Assert.False(state.ResultsOpen); Assert.True(state.UpdatedAt > DateTimeOffset.UnixEpoch); @@ -445,10 +425,7 @@ public class AdminTests await db.SaveChangesAsync(); }); - var reset = await admin.PostAsJsonAsync("/api/admin/reset", new - { - AdminPassword = "Pass123!" - }); + var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { }); reset.EnsureSuccessStatusCode(); await factory.WithDbContextAsync(async db => @@ -460,10 +437,7 @@ public class AdminTests Assert.False(state.ResultsOpen); }); - var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new - { - AdminPassword = "Pass123!" - }); + var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { }); factoryReset.EnsureSuccessStatusCode(); await factory.WithDbContextAsync(async db => { @@ -471,56 +445,4 @@ public class AdminTests Assert.False(state.ResultsOpen); }); } - - [Fact] - public async Task Admin_destructive_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("victim"); - - var delete = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{await player.GetProfileIdAsync()}") - { - Content = JsonContent.Create(new - { - AdminPassword = "wrong" - }) - }); - Assert.Equal(HttpStatusCode.Unauthorized, delete.StatusCode); - - var reset = await admin.PostAsJsonAsync("/api/admin/reset", new - { - AdminPassword = "wrong" - }); - Assert.Equal(HttpStatusCode.Unauthorized, reset.StatusCode); - - var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new - { - AdminPassword = "wrong" - }); - Assert.Equal(HttpStatusCode.Unauthorized, factoryReset.StatusCode); - } - - [Fact] - public async Task Admin_can_move_voter_back_to_suggest_via_phase_endpoint() - { - await using var factory = new TestWebApplicationFactory(); - var admin = factory.CreateClientWithCookies(); - await admin.RegisterAsync("admin", admin: true); - - var player = factory.CreateClientWithCookies(); - await player.RegisterAsync("moveme"); - await player.AdvanceToVoteAsync("Move seed"); - - var move = await admin.PostAsJsonAsync($"/api/admin/players/{await player.GetProfileIdAsync()}/phase", new - { - Phase = "Suggest" - }); - move.EnsureSuccessStatusCode(); - - var me = await player.GetFromJsonAsync("/api/me"); - Assert.Equal(nameof(Phase.Suggest), me.GetProperty("currentPhase").GetString()); - } } diff --git a/GameList.Tests/StateTests.cs b/GameList.Tests/StateTests.cs index c2994ee..b873894 100644 --- a/GameList.Tests/StateTests.cs +++ b/GameList.Tests/StateTests.cs @@ -224,32 +224,6 @@ public class StateTests Assert.Equal(nameof(Phase.Suggest), me.GetProperty("currentPhase").GetString()); } - [Fact] - public async Task Phase_prev_with_granted_joker_moves_player_back_once_and_consumes_it() - { - await using var factory = new TestWebApplicationFactory(); - var admin = factory.CreateClientWithCookies(); - await admin.RegisterAsync("admin", admin: true); - - var player = factory.CreateClientWithCookies(); - await player.RegisterAsync("jokerback"); - await player.AdvanceToVoteAsync("Joker back seed"); - - var grant = await admin.PostAsJsonAsync("/api/admin/joker", new { playerId = await player.GetProfileIdAsync() }); - grant.EnsureSuccessStatusCode(); - - var back = await player.PostAsJsonAsync("/api/me/phase/prev", new { }); - back.EnsureSuccessStatusCode(); - - var meAfterBack = await player.GetFromJsonAsync("/api/me"); - Assert.Equal(nameof(Phase.Suggest), meAfterBack.GetProperty("currentPhase").GetString()); - Assert.False(meAfterBack.GetProperty("hasJoker").GetBoolean()); - - await player.PostAsJsonAsync("/api/me/phase/next", new { }); - var denied = await player.PostAsJsonAsync("/api/me/phase/prev", new { }); - Assert.Equal(HttpStatusCode.BadRequest, denied.StatusCode); - } - [Fact] public async Task State_endpoint_requires_auth_and_counts() { diff --git a/README.md b/README.md index 7e30934..c2b7f7b 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,7 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS - Authentication: username/password with HttpOnly `player` cookie. - Admin authorization: authenticated account with `IsAdmin=true`. - Gameplay phases: `Suggest`, `Vote`, `Results`. -- Backward movement: admins can move backward; players can move `Vote -> Suggest` only when granted a one-time back pass. - Storage: SQLite database under `App_Data/gamelist.db`. -- Sensitive admin actions (`reset`, `factory-reset`, player deletion) require admin password confirmation. ## Module Ownership diff --git a/SPEC.md b/SPEC.md index 0330ceb..9c33856 100644 --- a/SPEC.md +++ b/SPEC.md @@ -11,7 +11,6 @@ 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 - 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) -- Admins can grant a one-time back pass so a voter can move from Vote back to Suggest once ## Suggest Phase - Up to **5 suggestions** per player @@ -24,13 +23,11 @@ Help a small Discord group (4–8 players) pick a co-op game via phased flow: - All suggestions visible with authors - Score each suggestion 0–10 - Players see only their own votes; can finalize/unfinalize their ballot -- A player with a granted back pass can move from Vote back to Suggest exactly once (consumed on use) - **Linked games**: admins can link duplicates; linked games share a vote group. Moving a slider on one updates all linked siblings. - Linking or unlinking games clears votes for the linked group and unfinalizes **all** players so ballots can be reviewed again ## Results Phase - Visible only after admin enables results; players auto-advance when opened -- When results are closed again, only players with one or more suggestions return to Vote; players without suggestions return to Suggest - Leaderboard sorted by average score; shows totals, counts, player’s own vote, and links/media ## Non-functional diff --git a/TESTS.md b/TESTS.md index 1636b68..c9c4f02 100644 --- a/TESTS.md +++ b/TESTS.md @@ -42,13 +42,13 @@ stateDiagram-v2 - /api/state returns player-specific phase, votesFinal, hasJoker, counts; unauthorized returns 401. - GetPhase auto-upgrades legacy Reveal -> Vote and realigns when resultsOpen toggles (to Results and back to Vote clearing votesFinal). - /me/phase/next: moves Suggest->Vote, Vote->Results only when resultsOpen true; clears votesFinal; rejects when results locked. -- /me/phase/prev: admin moves back one step; non-admin can move Vote->Suggest only with granted back pass; move clears votesFinal and consumes pass. +- /me/phase/prev: admin only; moves back one step, clears votesFinal, rejects for player. - Display name is immutable after registration; attempts to change via /api/me/name return 404. ### 3) Suggestions - GET /mine returns only caller’s suggestions ordered by CreatedAt. - POST /: success with valid data; enforces ≤5 per player; trims optional fields; requires display name; rejects bad image URL/ext, unreachable image (mocked), invalid game/youtube URLs, invalid player counts, missing name/too long. -- Back-pass path: admin grants pass in Vote, player can consume it to move Vote->Suggest once; consumable and clears finalized state. +- Joker path: when phase=Vote and HasJoker=true allows creation, consumes joker, resets VotesFinal for all players. - Phase gating: non-admin cannot create/update/delete outside Suggest (except joker create); admin bypasses phase checks for update/delete. - PUT /{id}: player can edit own in Suggest; name locked outside Suggest; admin can edit any time; validation mirrors create. - DELETE /{id}: player deletes own in Suggest; admin any time; also breaks child links and deletes related votes. @@ -65,15 +65,14 @@ stateDiagram-v2 - Phase mismatch and locked results return 400; unauthorized 401. ### 6) Admin Operations -- POST /admin/results toggles resultsOpen and aligns all player phases (to Results, or back to Vote only for players with suggestions and Suggest otherwise); updates UpdatedAt. +- POST /admin/results toggles resultsOpen and aligns all player phases (to Results or back to Vote clearing votesFinal); updates UpdatedAt. - 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 one-time back pass only when target in Vote; resets VotesFinal for target. -- POST /admin/players/{id}/phase allows admin to move a player from Vote back to Suggest. -- DELETE /admin/players/{id}: requires admin password; removes player, cascades suggestions, breaks links to their suggestions, deletes related votes, wrapped in transaction. +- POST /admin/joker grants joker only when target in Vote; resets VotesFinal for target. +- DELETE /admin/players/{id}: 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: requires admin password; wipes suggestions/votes, resets phases to Suggest, clears votesFinal/hasJoker, closes results, updates timestamp. -- POST /admin/factory-reset: requires admin password; wipes all players/suggestions/votes/state; reseeds AppState with defaults; transactional. +- 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. ### 7) Infrastructure/Helpers - PasswordHasher: hash+verify roundtrip, rejects empty password, constant-time compare (FixedTimeEquals usage). diff --git a/wwwroot/app.js b/wwwroot/app.js index abb523e..8c993c6 100644 --- a/wwwroot/app.js +++ b/wwwroot/app.js @@ -47,7 +47,7 @@ async function refreshWithUiErrorHandling() { function scheduleNextRefresh() { refreshTimerId = window.setTimeout(async () => { - if (!document.hidden && !state.adminStatusMenuOpen) { + if (!document.hidden) { await refreshWithUiErrorHandling(); } scheduleNextRefresh(); @@ -59,7 +59,7 @@ function startRefreshScheduler() { refreshSchedulerStarted = true; document.addEventListener("visibilitychange", () => { - if (!document.hidden && !state.adminStatusMenuOpen) { + if (!document.hidden) { refreshWithUiErrorHandling(); } }); diff --git a/wwwroot/css/admin.css b/wwwroot/css/admin.css index f4fdf88..6104cb8 100644 --- a/wwwroot/css/admin.css +++ b/wwwroot/css/admin.css @@ -30,11 +30,6 @@ font-size: 12px; letter-spacing: 0.3px; } -.admin-status-select { - width: 100%; - min-width: 140px; - padding: 6px 8px; -} .admin-panel { position: fixed; diff --git a/wwwroot/data/i18n/faq/de.md b/wwwroot/data/i18n/faq/de.md index 3c01790..bb1b54b 100644 --- a/wwwroot/data/i18n/faq/de.md +++ b/wwwroot/data/i18n/faq/de.md @@ -108,7 +108,7 @@ Wenn ein Admin doppelte Spiele verknüpft: Mit **„Finalisieren"** werden deine Bewertungen gesperrt. Deaktiviere es, um erneut zu bearbeiten. „Finalisieren" ist nur während der Abstimmungsphase verfügbar und wird automatisch zurückgesetzt, wenn: - - Du mit einem Zurück-Pass zurück in die Vorschlagsphase wechselst + - Ein Joker ein neues Spiel hinzufügt - Ein Admin Spiele verknüpft oder trennt ### Abstimmen nach Änderungen @@ -119,26 +119,26 @@ Wenn neue Spiele hinzugefügt oder Verknüpfungen geändert werden: Überprüfe deine Liste und bewerte erneut, bevor du wieder finalisierst. -## Zurück-Pass (Einmalige Rückkehr) +## Joker (Späte Ergänzungen) -### Was ist ein Zurück-Pass? +### Was ist ein Joker? -Ein **Zurück-Pass** ist eine einmalige Berechtigung, mit der du von der **Abstimmungsphase** zurück in die **Vorschlagsphase** wechseln kannst. Ein Admin muss ihn dir während der Abstimmung geben. +Ein **Joker** ist ein einmaliger zusätzlicher Vorschlags-Slot, der nur während der **Abstimmungsphase** verfügbar ist. Ein Admin muss ihn dir gewähren. ### So funktioniert es -Wenn du einen Zurück-Pass erhältst: -- Erscheint ein **Zurück**-Button in der Abstimmungsphase für dein Konto -- Bei Nutzung wechselst du einmal in die Vorschlagsphase zurück und der Pass wird verbraucht -- Deine Finalisierung wird beim Zurückwechseln aufgehoben +Wenn du einen Joker erhältst: +- Erscheint ein Button in der oberen Leiste, mit dem du ein weiteres Spiel hinzufügen kannst +- Nach der Nutzung wird der Joker sofort verbraucht +- Die Finalisierung aller Abstimmungen werden automatisch zurückgesetzt, damit das neue Spiel bewertet werden kann -Admins können bei Bedarf später einen weiteren Pass vergeben. +Admins können bei Bedarf zusätzliche Joker vergeben. ## Ergebnisse ### Wann sind die Ergebnisse sichtbar? -Die Ergebnisse bleiben verborgen, bis ein Admin sie freigibt. Danach werden alle Spieler automatisch in die **Ergebnisphase** verschoben. Falls nötig, kann ein Admin die Ergebnisse wieder schließen: Spieler mit mindestens einem Vorschlag kehren in die Abstimmungsphase zurück, Spieler ohne Vorschlag in die Vorschlagsphase, und Finalisierungen werden zurückgesetzt. +Die Ergebnisse bleiben verborgen, bis ein Admin sie freigibt. Danach werden alle Spieler automatisch in die **Ergebnisphase** verschoben. Falls nötig, kann ein Admin die Ergebnisse wieder schließen: Alle kehren in die Abstimmungsphase zurück und alle Abstimmungen werden zur Anpassung zurückgesetzt. ### Kann ich in der Ergebnisphase etwas bearbeiten? @@ -148,15 +148,13 @@ Nein. Vorschläge und Bewertungen sind schreibgeschützt. Wende dich bei Bedarf ### Was können Admin-Konten tun? -- Zurück-Pässe während der Abstimmung vergeben +- Joker während der Abstimmung vergeben - Doppelte Vorschläge verknüpfen oder trennen - Vorschläge löschen - Abstimmungsstatus einsehen (wer finalisiert hat) - Einen Spieler löschen (inklusive dessen Vorschläge und Stimmen) -- Spieler über den Status-Dropdown von Abstimmung zurück auf Vorschlag setzen - Die Datenbank auf Werkseinstellungen zurücksetzen - Zu vorherigen Phasen zurückkehren -- Reset-/Löschaktionen mit dem eigenen Admin-Passwort bestätigen ### Was können Admin-Konten nicht tun? @@ -176,7 +174,7 @@ Stelle sicher: ### „Du hast das Limit von 5 Vorschlägen erreicht." -Bitte einen Admin um einen Zurück-Pass, wenn du wieder in die Vorschlagsphase wechseln und deine Liste anpassen musst. +Warte auf die Abstimmungsphase und bitte bei Bedarf um einen Joker. ### „Füge mindestens einen Vorschlag hinzu, bevor du in die Abstimmungsphase wechselst." diff --git a/wwwroot/data/i18n/faq/en.md b/wwwroot/data/i18n/faq/en.md index 64807ba..af64fe6 100644 --- a/wwwroot/data/i18n/faq/en.md +++ b/wwwroot/data/i18n/faq/en.md @@ -82,20 +82,21 @@ Common reasons: Check the bottom-right corner of the screen for error messages. -## Back Pass (One-Time Return) +## Jokers (Late Additions) -### What is a back pass? +### What is a joker? -A **back pass** is a one-time permission that lets you move from **Vote** back to **Suggest**. An admin must grant it to you during Vote. +A **joker** is a one-time extra suggestion slot available only during the **Vote phase**. An admin must grant it to you. ### How it works -If you receive a back pass: - - A **Back** button appears in Vote for your account. - - Using it moves you to Suggest once and consumes the pass. - - Your finalized flag is cleared when you move back. +If you receive a joker: + - A button appears in the top bar allowing you to add one more game. + - Once used, the joker is consumed immediately. + - Your ballot becomes unfinalized. + - All players are unfinalized so the new game can be scored. -Admins may grant another pass later if needed. +Admins may grant additional jokers if necessary. ## Voting @@ -125,7 +126,7 @@ If an admin links duplicate games: Toggling **"Finalize"** locks your scores. Toggle it off to edit again. Finalize is only available during the Vote phase and will automatically reset if: - - You move back to Suggest with a granted back pass + - A joker adds a new game - An admin links or unlinks games ### Voting after changes @@ -141,7 +142,7 @@ Review your list and rescore before finalizing again. ### When are results visible? Results are hidden until an admin opens them. When opened, all players are automatically moved to the **Results phase**. -If needed, an admin can close the Results: players with at least one suggestion return to Vote, players without suggestions return to Suggest, and finalized ballots are cleared. +If needed, an admin can close the Results: everyone returns to the Vote phase, and all ballots are unfinalized for adjustments. ### Can I edit anything in Results? @@ -151,15 +152,13 @@ No. Suggestions and votes are read-only. Contact an admin for assistance. ### What can admin accounts do? +- Grant jokers during Vote - Link or unlink duplicate suggestions - Delete suggestions - View vote readiness (who has finalized) - Delete a player (removes their suggestions and votes) -- Move players from Vote back to Suggest from the status dropdown -- Grant one-time back passes - Reset the database to factory defaults - Move backward to previous phases -- Confirm reset/delete actions with their own admin password ### What can't admin accounts do? @@ -179,7 +178,7 @@ Make sure: ### "You have reached the 5 suggestion limit." -Ask an admin to grant a back pass if you need to return to Suggest and adjust your list. +Wait for the Vote phase and request a joker if needed. ### "Add at least one suggestion before entering the Vote phase." diff --git a/wwwroot/data/i18n/translations.json b/wwwroot/data/i18n/translations.json index 778cb5e..3f7d1c1 100644 --- a/wwwroot/data/i18n/translations.json +++ b/wwwroot/data/i18n/translations.json @@ -26,7 +26,6 @@ "counts.format": "Players: {players} • Suggestions: {suggestions} • Votes: {votes}", "nav.prev": "Back", "nav.next": "Next", - "nav.backToSuggestOnce": "Use pass: back to suggest", "nav.addSuggestionFirst": "Add a game first", "nav.waitingForResults": "Waiting…", "nav.freezeTitle": "Ready to reveal?", @@ -103,16 +102,11 @@ "vote.listUpdatedConfirm": "OK", "admin.title": "Admin", "admin.tools": "Admin tools", - "admin.resultsOpenButtonEnable": "Allow results phase", - "admin.resultsOpenButtonDisable": "Lock results phase", + "admin.resultsOpenToggle": "Allow results phase", "admin.resultsLocked": "Results locked by admin", "admin.resultsUpdated": "Results availability updated", "admin.reset": "Reset (keep players)", "admin.factoryReset": "Factory reset", - "admin.resetConfirmBody": "Enter your admin password to reset all games and votes while keeping player accounts.", - "admin.factoryResetConfirmBody": "Enter your admin password to permanently delete all accounts, games, votes, and state.", - "admin.passwordLabel": "Admin password", - "admin.passwordRequired": "Admin password is required.", "admin.resetDone": "Reset complete", "admin.factoryResetDone": "Factory reset complete", "admin.readyForResults": "Ready for results", @@ -121,10 +115,9 @@ "admin.playerUsername": "Username", "admin.playerStatus": "Status", "admin.playerGames": "Games", - "admin.playerJoker": "Back pass", + "admin.playerJoker": "Joker", "admin.playerDelete": "Delete", - "admin.grantJokerChip": "Grant back", - "admin.statusUpdated": "Player status updated", + "admin.grantJokerChip": "Grant", "admin.statusSuggesting": "Suggesting", "admin.statusVoting": "Voting", "admin.statusFinished": "Finished", @@ -132,7 +125,7 @@ "admin.deleteBody": "Delete player \"{name}\" and all their games and votes? This cannot be undone.", "admin.deleteConfirm": "Delete", "admin.deleteDone": "Player deleted", - "admin.jokerGranted": "Back pass granted", + "admin.jokerGranted": "Joker granted", "admin.linkTitle": "Link games", "admin.linkSource": "Game to link", "admin.linkTarget": "Link to (parent)", @@ -193,7 +186,6 @@ "counts.format": "Spieler: {players} • Vorschläge: {suggestions} • Stimmen: {votes}", "nav.prev": "Zurück", "nav.next": "Weiter", - "nav.backToSuggestOnce": "Pass nutzen: zurück zu Vorschlag", "nav.addSuggestionFirst": "Zuerst ein Spiel vorschlagen", "nav.waitingForResults": "Warten…", "nav.freezeTitle": "Bereit zum Aufdecken?", @@ -270,16 +262,11 @@ "vote.listUpdatedConfirm": "OK", "admin.title": "Admin", "admin.tools": "Admin-Werkzeuge", - "admin.resultsOpenButtonEnable": "Ergebnisse freigeben", - "admin.resultsOpenButtonDisable": "Ergebnisse sperren", + "admin.resultsOpenToggle": "Ergebnisse freigeben", "admin.resultsLocked": "Ergebnisse vom Admin gesperrt", "admin.resultsUpdated": "Ergebnisfreigabe aktualisiert", "admin.reset": "Zurücksetzen (Spieler behalten)", "admin.factoryReset": "Werkseinstellung", - "admin.resetConfirmBody": "Gib dein Admin-Passwort ein, um alle Spiele und Stimmen zurückzusetzen, aber die Konten zu behalten.", - "admin.factoryResetConfirmBody": "Gib dein Admin-Passwort ein, um alle Konten, Spiele, Stimmen und den Zustand dauerhaft zu löschen.", - "admin.passwordLabel": "Admin-Passwort", - "admin.passwordRequired": "Admin-Passwort ist erforderlich.", "admin.resetDone": "Zurücksetzen abgeschlossen", "admin.factoryResetDone": "Werkseinstellung abgeschlossen", "admin.readyForResults": "Bereit für Ergebnisse", @@ -288,10 +275,9 @@ "admin.playerUsername": "Benutzername", "admin.playerStatus": "Status", "admin.playerGames": "Spiele", - "admin.playerJoker": "Zurück-Pass", + "admin.playerJoker": "Joker", "admin.playerDelete": "Löschen", - "admin.grantJokerChip": "Pass geben", - "admin.statusUpdated": "Status aktualisiert", + "admin.grantJokerChip": "Joker", "admin.statusSuggesting": "Vorschlagen", "admin.statusVoting": "Bewerten", "admin.statusFinished": "Fertig", @@ -299,7 +285,7 @@ "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": "Zurück-Pass vergeben", + "admin.jokerGranted": "Joker vergeben", "admin.linkTitle": "Spiele verknüpfen", "admin.linkSource": "Spiel verknüpfen", "admin.linkTarget": "Verknüpfen mit", diff --git a/wwwroot/index.html b/wwwroot/index.html index 5e4eab0..3958d8e 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -170,7 +170,7 @@ Username Status Games - Back pass + Joker Delete @@ -178,7 +178,10 @@ - +