Revert "Implement admin back-pass flow and guarded admin actions"

This reverts commit 5595bfd3b1.
This commit is contained in:
2026-02-08 14:43:26 +01:00
parent 5595bfd3b1
commit 5ec18d20ea
25 changed files with 108 additions and 571 deletions

10
API.md
View File

@@ -14,7 +14,7 @@ GET /api/me — id, displayName, username, isAdmin, currentPhase, votesFinal
## Player (requires auth) ## 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/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) ## Suggestions (requires auth + phase gating)
GET /api/suggestions/mine — own suggestions (Suggest phase) GET /api/suggestions/mine — own suggestions (Suggest phase)
@@ -32,11 +32,9 @@ POST /api/votes/finalize — `{ final: bool }` toggles callers finalized stat
GET /api/results — leaderboard with totals, counts, averages, callers vote, media/links, link metadata GET /api/results — leaderboard with totals, counts, averages, callers vote, media/links, link metadata
## Admin (requires authenticated admin user) ## 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) 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/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/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/reset — 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/factory-reset — wipe players, suggestions, votes, state

View File

@@ -19,7 +19,3 @@ public record LinkSuggestionsRequest(int SourceSuggestionId, int TargetSuggestio
public record UnlinkSuggestionsRequest(int SuggestionId); public record UnlinkSuggestionsRequest(int SuggestionId);
public record GrantJokerRequest(Guid PlayerId); public record GrantJokerRequest(Guid PlayerId);
public record AdminPasswordRequest(string AdminPassword);
public record SetPlayerPhaseRequest(Phase Phase);

View File

@@ -17,23 +17,7 @@ public static class AdminEndpoints
admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, AdminWorkflowService service) => await service.GrantJokerAsync(request.PlayerId)); 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) => admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, AdminWorkflowService service) => await service.DeletePlayerAsync(playerId));
{
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.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) => 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); return await service.UnlinkSuggestionsAsync(player.Id, request.SuggestionId);
}); });
admin.MapPost("/reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) => admin.MapPost("/reset", async (AdminWorkflowService service) => await service.ResetAsync());
{
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null)
return EndpointHelpers.UnauthorizedError();
return await service.ResetAsync(player.Id, request.AdminPassword); admin.MapPost("/factory-reset", async (AdminWorkflowService service) => await service.FactoryResetAsync());
});
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);
});
} }
} }

View File

@@ -1,7 +1,6 @@
using GameList.Contracts; using GameList.Contracts;
using GameList.Data; using GameList.Data;
using GameList.Domain; using GameList.Domain;
using GameList.Infrastructure;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace GameList.Endpoints; namespace GameList.Endpoints;
@@ -22,21 +21,7 @@ internal sealed class AdminWorkflowService(AppDbContext db)
} }
else else
{ {
var playersWithSuggestions = await db.Suggestions.Select(s => s.PlayerId).Distinct().ToListAsync(); await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote).SetProperty(x => x.VotesFinal, false));
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.SaveChangesAsync(); 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())) .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(); .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; var ready = waiting.Count == 0;
return Results.Ok(new VoteStatusResponse(voters, ready, waiting)); return Results.Ok(new VoteStatusResponse(voters, ready, waiting));
} }
public async Task<IResult> 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<IResult> GrantJokerAsync(Guid playerId) public async Task<IResult> GrantJokerAsync(Guid playerId)
{ {
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == 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)); return Results.Ok(new AdminGrantJokerResponse(player.Id, player.HasJoker));
} }
public async Task<IResult> DeletePlayerAsync(Guid playerId, Guid adminPlayerId, string? adminPassword) public async Task<IResult> 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); var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null) if (player is null)
return EndpointHelpers.NotFoundError("Player not found."); 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())); return Results.Ok(new AdminUnlinkSuggestionsResponse(groupIds, await db.Players.CountAsync()));
} }
public async Task<IResult> ResetAsync(Guid adminPlayerId, string? adminPassword) public async Task<IResult> ResetAsync()
{ {
var passwordCheck = await ValidateAdminPasswordAsync(adminPlayerId, adminPassword);
if (!passwordCheck.IsValid)
return passwordCheck.Error!;
await using var tx = await db.Database.BeginTransactionAsync(); await using var tx = await db.Database.BeginTransactionAsync();
await db.Votes.ExecuteDeleteAsync(); 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)); return Results.Ok(new AdminResetStateResponse(Phase.Suggest, state.ResultsOpen, state.UpdatedAt));
} }
public async Task<IResult> FactoryResetAsync(Guid adminPlayerId, string? adminPassword) public async Task<IResult> FactoryResetAsync()
{ {
var passwordCheck = await ValidateAdminPasswordAsync(adminPlayerId, adminPassword);
if (!passwordCheck.IsValid)
return passwordCheck.Error!;
await using var tx = await db.Database.BeginTransactionAsync(); await using var tx = await db.Database.BeginTransactionAsync();
await db.Votes.ExecuteDeleteAsync(); 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)); 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);
}
} }

View File

@@ -72,32 +72,15 @@ internal sealed class StateWorkflowService(AppDbContext db)
public async Task<IResult> PrevPhaseAsync(Player player) public async Task<IResult> PrevPhaseAsync(Player player)
{ {
var appState = await db.AppState.SingleAsync();
var shouldSave = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen);
if (!player.IsAdmin) if (!player.IsAdmin)
{
if (player.CurrentPhase != Phase.Vote)
return EndpointHelpers.BadRequestError("You can only move back from the Vote phase.");
if (!player.HasJoker)
return EndpointHelpers.BadRequestError("Only admins can move backward."); return EndpointHelpers.BadRequestError("Only admins can move backward.");
player.CurrentPhase = Phase.Suggest; var appState = await db.AppState.SingleAsync();
player.VotesFinal = false; _ = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen);
player.HasJoker = false;
shouldSave = true;
await db.SaveChangesAsync();
return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
}
player.CurrentPhase = PrevPhase(player.CurrentPhase); player.CurrentPhase = PrevPhase(player.CurrentPhase);
player.VotesFinal = false; player.VotesFinal = false;
shouldSave = true;
if (shouldSave)
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen)); return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
} }

View File

@@ -77,13 +77,7 @@ public class AdminTests
Score = 8 Score = 8
}); });
var resp = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{await player.GetProfileIdAsync()}") var resp = await admin.DeleteAsync($"/api/admin/players/{await player.GetProfileIdAsync()}");
{
Content = JsonContent.Create(new
{
AdminPassword = "Pass123!"
})
});
resp.EnsureSuccessStatusCode(); resp.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(db => await factory.WithDbContextAsync(db =>
@@ -195,10 +189,7 @@ public class AdminTests
await player.RegisterAsync("player"); await player.RegisterAsync("player");
await player.CreateSuggestionAsync("Keep"); await player.CreateSuggestionAsync("Keep");
var reset = await admin.PostAsJsonAsync("/api/admin/reset", new var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { });
{
AdminPassword = "Pass123!"
});
reset.EnsureSuccessStatusCode(); reset.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(db => await factory.WithDbContextAsync(db =>
@@ -218,10 +209,7 @@ public class AdminTests
} }
}); });
var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { });
{
AdminPassword = "Pass123!"
});
factoryReset.EnsureSuccessStatusCode(); factoryReset.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(db => await factory.WithDbContextAsync(db =>
@@ -241,26 +229,21 @@ public class AdminTests
} }
[Fact] [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(); await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies(); var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true); await admin.RegisterAsync("admin", admin: true);
var player = factory.CreateClientWithCookies(); var player = factory.CreateClientWithCookies();
await player.RegisterAsync("player"); 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 }); var open = await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true });
open.EnsureSuccessStatusCode(); open.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
{ {
var p = await db.Players.SingleAsync(x => x.Username == "player"); var p = await db.Players.FirstAsync(x => !x.IsAdmin);
var freshPlayer = await db.Players.SingleAsync(x => x.Username == "fresh");
p.VotesFinal = true; p.VotesFinal = true;
freshPlayer.VotesFinal = true;
var state = await db.AppState.SingleAsync(); var state = await db.AppState.SingleAsync();
state.UpdatedAt = DateTimeOffset.UnixEpoch; state.UpdatedAt = DateTimeOffset.UnixEpoch;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -271,12 +254,9 @@ public class AdminTests
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
{ {
var p = await db.Players.SingleAsync(x => x.Username == "player"); var p = await db.Players.FirstAsync(x => !x.IsAdmin);
var freshPlayer = await db.Players.SingleAsync(x => x.Username == "fresh");
Assert.Equal(Phase.Vote, p.CurrentPhase); Assert.Equal(Phase.Vote, p.CurrentPhase);
Assert.False(p.VotesFinal); Assert.False(p.VotesFinal);
Assert.Equal(Phase.Suggest, freshPlayer.CurrentPhase);
Assert.False(freshPlayer.VotesFinal);
var state = await db.AppState.AsNoTracking().SingleAsync(); var state = await db.AppState.AsNoTracking().SingleAsync();
Assert.False(state.ResultsOpen); Assert.False(state.ResultsOpen);
Assert.True(state.UpdatedAt > DateTimeOffset.UnixEpoch); Assert.True(state.UpdatedAt > DateTimeOffset.UnixEpoch);
@@ -445,10 +425,7 @@ public class AdminTests
await db.SaveChangesAsync(); await db.SaveChangesAsync();
}); });
var reset = await admin.PostAsJsonAsync("/api/admin/reset", new var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { });
{
AdminPassword = "Pass123!"
});
reset.EnsureSuccessStatusCode(); reset.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
@@ -460,10 +437,7 @@ public class AdminTests
Assert.False(state.ResultsOpen); Assert.False(state.ResultsOpen);
}); });
var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { });
{
AdminPassword = "Pass123!"
});
factoryReset.EnsureSuccessStatusCode(); factoryReset.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
{ {
@@ -471,56 +445,4 @@ public class AdminTests
Assert.False(state.ResultsOpen); 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<JsonElement>("/api/me");
Assert.Equal(nameof(Phase.Suggest), me.GetProperty("currentPhase").GetString());
}
} }

View File

@@ -224,32 +224,6 @@ public class StateTests
Assert.Equal(nameof(Phase.Suggest), me.GetProperty("currentPhase").GetString()); 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<JsonElement>("/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] [Fact]
public async Task State_endpoint_requires_auth_and_counts() public async Task State_endpoint_requires_auth_and_counts()
{ {

View File

@@ -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. - Authentication: username/password with HttpOnly `player` cookie.
- Admin authorization: authenticated account with `IsAdmin=true`. - Admin authorization: authenticated account with `IsAdmin=true`.
- Gameplay phases: `Suggest`, `Vote`, `Results`. - 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`. - Storage: SQLite database under `App_Data/gamelist.db`.
- Sensitive admin actions (`reset`, `factory-reset`, player deletion) require admin password confirmation.
## Module Ownership ## Module Ownership

View File

@@ -11,7 +11,6 @@ Help a small Discord group (48 players) pick a co-op game via phased flow:
- Username/password login (cookie auth) - Username/password login (cookie auth)
- Admins flagged via admin key at registration - 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) - 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 ## Suggest Phase
- Up to **5 suggestions** per player - Up to **5 suggestions** per player
@@ -24,13 +23,11 @@ Help a small Discord group (48 players) pick a co-op game via phased flow:
- All suggestions visible with authors - All suggestions visible with authors
- Score each suggestion 010 - Score each suggestion 010
- Players see only their own votes; can finalize/unfinalize their ballot - 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. - **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 - Linking or unlinking games clears votes for the linked group and unfinalizes **all** players so ballots can be reviewed again
## Results Phase ## Results Phase
- Visible only after admin enables results; players auto-advance when opened - 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, players own vote, and links/media - Leaderboard sorted by average score; shows totals, counts, players own vote, and links/media
## Non-functional ## Non-functional

View File

@@ -42,13 +42,13 @@ stateDiagram-v2
- /api/state returns player-specific phase, votesFinal, hasJoker, counts; unauthorized returns 401. - /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). - 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/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. - Display name is immutable after registration; attempts to change via /api/me/name return 404.
### 3) Suggestions ### 3) Suggestions
- GET /mine returns only callers suggestions ordered by CreatedAt. - GET /mine returns only callers 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. - 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. - 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. - 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. - 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. - Phase mismatch and locked results return 400; unauthorized 401.
### 6) Admin Operations ### 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. - 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/joker grants joker 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}: removes player, cascades suggestions, breaks links to their suggestions, deletes related votes, wrapped in transaction.
- DELETE /admin/players/{id}: requires 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/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/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/reset: 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/factory-reset: wipes all players/suggestions/votes/state; reseeds AppState with defaults; transactional.
### 7) Infrastructure/Helpers ### 7) Infrastructure/Helpers
- PasswordHasher: hash+verify roundtrip, rejects empty password, constant-time compare (FixedTimeEquals usage). - PasswordHasher: hash+verify roundtrip, rejects empty password, constant-time compare (FixedTimeEquals usage).

View File

@@ -47,7 +47,7 @@ async function refreshWithUiErrorHandling() {
function scheduleNextRefresh() { function scheduleNextRefresh() {
refreshTimerId = window.setTimeout(async () => { refreshTimerId = window.setTimeout(async () => {
if (!document.hidden && !state.adminStatusMenuOpen) { if (!document.hidden) {
await refreshWithUiErrorHandling(); await refreshWithUiErrorHandling();
} }
scheduleNextRefresh(); scheduleNextRefresh();
@@ -59,7 +59,7 @@ function startRefreshScheduler() {
refreshSchedulerStarted = true; refreshSchedulerStarted = true;
document.addEventListener("visibilitychange", () => { document.addEventListener("visibilitychange", () => {
if (!document.hidden && !state.adminStatusMenuOpen) { if (!document.hidden) {
refreshWithUiErrorHandling(); refreshWithUiErrorHandling();
} }
}); });

View File

@@ -30,11 +30,6 @@
font-size: 12px; font-size: 12px;
letter-spacing: 0.3px; letter-spacing: 0.3px;
} }
.admin-status-select {
width: 100%;
min-width: 140px;
padding: 6px 8px;
}
.admin-panel { .admin-panel {
position: fixed; position: fixed;

View File

@@ -108,7 +108,7 @@ Wenn ein Admin doppelte Spiele verknüpft:
Mit **„Finalisieren"** werden deine Bewertungen gesperrt. Deaktiviere es, um erneut zu bearbeiten. 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: „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 - Ein Admin Spiele verknüpft oder trennt
### Abstimmen nach Änderungen ### 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. Ü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 ### So funktioniert es
Wenn du einen Zurück-Pass erhältst: Wenn du einen Joker erhältst:
- Erscheint ein **Zurück**-Button in der Abstimmungsphase für dein Konto - Erscheint ein Button in der oberen Leiste, mit dem du ein weiteres Spiel hinzufügen kannst
- Bei Nutzung wechselst du einmal in die Vorschlagsphase zurück und der Pass wird verbraucht - Nach der Nutzung wird der Joker sofort verbraucht
- Deine Finalisierung wird beim Zurückwechseln aufgehoben - 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 ## Ergebnisse
### Wann sind die Ergebnisse sichtbar? ### 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? ### 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? ### 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 - Doppelte Vorschläge verknüpfen oder trennen
- Vorschläge löschen - Vorschläge löschen
- Abstimmungsstatus einsehen (wer finalisiert hat) - Abstimmungsstatus einsehen (wer finalisiert hat)
- Einen Spieler löschen (inklusive dessen Vorschläge und Stimmen) - 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 - Die Datenbank auf Werkseinstellungen zurücksetzen
- Zu vorherigen Phasen zurückkehren - Zu vorherigen Phasen zurückkehren
- Reset-/Löschaktionen mit dem eigenen Admin-Passwort bestätigen
### Was können Admin-Konten nicht tun? ### Was können Admin-Konten nicht tun?
@@ -176,7 +174,7 @@ Stelle sicher:
### „Du hast das Limit von 5 Vorschlägen erreicht." ### „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." ### „Füge mindestens einen Vorschlag hinzu, bevor du in die Abstimmungsphase wechselst."

View File

@@ -82,20 +82,21 @@ Common reasons:
Check the bottom-right corner of the screen for error messages. 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 ### How it works
If you receive a back pass: If you receive a joker:
- A **Back** button appears in Vote for your account. - A button appears in the top bar allowing you to add one more game.
- Using it moves you to Suggest once and consumes the pass. - Once used, the joker is consumed immediately.
- Your finalized flag is cleared when you move back. - 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 ## Voting
@@ -125,7 +126,7 @@ If an admin links duplicate games:
Toggling **"Finalize"** locks your scores. Toggle it off to edit again. Toggling **"Finalize"** locks your scores. Toggle it off to edit again.
Finalize is only available during the Vote phase and will automatically reset if: 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 - An admin links or unlinks games
### Voting after changes ### Voting after changes
@@ -141,7 +142,7 @@ Review your list and rescore before finalizing again.
### When are results visible? ### When are results visible?
Results are hidden until an admin opens them. When opened, all players are automatically moved to the **Results phase**. 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? ### 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? ### What can admin accounts do?
- Grant jokers during Vote
- Link or unlink duplicate suggestions - Link or unlink duplicate suggestions
- Delete suggestions - Delete suggestions
- View vote readiness (who has finalized) - View vote readiness (who has finalized)
- Delete a player (removes their suggestions and votes) - 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 - Reset the database to factory defaults
- Move backward to previous phases - Move backward to previous phases
- Confirm reset/delete actions with their own admin password
### What can't admin accounts do? ### What can't admin accounts do?
@@ -179,7 +178,7 @@ Make sure:
### "You have reached the 5 suggestion limit." ### "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." ### "Add at least one suggestion before entering the Vote phase."

View File

@@ -26,7 +26,6 @@
"counts.format": "Players: {players} • Suggestions: {suggestions} • Votes: {votes}", "counts.format": "Players: {players} • Suggestions: {suggestions} • Votes: {votes}",
"nav.prev": "Back", "nav.prev": "Back",
"nav.next": "Next", "nav.next": "Next",
"nav.backToSuggestOnce": "Use pass: back to suggest",
"nav.addSuggestionFirst": "Add a game first", "nav.addSuggestionFirst": "Add a game first",
"nav.waitingForResults": "Waiting…", "nav.waitingForResults": "Waiting…",
"nav.freezeTitle": "Ready to reveal?", "nav.freezeTitle": "Ready to reveal?",
@@ -103,16 +102,11 @@
"vote.listUpdatedConfirm": "OK", "vote.listUpdatedConfirm": "OK",
"admin.title": "Admin", "admin.title": "Admin",
"admin.tools": "Admin tools", "admin.tools": "Admin tools",
"admin.resultsOpenButtonEnable": "Allow results phase", "admin.resultsOpenToggle": "Allow results phase",
"admin.resultsOpenButtonDisable": "Lock results phase",
"admin.resultsLocked": "Results locked by admin", "admin.resultsLocked": "Results locked by admin",
"admin.resultsUpdated": "Results availability updated", "admin.resultsUpdated": "Results availability updated",
"admin.reset": "Reset (keep players)", "admin.reset": "Reset (keep players)",
"admin.factoryReset": "Factory reset", "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.resetDone": "Reset complete",
"admin.factoryResetDone": "Factory reset complete", "admin.factoryResetDone": "Factory reset complete",
"admin.readyForResults": "Ready for results", "admin.readyForResults": "Ready for results",
@@ -121,10 +115,9 @@
"admin.playerUsername": "Username", "admin.playerUsername": "Username",
"admin.playerStatus": "Status", "admin.playerStatus": "Status",
"admin.playerGames": "Games", "admin.playerGames": "Games",
"admin.playerJoker": "Back pass", "admin.playerJoker": "Joker",
"admin.playerDelete": "Delete", "admin.playerDelete": "Delete",
"admin.grantJokerChip": "Grant back", "admin.grantJokerChip": "Grant",
"admin.statusUpdated": "Player status updated",
"admin.statusSuggesting": "Suggesting", "admin.statusSuggesting": "Suggesting",
"admin.statusVoting": "Voting", "admin.statusVoting": "Voting",
"admin.statusFinished": "Finished", "admin.statusFinished": "Finished",
@@ -132,7 +125,7 @@
"admin.deleteBody": "Delete player \"{name}\" and all their games and votes? This cannot be undone.", "admin.deleteBody": "Delete player \"{name}\" and all their games and votes? This cannot be undone.",
"admin.deleteConfirm": "Delete", "admin.deleteConfirm": "Delete",
"admin.deleteDone": "Player deleted", "admin.deleteDone": "Player deleted",
"admin.jokerGranted": "Back pass granted", "admin.jokerGranted": "Joker granted",
"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)",
@@ -193,7 +186,6 @@
"counts.format": "Spieler: {players} • Vorschläge: {suggestions} • Stimmen: {votes}", "counts.format": "Spieler: {players} • Vorschläge: {suggestions} • Stimmen: {votes}",
"nav.prev": "Zurück", "nav.prev": "Zurück",
"nav.next": "Weiter", "nav.next": "Weiter",
"nav.backToSuggestOnce": "Pass nutzen: zurück zu Vorschlag",
"nav.addSuggestionFirst": "Zuerst ein Spiel vorschlagen", "nav.addSuggestionFirst": "Zuerst ein Spiel vorschlagen",
"nav.waitingForResults": "Warten…", "nav.waitingForResults": "Warten…",
"nav.freezeTitle": "Bereit zum Aufdecken?", "nav.freezeTitle": "Bereit zum Aufdecken?",
@@ -270,16 +262,11 @@
"vote.listUpdatedConfirm": "OK", "vote.listUpdatedConfirm": "OK",
"admin.title": "Admin", "admin.title": "Admin",
"admin.tools": "Admin-Werkzeuge", "admin.tools": "Admin-Werkzeuge",
"admin.resultsOpenButtonEnable": "Ergebnisse freigeben", "admin.resultsOpenToggle": "Ergebnisse freigeben",
"admin.resultsOpenButtonDisable": "Ergebnisse sperren",
"admin.resultsLocked": "Ergebnisse vom Admin gesperrt", "admin.resultsLocked": "Ergebnisse vom Admin gesperrt",
"admin.resultsUpdated": "Ergebnisfreigabe aktualisiert", "admin.resultsUpdated": "Ergebnisfreigabe aktualisiert",
"admin.reset": "Zurücksetzen (Spieler behalten)", "admin.reset": "Zurücksetzen (Spieler behalten)",
"admin.factoryReset": "Werkseinstellung", "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.resetDone": "Zurücksetzen abgeschlossen",
"admin.factoryResetDone": "Werkseinstellung abgeschlossen", "admin.factoryResetDone": "Werkseinstellung abgeschlossen",
"admin.readyForResults": "Bereit für Ergebnisse", "admin.readyForResults": "Bereit für Ergebnisse",
@@ -288,10 +275,9 @@
"admin.playerUsername": "Benutzername", "admin.playerUsername": "Benutzername",
"admin.playerStatus": "Status", "admin.playerStatus": "Status",
"admin.playerGames": "Spiele", "admin.playerGames": "Spiele",
"admin.playerJoker": "Zurück-Pass", "admin.playerJoker": "Joker",
"admin.playerDelete": "Löschen", "admin.playerDelete": "Löschen",
"admin.grantJokerChip": "Pass geben", "admin.grantJokerChip": "Joker",
"admin.statusUpdated": "Status aktualisiert",
"admin.statusSuggesting": "Vorschlagen", "admin.statusSuggesting": "Vorschlagen",
"admin.statusVoting": "Bewerten", "admin.statusVoting": "Bewerten",
"admin.statusFinished": "Fertig", "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.deleteBody": "Spieler \"{name}\" samt Spielen und Stimmen löschen? Dies kann nicht rückgängig gemacht werden.",
"admin.deleteConfirm": "Löschen", "admin.deleteConfirm": "Löschen",
"admin.deleteDone": "Spieler gelöscht", "admin.deleteDone": "Spieler gelöscht",
"admin.jokerGranted": "Zurück-Pass vergeben", "admin.jokerGranted": "Joker vergeben",
"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

@@ -170,7 +170,7 @@
<th data-i18n="admin.playerUsername">Username</th> <th data-i18n="admin.playerUsername">Username</th>
<th data-i18n="admin.playerStatus">Status</th> <th data-i18n="admin.playerStatus">Status</th>
<th data-i18n="admin.playerGames">Games</th> <th data-i18n="admin.playerGames">Games</th>
<th data-i18n="admin.playerJoker">Back pass</th> <th data-i18n="admin.playerJoker">Joker</th>
<th data-i18n="admin.playerDelete">Delete</th> <th data-i18n="admin.playerDelete">Delete</th>
</tr> </tr>
</thead> </thead>
@@ -178,7 +178,10 @@
</table> </table>
</div> </div>
</div> </div>
<button id="results-open-toggle" class="secondary" type="button" data-i18n="admin.resultsOpenButtonEnable">Allow results phase</button> <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-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

@@ -15,27 +15,6 @@ function displayPlayerStatus(player) {
return phase; return phase;
} }
function renderStatusSelect(player) {
const statusText = displayPlayerStatus(player);
const safeStatusText = escapeHtml(statusText);
const playerId = escapeHtml(player.playerId);
if (player.phase === "Vote") {
return `
<select class="admin-status-select" data-player-phase="${playerId}" data-current-phase="Vote">
<option value="Vote" selected>${safeStatusText}</option>
<option value="Suggest">${escapeHtml(t("admin.statusSuggesting"))}</option>
</select>
`;
}
return `
<select class="admin-status-select" disabled data-player-phase="${playerId}" data-current-phase="${escapeHtml(player.phase)}">
<option value="${escapeHtml(player.phase)}" selected>${safeStatusText}</option>
</select>
`;
}
export function renderAdminVoteStatus() { export function renderAdminVoteStatus() {
if (!state.me?.isAdmin) return; if (!state.me?.isAdmin) return;
const statusBadge = $("admin-ready-status"); const statusBadge = $("admin-ready-status");
@@ -45,13 +24,14 @@ export function renderAdminVoteStatus() {
table.innerHTML = ""; table.innerHTML = "";
state.adminVoteStatus.voters.forEach((v) => { state.adminVoteStatus.voters.forEach((v) => {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
const statusText = displayPlayerStatus(v);
const gamesTooltip = escapeHtml((v.suggestionTitles || []).join(", ")); const gamesTooltip = escapeHtml((v.suggestionTitles || []).join(", "));
const nameText = escapeHtml(truncate(v.name, 28)); const nameText = escapeHtml(truncate(v.name, 28));
const userText = escapeHtml(truncate(v.username, 24)); const userText = escapeHtml(truncate(v.username, 24));
tr.innerHTML = ` tr.innerHTML = `
<td title="${escapeHtml(v.name)}">${nameText}</td> <td title="${escapeHtml(v.name)}">${nameText}</td>
<td class="muted small" title="${escapeHtml(v.username)}">${userText}</td> <td class="muted small" title="${escapeHtml(v.username)}">${userText}</td>
<td>${renderStatusSelect(v)}</td> <td>${statusText}</td>
<td title="${gamesTooltip}">${v.suggestionCount ?? 0}</td> <td title="${gamesTooltip}">${v.suggestionCount ?? 0}</td>
<td><button class="chip" data-grant-joker="${v.playerId}" type="button">${v.hasJoker ? "🎟" : t("admin.grantJokerChip")}</button></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> <td><button class="chip danger-chip" data-delete-player="${v.playerId}" data-name="${v.name}" type="button">✕</button></td>

View File

@@ -56,11 +56,10 @@ export const api = {
export const adminApi = { export const adminApi = {
setResultsOpen: (resultsOpen) => request("/api/admin/results", { method: "POST", body: { resultsOpen } }), setResultsOpen: (resultsOpen) => request("/api/admin/results", { method: "POST", body: { resultsOpen } }),
voteStatus: () => request("/api/admin/vote-status"), voteStatus: () => request("/api/admin/vote-status"),
reset: (adminPassword) => request("/api/admin/reset", { method: "POST", body: { adminPassword } }), reset: () => request("/api/admin/reset", { method: "POST" }),
factoryReset: (adminPassword) => request("/api/admin/factory-reset", { method: "POST", body: { adminPassword } }), 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, adminPassword) => request(`/api/admin/players/${playerId}`, { method: "DELETE", body: { adminPassword } }), deletePlayer: (playerId) => request(`/api/admin/players/${playerId}`, { method: "DELETE" }),
setPlayerPhase: (playerId, phase) => request(`/api/admin/players/${playerId}/phase`, { method: "POST", body: { phase } }),
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

@@ -3,7 +3,7 @@ import { t } from "./i18n.js";
import { state } from "./state.js"; import { state } from "./state.js";
import { $, toast } from "./dom.js"; import { $, toast } from "./dom.js";
import { import {
openPasswordConfirmModal, openConfirmModal,
openResultsRelockModal, openResultsRelockModal,
renderPhasePill, renderPhasePill,
} from "./ui.js"; } from "./ui.js";
@@ -13,10 +13,8 @@ async function adminAction(fn, successMessage, runSerializedRefresh) {
await fn(); await fn();
toast(successMessage); toast(successMessage);
await runSerializedRefresh(); await runSerializedRefresh();
return true;
} catch (err) { } catch (err) {
toast(err.message, true); toast(err.message, true);
return false;
} }
} }
@@ -34,56 +32,24 @@ function setupAdminPanelToggle() {
} }
function setupResetButtons(runSerializedRefresh) { function setupResetButtons(runSerializedRefresh) {
const askPasswordThenRun = ({
title,
body,
confirmLabel,
action,
done,
}) => {
openPasswordConfirmModal({
title,
body,
confirmLabel,
onConfirm: async (password, close) => {
const success = await adminAction(
() => action(password),
done,
runSerializedRefresh,
);
if (success) close();
},
});
};
$("reset").addEventListener("click", () => $("reset").addEventListener("click", () =>
askPasswordThenRun({ adminAction(adminApi.reset, t("admin.resetDone"), runSerializedRefresh),
title: t("admin.reset"),
body: t("admin.resetConfirmBody"),
confirmLabel: t("admin.reset"),
action: (password) => adminApi.reset(password),
done: t("admin.resetDone"),
}),
); );
$("factory-reset").addEventListener("click", () => $("factory-reset").addEventListener("click", () =>
askPasswordThenRun({ adminAction(
title: t("admin.factoryReset"), adminApi.factoryReset,
body: t("admin.factoryResetConfirmBody"), t("admin.factoryResetDone"),
confirmLabel: t("admin.factoryReset"), runSerializedRefresh,
action: (password) => adminApi.factoryReset(password), ),
done: t("admin.factoryResetDone"),
}),
); );
} }
function setupResultsToggle(runSerializedRefresh) { function setupResultsToggle(runSerializedRefresh) {
const resultsToggle = $("results-open-toggle"); const resultsToggle = $("results-open");
if (!resultsToggle) return; if (!resultsToggle) return;
resultsToggle.addEventListener("click", async () => { resultsToggle.addEventListener("change", async (e) => {
const desired = !state.resultsOpen; const desired = !!e.target.checked;
resultsToggle.disabled = true;
try { try {
const resp = await adminApi.setResultsOpen(desired); const resp = await adminApi.setResultsOpen(desired);
const wasResultsOpen = state.resultsOpen; const wasResultsOpen = state.resultsOpen;
@@ -96,9 +62,8 @@ function setupResultsToggle(runSerializedRefresh) {
toast(t("admin.resultsUpdated")); toast(t("admin.resultsUpdated"));
await runSerializedRefresh(); await runSerializedRefresh();
} catch (err) { } catch (err) {
e.target.checked = !desired;
toast(err.message, true); toast(err.message, true);
} finally {
resultsToggle.disabled = false;
} }
}); });
} }
@@ -127,45 +92,6 @@ function setupPlayerTableActions(runSerializedRefresh) {
const playerTable = $("admin-player-table"); const playerTable = $("admin-player-table");
if (!playerTable) return; if (!playerTable) return;
const syncSelectFocusState = () => {
state.adminStatusMenuOpen = !!playerTable.querySelector(
".admin-status-select:focus",
);
};
playerTable.addEventListener("focusin", (e) => {
if (e.target.closest(".admin-status-select")) {
state.adminStatusMenuOpen = true;
}
});
playerTable.addEventListener("focusout", () => {
window.setTimeout(syncSelectFocusState, 0);
});
playerTable.addEventListener("change", async (e) => {
const statusSelect = e.target.closest(".admin-status-select");
if (!statusSelect || statusSelect.disabled) return;
const playerId = statusSelect.dataset.playerPhase;
const currentPhase = statusSelect.dataset.currentPhase;
const desiredPhase = statusSelect.value;
if (!playerId || !desiredPhase || desiredPhase === currentPhase) return;
statusSelect.disabled = true;
try {
await adminApi.setPlayerPhase(playerId, desiredPhase);
toast(t("admin.statusUpdated"));
await runSerializedRefresh();
} catch (err) {
statusSelect.value = currentPhase ?? statusSelect.value;
toast(err.message, true);
} finally {
statusSelect.disabled = false;
state.adminStatusMenuOpen = false;
}
});
playerTable.addEventListener("click", async (e) => { playerTable.addEventListener("click", async (e) => {
const grantBtn = e.target.closest("[data-grant-joker]"); const grantBtn = e.target.closest("[data-grant-joker]");
const deleteBtn = e.target.closest("[data-delete-player]"); const deleteBtn = e.target.closest("[data-delete-player]");
@@ -181,13 +107,13 @@ function setupPlayerTableActions(runSerializedRefresh) {
} else if (deleteBtn) { } else if (deleteBtn) {
const playerId = deleteBtn.dataset.deletePlayer; const playerId = deleteBtn.dataset.deletePlayer;
const name = deleteBtn.dataset.name || ""; const name = deleteBtn.dataset.name || "";
openPasswordConfirmModal({ openConfirmModal({
title: t("admin.deleteTitle"), title: t("admin.deleteTitle"),
body: t("admin.deleteBody", { name }), body: t("admin.deleteBody", { name }),
confirmLabel: t("admin.deleteConfirm"), confirmLabel: t("admin.deleteConfirm"),
onConfirm: async (password, close) => { onConfirm: async (close) => {
try { try {
await adminApi.deletePlayer(playerId, password); await adminApi.deletePlayer(playerId);
toast(t("admin.deleteDone")); toast(t("admin.deleteDone"));
close(); close();
await runSerializedRefresh(); await runSerializedRefresh();

View File

@@ -1,11 +1,6 @@
import { api } from "./api.js"; import { api } from "./api.js";
import { t } from "./i18n.js"; import { t } from "./i18n.js";
import { import { state, clearUserState, setSavedUsername } from "./state.js";
state,
clearUserState,
clearSavedUsername,
setSavedUsername,
} from "./state.js";
import { $, toast } from "./dom.js"; import { $, toast } from "./dom.js";
import { import {
handleAuthError, handleAuthError,
@@ -144,38 +139,24 @@ function setupLogoutHandler() {
const logoutBtn = $("logout"); const logoutBtn = $("logout");
if (!logoutBtn) return; if (!logoutBtn) return;
const clearAuthFormFields = () => {
[
"login-username",
"login-password",
"register-username",
"register-password",
"register-displayName",
"register-adminkey",
].forEach((id) => {
const input = $(id);
if (input) input.value = "";
});
["login-consent", "register-consent"].forEach((id) => {
const box = $(id);
if (box) box.checked = false;
});
};
logoutBtn.addEventListener("click", async (e) => { logoutBtn.addEventListener("click", async (e) => {
e.preventDefault(); e.preventDefault();
const lastUser = state.me?.username;
try { try {
await api.logout(); await api.logout();
} catch (err) { } catch (err) {
toast(err.message, true); toast(err.message, true);
} }
clearUserState(); clearUserState();
clearSavedUsername();
state.isAuthenticated = false; state.isAuthenticated = false;
setAuthMode("login");
setAuthUI(false); setAuthUI(false);
clearAuthFormFields(); if (lastUser) {
setSavedUsername(lastUser);
const loginUser = $("login-username");
if (loginUser) loginUser.value = lastUser;
const loginPass = $("login-password");
if (loginPass) loginPass.value = "";
}
}); });
} }

View File

@@ -34,15 +34,7 @@ export async function loadSuggestionsData() {
const latest = await api.allSuggestions(); const latest = await api.allSuggestions();
const latestSig = signatureSuggestions(latest); const latestSig = signatureSuggestions(latest);
const changed = latestSig !== state.allSuggestionsSig; const changed = latestSig !== state.allSuggestionsSig;
const canCompareWithDisplayedVoteList = if (changed && state.phase === "Vote" && state.allSuggestionsSig) {
state.phase === "Vote" &&
state.votesRendered &&
!!state.displayedVoteSuggestionsSig;
if (
changed &&
canCompareWithDisplayedVoteList &&
latestSig !== state.displayedVoteSuggestionsSig
) {
const added = latest const added = latest
.filter((s) => !prevById[s.id]) .filter((s) => !prevById[s.id])
.map((s) => s.name); .map((s) => s.name);

View File

@@ -80,84 +80,6 @@ export function openConfirmModal({
document.body.appendChild(overlay); document.body.appendChild(overlay);
} }
export function openPasswordConfirmModal({
title,
body,
confirmLabel,
cancelLabel = t("modal.cancel"),
onConfirm,
}) {
const overlay = document.createElement("div");
overlay.className = "edit-modal";
const panel = document.createElement("div");
panel.className = "edit-panel";
panel.innerHTML = `
<div class="edit-header">
<h3>${title}</h3>
<button class="lightbox-close" aria-label="${t("modal.close")}">x</button>
</div>
<div class="edit-body">
<p>${body}</p>
</div>
`;
const close = () => overlay.remove();
const bodyWrap = panel.querySelector(".edit-body");
const fieldWrap = document.createElement("label");
fieldWrap.className = "stack";
fieldWrap.innerHTML = `
<span class="label">${t("admin.passwordLabel")}</span>
<input type="password" autocomplete="current-password" />
`;
bodyWrap?.appendChild(fieldWrap);
const passwordInput = fieldWrap.querySelector("input");
const actions = document.createElement("div");
actions.className = "stack horizontal";
const confirmBtn = document.createElement("button");
confirmBtn.className = "danger";
confirmBtn.textContent = confirmLabel ?? t("modal.confirm");
actions.append(confirmBtn);
if (cancelLabel !== null && cancelLabel !== undefined) {
const cancelBtn = document.createElement("button");
cancelBtn.className = "ghost";
cancelBtn.type = "button";
cancelBtn.textContent = cancelLabel;
actions.append(cancelBtn);
cancelBtn.addEventListener("click", close);
}
bodyWrap?.appendChild(actions);
overlay.addEventListener("click", (e) => {
if (
e.target.classList.contains("edit-modal") ||
e.target.classList.contains("lightbox-close")
) {
close();
}
});
confirmBtn.addEventListener("click", async () => {
const password = passwordInput?.value ?? "";
if (!password.trim()) {
toast(t("admin.passwordRequired"), true);
passwordInput?.focus();
return;
}
try {
await onConfirm?.(password, close);
} catch (err) {
toast(err.message, true);
}
});
overlay.appendChild(panel);
document.body.appendChild(overlay);
passwordInput?.focus();
}
export function openResultsRelockModal() { export function openResultsRelockModal() {
openConfirmModal({ openConfirmModal({
title: t("results.relockedTitle"), title: t("results.relockedTitle"),

View File

@@ -11,12 +11,10 @@ export const state = {
mySuggestions: [], mySuggestions: [],
allSuggestions: [], allSuggestions: [],
allSuggestionsSig: null, allSuggestionsSig: null,
displayedVoteSuggestionsSig: null,
myVotes: [], myVotes: [],
results: [], results: [],
votesRendered: false, votesRendered: false,
adminVoteStatus: null, adminVoteStatus: null,
adminStatusMenuOpen: false,
}; };
export function clearUserState() { export function clearUserState() {
@@ -29,13 +27,9 @@ export function clearUserState() {
state.counts = null; state.counts = null;
state.mySuggestions = []; state.mySuggestions = [];
state.allSuggestions = []; state.allSuggestions = [];
state.allSuggestionsSig = null;
state.displayedVoteSuggestionsSig = null;
state.myVotes = []; state.myVotes = [];
state.results = []; state.results = [];
state.votesRendered = false; state.votesRendered = false;
state.adminVoteStatus = null;
state.adminStatusMenuOpen = false;
const adminCard = document.getElementById("admin-card"); const adminCard = document.getElementById("admin-card");
if (adminCard) adminCard.classList.add("hidden"); if (adminCard) adminCard.classList.add("hidden");
} }
@@ -44,5 +38,3 @@ export const getSavedUsername = () =>
localStorage.getItem("last_username") || ""; localStorage.getItem("last_username") || "";
export const setSavedUsername = (name) => export const setSavedUsername = (name) =>
localStorage.setItem("last_username", name); localStorage.setItem("last_username", name);
export const clearSavedUsername = () =>
localStorage.removeItem("last_username");

View File

@@ -27,7 +27,6 @@ import { renderResults } from "./results-ui.js";
import { import {
openConfirmModal, openConfirmModal,
openLightbox, openLightbox,
openPasswordConfirmModal,
openResultsRelockModal, openResultsRelockModal,
openSuggestionsChangedModal, openSuggestionsChangedModal,
} from "./modals-ui.js"; } from "./modals-ui.js";
@@ -65,7 +64,6 @@ export {
openConfirmModal, openConfirmModal,
openLightbox, openLightbox,
openNewSuggestionModal, openNewSuggestionModal,
openPasswordConfirmModal,
openResultsRelockModal, openResultsRelockModal,
openSuggestionsChangedModal, openSuggestionsChangedModal,
renderAllSuggestions, renderAllSuggestions,

View File

@@ -42,7 +42,6 @@ export function renderVotes() {
li.querySelector(".card-body").appendChild(footer); li.querySelector(".card-body").appendChild(footer);
list.appendChild(li); list.appendChild(li);
}); });
state.displayedVoteSuggestionsSig = state.allSuggestionsSig;
updatePhaseNav(); updatePhaseNav();
updateMissingBadgeFromDom(); updateMissingBadgeFromDom();
list.scrollTop = prevScroll; list.scrollTop = prevScroll;
@@ -203,11 +202,9 @@ export function updatePhaseNav() {
showNav("nav-suggest", phase === "Suggest"); showNav("nav-suggest", phase === "Suggest");
showNav("nav-vote", phase === "Vote"); showNav("nav-vote", phase === "Vote");
const playerCanMoveBackToSuggest =
!isAdmin && phase === "Vote" && state.hasJoker;
const jokerBtn = $("open-joker-modal"); const jokerBtn = $("open-joker-modal");
if (jokerBtn) { if (jokerBtn) {
const showJoker = false; const showJoker = phase === "Vote" && state.hasJoker;
jokerBtn.classList.toggle("hidden", !showJoker); jokerBtn.classList.toggle("hidden", !showJoker);
jokerBtn.disabled = !showJoker; jokerBtn.disabled = !showJoker;
} }
@@ -245,14 +242,11 @@ export function updatePhaseNav() {
renderAdminLinker(); renderAdminLinker();
updateMissingBadgeFromDom(); updateMissingBadgeFromDom();
const votePrev = $("nav-vote-prev"); const backButtons = ["nav-vote-prev"];
if (votePrev) { backButtons.forEach((id) => {
const canUseBack = isAdmin || playerCanMoveBackToSuggest; const btn = $(id);
votePrev.classList.toggle("hidden", !canUseBack); if (btn) btn.classList.toggle("hidden", !isAdmin);
votePrev.textContent = playerCanMoveBackToSuggest });
? t("nav.backToSuggestOnce")
: t("nav.prev");
}
const suggestNext = $("nav-suggest-next"); const suggestNext = $("nav-suggest-next");
if (suggestNext) { if (suggestNext) {
@@ -274,10 +268,8 @@ export function updatePhaseNav() {
: t("nav.next"); : t("nav.next");
} }
const adminResultsToggle = $("results-open-toggle"); const adminResultsToggle = $("results-open");
if (adminResultsToggle) { if (adminResultsToggle) {
adminResultsToggle.textContent = state.resultsOpen adminResultsToggle.checked = !!state.resultsOpen;
? t("admin.resultsOpenButtonDisable")
: t("admin.resultsOpenButtonEnable");
} }
} }