Merge branch 'codex/tasks-md-2026-02-08'

This commit is contained in:
2026-02-08 16:11:23 +01:00
25 changed files with 447 additions and 76 deletions

View File

@@ -12,8 +12,9 @@ Also see the user-facing documentation: per-language md files in wwwroot/data/i1
- After every iteration, run "scripts/ci-local.ps1" and ensure that nothing broke. - After every iteration, run "scripts/ci-local.ps1" and ensure that nothing broke.
- After every iteration, update all related documentation according to the change, and evaluate if a FAQ entry would help the users, serving as public documentation for this project. - After every iteration, update all related documentation according to the change, and evaluate if a FAQ entry would help the users, serving as public documentation for this project.
- After every iteration, do a git commit with a brief summary of the changes as a commit message. - After every iteration, do a git commit with a brief summary of the changes as a commit message.
- Keep changes small and commit often. If one iteration encompasses many smaller tasks, create a git branch and do the commits there. Let me review the branch before merging it back to master.
- If you find unexpected changes in the code (deletions, changes, diff results that were not communicated.), never revert them and never restore the old state. Assume that those changes happened with intent. - If you find unexpected changes in the code (deletions, changes, diff results that were not communicated.), never revert them and never restore the old state. Assume that those changes happened with intent.
- After changing the backend, feel free run "dotnet build" and "dotnet ef database update". If this is blocked by a running dotnet process, feel free to kill the process and retry the operations once. - After changing the database, run "dotnet ef database update". If this is blocked by a running dotnet process, feel free to kill the process and retry the operations once.
## Tech constraints: ## Tech constraints:
- .NET 10 - .NET 10

7
API.md
View File

@@ -34,7 +34,10 @@ GET /api/results — leaderboard with totals, counts, averages, callers vote,
## Admin (requires authenticated admin user) ## Admin (requires authenticated admin user)
POST /api/admin/results — `{ resultsOpen: bool }` locks/unlocks results and aligns player phases 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/joker — `{ playerId }` grants a vote-phase joker to the target player
POST /api/admin/player-phase — `{ playerId, phase }`; currently supports Vote→Suggest transitions only
DELETE /api/admin/players/{playerId} — `{ password }`; deletes player account plus their suggestions/votes
POST /api/admin/link-suggestions — `{ sourceSuggestionId, targetSuggestionId }`; merges vote groups during Vote, clears votes in the linked group, unfinalizes **all** players POST /api/admin/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 — clear suggestions/votes; keep players; reset phases/vote-final flags POST /api/admin/reset — `{ password }`; clear suggestions/votes, keep players, reset phases/vote-final flags
POST /api/admin/factory-reset — wipe players, suggestions, votes, state POST /api/admin/factory-reset — `{ password }`; wipe players, suggestions, votes, state

View File

@@ -19,3 +19,7 @@ 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 SetPlayerPhaseRequest(Guid PlayerId, Phase Phase);
public record AdminPasswordRequest(string Password);

View File

@@ -24,6 +24,8 @@ public record AdminResultsStateResponse(bool ResultsOpen, DateTimeOffset Updated
public record AdminGrantJokerResponse(Guid Id, bool HasJoker); public record AdminGrantJokerResponse(Guid Id, bool HasJoker);
public record AdminSetPlayerPhaseResponse(Guid PlayerId, Phase CurrentPhase, bool VotesFinal);
public record AdminDeletePlayerResponse(Guid DeletedPlayerId); public record AdminDeletePlayerResponse(Guid DeletedPlayerId);
public record AdminLinkSuggestionsResponse(int RootId, IReadOnlyList<int> LinkedSuggestionIds, int UnfinalizedPlayers); public record AdminLinkSuggestionsResponse(int RootId, IReadOnlyList<int> LinkedSuggestionIds, int UnfinalizedPlayers);

View File

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

View File

@@ -1,6 +1,7 @@
using GameList.Contracts; using GameList.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;
@@ -21,7 +22,12 @@ internal sealed class AdminWorkflowService(AppDbContext db)
} }
else else
{ {
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote).SetProperty(x => x.VotesFinal, false)); await db.Players
.Where(p => p.Suggestions.Any())
.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote).SetProperty(x => x.VotesFinal, false));
await db.Players
.Where(p => !p.Suggestions.Any())
.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest).SetProperty(x => x.VotesFinal, false));
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -61,8 +67,32 @@ 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) public async Task<IResult> SetPlayerPhaseAsync(Guid playerId, Phase phase)
{ {
if (phase != Phase.Suggest)
return EndpointHelpers.BadRequestError("Only transition to Suggest is supported.");
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null)
return EndpointHelpers.NotFoundError("Player not found.");
var currentPhase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
if (currentPhase != Phase.Vote)
return EndpointHelpers.BadRequestError("Player must currently be in the Vote phase.");
player.CurrentPhase = Phase.Suggest;
player.VotesFinal = false;
await db.SaveChangesAsync();
return Results.Ok(new AdminSetPlayerPhaseResponse(player.Id, player.CurrentPhase, player.VotesFinal));
}
public async Task<IResult> DeletePlayerAsync(Guid playerId, Guid adminPlayerId, string password)
{
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password);
if (passwordError is not null)
return passwordError;
var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId); 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.");
@@ -178,8 +208,12 @@ 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() public async Task<IResult> ResetAsync(Guid adminPlayerId, string password)
{ {
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password);
if (passwordError is not null)
return passwordError;
await using var tx = await db.Database.BeginTransactionAsync(); await using var tx = await db.Database.BeginTransactionAsync();
await db.Votes.ExecuteDeleteAsync(); await db.Votes.ExecuteDeleteAsync();
@@ -195,8 +229,12 @@ 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() public async Task<IResult> FactoryResetAsync(Guid adminPlayerId, string password)
{ {
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password);
if (passwordError is not null)
return passwordError;
await using var tx = await db.Database.BeginTransactionAsync(); await using var tx = await db.Database.BeginTransactionAsync();
await db.Votes.ExecuteDeleteAsync(); await db.Votes.ExecuteDeleteAsync();
@@ -212,4 +250,18 @@ 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<IResult?> ValidateAdminPasswordAsync(Guid adminPlayerId, string password)
{
if (string.IsNullOrWhiteSpace(password))
return EndpointHelpers.BadRequestError("Admin password is required.");
var admin = await db.Players.AsNoTracking().FirstOrDefaultAsync(p => p.Id == adminPlayerId && p.IsAdmin);
if (admin is null)
return EndpointHelpers.UnauthorizedError();
return PasswordHasher.Verify(password, admin.PasswordHash, admin.PasswordSalt)
? null
: EndpointHelpers.BadRequestError("Invalid admin password.");
}
} }

View File

@@ -9,6 +9,8 @@ namespace GameList.Tests;
public class AdminTests public class AdminTests
{ {
private const string AdminPassword = "Pass123!";
[Fact] [Fact]
public async Task Admin_vote_status_marks_ready_when_all_finalized() public async Task Admin_vote_status_marks_ready_when_all_finalized()
{ {
@@ -59,6 +61,63 @@ public class AdminTests
Assert.Equal(HttpStatusCode.BadRequest, give.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, give.StatusCode);
} }
[Fact]
public async Task Admin_can_move_vote_player_back_to_suggest()
{
await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true);
var player = factory.CreateClientWithCookies();
await player.RegisterAsync("player");
await player.CreateSuggestionAsync("Game");
await player.PostAsJsonAsync("/api/me/phase/next", new { });
await factory.WithDbContextAsync(async db =>
{
var p = await db.Players.SingleAsync(x => x.Username == "player");
p.VotesFinal = true;
await db.SaveChangesAsync();
});
var resp = await admin.PostAsJsonAsync("/api/admin/player-phase", new
{
playerId = await player.GetProfileIdAsync(),
phase = nameof(Phase.Suggest)
});
resp.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(async db =>
{
var p = await db.Players.SingleAsync(x => x.Username == "player");
Assert.Equal(Phase.Suggest, p.CurrentPhase);
Assert.False(p.VotesFinal);
});
}
[Fact]
public async Task Admin_player_phase_requires_vote_phase_and_suggest_target()
{
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 wrongTarget = await admin.PostAsJsonAsync("/api/admin/player-phase", new
{
playerId = await player.GetProfileIdAsync(),
phase = nameof(Phase.Results)
});
Assert.Equal(HttpStatusCode.BadRequest, wrongTarget.StatusCode);
var wrongCurrentPhase = await admin.PostAsJsonAsync("/api/admin/player-phase", new
{
playerId = await player.GetProfileIdAsync(),
phase = nameof(Phase.Suggest)
});
Assert.Equal(HttpStatusCode.BadRequest, wrongCurrentPhase.StatusCode);
}
[Fact] [Fact]
public async Task Delete_player_cascades_suggestions_and_votes() public async Task Delete_player_cascades_suggestions_and_votes()
{ {
@@ -77,7 +136,10 @@ public class AdminTests
Score = 8 Score = 8
}); });
var resp = await admin.DeleteAsync($"/api/admin/players/{await player.GetProfileIdAsync()}"); var resp = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{await player.GetProfileIdAsync()}")
{
Content = JsonContent.Create(new { password = AdminPassword })
});
resp.EnsureSuccessStatusCode(); resp.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(db => await factory.WithDbContextAsync(db =>
@@ -189,7 +251,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 { password = AdminPassword });
reset.EnsureSuccessStatusCode(); reset.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(db => await factory.WithDbContextAsync(db =>
@@ -209,7 +271,7 @@ public class AdminTests
} }
}); });
var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { }); var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { password = AdminPassword });
factoryReset.EnsureSuccessStatusCode(); factoryReset.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(db => await factory.WithDbContextAsync(db =>
@@ -236,6 +298,7 @@ public class AdminTests
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");
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();
@@ -263,6 +326,37 @@ public class AdminTests
}); });
} }
[Fact]
public async Task Admin_results_closing_sends_players_without_suggestions_to_suggest_phase()
{
await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true);
var voter = factory.CreateClientWithCookies();
await voter.RegisterAsync("voter");
await voter.CreateSuggestionAsync("Voter game");
var open = await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true });
open.EnsureSuccessStatusCode();
var lateJoiner = factory.CreateClientWithCookies();
await lateJoiner.RegisterAsync("late");
var close = await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = false });
close.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(async db =>
{
var voterPlayer = await db.Players.SingleAsync(p => p.Username == "voter");
var latePlayer = await db.Players.SingleAsync(p => p.Username == "late");
Assert.Equal(Phase.Vote, voterPlayer.CurrentPhase);
Assert.Equal(Phase.Suggest, latePlayer.CurrentPhase);
Assert.False(voterPlayer.VotesFinal);
Assert.False(latePlayer.VotesFinal);
});
}
[Fact] [Fact]
public async Task Vote_status_lists_waiting_players() public async Task Vote_status_lists_waiting_players()
{ {
@@ -425,7 +519,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 { password = AdminPassword });
reset.EnsureSuccessStatusCode(); reset.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
@@ -437,7 +531,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 { password = AdminPassword });
factoryReset.EnsureSuccessStatusCode(); factoryReset.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(async db => await factory.WithDbContextAsync(async db =>
{ {
@@ -445,4 +539,25 @@ public class AdminTests
Assert.False(state.ResultsOpen); Assert.False(state.ResultsOpen);
}); });
} }
[Fact]
public async Task Destructive_admin_actions_require_valid_admin_password()
{
await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("admin", admin: true);
var player = factory.CreateClientWithCookies();
await player.RegisterAsync("target");
var resetWrongPassword = await admin.PostAsJsonAsync("/api/admin/reset", new { password = "wrong" });
Assert.Equal(HttpStatusCode.BadRequest, resetWrongPassword.StatusCode);
var playerId = await factory.WithDbContextAsync(async db => await db.Players.Where(p => p.Username == "target").Select(p => p.Id).SingleAsync());
var deleteWrongPassword = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{playerId}")
{
Content = JsonContent.Create(new { password = "wrong" })
});
Assert.Equal(HttpStatusCode.BadRequest, deleteWrongPassword.StatusCode);
}
} }

View File

@@ -10,6 +10,8 @@ Help a small Discord group (48 players) pick a co-op game via phased flow:
- Single shared instance - Single shared instance
- Username/password login (cookie auth) - Username/password login (cookie auth)
- Admins flagged via admin key at registration - Admins flagged via admin key at registration
- Logout returns to the login form and clears all auth form fields
- Destructive admin actions (player delete, reset, factory reset) require admin password confirmation
- Per-user phase tracking; admins can move themselves backward, everyone can move forward (subject to admin “results open” toggle and Suggest→Vote requiring at least one own suggestion) - Per-user phase tracking; admins can move themselves backward, everyone can move forward (subject to admin “results open” toggle and Suggest→Vote requiring at least one own suggestion)
## Suggest Phase ## Suggest Phase
@@ -17,6 +19,7 @@ Help a small Discord group (48 players) pick a co-op game via phased flow:
- Name required; optional genre, description, screenshot URL, YouTube URL, external game link, min/max players - Name required; optional genre, description, screenshot URL, YouTube URL, external game link, min/max players
- Players see only their own suggestions until voting - Players see only their own suggestions until voting
- A player can enter Vote only after submitting at least one own suggestion - A player can enter Vote only after submitting at least one own suggestion
- The Suggest phase shows a non-interactive “add a game first” hint until the first successful suggestion, then immediately shows the `Next` button
- Screenshots validated as reachable images - Screenshots validated as reachable images
## Vote Phase ## Vote Phase
@@ -25,10 +28,14 @@ Help a small Discord group (48 players) pick a co-op game via phased flow:
- Players see only their own votes; can finalize/unfinalize their ballot - Players see only their own votes; can finalize/unfinalize their ballot
- **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
- Admin status controls can move a player from Vote back to Suggest for exceptional cases
- The “new/linked games” vote popup appears only when the vote list changes after the player has already seen that vote list
## 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
- Admin controls results availability with a single toggle button whose label reflects enabled/disabled state
- 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
- When results are closed again, only accounts with at least one suggestion return to Vote; accounts without suggestions return to Suggest
## Non-functional ## Non-functional
- Desktop + mobile friendly - Desktop + mobile friendly

View File

@@ -8,7 +8,7 @@ Purpose: full coverage of backend + critical UI flows using a mock (in-memory) S
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| Unauthenticated visitor | No API access; only static assets | — | — | Health check only | | Unauthenticated visitor | No API access; only static assets | — | — | Health check only |
| Player (non-admin) | Create/see own suggestions (≤5), edit all fields, delete own; can advance to Vote; title locks after leaving phase | View all suggestions, vote 010, finalize/unfinalize, use joker once to add a game; cannot go backward | Read leaderboard only when resultsOpen=true; no writes | Login/logout, read /state and /me | | Player (non-admin) | Create/see own suggestions (≤5), edit all fields, delete own; can advance to Vote; title locks after leaving phase | View all suggestions, vote 010, finalize/unfinalize, use joker once to add a game; cannot go backward | Read leaderboard only when resultsOpen=true; no writes | Login/logout, read /state and /me |
| Admin (isAdmin=true) | Same as player; may edit/delete any suggestion | All player actions; may grant jokers, link/unlink games, delete players | Open/close results; sees leaderboard like player | Toggle results, reset/factory-reset DB, fetch vote status, move self backward | | Admin (isAdmin=true) | Same as player; may edit/delete any suggestion | All player actions; may grant jokers, link/unlink games, delete players, move a voter back to Suggest | Open/close results; sees leaderboard like player | Toggle results, reset/factory-reset DB, fetch vote status, move self backward |
## Phase/Permission Chart (for tests) ## Phase/Permission Chart (for tests)
```mermaid ```mermaid
@@ -68,11 +68,12 @@ stateDiagram-v2
- POST /admin/results toggles resultsOpen and aligns all player phases (to Results or back to Vote clearing votesFinal); 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 joker only when target in Vote; resets VotesFinal for target. - 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/player-phase allows Vote->Suggest transitions only; rejects other targets/current phases; clears target VotesFinal.
- DELETE /admin/players/{id}: requires valid admin password; removes player, cascades suggestions, breaks links to their suggestions, deletes related votes, wrapped in transaction.
- POST /admin/link-suggestions: only in Vote; errors on same ids/already linked/not found; re-parents groups correctly; deletes votes for affected group and unfinalizes affected players. - POST /admin/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: wipes suggestions/votes, resets phases to Suggest, clears votesFinal/hasJoker, closes results, updates timestamp. - POST /admin/reset: requires valid admin password; wipes suggestions/votes, resets phases to Suggest, clears votesFinal/hasJoker, closes results, updates timestamp.
- POST /admin/factory-reset: wipes all players/suggestions/votes/state; reseeds AppState with defaults; transactional. - POST /admin/factory-reset: requires valid admin password; 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) { if (!document.hidden && !state.adminStatusSelectActive) {
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) { if (!document.hidden && !state.adminStatusSelectActive) {
refreshWithUiErrorHandling(); refreshWithUiErrorHandling();
} }
}); });

View File

@@ -55,3 +55,9 @@
border: 1px solid #e3d4bd; border: 1px solid #e3d4bd;
background: #fffaf3; background: #fffaf3;
} }
.admin-status-select {
width: 100%;
min-width: 140px;
background: #fffaf3;
}

View File

@@ -173,6 +173,10 @@ button .chip {
align-items: center; align-items: center;
} }
.nav-hint {
font-weight: 600;
}
.warning-text { .warning-text {
color: #b23b3b; color: #b23b3b;
font-weight: 600; font-weight: 600;

View File

@@ -68,6 +68,9 @@
overflow: auto; overflow: auto;
max-height: 70vh; max-height: 70vh;
} }
.edit-modal .edit-body .confirm-actions {
margin-top: 12px;
}
.edit-modal .delete-body { .edit-modal .delete-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -116,6 +116,7 @@ Mit **„Finalisieren"** werden deine Bewertungen gesperrt. Deaktiviere es, um e
Wenn neue Spiele hinzugefügt oder Verknüpfungen geändert werden: Wenn neue Spiele hinzugefügt oder Verknüpfungen geändert werden:
- Betroffene Stimmen werden gelöscht - Betroffene Stimmen werden gelöscht
- Deine Abstimmung wird automatisch zurückgesetzt - Deine Abstimmung wird automatisch zurückgesetzt
- Das Update-Popup erscheint nur, wenn sich deine bereits sichtbare Abstimmungsliste verändert
Überprüfe deine Liste und bewerte erneut, bevor du wieder finalisierst. Überprüfe deine Liste und bewerte erneut, bevor du wieder finalisierst.
@@ -138,7 +139,7 @@ Admins können bei Bedarf zusätzliche Joker vergeben.
### 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: Alle kehren in die Abstimmungsphase zurück und alle Abstimmungen werden zur Anpassung 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: Konten mit mindestens einem eigenen Vorschlag kehren in die Abstimmungsphase zurück, Konten ohne Vorschläge in die Vorschlagsphase, und alle Abstimmungen werden zur Anpassung zurückgesetzt.
### Kann ich in der Ergebnisphase etwas bearbeiten? ### Kann ich in der Ergebnisphase etwas bearbeiten?
@@ -149,10 +150,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?
- Joker während der Abstimmung vergeben - Joker während der Abstimmung vergeben
- Einen Bewerter zurück in die Vorschlagsphase setzen (stärker als ein Joker; sparsam einsetzen)
- Ergebniszugriff mit einem einzelnen Button umschalten (Beschriftung wechselt je nach Zustand)
- 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)
- Für Konto-Löschung, Zurücksetzen und Werkseinstellung das Admin-Passwort bestätigen
- Die Datenbank auf Werkseinstellungen zurücksetzen - Die Datenbank auf Werkseinstellungen zurücksetzen
- Zu vorherigen Phasen zurückkehren - Zu vorherigen Phasen zurückkehren
@@ -179,6 +183,7 @@ 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."
Füge mit deinem aktuellen Konto mindestens einen Spielvorschlag hinzu. Erst dann kannst du von der Vorschlagsphase in die Abstimmungsphase wechseln. Diesese Verhalten erschwert die Abgabe von mehreren Stimmen pro Benutzer. Füge mit deinem aktuellen Konto mindestens einen Spielvorschlag hinzu. Erst dann kannst du von der Vorschlagsphase in die Abstimmungsphase wechseln. Diesese Verhalten erschwert die Abgabe von mehreren Stimmen pro Benutzer.
Bis dahin zeigt die Navigation in der Vorschlagsphase einen Hinweis statt eines Weiter-Buttons und wechselt direkt nach der ersten erfolgreichen Einreichung.
### „Ungültiger Admin-Schlüssel." ### „Ungültiger Admin-Schlüssel."
@@ -188,5 +193,5 @@ Registriere dich erneut mit dem korrekten Schlüssel vom Host oder lasse das
- Vorschläge, Stimmen und Phasenstatus werden in einer gemeinsamen **SQLite-Datenbank** gespeichert. - Vorschläge, Stimmen und Phasenstatus werden in einer gemeinsamen **SQLite-Datenbank** gespeichert.
- Passwörtwer werden mit einer SHA256 Verschlüsselung gespeichert. - Passwörtwer werden mit einer SHA256 Verschlüsselung gespeichert.
- Beim Abmelden wird dein Authentifizierungs-Cookie gelöscht. - Beim Abmelden wird dein Authentifizierungs-Cookie gelöscht und die Eingaben in Login/Registrierung werden zurückgesetzt.
- Wenn ein Admin dein Spielerkonto löscht, werden auch deine Vorschläge und Stimmen entfernt. - Wenn ein Admin dein Spielerkonto löscht, werden auch deine Vorschläge und Stimmen entfernt.

View File

@@ -134,6 +134,7 @@ Finalize is only available during the Vote phase and will automatically reset if
If new games are added or links are modified: If new games are added or links are modified:
- Affected votes are cleared - Affected votes are cleared
- You are automatically unfinalized - You are automatically unfinalized
- The update popup appears only when your already-visible Vote list changes
Review your list and rescore before finalizing again. Review your list and rescore before finalizing again.
@@ -142,7 +143,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: everyone returns to the Vote phase, and all ballots are unfinalized for adjustments. If needed, an admin can close the Results: players with at least one own suggestion return to the Vote phase, accounts without suggestions return to Suggest, and all ballots are unfinalized for adjustments.
### Can I edit anything in Results? ### Can I edit anything in Results?
@@ -153,10 +154,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 - Grant jokers during Vote
- Move a voter back to Suggest (stronger than a joker; use sparingly)
- Toggle results access with a single button (label switches by current state)
- 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)
- Confirm admin password for account deletion, reset, and factory reset
- Reset the database to factory defaults - Reset the database to factory defaults
- Move backward to previous phases - Move backward to previous phases
@@ -183,6 +187,7 @@ 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."
Add at least one game suggestion with your current account. Only then can you move from Suggest to Vote. This behavior hinders the submission of multiple votes per user. Add at least one game suggestion with your current account. Only then can you move from Suggest to Vote. This behavior hinders the submission of multiple votes per user.
Until then, the Suggest navigation shows a hint instead of a Next button, and switches immediately after your first successful submission.
### "Invalid admin key." ### "Invalid admin key."
@@ -192,5 +197,5 @@ Register again using the correct key from the host or leave it blank to crea
- Suggestions, votes, and phase states are stored in a shared **SQLite database**. - Suggestions, votes, and phase states are stored in a shared **SQLite database**.
- Passwords are stored with a SHA256 encryption. - Passwords are stored with a SHA256 encryption.
- Logging out clears your authentication cookie. - Logging out clears your authentication cookie and resets login/register form inputs.
- If an admin deletes your player account, your suggestions and votes are removed as well. - If an admin deletes your player account, your suggestions and votes are removed as well.

View File

@@ -103,10 +103,18 @@
"admin.title": "Admin", "admin.title": "Admin",
"admin.tools": "Admin tools", "admin.tools": "Admin tools",
"admin.resultsOpenToggle": "Allow results phase", "admin.resultsOpenToggle": "Allow results phase",
"admin.resultsOpenEnable": "Enable results phase",
"admin.resultsOpenDisable": "Disable 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.resetConfirmTitle": "Reset round data?",
"admin.resetConfirmBody": "This clears suggestions and votes while keeping accounts. Enter your admin password to continue.",
"admin.factoryResetConfirmTitle": "Factory reset everything?",
"admin.factoryResetConfirmBody": "This removes all players, suggestions, and votes. Enter your admin password to continue.",
"admin.confirmPasswordLabel": "Admin password",
"admin.confirmPasswordRequired": "Admin password is required.",
"admin.resetDone": "Reset complete", "admin.resetDone": "Reset complete",
"admin.factoryResetDone": "Factory reset complete", "admin.factoryResetDone": "Factory reset complete",
"admin.readyForResults": "Ready for results", "admin.readyForResults": "Ready for results",
@@ -121,6 +129,8 @@
"admin.statusSuggesting": "Suggesting", "admin.statusSuggesting": "Suggesting",
"admin.statusVoting": "Voting", "admin.statusVoting": "Voting",
"admin.statusFinished": "Finished", "admin.statusFinished": "Finished",
"admin.statusMoveToSuggest": "Move to Suggest",
"admin.statusUpdated": "Player phase updated",
"admin.deleteTitle": "Delete account?", "admin.deleteTitle": "Delete account?",
"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",
@@ -263,10 +273,18 @@
"admin.title": "Admin", "admin.title": "Admin",
"admin.tools": "Admin-Werkzeuge", "admin.tools": "Admin-Werkzeuge",
"admin.resultsOpenToggle": "Ergebnisse freigeben", "admin.resultsOpenToggle": "Ergebnisse freigeben",
"admin.resultsOpenEnable": "Ergebnisse freigeben",
"admin.resultsOpenDisable": "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.resetConfirmTitle": "Rundendaten zurücksetzen?",
"admin.resetConfirmBody": "Dadurch werden Vorschläge und Stimmen gelöscht, die Konten bleiben erhalten. Gib dein Admin-Passwort ein, um fortzufahren.",
"admin.factoryResetConfirmTitle": "Alles auf Werkseinstellung setzen?",
"admin.factoryResetConfirmBody": "Dadurch werden alle Spieler, Vorschläge und Stimmen gelöscht. Gib dein Admin-Passwort ein, um fortzufahren.",
"admin.confirmPasswordLabel": "Admin-Passwort",
"admin.confirmPasswordRequired": "Admin-Passwort ist erforderlich.",
"admin.resetDone": "Zurücksetzen abgeschlossen", "admin.resetDone": "Zurücksetzen abgeschlossen",
"admin.factoryResetDone": "Werkseinstellung abgeschlossen", "admin.factoryResetDone": "Werkseinstellung abgeschlossen",
"admin.readyForResults": "Bereit für Ergebnisse", "admin.readyForResults": "Bereit für Ergebnisse",
@@ -281,6 +299,8 @@
"admin.statusSuggesting": "Vorschlagen", "admin.statusSuggesting": "Vorschlagen",
"admin.statusVoting": "Bewerten", "admin.statusVoting": "Bewerten",
"admin.statusFinished": "Fertig", "admin.statusFinished": "Fertig",
"admin.statusMoveToSuggest": "Zur Vorschlagsphase",
"admin.statusUpdated": "Spielerphase aktualisiert",
"admin.deleteTitle": "Konto löschen?", "admin.deleteTitle": "Konto löschen?",
"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",

View File

@@ -121,6 +121,7 @@
<p data-i18n="nav.freezeHint">Moving forward will freeze your suggestions. Titles become locked; only extra details stay editable.</p> <p data-i18n="nav.freezeHint">Moving forward will freeze your suggestions. Titles become locked; only extra details stay editable.</p>
</div> </div>
<div class="nav-actions"> <div class="nav-actions">
<span id="nav-suggest-hint" class="muted nav-hint" data-i18n="nav.addSuggestionFirst">Add a game first</span>
<button id="nav-suggest-next" class="primary" data-i18n="nav.next">Next</button> <button id="nav-suggest-next" class="primary" data-i18n="nav.next">Next</button>
</div> </div>
</div> </div>
@@ -178,10 +179,7 @@
</table> </table>
</div> </div>
</div> </div>
<label class="stack toggle-row"> <button id="results-open" class="secondary" type="button" data-i18n="admin.resultsOpenEnable">Enable results phase</button>
<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,8 +15,20 @@ function displayPlayerStatus(player) {
return phase; return phase;
} }
function buildStatusSelect(player) {
const statusText = displayPlayerStatus(player);
const canMoveToSuggest = player.phase === "Vote";
return `
<select class="chip admin-status-select" data-set-player-phase="${player.playerId}" aria-label="${t("admin.playerStatus")}">
<option value="" selected>${statusText}</option>
<option value="Suggest" ${canMoveToSuggest ? "" : "disabled"}>${t("admin.statusMoveToSuggest")}</option>
</select>
`;
}
export function renderAdminVoteStatus() { export function renderAdminVoteStatus() {
if (!state.me?.isAdmin) return; if (!state.me?.isAdmin) return;
if (state.adminStatusSelectActive) return;
const statusBadge = $("admin-ready-status"); const statusBadge = $("admin-ready-status");
const table = $("admin-player-table")?.querySelector("tbody"); const table = $("admin-player-table")?.querySelector("tbody");
if (!state.adminVoteStatus || !statusBadge || !table) return; if (!state.adminVoteStatus || !statusBadge || !table) return;
@@ -24,14 +36,13 @@ 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>${statusText}</td> <td>${buildStatusSelect(v)}</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,10 +56,18 @@ 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: () => request("/api/admin/reset", { method: "POST" }), reset: (password) =>
factoryReset: () => request("/api/admin/factory-reset", { method: "POST" }), request("/api/admin/reset", { method: "POST", body: { password } }),
factoryReset: (password) =>
request("/api/admin/factory-reset", { method: "POST", body: { password } }),
grantJoker: (playerId) => request("/api/admin/joker", { method: "POST", body: { playerId } }), grantJoker: (playerId) => request("/api/admin/joker", { method: "POST", body: { playerId } }),
deletePlayer: (playerId) => request(`/api/admin/players/${playerId}`, { method: "DELETE" }), setPlayerPhase: (playerId, phase) =>
request("/api/admin/player-phase", { method: "POST", body: { playerId, phase } }),
deletePlayer: (playerId, password) =>
request(`/api/admin/players/${playerId}`, {
method: "DELETE",
body: { password },
}),
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

@@ -8,14 +8,23 @@ import {
renderPhasePill, renderPhasePill,
} from "./ui.js"; } from "./ui.js";
async function adminAction(fn, successMessage, runSerializedRefresh) { function openAdminPasswordModal({ title, body, confirmLabel, onConfirm }) {
try { openConfirmModal({
await fn(); title,
toast(successMessage); body,
await runSerializedRefresh(); confirmLabel,
} catch (err) { confirmClass: "danger",
toast(err.message, true); requirePassword: true,
passwordLabel: t("admin.confirmPasswordLabel"),
onConfirm: async (close, payload) => {
const password = (payload?.password || "").trim();
if (!password) {
toast(t("admin.confirmPasswordRequired"), true);
return;
} }
await onConfirm(password, close);
},
});
} }
function setupAdminPanelToggle() { function setupAdminPanelToggle() {
@@ -32,24 +41,49 @@ function setupAdminPanelToggle() {
} }
function setupResetButtons(runSerializedRefresh) { function setupResetButtons(runSerializedRefresh) {
$("reset").addEventListener("click", () => $("reset").addEventListener("click", () => {
adminAction(adminApi.reset, t("admin.resetDone"), runSerializedRefresh), openAdminPasswordModal({
); title: t("admin.resetConfirmTitle"),
$("factory-reset").addEventListener("click", () => body: t("admin.resetConfirmBody"),
adminAction( confirmLabel: t("admin.reset"),
adminApi.factoryReset, onConfirm: async (password, close) => {
t("admin.factoryResetDone"), try {
runSerializedRefresh, await adminApi.reset(password);
), toast(t("admin.resetDone"));
); close();
await runSerializedRefresh();
} catch (err) {
toast(err.message, true);
}
},
});
});
$("factory-reset").addEventListener("click", () => {
openAdminPasswordModal({
title: t("admin.factoryResetConfirmTitle"),
body: t("admin.factoryResetConfirmBody"),
confirmLabel: t("admin.factoryReset"),
onConfirm: async (password, close) => {
try {
await adminApi.factoryReset(password);
toast(t("admin.factoryResetDone"));
close();
await runSerializedRefresh();
} catch (err) {
toast(err.message, true);
}
},
});
});
} }
function setupResultsToggle(runSerializedRefresh) { function setupResultsToggle(runSerializedRefresh) {
const resultsToggle = $("results-open"); const resultsToggle = $("results-open");
if (!resultsToggle) return; if (!resultsToggle) return;
resultsToggle.addEventListener("change", async (e) => { resultsToggle.addEventListener("click", async () => {
const desired = !!e.target.checked; const desired = !state.resultsOpen;
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;
@@ -62,8 +96,9 @@ 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;
} }
}); });
} }
@@ -91,6 +126,44 @@ function setupLinkApply(runSerializedRefresh) {
function setupPlayerTableActions(runSerializedRefresh) { function setupPlayerTableActions(runSerializedRefresh) {
const playerTable = $("admin-player-table"); const playerTable = $("admin-player-table");
if (!playerTable) return; if (!playerTable) return;
const phaseSelectSelector = "[data-set-player-phase]";
playerTable.addEventListener("focusin", (e) => {
if (e.target.matches?.(phaseSelectSelector)) {
state.adminStatusSelectActive = true;
}
});
playerTable.addEventListener("focusout", (e) => {
if (!e.target.matches?.(phaseSelectSelector)) return;
window.setTimeout(() => {
const focused = document.activeElement;
state.adminStatusSelectActive =
!!focused?.matches?.(phaseSelectSelector);
}, 0);
});
playerTable.addEventListener("change", async (e) => {
const select = e.target.closest(phaseSelectSelector);
if (!select) return;
const playerId = select.dataset.setPlayerPhase;
const phase = select.value;
if (!playerId || !phase) return;
select.disabled = true;
try {
await adminApi.setPlayerPhase(playerId, phase);
toast(t("admin.statusUpdated"));
state.adminStatusSelectActive = false;
await runSerializedRefresh();
} catch (err) {
select.value = "";
toast(err.message, true);
} finally {
select.disabled = false;
state.adminStatusSelectActive = 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]");
@@ -107,13 +180,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 || "";
openConfirmModal({ openAdminPasswordModal({
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 (close) => { onConfirm: async (password, close) => {
try { try {
await adminApi.deletePlayer(playerId); await adminApi.deletePlayer(playerId, password);
toast(t("admin.deleteDone")); toast(t("admin.deleteDone"));
close(); close();
await runSerializedRefresh(); await runSerializedRefresh();

View File

@@ -141,22 +141,17 @@ function setupLogoutHandler() {
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);
} }
document.querySelectorAll(".auth-form").forEach((form) => form.reset());
setAuthMode("login");
setSavedUsername("");
clearUserState(); clearUserState();
state.isAuthenticated = false; state.isAuthenticated = false;
setAuthUI(false); setAuthUI(false);
if (lastUser) {
setSavedUsername(lastUser);
const loginUser = $("login-username");
if (loginUser) loginUser.value = lastUser;
const loginPass = $("login-password");
if (loginPass) loginPass.value = "";
}
}); });
} }

View File

@@ -25,6 +25,7 @@ export async function loadSuggestData() {
if (state.phase !== "Suggest") return; if (state.phase !== "Suggest") return;
state.mySuggestions = await api.mySuggestions(); state.mySuggestions = await api.mySuggestions();
renderMySuggestions(); renderMySuggestions();
updatePhaseNav();
} }
export async function loadSuggestionsData() { export async function loadSuggestionsData() {
@@ -34,7 +35,12 @@ 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;
if (changed && state.phase === "Vote" && state.allSuggestionsSig) { if (
changed &&
state.phase === "Vote" &&
state.votesRendered &&
state.allSuggestionsSig
) {
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

@@ -29,6 +29,9 @@ export function openConfirmModal({
body, body,
confirmLabel, confirmLabel,
cancelLabel = t("modal.cancel"), cancelLabel = t("modal.cancel"),
confirmClass = null,
requirePassword = false,
passwordLabel = t("auth.password"),
onConfirm, onConfirm,
}) { }) {
const overlay = document.createElement("div"); const overlay = document.createElement("div");
@@ -46,9 +49,11 @@ export function openConfirmModal({
`; `;
const close = () => overlay.remove(); const close = () => overlay.remove();
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.className = "stack horizontal"; actions.className = "stack horizontal confirm-actions";
const confirmBtn = document.createElement("button"); const confirmBtn = document.createElement("button");
if (confirmClass) confirmBtn.className = confirmClass;
confirmBtn.textContent = confirmLabel ?? t("modal.confirm"); confirmBtn.textContent = confirmLabel ?? t("modal.confirm");
confirmBtn.disabled = requirePassword;
actions.append(confirmBtn); actions.append(confirmBtn);
if (cancelLabel !== null && cancelLabel !== undefined) { if (cancelLabel !== null && cancelLabel !== undefined) {
const cancelBtn = document.createElement("button"); const cancelBtn = document.createElement("button");
@@ -58,7 +63,24 @@ export function openConfirmModal({
actions.append(cancelBtn); actions.append(cancelBtn);
cancelBtn.addEventListener("click", close); cancelBtn.addEventListener("click", close);
} }
panel.querySelector(".edit-body")?.appendChild(actions); const bodyContainer = panel.querySelector(".edit-body");
let passwordInput = null;
if (requirePassword && bodyContainer) {
const field = document.createElement("label");
field.className = "stack";
const label = document.createElement("span");
label.className = "label";
label.textContent = passwordLabel;
passwordInput = document.createElement("input");
passwordInput.type = "password";
passwordInput.autocomplete = "current-password";
field.append(label, passwordInput);
bodyContainer.appendChild(field);
passwordInput.addEventListener("input", () => {
confirmBtn.disabled = !(passwordInput.value || "").trim();
});
}
bodyContainer?.appendChild(actions);
overlay.addEventListener("click", (e) => { overlay.addEventListener("click", (e) => {
if ( if (
@@ -70,7 +92,7 @@ export function openConfirmModal({
}); });
confirmBtn.addEventListener("click", async () => { confirmBtn.addEventListener("click", async () => {
try { try {
await onConfirm?.(close); await onConfirm?.(close, { password: passwordInput?.value ?? "" });
} catch (err) { } catch (err) {
toast(err.message, true); toast(err.message, true);
} }

View File

@@ -15,6 +15,7 @@ export const state = {
results: [], results: [],
votesRendered: false, votesRendered: false,
adminVoteStatus: null, adminVoteStatus: null,
adminStatusSelectActive: false,
}; };
export function clearUserState() { export function clearUserState() {
@@ -27,9 +28,11 @@ export function clearUserState() {
state.counts = null; state.counts = null;
state.mySuggestions = []; state.mySuggestions = [];
state.allSuggestions = []; state.allSuggestions = [];
state.allSuggestionsSig = null;
state.myVotes = []; state.myVotes = [];
state.results = []; state.results = [];
state.votesRendered = false; state.votesRendered = false;
state.adminStatusSelectActive = 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");
} }

View File

@@ -249,14 +249,16 @@ export function updatePhaseNav() {
}); });
const suggestNext = $("nav-suggest-next"); const suggestNext = $("nav-suggest-next");
const suggestHint = $("nav-suggest-hint");
if (suggestNext) { if (suggestNext) {
const hasSuggestions = (state.mySuggestions?.length ?? 0) > 0; const hasSuggestions = (state.mySuggestions?.length ?? 0) > 0;
const needsSuggestion = phase === "Suggest" && !hasSuggestions; const needsSuggestion = phase === "Suggest" && !hasSuggestions;
suggestNext.disabled = needsSuggestion; suggestNext.classList.toggle("hidden", needsSuggestion);
suggestNext.classList.toggle("needs-suggestion", needsSuggestion); suggestNext.textContent = t("nav.next");
suggestNext.textContent = needsSuggestion if (suggestHint) {
? t("nav.addSuggestionFirst") suggestHint.classList.toggle("hidden", !needsSuggestion);
: t("nav.next"); suggestHint.textContent = t("nav.addSuggestionFirst");
}
} }
const voteNext = $("nav-vote-next"); const voteNext = $("nav-vote-next");
@@ -270,6 +272,8 @@ export function updatePhaseNav() {
const adminResultsToggle = $("results-open"); const adminResultsToggle = $("results-open");
if (adminResultsToggle) { if (adminResultsToggle) {
adminResultsToggle.checked = !!state.resultsOpen; adminResultsToggle.textContent = state.resultsOpen
? t("admin.resultsOpenDisable")
: t("admin.resultsOpenEnable");
} }
} }