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, 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.
- 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.
- 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:
- .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)
POST /api/admin/results — `{ resultsOpen: bool }` locks/unlocks results and aligns player phases
GET /api/admin/vote-status — readiness overview (who finalized)
POST /api/admin/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/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/factory-reset — wipe players, suggestions, votes, state
POST /api/admin/reset — `{ password }`; clear suggestions/votes, keep players, reset phases/vote-final flags
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 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 AdminSetPlayerPhaseResponse(Guid PlayerId, Phase CurrentPhase, bool VotesFinal);
public record AdminDeletePlayerResponse(Guid DeletedPlayerId);
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.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) =>
{
@@ -37,9 +46,23 @@ public static class AdminEndpoints
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.Data;
using GameList.Domain;
using GameList.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace GameList.Endpoints;
@@ -21,7 +22,12 @@ internal sealed class AdminWorkflowService(AppDbContext db)
}
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();
@@ -61,8 +67,32 @@ internal sealed class AdminWorkflowService(AppDbContext db)
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);
if (player is null)
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()));
}
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 db.Votes.ExecuteDeleteAsync();
@@ -195,8 +229,12 @@ internal sealed class AdminWorkflowService(AppDbContext db)
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 db.Votes.ExecuteDeleteAsync();
@@ -212,4 +250,18 @@ internal sealed class AdminWorkflowService(AppDbContext db)
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
{
private const string AdminPassword = "Pass123!";
[Fact]
public async Task Admin_vote_status_marks_ready_when_all_finalized()
{
@@ -59,6 +61,63 @@ public class AdminTests
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]
public async Task Delete_player_cascades_suggestions_and_votes()
{
@@ -77,7 +136,10 @@ public class AdminTests
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();
await factory.WithDbContextAsync(db =>
@@ -189,7 +251,7 @@ public class AdminTests
await player.RegisterAsync("player");
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();
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();
await factory.WithDbContextAsync(db =>
@@ -236,6 +298,7 @@ public class AdminTests
await admin.RegisterAsync("admin", admin: true);
var player = factory.CreateClientWithCookies();
await player.RegisterAsync("player");
await player.CreateSuggestionAsync("Player game");
var open = await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true });
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]
public async Task Vote_status_lists_waiting_players()
{
@@ -425,7 +519,7 @@ public class AdminTests
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();
await factory.WithDbContextAsync(async db =>
@@ -437,7 +531,7 @@ public class AdminTests
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();
await factory.WithDbContextAsync(async db =>
{
@@ -445,4 +539,25 @@ public class AdminTests
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
- Username/password login (cookie auth)
- 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)
## 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
- Players see only their own suggestions until voting
- 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
## 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
- **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
- 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
- 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
- When results are closed again, only accounts with at least one suggestion return to Vote; accounts without suggestions return to Suggest
## Non-functional
- 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 |
| 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)
```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.
- 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.
- 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/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/factory-reset: wipes all players/suggestions/votes/state; reseeds AppState with defaults; transactional.
- 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: requires valid admin password; wipes all players/suggestions/votes/state; reseeds AppState with defaults; transactional.
### 7) Infrastructure/Helpers
- PasswordHasher: hash+verify roundtrip, rejects empty password, constant-time compare (FixedTimeEquals usage).

View File

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

View File

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

View File

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

View File

@@ -68,6 +68,9 @@
overflow: auto;
max-height: 70vh;
}
.edit-modal .edit-body .confirm-actions {
margin-top: 12px;
}
.edit-modal .delete-body {
display: flex;
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:
- Betroffene Stimmen werden gelöscht
- 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.
@@ -138,7 +139,7 @@ Admins können bei Bedarf zusätzliche Joker vergeben.
### 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?
@@ -149,10 +150,13 @@ Nein. Vorschläge und Bewertungen sind schreibgeschützt. Wende dich bei Bedarf
### Was können Admin-Konten tun?
- 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
- Vorschläge löschen
- Abstimmungsstatus einsehen (wer finalisiert hat)
- 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
- 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 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."
@@ -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.
- 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.

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:
- Affected votes are cleared
- You are automatically unfinalized
- The update popup appears only when your already-visible Vote list changes
Review your list and rescore before finalizing again.
@@ -142,7 +143,7 @@ Review your list and rescore before finalizing again.
### When are results visible?
Results are hidden until an admin opens them. When opened, all players are automatically moved to the **Results phase**.
If needed, an admin can close the Results: 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?
@@ -153,10 +154,13 @@ No. Suggestions and votes are read-only. Contact an admin for assistance.
### What can admin accounts do?
- 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
- Delete suggestions
- View vote readiness (who has finalized)
- Delete a player (removes their suggestions and votes)
- Confirm admin password for account deletion, reset, and factory reset
- Reset the database to factory defaults
- 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 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."
@@ -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**.
- 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.

View File

@@ -103,10 +103,18 @@
"admin.title": "Admin",
"admin.tools": "Admin tools",
"admin.resultsOpenToggle": "Allow results phase",
"admin.resultsOpenEnable": "Enable results phase",
"admin.resultsOpenDisable": "Disable results phase",
"admin.resultsLocked": "Results locked by admin",
"admin.resultsUpdated": "Results availability updated",
"admin.reset": "Reset (keep players)",
"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.factoryResetDone": "Factory reset complete",
"admin.readyForResults": "Ready for results",
@@ -121,6 +129,8 @@
"admin.statusSuggesting": "Suggesting",
"admin.statusVoting": "Voting",
"admin.statusFinished": "Finished",
"admin.statusMoveToSuggest": "Move to Suggest",
"admin.statusUpdated": "Player phase updated",
"admin.deleteTitle": "Delete account?",
"admin.deleteBody": "Delete player \"{name}\" and all their games and votes? This cannot be undone.",
"admin.deleteConfirm": "Delete",
@@ -263,10 +273,18 @@
"admin.title": "Admin",
"admin.tools": "Admin-Werkzeuge",
"admin.resultsOpenToggle": "Ergebnisse freigeben",
"admin.resultsOpenEnable": "Ergebnisse freigeben",
"admin.resultsOpenDisable": "Ergebnisse sperren",
"admin.resultsLocked": "Ergebnisse vom Admin gesperrt",
"admin.resultsUpdated": "Ergebnisfreigabe aktualisiert",
"admin.reset": "Zurücksetzen (Spieler behalten)",
"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.factoryResetDone": "Werkseinstellung abgeschlossen",
"admin.readyForResults": "Bereit für Ergebnisse",
@@ -281,6 +299,8 @@
"admin.statusSuggesting": "Vorschlagen",
"admin.statusVoting": "Bewerten",
"admin.statusFinished": "Fertig",
"admin.statusMoveToSuggest": "Zur Vorschlagsphase",
"admin.statusUpdated": "Spielerphase aktualisiert",
"admin.deleteTitle": "Konto löschen?",
"admin.deleteBody": "Spieler \"{name}\" samt Spielen und Stimmen löschen? Dies kann nicht rückgängig gemacht werden.",
"admin.deleteConfirm": "Löschen",

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>
</div>
<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>
</div>
</div>
@@ -178,10 +179,7 @@
</table>
</div>
</div>
<label class="stack toggle-row">
<input type="checkbox" id="results-open" />
<span data-i18n="admin.resultsOpenToggle">Allow results phase</span>
</label>
<button id="results-open" class="secondary" type="button" data-i18n="admin.resultsOpenEnable">Enable results phase</button>
<div class="stack hidden" id="admin-linker">
<h4 data-i18n="admin.linkTitle">Link games</h4>
<label class="stack">

View File

@@ -15,8 +15,20 @@ function displayPlayerStatus(player) {
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() {
if (!state.me?.isAdmin) return;
if (state.adminStatusSelectActive) return;
const statusBadge = $("admin-ready-status");
const table = $("admin-player-table")?.querySelector("tbody");
if (!state.adminVoteStatus || !statusBadge || !table) return;
@@ -24,14 +36,13 @@ export function renderAdminVoteStatus() {
table.innerHTML = "";
state.adminVoteStatus.voters.forEach((v) => {
const tr = document.createElement("tr");
const statusText = displayPlayerStatus(v);
const gamesTooltip = escapeHtml((v.suggestionTitles || []).join(", "));
const nameText = escapeHtml(truncate(v.name, 28));
const userText = escapeHtml(truncate(v.username, 24));
tr.innerHTML = `
<td title="${escapeHtml(v.name)}">${nameText}</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><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>

View File

@@ -56,10 +56,18 @@ export const api = {
export const adminApi = {
setResultsOpen: (resultsOpen) => request("/api/admin/results", { method: "POST", body: { resultsOpen } }),
voteStatus: () => request("/api/admin/vote-status"),
reset: () => request("/api/admin/reset", { method: "POST" }),
factoryReset: () => request("/api/admin/factory-reset", { method: "POST" }),
reset: (password) =>
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 } }),
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) =>
request("/api/admin/link-suggestions", { method: "POST", body: { sourceSuggestionId, targetSuggestionId } }),
unlinkSuggestions: (suggestionId) =>

View File

@@ -8,14 +8,23 @@ import {
renderPhasePill,
} from "./ui.js";
async function adminAction(fn, successMessage, runSerializedRefresh) {
try {
await fn();
toast(successMessage);
await runSerializedRefresh();
} catch (err) {
toast(err.message, true);
}
function openAdminPasswordModal({ title, body, confirmLabel, onConfirm }) {
openConfirmModal({
title,
body,
confirmLabel,
confirmClass: "danger",
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() {
@@ -32,24 +41,49 @@ function setupAdminPanelToggle() {
}
function setupResetButtons(runSerializedRefresh) {
$("reset").addEventListener("click", () =>
adminAction(adminApi.reset, t("admin.resetDone"), runSerializedRefresh),
);
$("factory-reset").addEventListener("click", () =>
adminAction(
adminApi.factoryReset,
t("admin.factoryResetDone"),
runSerializedRefresh,
),
);
$("reset").addEventListener("click", () => {
openAdminPasswordModal({
title: t("admin.resetConfirmTitle"),
body: t("admin.resetConfirmBody"),
confirmLabel: t("admin.reset"),
onConfirm: async (password, close) => {
try {
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) {
const resultsToggle = $("results-open");
if (!resultsToggle) return;
resultsToggle.addEventListener("change", async (e) => {
const desired = !!e.target.checked;
resultsToggle.addEventListener("click", async () => {
const desired = !state.resultsOpen;
resultsToggle.disabled = true;
try {
const resp = await adminApi.setResultsOpen(desired);
const wasResultsOpen = state.resultsOpen;
@@ -62,8 +96,9 @@ function setupResultsToggle(runSerializedRefresh) {
toast(t("admin.resultsUpdated"));
await runSerializedRefresh();
} catch (err) {
e.target.checked = !desired;
toast(err.message, true);
} finally {
resultsToggle.disabled = false;
}
});
}
@@ -91,6 +126,44 @@ function setupLinkApply(runSerializedRefresh) {
function setupPlayerTableActions(runSerializedRefresh) {
const playerTable = $("admin-player-table");
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) => {
const grantBtn = e.target.closest("[data-grant-joker]");
@@ -107,13 +180,13 @@ function setupPlayerTableActions(runSerializedRefresh) {
} else if (deleteBtn) {
const playerId = deleteBtn.dataset.deletePlayer;
const name = deleteBtn.dataset.name || "";
openConfirmModal({
openAdminPasswordModal({
title: t("admin.deleteTitle"),
body: t("admin.deleteBody", { name }),
confirmLabel: t("admin.deleteConfirm"),
onConfirm: async (close) => {
onConfirm: async (password, close) => {
try {
await adminApi.deletePlayer(playerId);
await adminApi.deletePlayer(playerId, password);
toast(t("admin.deleteDone"));
close();
await runSerializedRefresh();

View File

@@ -141,22 +141,17 @@ function setupLogoutHandler() {
logoutBtn.addEventListener("click", async (e) => {
e.preventDefault();
const lastUser = state.me?.username;
try {
await api.logout();
} catch (err) {
toast(err.message, true);
}
document.querySelectorAll(".auth-form").forEach((form) => form.reset());
setAuthMode("login");
setSavedUsername("");
clearUserState();
state.isAuthenticated = 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;
state.mySuggestions = await api.mySuggestions();
renderMySuggestions();
updatePhaseNav();
}
export async function loadSuggestionsData() {
@@ -34,7 +35,12 @@ export async function loadSuggestionsData() {
const latest = await api.allSuggestions();
const latestSig = signatureSuggestions(latest);
const changed = latestSig !== state.allSuggestionsSig;
if (changed && state.phase === "Vote" && state.allSuggestionsSig) {
if (
changed &&
state.phase === "Vote" &&
state.votesRendered &&
state.allSuggestionsSig
) {
const added = latest
.filter((s) => !prevById[s.id])
.map((s) => s.name);

View File

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

View File

@@ -15,6 +15,7 @@ export const state = {
results: [],
votesRendered: false,
adminVoteStatus: null,
adminStatusSelectActive: false,
};
export function clearUserState() {
@@ -27,9 +28,11 @@ export function clearUserState() {
state.counts = null;
state.mySuggestions = [];
state.allSuggestions = [];
state.allSuggestionsSig = null;
state.myVotes = [];
state.results = [];
state.votesRendered = false;
state.adminStatusSelectActive = false;
const adminCard = document.getElementById("admin-card");
if (adminCard) adminCard.classList.add("hidden");
}

View File

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