Implement admin back-pass flow and guarded admin actions
This commit is contained in:
10
API.md
10
API.md
@@ -14,7 +14,7 @@ GET /api/me — id, displayName, username, isAdmin, currentPhase, votesFinal
|
|||||||
|
|
||||||
## Player (requires auth)
|
## Player (requires auth)
|
||||||
POST /api/me/phase/next — advance caller to next phase (Suggest→Vote requires at least one own suggestion; Vote→Results is gated by resultsOpen)
|
POST /api/me/phase/next — advance caller to next phase (Suggest→Vote requires at least one own suggestion; Vote→Results is gated by resultsOpen)
|
||||||
POST /api/me/phase/prev — admin-only move caller backward (Results→Vote→Suggest)
|
POST /api/me/phase/prev — move caller backward (admin: Results→Vote→Suggest, player: Vote→Suggest only when granted a one-time back pass)
|
||||||
|
|
||||||
## Suggestions (requires auth + phase gating)
|
## Suggestions (requires auth + phase gating)
|
||||||
GET /api/suggestions/mine — own suggestions (Suggest phase)
|
GET /api/suggestions/mine — own suggestions (Suggest phase)
|
||||||
@@ -32,9 +32,11 @@ POST /api/votes/finalize — `{ final: bool }` toggles caller’s finalized stat
|
|||||||
GET /api/results — leaderboard with totals, counts, averages, caller’s vote, media/links, link metadata
|
GET /api/results — leaderboard with totals, counts, averages, caller’s vote, media/links, link metadata
|
||||||
|
|
||||||
## Admin (requires authenticated admin user)
|
## Admin (requires authenticated admin user)
|
||||||
POST /api/admin/results — `{ resultsOpen: bool }` locks/unlocks results and aligns player phases
|
POST /api/admin/results — `{ resultsOpen: bool }` locks/unlocks results and aligns player phases (closing results moves players with suggestions to `Vote`, players without suggestions to `Suggest`)
|
||||||
GET /api/admin/vote-status — readiness overview (who finalized)
|
GET /api/admin/vote-status — readiness overview (who finalized)
|
||||||
|
POST /api/admin/players/{playerId}/phase — `{ phase: "Suggest" }`; move a player from `Vote` back to `Suggest`
|
||||||
|
DELETE /api/admin/players/{playerId} — `{ adminPassword: string }`; delete a player and all related data
|
||||||
POST /api/admin/link-suggestions — `{ sourceSuggestionId, targetSuggestionId }`; merges vote groups during Vote, clears votes in the linked group, unfinalizes **all** players
|
POST /api/admin/link-suggestions — `{ sourceSuggestionId, targetSuggestionId }`; merges vote groups during Vote, clears votes in the linked group, unfinalizes **all** players
|
||||||
POST /api/admin/unlink-suggestions — `{ suggestionId }`; breaks links, clears votes for that group, unfinalizes **all** players
|
POST /api/admin/unlink-suggestions — `{ suggestionId }`; breaks links, clears votes for that group, unfinalizes **all** players
|
||||||
POST /api/admin/reset — clear suggestions/votes; keep players; reset phases/vote-final flags
|
POST /api/admin/reset — `{ adminPassword: string }`; 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 — `{ adminPassword: string }`; wipe players, suggestions, votes, state
|
||||||
|
|||||||
@@ -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 AdminPasswordRequest(string AdminPassword);
|
||||||
|
|
||||||
|
public record SetPlayerPhaseRequest(Phase Phase);
|
||||||
|
|||||||
@@ -17,7 +17,23 @@ 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("/players/{playerId:guid}/phase", async (Guid playerId, [FromBody] SetPlayerPhaseRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
|
||||||
|
{
|
||||||
|
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||||
|
if (player is null)
|
||||||
|
return EndpointHelpers.UnauthorizedError();
|
||||||
|
|
||||||
|
return await service.SetPlayerPhaseAsync(playerId, request.Phase);
|
||||||
|
});
|
||||||
|
|
||||||
|
admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, [FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
|
||||||
|
{
|
||||||
|
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||||
|
if (player is null)
|
||||||
|
return EndpointHelpers.UnauthorizedError();
|
||||||
|
|
||||||
|
return await service.DeletePlayerAsync(playerId, player.Id, request.AdminPassword);
|
||||||
|
});
|
||||||
|
|
||||||
admin.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 +53,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.AdminPassword);
|
||||||
|
});
|
||||||
|
|
||||||
|
admin.MapPost("/factory-reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
|
||||||
|
{
|
||||||
|
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||||
|
if (player is null)
|
||||||
|
return EndpointHelpers.UnauthorizedError();
|
||||||
|
|
||||||
|
return await service.FactoryResetAsync(player.Id, request.AdminPassword);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,21 @@ 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));
|
var playersWithSuggestions = await db.Suggestions.Select(s => s.PlayerId).Distinct().ToListAsync();
|
||||||
|
if (playersWithSuggestions.Count == 0)
|
||||||
|
{
|
||||||
|
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest).SetProperty(x => x.VotesFinal, false));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await db.Players
|
||||||
|
.Where(p => playersWithSuggestions.Contains(p.Id))
|
||||||
|
.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote).SetProperty(x => x.VotesFinal, false));
|
||||||
|
|
||||||
|
await db.Players
|
||||||
|
.Where(p => !playersWithSuggestions.Contains(p.Id))
|
||||||
|
.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest).SetProperty(x => x.VotesFinal, false));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
@@ -39,11 +54,32 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
|||||||
.Select(p => new VoteStatusDto(p.Id, p.DisplayName ?? p.Username, p.Username, p.CurrentPhase, p.VotesFinal, p.HasJoker, p.Suggestions.Count, p.Suggestions.Select(s => s.Name).ToList()))
|
.Select(p => new VoteStatusDto(p.Id, p.DisplayName ?? p.Username, p.Username, p.CurrentPhase, p.VotesFinal, p.HasJoker, p.Suggestions.Count, p.Suggestions.Select(s => s.Name).ToList()))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var waiting = voters.Where(v => !v.Finalized).Select(v => v.Name).ToList();
|
var waiting = voters.Where(v => v.Phase == Phase.Vote && !v.Finalized).Select(v => v.Name).ToList();
|
||||||
var ready = waiting.Count == 0;
|
var ready = waiting.Count == 0;
|
||||||
return Results.Ok(new VoteStatusResponse(voters, ready, waiting));
|
return Results.Ok(new VoteStatusResponse(voters, ready, waiting));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IResult> SetPlayerPhaseAsync(Guid playerId, Phase phase)
|
||||||
|
{
|
||||||
|
if (phase != Phase.Suggest)
|
||||||
|
return EndpointHelpers.BadRequestError("Players can only be moved back to the Suggest phase.");
|
||||||
|
|
||||||
|
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
|
||||||
|
if (player is null)
|
||||||
|
return EndpointHelpers.NotFoundError("Player not found.");
|
||||||
|
|
||||||
|
var current = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
|
||||||
|
if (current != Phase.Vote)
|
||||||
|
return EndpointHelpers.BadRequestError("Player must currently be in the Vote phase to move back.");
|
||||||
|
|
||||||
|
player.CurrentPhase = Phase.Suggest;
|
||||||
|
player.VotesFinal = false;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var state = await db.AppState.AsNoTracking().SingleAsync();
|
||||||
|
return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, state.ResultsOpen));
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IResult> GrantJokerAsync(Guid playerId)
|
public async Task<IResult> GrantJokerAsync(Guid playerId)
|
||||||
{
|
{
|
||||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
|
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
|
||||||
@@ -61,8 +97,12 @@ 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> DeletePlayerAsync(Guid playerId, Guid adminPlayerId, string? adminPassword)
|
||||||
{
|
{
|
||||||
|
var passwordCheck = await ValidateAdminPasswordAsync(adminPlayerId, adminPassword);
|
||||||
|
if (!passwordCheck.IsValid)
|
||||||
|
return passwordCheck.Error!;
|
||||||
|
|
||||||
var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId);
|
var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId);
|
||||||
if (player is null)
|
if (player is null)
|
||||||
return EndpointHelpers.NotFoundError("Player not found.");
|
return EndpointHelpers.NotFoundError("Player not found.");
|
||||||
@@ -178,8 +218,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? adminPassword)
|
||||||
{
|
{
|
||||||
|
var passwordCheck = await ValidateAdminPasswordAsync(adminPlayerId, adminPassword);
|
||||||
|
if (!passwordCheck.IsValid)
|
||||||
|
return passwordCheck.Error!;
|
||||||
|
|
||||||
await using var tx = await db.Database.BeginTransactionAsync();
|
await using var tx = await db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
await db.Votes.ExecuteDeleteAsync();
|
await db.Votes.ExecuteDeleteAsync();
|
||||||
@@ -195,8 +239,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? adminPassword)
|
||||||
{
|
{
|
||||||
|
var passwordCheck = await ValidateAdminPasswordAsync(adminPlayerId, adminPassword);
|
||||||
|
if (!passwordCheck.IsValid)
|
||||||
|
return passwordCheck.Error!;
|
||||||
|
|
||||||
await using var tx = await db.Database.BeginTransactionAsync();
|
await using var tx = await db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
await db.Votes.ExecuteDeleteAsync();
|
await db.Votes.ExecuteDeleteAsync();
|
||||||
@@ -212,4 +260,19 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
|||||||
|
|
||||||
return Results.Ok(new AdminResetStateResponse(Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt));
|
return Results.Ok(new AdminResetStateResponse(Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<(bool IsValid, IResult? Error)> ValidateAdminPasswordAsync(Guid adminPlayerId, string? adminPassword)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(adminPassword))
|
||||||
|
return (false, EndpointHelpers.BadRequestError("Admin password is required."));
|
||||||
|
|
||||||
|
var admin = await db.Players.AsNoTracking().FirstOrDefaultAsync(p => p.Id == adminPlayerId);
|
||||||
|
if (admin is null)
|
||||||
|
return (false, EndpointHelpers.UnauthorizedError());
|
||||||
|
|
||||||
|
if (!PasswordHasher.Verify(adminPassword, admin.PasswordHash, admin.PasswordSalt))
|
||||||
|
return (false, EndpointHelpers.UnauthorizedError("Invalid admin password."));
|
||||||
|
|
||||||
|
return (true, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,15 +72,32 @@ internal sealed class StateWorkflowService(AppDbContext db)
|
|||||||
|
|
||||||
public async Task<IResult> PrevPhaseAsync(Player player)
|
public async Task<IResult> PrevPhaseAsync(Player player)
|
||||||
{
|
{
|
||||||
|
var appState = await db.AppState.SingleAsync();
|
||||||
|
var shouldSave = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen);
|
||||||
|
|
||||||
if (!player.IsAdmin)
|
if (!player.IsAdmin)
|
||||||
|
{
|
||||||
|
if (player.CurrentPhase != Phase.Vote)
|
||||||
|
return EndpointHelpers.BadRequestError("You can only move back from the Vote phase.");
|
||||||
|
|
||||||
|
if (!player.HasJoker)
|
||||||
return EndpointHelpers.BadRequestError("Only admins can move backward.");
|
return EndpointHelpers.BadRequestError("Only admins can move backward.");
|
||||||
|
|
||||||
var appState = await db.AppState.SingleAsync();
|
player.CurrentPhase = Phase.Suggest;
|
||||||
_ = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen);
|
player.VotesFinal = false;
|
||||||
|
player.HasJoker = false;
|
||||||
|
shouldSave = true;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
|
||||||
|
}
|
||||||
|
|
||||||
player.CurrentPhase = PrevPhase(player.CurrentPhase);
|
player.CurrentPhase = PrevPhase(player.CurrentPhase);
|
||||||
player.VotesFinal = false;
|
player.VotesFinal = false;
|
||||||
|
shouldSave = true;
|
||||||
|
|
||||||
|
if (shouldSave)
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
|
return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,13 @@ 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
|
||||||
|
{
|
||||||
|
AdminPassword = "Pass123!"
|
||||||
|
})
|
||||||
|
});
|
||||||
resp.EnsureSuccessStatusCode();
|
resp.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
await factory.WithDbContextAsync(db =>
|
await factory.WithDbContextAsync(db =>
|
||||||
@@ -189,7 +195,10 @@ public class AdminTests
|
|||||||
await player.RegisterAsync("player");
|
await player.RegisterAsync("player");
|
||||||
await player.CreateSuggestionAsync("Keep");
|
await player.CreateSuggestionAsync("Keep");
|
||||||
|
|
||||||
var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { });
|
var reset = await admin.PostAsJsonAsync("/api/admin/reset", new
|
||||||
|
{
|
||||||
|
AdminPassword = "Pass123!"
|
||||||
|
});
|
||||||
reset.EnsureSuccessStatusCode();
|
reset.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
await factory.WithDbContextAsync(db =>
|
await factory.WithDbContextAsync(db =>
|
||||||
@@ -209,7 +218,10 @@ public class AdminTests
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { });
|
var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new
|
||||||
|
{
|
||||||
|
AdminPassword = "Pass123!"
|
||||||
|
});
|
||||||
factoryReset.EnsureSuccessStatusCode();
|
factoryReset.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
await factory.WithDbContextAsync(db =>
|
await factory.WithDbContextAsync(db =>
|
||||||
@@ -229,21 +241,26 @@ public class AdminTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Admin_results_closing_moves_back_to_vote_and_clears_finalize()
|
public async Task Admin_results_closing_moves_only_players_with_suggestions_back_to_vote()
|
||||||
{
|
{
|
||||||
await using var factory = new TestWebApplicationFactory();
|
await using var factory = new TestWebApplicationFactory();
|
||||||
var admin = factory.CreateClientWithCookies();
|
var admin = factory.CreateClientWithCookies();
|
||||||
await admin.RegisterAsync("admin", admin: true);
|
await admin.RegisterAsync("admin", admin: true);
|
||||||
var player = factory.CreateClientWithCookies();
|
var player = factory.CreateClientWithCookies();
|
||||||
await player.RegisterAsync("player");
|
await player.RegisterAsync("player");
|
||||||
|
var fresh = factory.CreateClientWithCookies();
|
||||||
|
await fresh.RegisterAsync("fresh");
|
||||||
|
await player.CreateSuggestionAsync("Player game");
|
||||||
|
|
||||||
var open = await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true });
|
var open = await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true });
|
||||||
open.EnsureSuccessStatusCode();
|
open.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
await factory.WithDbContextAsync(async db =>
|
await factory.WithDbContextAsync(async db =>
|
||||||
{
|
{
|
||||||
var p = await db.Players.FirstAsync(x => !x.IsAdmin);
|
var p = await db.Players.SingleAsync(x => x.Username == "player");
|
||||||
|
var freshPlayer = await db.Players.SingleAsync(x => x.Username == "fresh");
|
||||||
p.VotesFinal = true;
|
p.VotesFinal = true;
|
||||||
|
freshPlayer.VotesFinal = true;
|
||||||
var state = await db.AppState.SingleAsync();
|
var state = await db.AppState.SingleAsync();
|
||||||
state.UpdatedAt = DateTimeOffset.UnixEpoch;
|
state.UpdatedAt = DateTimeOffset.UnixEpoch;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
@@ -254,9 +271,12 @@ public class AdminTests
|
|||||||
|
|
||||||
await factory.WithDbContextAsync(async db =>
|
await factory.WithDbContextAsync(async db =>
|
||||||
{
|
{
|
||||||
var p = await db.Players.FirstAsync(x => !x.IsAdmin);
|
var p = await db.Players.SingleAsync(x => x.Username == "player");
|
||||||
|
var freshPlayer = await db.Players.SingleAsync(x => x.Username == "fresh");
|
||||||
Assert.Equal(Phase.Vote, p.CurrentPhase);
|
Assert.Equal(Phase.Vote, p.CurrentPhase);
|
||||||
Assert.False(p.VotesFinal);
|
Assert.False(p.VotesFinal);
|
||||||
|
Assert.Equal(Phase.Suggest, freshPlayer.CurrentPhase);
|
||||||
|
Assert.False(freshPlayer.VotesFinal);
|
||||||
var state = await db.AppState.AsNoTracking().SingleAsync();
|
var state = await db.AppState.AsNoTracking().SingleAsync();
|
||||||
Assert.False(state.ResultsOpen);
|
Assert.False(state.ResultsOpen);
|
||||||
Assert.True(state.UpdatedAt > DateTimeOffset.UnixEpoch);
|
Assert.True(state.UpdatedAt > DateTimeOffset.UnixEpoch);
|
||||||
@@ -425,7 +445,10 @@ public class AdminTests
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
});
|
});
|
||||||
|
|
||||||
var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { });
|
var reset = await admin.PostAsJsonAsync("/api/admin/reset", new
|
||||||
|
{
|
||||||
|
AdminPassword = "Pass123!"
|
||||||
|
});
|
||||||
reset.EnsureSuccessStatusCode();
|
reset.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
await factory.WithDbContextAsync(async db =>
|
await factory.WithDbContextAsync(async db =>
|
||||||
@@ -437,7 +460,10 @@ public class AdminTests
|
|||||||
Assert.False(state.ResultsOpen);
|
Assert.False(state.ResultsOpen);
|
||||||
});
|
});
|
||||||
|
|
||||||
var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { });
|
var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new
|
||||||
|
{
|
||||||
|
AdminPassword = "Pass123!"
|
||||||
|
});
|
||||||
factoryReset.EnsureSuccessStatusCode();
|
factoryReset.EnsureSuccessStatusCode();
|
||||||
await factory.WithDbContextAsync(async db =>
|
await factory.WithDbContextAsync(async db =>
|
||||||
{
|
{
|
||||||
@@ -445,4 +471,56 @@ public class AdminTests
|
|||||||
Assert.False(state.ResultsOpen);
|
Assert.False(state.ResultsOpen);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Admin_destructive_actions_require_valid_admin_password()
|
||||||
|
{
|
||||||
|
await using var factory = new TestWebApplicationFactory();
|
||||||
|
var admin = factory.CreateClientWithCookies();
|
||||||
|
await admin.RegisterAsync("admin", admin: true);
|
||||||
|
var player = factory.CreateClientWithCookies();
|
||||||
|
await player.RegisterAsync("victim");
|
||||||
|
|
||||||
|
var delete = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{await player.GetProfileIdAsync()}")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new
|
||||||
|
{
|
||||||
|
AdminPassword = "wrong"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, delete.StatusCode);
|
||||||
|
|
||||||
|
var reset = await admin.PostAsJsonAsync("/api/admin/reset", new
|
||||||
|
{
|
||||||
|
AdminPassword = "wrong"
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, reset.StatusCode);
|
||||||
|
|
||||||
|
var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new
|
||||||
|
{
|
||||||
|
AdminPassword = "wrong"
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, factoryReset.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Admin_can_move_voter_back_to_suggest_via_phase_endpoint()
|
||||||
|
{
|
||||||
|
await using var factory = new TestWebApplicationFactory();
|
||||||
|
var admin = factory.CreateClientWithCookies();
|
||||||
|
await admin.RegisterAsync("admin", admin: true);
|
||||||
|
|
||||||
|
var player = factory.CreateClientWithCookies();
|
||||||
|
await player.RegisterAsync("moveme");
|
||||||
|
await player.AdvanceToVoteAsync("Move seed");
|
||||||
|
|
||||||
|
var move = await admin.PostAsJsonAsync($"/api/admin/players/{await player.GetProfileIdAsync()}/phase", new
|
||||||
|
{
|
||||||
|
Phase = "Suggest"
|
||||||
|
});
|
||||||
|
move.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var me = await player.GetFromJsonAsync<JsonElement>("/api/me");
|
||||||
|
Assert.Equal(nameof(Phase.Suggest), me.GetProperty("currentPhase").GetString());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,6 +224,32 @@ public class StateTests
|
|||||||
Assert.Equal(nameof(Phase.Suggest), me.GetProperty("currentPhase").GetString());
|
Assert.Equal(nameof(Phase.Suggest), me.GetProperty("currentPhase").GetString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Phase_prev_with_granted_joker_moves_player_back_once_and_consumes_it()
|
||||||
|
{
|
||||||
|
await using var factory = new TestWebApplicationFactory();
|
||||||
|
var admin = factory.CreateClientWithCookies();
|
||||||
|
await admin.RegisterAsync("admin", admin: true);
|
||||||
|
|
||||||
|
var player = factory.CreateClientWithCookies();
|
||||||
|
await player.RegisterAsync("jokerback");
|
||||||
|
await player.AdvanceToVoteAsync("Joker back seed");
|
||||||
|
|
||||||
|
var grant = await admin.PostAsJsonAsync("/api/admin/joker", new { playerId = await player.GetProfileIdAsync() });
|
||||||
|
grant.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var back = await player.PostAsJsonAsync("/api/me/phase/prev", new { });
|
||||||
|
back.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var meAfterBack = await player.GetFromJsonAsync<JsonElement>("/api/me");
|
||||||
|
Assert.Equal(nameof(Phase.Suggest), meAfterBack.GetProperty("currentPhase").GetString());
|
||||||
|
Assert.False(meAfterBack.GetProperty("hasJoker").GetBoolean());
|
||||||
|
|
||||||
|
await player.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||||
|
var denied = await player.PostAsJsonAsync("/api/me/phase/prev", new { });
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, denied.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task State_endpoint_requires_auth_and_counts()
|
public async Task State_endpoint_requires_auth_and_counts()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
|
|||||||
- Authentication: username/password with HttpOnly `player` cookie.
|
- Authentication: username/password with HttpOnly `player` cookie.
|
||||||
- Admin authorization: authenticated account with `IsAdmin=true`.
|
- Admin authorization: authenticated account with `IsAdmin=true`.
|
||||||
- Gameplay phases: `Suggest`, `Vote`, `Results`.
|
- Gameplay phases: `Suggest`, `Vote`, `Results`.
|
||||||
|
- Backward movement: admins can move backward; players can move `Vote -> Suggest` only when granted a one-time back pass.
|
||||||
- Storage: SQLite database under `App_Data/gamelist.db`.
|
- Storage: SQLite database under `App_Data/gamelist.db`.
|
||||||
|
- Sensitive admin actions (`reset`, `factory-reset`, player deletion) require admin password confirmation.
|
||||||
|
|
||||||
## Module Ownership
|
## Module Ownership
|
||||||
|
|
||||||
|
|||||||
3
SPEC.md
3
SPEC.md
@@ -11,6 +11,7 @@ Help a small Discord group (4–8 players) pick a co-op game via phased flow:
|
|||||||
- Username/password login (cookie auth)
|
- Username/password login (cookie auth)
|
||||||
- Admins flagged via admin key at registration
|
- Admins flagged via admin key at registration
|
||||||
- Per-user phase tracking; admins can move themselves backward, everyone can move forward (subject to admin “results open” toggle and Suggest→Vote requiring at least one own suggestion)
|
- Per-user phase tracking; admins can move themselves backward, everyone can move forward (subject to admin “results open” toggle and Suggest→Vote requiring at least one own suggestion)
|
||||||
|
- Admins can grant a one-time back pass so a voter can move from Vote back to Suggest once
|
||||||
|
|
||||||
## Suggest Phase
|
## Suggest Phase
|
||||||
- Up to **5 suggestions** per player
|
- Up to **5 suggestions** per player
|
||||||
@@ -23,11 +24,13 @@ Help a small Discord group (4–8 players) pick a co-op game via phased flow:
|
|||||||
- All suggestions visible with authors
|
- All suggestions visible with authors
|
||||||
- Score each suggestion 0–10
|
- Score each suggestion 0–10
|
||||||
- Players see only their own votes; can finalize/unfinalize their ballot
|
- Players see only their own votes; can finalize/unfinalize their ballot
|
||||||
|
- A player with a granted back pass can move from Vote back to Suggest exactly once (consumed on use)
|
||||||
- **Linked games**: admins can link duplicates; linked games share a vote group. Moving a slider on one updates all linked siblings.
|
- **Linked games**: admins can link duplicates; linked games share a vote group. Moving a slider on one updates all linked siblings.
|
||||||
- Linking or unlinking games clears votes for the linked group and unfinalizes **all** players so ballots can be reviewed again
|
- Linking or unlinking games clears votes for the linked group and unfinalizes **all** players so ballots can be reviewed again
|
||||||
|
|
||||||
## Results Phase
|
## Results Phase
|
||||||
- Visible only after admin enables results; players auto-advance when opened
|
- Visible only after admin enables results; players auto-advance when opened
|
||||||
|
- When results are closed again, only players with one or more suggestions return to Vote; players without suggestions return to Suggest
|
||||||
- Leaderboard sorted by average score; shows totals, counts, player’s own vote, and links/media
|
- Leaderboard sorted by average score; shows totals, counts, player’s own vote, and links/media
|
||||||
|
|
||||||
## Non-functional
|
## Non-functional
|
||||||
|
|||||||
15
TESTS.md
15
TESTS.md
@@ -42,13 +42,13 @@ stateDiagram-v2
|
|||||||
- /api/state returns player-specific phase, votesFinal, hasJoker, counts; unauthorized returns 401.
|
- /api/state returns player-specific phase, votesFinal, hasJoker, counts; unauthorized returns 401.
|
||||||
- GetPhase auto-upgrades legacy Reveal -> Vote and realigns when resultsOpen toggles (to Results and back to Vote clearing votesFinal).
|
- GetPhase auto-upgrades legacy Reveal -> Vote and realigns when resultsOpen toggles (to Results and back to Vote clearing votesFinal).
|
||||||
- /me/phase/next: moves Suggest->Vote, Vote->Results only when resultsOpen true; clears votesFinal; rejects when results locked.
|
- /me/phase/next: moves Suggest->Vote, Vote->Results only when resultsOpen true; clears votesFinal; rejects when results locked.
|
||||||
- /me/phase/prev: admin only; moves back one step, clears votesFinal, rejects for player.
|
- /me/phase/prev: admin moves back one step; non-admin can move Vote->Suggest only with granted back pass; move clears votesFinal and consumes pass.
|
||||||
- Display name is immutable after registration; attempts to change via /api/me/name return 404.
|
- Display name is immutable after registration; attempts to change via /api/me/name return 404.
|
||||||
|
|
||||||
### 3) Suggestions
|
### 3) Suggestions
|
||||||
- GET /mine returns only caller’s suggestions ordered by CreatedAt.
|
- GET /mine returns only caller’s suggestions ordered by CreatedAt.
|
||||||
- POST /: success with valid data; enforces ≤5 per player; trims optional fields; requires display name; rejects bad image URL/ext, unreachable image (mocked), invalid game/youtube URLs, invalid player counts, missing name/too long.
|
- POST /: success with valid data; enforces ≤5 per player; trims optional fields; requires display name; rejects bad image URL/ext, unreachable image (mocked), invalid game/youtube URLs, invalid player counts, missing name/too long.
|
||||||
- Joker path: when phase=Vote and HasJoker=true allows creation, consumes joker, resets VotesFinal for all players.
|
- Back-pass path: admin grants pass in Vote, player can consume it to move Vote->Suggest once; consumable and clears finalized state.
|
||||||
- Phase gating: non-admin cannot create/update/delete outside Suggest (except joker create); admin bypasses phase checks for update/delete.
|
- Phase gating: non-admin cannot create/update/delete outside Suggest (except joker create); admin bypasses phase checks for update/delete.
|
||||||
- PUT /{id}: player can edit own in Suggest; name locked outside Suggest; admin can edit any time; validation mirrors create.
|
- PUT /{id}: player can edit own in Suggest; name locked outside Suggest; admin can edit any time; validation mirrors create.
|
||||||
- DELETE /{id}: player deletes own in Suggest; admin any time; also breaks child links and deletes related votes.
|
- DELETE /{id}: player deletes own in Suggest; admin any time; also breaks child links and deletes related votes.
|
||||||
@@ -65,14 +65,15 @@ stateDiagram-v2
|
|||||||
- Phase mismatch and locked results return 400; unauthorized 401.
|
- Phase mismatch and locked results return 400; unauthorized 401.
|
||||||
|
|
||||||
### 6) Admin Operations
|
### 6) Admin Operations
|
||||||
- POST /admin/results toggles resultsOpen and aligns all player phases (to Results or back to Vote clearing votesFinal); updates UpdatedAt.
|
- POST /admin/results toggles resultsOpen and aligns all player phases (to Results, or back to Vote only for players with suggestions and Suggest otherwise); updates UpdatedAt.
|
||||||
- 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 one-time back pass 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/players/{id}/phase allows admin to move a player from Vote back to Suggest.
|
||||||
|
- DELETE /admin/players/{id}: requires admin password; removes player, cascades suggestions, breaks links to their suggestions, deletes related votes, wrapped in transaction.
|
||||||
- POST /admin/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 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 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).
|
||||||
|
|||||||
@@ -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.adminStatusMenuOpen) {
|
||||||
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.adminStatusMenuOpen) {
|
||||||
refreshWithUiErrorHandling();
|
refreshWithUiErrorHandling();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,6 +30,11 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
}
|
}
|
||||||
|
.admin-status-select {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 140px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-panel {
|
.admin-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ Wenn ein Admin doppelte Spiele verknüpft:
|
|||||||
Mit **„Finalisieren"** werden deine Bewertungen gesperrt. Deaktiviere es, um erneut zu bearbeiten.
|
Mit **„Finalisieren"** werden deine Bewertungen gesperrt. Deaktiviere es, um erneut zu bearbeiten.
|
||||||
|
|
||||||
„Finalisieren" ist nur während der Abstimmungsphase verfügbar und wird automatisch zurückgesetzt, wenn:
|
„Finalisieren" ist nur während der Abstimmungsphase verfügbar und wird automatisch zurückgesetzt, wenn:
|
||||||
- Ein Joker ein neues Spiel hinzufügt
|
- Du mit einem Zurück-Pass zurück in die Vorschlagsphase wechselst
|
||||||
- Ein Admin Spiele verknüpft oder trennt
|
- Ein Admin Spiele verknüpft oder trennt
|
||||||
|
|
||||||
### Abstimmen nach Änderungen
|
### Abstimmen nach Änderungen
|
||||||
@@ -119,26 +119,26 @@ Wenn neue Spiele hinzugefügt oder Verknüpfungen geändert werden:
|
|||||||
|
|
||||||
Überprüfe deine Liste und bewerte erneut, bevor du wieder finalisierst.
|
Überprüfe deine Liste und bewerte erneut, bevor du wieder finalisierst.
|
||||||
|
|
||||||
## Joker (Späte Ergänzungen)
|
## Zurück-Pass (Einmalige Rückkehr)
|
||||||
|
|
||||||
### Was ist ein Joker?
|
### Was ist ein Zurück-Pass?
|
||||||
|
|
||||||
Ein **Joker** ist ein einmaliger zusätzlicher Vorschlags-Slot, der nur während der **Abstimmungsphase** verfügbar ist. Ein Admin muss ihn dir gewähren.
|
Ein **Zurück-Pass** ist eine einmalige Berechtigung, mit der du von der **Abstimmungsphase** zurück in die **Vorschlagsphase** wechseln kannst. Ein Admin muss ihn dir während der Abstimmung geben.
|
||||||
|
|
||||||
### So funktioniert es
|
### So funktioniert es
|
||||||
|
|
||||||
Wenn du einen Joker erhältst:
|
Wenn du einen Zurück-Pass erhältst:
|
||||||
- Erscheint ein Button in der oberen Leiste, mit dem du ein weiteres Spiel hinzufügen kannst
|
- Erscheint ein **Zurück**-Button in der Abstimmungsphase für dein Konto
|
||||||
- Nach der Nutzung wird der Joker sofort verbraucht
|
- Bei Nutzung wechselst du einmal in die Vorschlagsphase zurück und der Pass wird verbraucht
|
||||||
- Die Finalisierung aller Abstimmungen werden automatisch zurückgesetzt, damit das neue Spiel bewertet werden kann
|
- Deine Finalisierung wird beim Zurückwechseln aufgehoben
|
||||||
|
|
||||||
Admins können bei Bedarf zusätzliche Joker vergeben.
|
Admins können bei Bedarf später einen weiteren Pass vergeben.
|
||||||
|
|
||||||
## Ergebnisse
|
## Ergebnisse
|
||||||
|
|
||||||
### Wann sind die Ergebnisse sichtbar?
|
### Wann sind die Ergebnisse sichtbar?
|
||||||
|
|
||||||
Die Ergebnisse bleiben verborgen, bis ein Admin sie freigibt. Danach werden alle Spieler automatisch in die **Ergebnisphase** verschoben. Falls nötig, kann ein Admin die Ergebnisse wieder schließen: 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: Spieler mit mindestens einem Vorschlag kehren in die Abstimmungsphase zurück, Spieler ohne Vorschlag in die Vorschlagsphase, und Finalisierungen werden zurückgesetzt.
|
||||||
|
|
||||||
### Kann ich in der Ergebnisphase etwas bearbeiten?
|
### Kann ich in der Ergebnisphase etwas bearbeiten?
|
||||||
|
|
||||||
@@ -148,13 +148,15 @@ 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
|
- Zurück-Pässe während der Abstimmung vergeben
|
||||||
- Doppelte Vorschläge verknüpfen oder trennen
|
- Doppelte Vorschläge verknüpfen oder trennen
|
||||||
- Vorschläge löschen
|
- Vorschläge löschen
|
||||||
- Abstimmungsstatus einsehen (wer finalisiert hat)
|
- Abstimmungsstatus einsehen (wer finalisiert hat)
|
||||||
- Einen Spieler löschen (inklusive dessen Vorschläge und Stimmen)
|
- Einen Spieler löschen (inklusive dessen Vorschläge und Stimmen)
|
||||||
|
- Spieler über den Status-Dropdown von Abstimmung zurück auf Vorschlag setzen
|
||||||
- Die Datenbank auf Werkseinstellungen zurücksetzen
|
- Die Datenbank auf Werkseinstellungen zurücksetzen
|
||||||
- Zu vorherigen Phasen zurückkehren
|
- Zu vorherigen Phasen zurückkehren
|
||||||
|
- Reset-/Löschaktionen mit dem eigenen Admin-Passwort bestätigen
|
||||||
|
|
||||||
### Was können Admin-Konten nicht tun?
|
### Was können Admin-Konten nicht tun?
|
||||||
|
|
||||||
@@ -174,7 +176,7 @@ Stelle sicher:
|
|||||||
|
|
||||||
### „Du hast das Limit von 5 Vorschlägen erreicht."
|
### „Du hast das Limit von 5 Vorschlägen erreicht."
|
||||||
|
|
||||||
Warte auf die Abstimmungsphase und bitte bei Bedarf um einen Joker.
|
Bitte einen Admin um einen Zurück-Pass, wenn du wieder in die Vorschlagsphase wechseln und deine Liste anpassen musst.
|
||||||
|
|
||||||
### „Füge mindestens einen Vorschlag hinzu, bevor du in die Abstimmungsphase wechselst."
|
### „Füge mindestens einen Vorschlag hinzu, bevor du in die Abstimmungsphase wechselst."
|
||||||
|
|
||||||
|
|||||||
@@ -82,21 +82,20 @@ Common reasons:
|
|||||||
|
|
||||||
Check the bottom-right corner of the screen for error messages.
|
Check the bottom-right corner of the screen for error messages.
|
||||||
|
|
||||||
## Jokers (Late Additions)
|
## Back Pass (One-Time Return)
|
||||||
|
|
||||||
### What is a joker?
|
### What is a back pass?
|
||||||
|
|
||||||
A **joker** is a one-time extra suggestion slot available only during the **Vote phase**. An admin must grant it to you.
|
A **back pass** is a one-time permission that lets you move from **Vote** back to **Suggest**. An admin must grant it to you during Vote.
|
||||||
|
|
||||||
### How it works
|
### How it works
|
||||||
|
|
||||||
If you receive a joker:
|
If you receive a back pass:
|
||||||
- A button appears in the top bar allowing you to add one more game.
|
- A **Back** button appears in Vote for your account.
|
||||||
- Once used, the joker is consumed immediately.
|
- Using it moves you to Suggest once and consumes the pass.
|
||||||
- Your ballot becomes unfinalized.
|
- Your finalized flag is cleared when you move back.
|
||||||
- All players are unfinalized so the new game can be scored.
|
|
||||||
|
|
||||||
Admins may grant additional jokers if necessary.
|
Admins may grant another pass later if needed.
|
||||||
|
|
||||||
## Voting
|
## Voting
|
||||||
|
|
||||||
@@ -126,7 +125,7 @@ If an admin links duplicate games:
|
|||||||
Toggling **"Finalize"** locks your scores. Toggle it off to edit again.
|
Toggling **"Finalize"** locks your scores. Toggle it off to edit again.
|
||||||
|
|
||||||
Finalize is only available during the Vote phase and will automatically reset if:
|
Finalize is only available during the Vote phase and will automatically reset if:
|
||||||
- A joker adds a new game
|
- You move back to Suggest with a granted back pass
|
||||||
- An admin links or unlinks games
|
- An admin links or unlinks games
|
||||||
|
|
||||||
### Voting after changes
|
### Voting after changes
|
||||||
@@ -142,7 +141,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 suggestion return to Vote, players without suggestions return to Suggest, and finalized ballots are cleared.
|
||||||
|
|
||||||
### Can I edit anything in Results?
|
### Can I edit anything in Results?
|
||||||
|
|
||||||
@@ -152,13 +151,15 @@ No. Suggestions and votes are read-only. Contact an admin for assistance.
|
|||||||
|
|
||||||
### What can admin accounts do?
|
### What can admin accounts do?
|
||||||
|
|
||||||
- Grant jokers during Vote
|
|
||||||
- Link or unlink duplicate suggestions
|
- Link or unlink duplicate suggestions
|
||||||
- Delete suggestions
|
- Delete suggestions
|
||||||
- View vote readiness (who has finalized)
|
- View vote readiness (who has finalized)
|
||||||
- Delete a player (removes their suggestions and votes)
|
- Delete a player (removes their suggestions and votes)
|
||||||
|
- Move players from Vote back to Suggest from the status dropdown
|
||||||
|
- Grant one-time back passes
|
||||||
- Reset the database to factory defaults
|
- Reset the database to factory defaults
|
||||||
- Move backward to previous phases
|
- Move backward to previous phases
|
||||||
|
- Confirm reset/delete actions with their own admin password
|
||||||
|
|
||||||
### What can't admin accounts do?
|
### What can't admin accounts do?
|
||||||
|
|
||||||
@@ -178,7 +179,7 @@ Make sure:
|
|||||||
|
|
||||||
### "You have reached the 5 suggestion limit."
|
### "You have reached the 5 suggestion limit."
|
||||||
|
|
||||||
Wait for the Vote phase and request a joker if needed.
|
Ask an admin to grant a back pass if you need to return to Suggest and adjust your list.
|
||||||
|
|
||||||
### "Add at least one suggestion before entering the Vote phase."
|
### "Add at least one suggestion before entering the Vote phase."
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"counts.format": "Players: {players} • Suggestions: {suggestions} • Votes: {votes}",
|
"counts.format": "Players: {players} • Suggestions: {suggestions} • Votes: {votes}",
|
||||||
"nav.prev": "Back",
|
"nav.prev": "Back",
|
||||||
"nav.next": "Next",
|
"nav.next": "Next",
|
||||||
|
"nav.backToSuggestOnce": "Use pass: back to suggest",
|
||||||
"nav.addSuggestionFirst": "Add a game first",
|
"nav.addSuggestionFirst": "Add a game first",
|
||||||
"nav.waitingForResults": "Waiting…",
|
"nav.waitingForResults": "Waiting…",
|
||||||
"nav.freezeTitle": "Ready to reveal?",
|
"nav.freezeTitle": "Ready to reveal?",
|
||||||
@@ -102,11 +103,16 @@
|
|||||||
"vote.listUpdatedConfirm": "OK",
|
"vote.listUpdatedConfirm": "OK",
|
||||||
"admin.title": "Admin",
|
"admin.title": "Admin",
|
||||||
"admin.tools": "Admin tools",
|
"admin.tools": "Admin tools",
|
||||||
"admin.resultsOpenToggle": "Allow results phase",
|
"admin.resultsOpenButtonEnable": "Allow results phase",
|
||||||
|
"admin.resultsOpenButtonDisable": "Lock results phase",
|
||||||
"admin.resultsLocked": "Results locked by admin",
|
"admin.resultsLocked": "Results locked by admin",
|
||||||
"admin.resultsUpdated": "Results availability updated",
|
"admin.resultsUpdated": "Results availability updated",
|
||||||
"admin.reset": "Reset (keep players)",
|
"admin.reset": "Reset (keep players)",
|
||||||
"admin.factoryReset": "Factory reset",
|
"admin.factoryReset": "Factory reset",
|
||||||
|
"admin.resetConfirmBody": "Enter your admin password to reset all games and votes while keeping player accounts.",
|
||||||
|
"admin.factoryResetConfirmBody": "Enter your admin password to permanently delete all accounts, games, votes, and state.",
|
||||||
|
"admin.passwordLabel": "Admin password",
|
||||||
|
"admin.passwordRequired": "Admin password is required.",
|
||||||
"admin.resetDone": "Reset complete",
|
"admin.resetDone": "Reset complete",
|
||||||
"admin.factoryResetDone": "Factory reset complete",
|
"admin.factoryResetDone": "Factory reset complete",
|
||||||
"admin.readyForResults": "Ready for results",
|
"admin.readyForResults": "Ready for results",
|
||||||
@@ -115,9 +121,10 @@
|
|||||||
"admin.playerUsername": "Username",
|
"admin.playerUsername": "Username",
|
||||||
"admin.playerStatus": "Status",
|
"admin.playerStatus": "Status",
|
||||||
"admin.playerGames": "Games",
|
"admin.playerGames": "Games",
|
||||||
"admin.playerJoker": "Joker",
|
"admin.playerJoker": "Back pass",
|
||||||
"admin.playerDelete": "Delete",
|
"admin.playerDelete": "Delete",
|
||||||
"admin.grantJokerChip": "Grant",
|
"admin.grantJokerChip": "Grant back",
|
||||||
|
"admin.statusUpdated": "Player status updated",
|
||||||
"admin.statusSuggesting": "Suggesting",
|
"admin.statusSuggesting": "Suggesting",
|
||||||
"admin.statusVoting": "Voting",
|
"admin.statusVoting": "Voting",
|
||||||
"admin.statusFinished": "Finished",
|
"admin.statusFinished": "Finished",
|
||||||
@@ -125,7 +132,7 @@
|
|||||||
"admin.deleteBody": "Delete player \"{name}\" and all their games and votes? This cannot be undone.",
|
"admin.deleteBody": "Delete player \"{name}\" and all their games and votes? This cannot be undone.",
|
||||||
"admin.deleteConfirm": "Delete",
|
"admin.deleteConfirm": "Delete",
|
||||||
"admin.deleteDone": "Player deleted",
|
"admin.deleteDone": "Player deleted",
|
||||||
"admin.jokerGranted": "Joker granted",
|
"admin.jokerGranted": "Back pass granted",
|
||||||
"admin.linkTitle": "Link games",
|
"admin.linkTitle": "Link games",
|
||||||
"admin.linkSource": "Game to link",
|
"admin.linkSource": "Game to link",
|
||||||
"admin.linkTarget": "Link to (parent)",
|
"admin.linkTarget": "Link to (parent)",
|
||||||
@@ -186,6 +193,7 @@
|
|||||||
"counts.format": "Spieler: {players} • Vorschläge: {suggestions} • Stimmen: {votes}",
|
"counts.format": "Spieler: {players} • Vorschläge: {suggestions} • Stimmen: {votes}",
|
||||||
"nav.prev": "Zurück",
|
"nav.prev": "Zurück",
|
||||||
"nav.next": "Weiter",
|
"nav.next": "Weiter",
|
||||||
|
"nav.backToSuggestOnce": "Pass nutzen: zurück zu Vorschlag",
|
||||||
"nav.addSuggestionFirst": "Zuerst ein Spiel vorschlagen",
|
"nav.addSuggestionFirst": "Zuerst ein Spiel vorschlagen",
|
||||||
"nav.waitingForResults": "Warten…",
|
"nav.waitingForResults": "Warten…",
|
||||||
"nav.freezeTitle": "Bereit zum Aufdecken?",
|
"nav.freezeTitle": "Bereit zum Aufdecken?",
|
||||||
@@ -262,11 +270,16 @@
|
|||||||
"vote.listUpdatedConfirm": "OK",
|
"vote.listUpdatedConfirm": "OK",
|
||||||
"admin.title": "Admin",
|
"admin.title": "Admin",
|
||||||
"admin.tools": "Admin-Werkzeuge",
|
"admin.tools": "Admin-Werkzeuge",
|
||||||
"admin.resultsOpenToggle": "Ergebnisse freigeben",
|
"admin.resultsOpenButtonEnable": "Ergebnisse freigeben",
|
||||||
|
"admin.resultsOpenButtonDisable": "Ergebnisse sperren",
|
||||||
"admin.resultsLocked": "Ergebnisse vom Admin gesperrt",
|
"admin.resultsLocked": "Ergebnisse vom Admin gesperrt",
|
||||||
"admin.resultsUpdated": "Ergebnisfreigabe aktualisiert",
|
"admin.resultsUpdated": "Ergebnisfreigabe aktualisiert",
|
||||||
"admin.reset": "Zurücksetzen (Spieler behalten)",
|
"admin.reset": "Zurücksetzen (Spieler behalten)",
|
||||||
"admin.factoryReset": "Werkseinstellung",
|
"admin.factoryReset": "Werkseinstellung",
|
||||||
|
"admin.resetConfirmBody": "Gib dein Admin-Passwort ein, um alle Spiele und Stimmen zurückzusetzen, aber die Konten zu behalten.",
|
||||||
|
"admin.factoryResetConfirmBody": "Gib dein Admin-Passwort ein, um alle Konten, Spiele, Stimmen und den Zustand dauerhaft zu löschen.",
|
||||||
|
"admin.passwordLabel": "Admin-Passwort",
|
||||||
|
"admin.passwordRequired": "Admin-Passwort ist erforderlich.",
|
||||||
"admin.resetDone": "Zurücksetzen abgeschlossen",
|
"admin.resetDone": "Zurücksetzen abgeschlossen",
|
||||||
"admin.factoryResetDone": "Werkseinstellung abgeschlossen",
|
"admin.factoryResetDone": "Werkseinstellung abgeschlossen",
|
||||||
"admin.readyForResults": "Bereit für Ergebnisse",
|
"admin.readyForResults": "Bereit für Ergebnisse",
|
||||||
@@ -275,9 +288,10 @@
|
|||||||
"admin.playerUsername": "Benutzername",
|
"admin.playerUsername": "Benutzername",
|
||||||
"admin.playerStatus": "Status",
|
"admin.playerStatus": "Status",
|
||||||
"admin.playerGames": "Spiele",
|
"admin.playerGames": "Spiele",
|
||||||
"admin.playerJoker": "Joker",
|
"admin.playerJoker": "Zurück-Pass",
|
||||||
"admin.playerDelete": "Löschen",
|
"admin.playerDelete": "Löschen",
|
||||||
"admin.grantJokerChip": "Joker",
|
"admin.grantJokerChip": "Pass geben",
|
||||||
|
"admin.statusUpdated": "Status aktualisiert",
|
||||||
"admin.statusSuggesting": "Vorschlagen",
|
"admin.statusSuggesting": "Vorschlagen",
|
||||||
"admin.statusVoting": "Bewerten",
|
"admin.statusVoting": "Bewerten",
|
||||||
"admin.statusFinished": "Fertig",
|
"admin.statusFinished": "Fertig",
|
||||||
@@ -285,7 +299,7 @@
|
|||||||
"admin.deleteBody": "Spieler \"{name}\" samt Spielen und Stimmen löschen? Dies kann nicht rückgängig gemacht werden.",
|
"admin.deleteBody": "Spieler \"{name}\" samt Spielen und Stimmen löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||||
"admin.deleteConfirm": "Löschen",
|
"admin.deleteConfirm": "Löschen",
|
||||||
"admin.deleteDone": "Spieler gelöscht",
|
"admin.deleteDone": "Spieler gelöscht",
|
||||||
"admin.jokerGranted": "Joker vergeben",
|
"admin.jokerGranted": "Zurück-Pass vergeben",
|
||||||
"admin.linkTitle": "Spiele verknüpfen",
|
"admin.linkTitle": "Spiele verknüpfen",
|
||||||
"admin.linkSource": "Spiel verknüpfen",
|
"admin.linkSource": "Spiel verknüpfen",
|
||||||
"admin.linkTarget": "Verknüpfen mit",
|
"admin.linkTarget": "Verknüpfen mit",
|
||||||
|
|||||||
@@ -170,7 +170,7 @@
|
|||||||
<th data-i18n="admin.playerUsername">Username</th>
|
<th data-i18n="admin.playerUsername">Username</th>
|
||||||
<th data-i18n="admin.playerStatus">Status</th>
|
<th data-i18n="admin.playerStatus">Status</th>
|
||||||
<th data-i18n="admin.playerGames">Games</th>
|
<th data-i18n="admin.playerGames">Games</th>
|
||||||
<th data-i18n="admin.playerJoker">Joker</th>
|
<th data-i18n="admin.playerJoker">Back pass</th>
|
||||||
<th data-i18n="admin.playerDelete">Delete</th>
|
<th data-i18n="admin.playerDelete">Delete</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -178,10 +178,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label class="stack toggle-row">
|
<button id="results-open-toggle" class="secondary" type="button" data-i18n="admin.resultsOpenButtonEnable">Allow 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">
|
||||||
|
|||||||
@@ -15,6 +15,27 @@ function displayPlayerStatus(player) {
|
|||||||
return phase;
|
return phase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderStatusSelect(player) {
|
||||||
|
const statusText = displayPlayerStatus(player);
|
||||||
|
const safeStatusText = escapeHtml(statusText);
|
||||||
|
const playerId = escapeHtml(player.playerId);
|
||||||
|
|
||||||
|
if (player.phase === "Vote") {
|
||||||
|
return `
|
||||||
|
<select class="admin-status-select" data-player-phase="${playerId}" data-current-phase="Vote">
|
||||||
|
<option value="Vote" selected>${safeStatusText}</option>
|
||||||
|
<option value="Suggest">${escapeHtml(t("admin.statusSuggesting"))}</option>
|
||||||
|
</select>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<select class="admin-status-select" disabled data-player-phase="${playerId}" data-current-phase="${escapeHtml(player.phase)}">
|
||||||
|
<option value="${escapeHtml(player.phase)}" selected>${safeStatusText}</option>
|
||||||
|
</select>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
export function renderAdminVoteStatus() {
|
export function renderAdminVoteStatus() {
|
||||||
if (!state.me?.isAdmin) return;
|
if (!state.me?.isAdmin) return;
|
||||||
const statusBadge = $("admin-ready-status");
|
const statusBadge = $("admin-ready-status");
|
||||||
@@ -24,14 +45,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>${renderStatusSelect(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>
|
||||||
|
|||||||
@@ -56,10 +56,11 @@ 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: (adminPassword) => request("/api/admin/reset", { method: "POST", body: { adminPassword } }),
|
||||||
factoryReset: () => request("/api/admin/factory-reset", { method: "POST" }),
|
factoryReset: (adminPassword) => request("/api/admin/factory-reset", { method: "POST", body: { adminPassword } }),
|
||||||
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" }),
|
deletePlayer: (playerId, adminPassword) => request(`/api/admin/players/${playerId}`, { method: "DELETE", body: { adminPassword } }),
|
||||||
|
setPlayerPhase: (playerId, phase) => request(`/api/admin/players/${playerId}/phase`, { method: "POST", body: { phase } }),
|
||||||
linkSuggestions: (sourceSuggestionId, targetSuggestionId) =>
|
linkSuggestions: (sourceSuggestionId, targetSuggestionId) =>
|
||||||
request("/api/admin/link-suggestions", { method: "POST", body: { sourceSuggestionId, targetSuggestionId } }),
|
request("/api/admin/link-suggestions", { method: "POST", body: { sourceSuggestionId, targetSuggestionId } }),
|
||||||
unlinkSuggestions: (suggestionId) =>
|
unlinkSuggestions: (suggestionId) =>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { t } from "./i18n.js";
|
|||||||
import { state } from "./state.js";
|
import { state } from "./state.js";
|
||||||
import { $, toast } from "./dom.js";
|
import { $, toast } from "./dom.js";
|
||||||
import {
|
import {
|
||||||
openConfirmModal,
|
openPasswordConfirmModal,
|
||||||
openResultsRelockModal,
|
openResultsRelockModal,
|
||||||
renderPhasePill,
|
renderPhasePill,
|
||||||
} from "./ui.js";
|
} from "./ui.js";
|
||||||
@@ -13,8 +13,10 @@ async function adminAction(fn, successMessage, runSerializedRefresh) {
|
|||||||
await fn();
|
await fn();
|
||||||
toast(successMessage);
|
toast(successMessage);
|
||||||
await runSerializedRefresh();
|
await runSerializedRefresh();
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.message, true);
|
toast(err.message, true);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,24 +34,56 @@ function setupAdminPanelToggle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setupResetButtons(runSerializedRefresh) {
|
function setupResetButtons(runSerializedRefresh) {
|
||||||
$("reset").addEventListener("click", () =>
|
const askPasswordThenRun = ({
|
||||||
adminAction(adminApi.reset, t("admin.resetDone"), runSerializedRefresh),
|
title,
|
||||||
);
|
body,
|
||||||
$("factory-reset").addEventListener("click", () =>
|
confirmLabel,
|
||||||
adminAction(
|
action,
|
||||||
adminApi.factoryReset,
|
done,
|
||||||
t("admin.factoryResetDone"),
|
}) => {
|
||||||
|
openPasswordConfirmModal({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
confirmLabel,
|
||||||
|
onConfirm: async (password, close) => {
|
||||||
|
const success = await adminAction(
|
||||||
|
() => action(password),
|
||||||
|
done,
|
||||||
runSerializedRefresh,
|
runSerializedRefresh,
|
||||||
),
|
);
|
||||||
|
if (success) close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
$("reset").addEventListener("click", () =>
|
||||||
|
askPasswordThenRun({
|
||||||
|
title: t("admin.reset"),
|
||||||
|
body: t("admin.resetConfirmBody"),
|
||||||
|
confirmLabel: t("admin.reset"),
|
||||||
|
action: (password) => adminApi.reset(password),
|
||||||
|
done: t("admin.resetDone"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
$("factory-reset").addEventListener("click", () =>
|
||||||
|
askPasswordThenRun({
|
||||||
|
title: t("admin.factoryReset"),
|
||||||
|
body: t("admin.factoryResetConfirmBody"),
|
||||||
|
confirmLabel: t("admin.factoryReset"),
|
||||||
|
action: (password) => adminApi.factoryReset(password),
|
||||||
|
done: t("admin.factoryResetDone"),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupResultsToggle(runSerializedRefresh) {
|
function setupResultsToggle(runSerializedRefresh) {
|
||||||
const resultsToggle = $("results-open");
|
const resultsToggle = $("results-open-toggle");
|
||||||
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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -92,6 +127,45 @@ function setupPlayerTableActions(runSerializedRefresh) {
|
|||||||
const playerTable = $("admin-player-table");
|
const playerTable = $("admin-player-table");
|
||||||
if (!playerTable) return;
|
if (!playerTable) return;
|
||||||
|
|
||||||
|
const syncSelectFocusState = () => {
|
||||||
|
state.adminStatusMenuOpen = !!playerTable.querySelector(
|
||||||
|
".admin-status-select:focus",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
playerTable.addEventListener("focusin", (e) => {
|
||||||
|
if (e.target.closest(".admin-status-select")) {
|
||||||
|
state.adminStatusMenuOpen = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
playerTable.addEventListener("focusout", () => {
|
||||||
|
window.setTimeout(syncSelectFocusState, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
playerTable.addEventListener("change", async (e) => {
|
||||||
|
const statusSelect = e.target.closest(".admin-status-select");
|
||||||
|
if (!statusSelect || statusSelect.disabled) return;
|
||||||
|
|
||||||
|
const playerId = statusSelect.dataset.playerPhase;
|
||||||
|
const currentPhase = statusSelect.dataset.currentPhase;
|
||||||
|
const desiredPhase = statusSelect.value;
|
||||||
|
if (!playerId || !desiredPhase || desiredPhase === currentPhase) return;
|
||||||
|
|
||||||
|
statusSelect.disabled = true;
|
||||||
|
try {
|
||||||
|
await adminApi.setPlayerPhase(playerId, desiredPhase);
|
||||||
|
toast(t("admin.statusUpdated"));
|
||||||
|
await runSerializedRefresh();
|
||||||
|
} catch (err) {
|
||||||
|
statusSelect.value = currentPhase ?? statusSelect.value;
|
||||||
|
toast(err.message, true);
|
||||||
|
} finally {
|
||||||
|
statusSelect.disabled = false;
|
||||||
|
state.adminStatusMenuOpen = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
playerTable.addEventListener("click", async (e) => {
|
playerTable.addEventListener("click", async (e) => {
|
||||||
const grantBtn = e.target.closest("[data-grant-joker]");
|
const grantBtn = e.target.closest("[data-grant-joker]");
|
||||||
const deleteBtn = e.target.closest("[data-delete-player]");
|
const deleteBtn = e.target.closest("[data-delete-player]");
|
||||||
@@ -107,13 +181,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({
|
openPasswordConfirmModal({
|
||||||
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();
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { api } from "./api.js";
|
import { api } from "./api.js";
|
||||||
import { t } from "./i18n.js";
|
import { t } from "./i18n.js";
|
||||||
import { state, clearUserState, setSavedUsername } from "./state.js";
|
import {
|
||||||
|
state,
|
||||||
|
clearUserState,
|
||||||
|
clearSavedUsername,
|
||||||
|
setSavedUsername,
|
||||||
|
} from "./state.js";
|
||||||
import { $, toast } from "./dom.js";
|
import { $, toast } from "./dom.js";
|
||||||
import {
|
import {
|
||||||
handleAuthError,
|
handleAuthError,
|
||||||
@@ -139,24 +144,38 @@ function setupLogoutHandler() {
|
|||||||
const logoutBtn = $("logout");
|
const logoutBtn = $("logout");
|
||||||
if (!logoutBtn) return;
|
if (!logoutBtn) return;
|
||||||
|
|
||||||
|
const clearAuthFormFields = () => {
|
||||||
|
[
|
||||||
|
"login-username",
|
||||||
|
"login-password",
|
||||||
|
"register-username",
|
||||||
|
"register-password",
|
||||||
|
"register-displayName",
|
||||||
|
"register-adminkey",
|
||||||
|
].forEach((id) => {
|
||||||
|
const input = $(id);
|
||||||
|
if (input) input.value = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
["login-consent", "register-consent"].forEach((id) => {
|
||||||
|
const box = $(id);
|
||||||
|
if (box) box.checked = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
logoutBtn.addEventListener("click", async (e) => {
|
logoutBtn.addEventListener("click", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const lastUser = state.me?.username;
|
|
||||||
try {
|
try {
|
||||||
await api.logout();
|
await api.logout();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.message, true);
|
toast(err.message, true);
|
||||||
}
|
}
|
||||||
clearUserState();
|
clearUserState();
|
||||||
|
clearSavedUsername();
|
||||||
state.isAuthenticated = false;
|
state.isAuthenticated = false;
|
||||||
|
setAuthMode("login");
|
||||||
setAuthUI(false);
|
setAuthUI(false);
|
||||||
if (lastUser) {
|
clearAuthFormFields();
|
||||||
setSavedUsername(lastUser);
|
|
||||||
const loginUser = $("login-username");
|
|
||||||
if (loginUser) loginUser.value = lastUser;
|
|
||||||
const loginPass = $("login-password");
|
|
||||||
if (loginPass) loginPass.value = "";
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,15 @@ 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) {
|
const canCompareWithDisplayedVoteList =
|
||||||
|
state.phase === "Vote" &&
|
||||||
|
state.votesRendered &&
|
||||||
|
!!state.displayedVoteSuggestionsSig;
|
||||||
|
if (
|
||||||
|
changed &&
|
||||||
|
canCompareWithDisplayedVoteList &&
|
||||||
|
latestSig !== state.displayedVoteSuggestionsSig
|
||||||
|
) {
|
||||||
const added = latest
|
const added = latest
|
||||||
.filter((s) => !prevById[s.id])
|
.filter((s) => !prevById[s.id])
|
||||||
.map((s) => s.name);
|
.map((s) => s.name);
|
||||||
|
|||||||
@@ -80,6 +80,84 @@ export function openConfirmModal({
|
|||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function openPasswordConfirmModal({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
confirmLabel,
|
||||||
|
cancelLabel = t("modal.cancel"),
|
||||||
|
onConfirm,
|
||||||
|
}) {
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.className = "edit-modal";
|
||||||
|
const panel = document.createElement("div");
|
||||||
|
panel.className = "edit-panel";
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="edit-header">
|
||||||
|
<h3>${title}</h3>
|
||||||
|
<button class="lightbox-close" aria-label="${t("modal.close")}">x</button>
|
||||||
|
</div>
|
||||||
|
<div class="edit-body">
|
||||||
|
<p>${body}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const close = () => overlay.remove();
|
||||||
|
const bodyWrap = panel.querySelector(".edit-body");
|
||||||
|
const fieldWrap = document.createElement("label");
|
||||||
|
fieldWrap.className = "stack";
|
||||||
|
fieldWrap.innerHTML = `
|
||||||
|
<span class="label">${t("admin.passwordLabel")}</span>
|
||||||
|
<input type="password" autocomplete="current-password" />
|
||||||
|
`;
|
||||||
|
bodyWrap?.appendChild(fieldWrap);
|
||||||
|
const passwordInput = fieldWrap.querySelector("input");
|
||||||
|
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.className = "stack horizontal";
|
||||||
|
const confirmBtn = document.createElement("button");
|
||||||
|
confirmBtn.className = "danger";
|
||||||
|
confirmBtn.textContent = confirmLabel ?? t("modal.confirm");
|
||||||
|
actions.append(confirmBtn);
|
||||||
|
|
||||||
|
if (cancelLabel !== null && cancelLabel !== undefined) {
|
||||||
|
const cancelBtn = document.createElement("button");
|
||||||
|
cancelBtn.className = "ghost";
|
||||||
|
cancelBtn.type = "button";
|
||||||
|
cancelBtn.textContent = cancelLabel;
|
||||||
|
actions.append(cancelBtn);
|
||||||
|
cancelBtn.addEventListener("click", close);
|
||||||
|
}
|
||||||
|
bodyWrap?.appendChild(actions);
|
||||||
|
|
||||||
|
overlay.addEventListener("click", (e) => {
|
||||||
|
if (
|
||||||
|
e.target.classList.contains("edit-modal") ||
|
||||||
|
e.target.classList.contains("lightbox-close")
|
||||||
|
) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
confirmBtn.addEventListener("click", async () => {
|
||||||
|
const password = passwordInput?.value ?? "";
|
||||||
|
if (!password.trim()) {
|
||||||
|
toast(t("admin.passwordRequired"), true);
|
||||||
|
passwordInput?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onConfirm?.(password, close);
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.appendChild(panel);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
passwordInput?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
export function openResultsRelockModal() {
|
export function openResultsRelockModal() {
|
||||||
openConfirmModal({
|
openConfirmModal({
|
||||||
title: t("results.relockedTitle"),
|
title: t("results.relockedTitle"),
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ export const state = {
|
|||||||
mySuggestions: [],
|
mySuggestions: [],
|
||||||
allSuggestions: [],
|
allSuggestions: [],
|
||||||
allSuggestionsSig: null,
|
allSuggestionsSig: null,
|
||||||
|
displayedVoteSuggestionsSig: null,
|
||||||
myVotes: [],
|
myVotes: [],
|
||||||
results: [],
|
results: [],
|
||||||
votesRendered: false,
|
votesRendered: false,
|
||||||
adminVoteStatus: null,
|
adminVoteStatus: null,
|
||||||
|
adminStatusMenuOpen: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function clearUserState() {
|
export function clearUserState() {
|
||||||
@@ -27,9 +29,13 @@ export function clearUserState() {
|
|||||||
state.counts = null;
|
state.counts = null;
|
||||||
state.mySuggestions = [];
|
state.mySuggestions = [];
|
||||||
state.allSuggestions = [];
|
state.allSuggestions = [];
|
||||||
|
state.allSuggestionsSig = null;
|
||||||
|
state.displayedVoteSuggestionsSig = null;
|
||||||
state.myVotes = [];
|
state.myVotes = [];
|
||||||
state.results = [];
|
state.results = [];
|
||||||
state.votesRendered = false;
|
state.votesRendered = false;
|
||||||
|
state.adminVoteStatus = null;
|
||||||
|
state.adminStatusMenuOpen = false;
|
||||||
const adminCard = document.getElementById("admin-card");
|
const adminCard = document.getElementById("admin-card");
|
||||||
if (adminCard) adminCard.classList.add("hidden");
|
if (adminCard) adminCard.classList.add("hidden");
|
||||||
}
|
}
|
||||||
@@ -38,3 +44,5 @@ export const getSavedUsername = () =>
|
|||||||
localStorage.getItem("last_username") || "";
|
localStorage.getItem("last_username") || "";
|
||||||
export const setSavedUsername = (name) =>
|
export const setSavedUsername = (name) =>
|
||||||
localStorage.setItem("last_username", name);
|
localStorage.setItem("last_username", name);
|
||||||
|
export const clearSavedUsername = () =>
|
||||||
|
localStorage.removeItem("last_username");
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { renderResults } from "./results-ui.js";
|
|||||||
import {
|
import {
|
||||||
openConfirmModal,
|
openConfirmModal,
|
||||||
openLightbox,
|
openLightbox,
|
||||||
|
openPasswordConfirmModal,
|
||||||
openResultsRelockModal,
|
openResultsRelockModal,
|
||||||
openSuggestionsChangedModal,
|
openSuggestionsChangedModal,
|
||||||
} from "./modals-ui.js";
|
} from "./modals-ui.js";
|
||||||
@@ -64,6 +65,7 @@ export {
|
|||||||
openConfirmModal,
|
openConfirmModal,
|
||||||
openLightbox,
|
openLightbox,
|
||||||
openNewSuggestionModal,
|
openNewSuggestionModal,
|
||||||
|
openPasswordConfirmModal,
|
||||||
openResultsRelockModal,
|
openResultsRelockModal,
|
||||||
openSuggestionsChangedModal,
|
openSuggestionsChangedModal,
|
||||||
renderAllSuggestions,
|
renderAllSuggestions,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export function renderVotes() {
|
|||||||
li.querySelector(".card-body").appendChild(footer);
|
li.querySelector(".card-body").appendChild(footer);
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
});
|
});
|
||||||
|
state.displayedVoteSuggestionsSig = state.allSuggestionsSig;
|
||||||
updatePhaseNav();
|
updatePhaseNav();
|
||||||
updateMissingBadgeFromDom();
|
updateMissingBadgeFromDom();
|
||||||
list.scrollTop = prevScroll;
|
list.scrollTop = prevScroll;
|
||||||
@@ -202,9 +203,11 @@ export function updatePhaseNav() {
|
|||||||
|
|
||||||
showNav("nav-suggest", phase === "Suggest");
|
showNav("nav-suggest", phase === "Suggest");
|
||||||
showNav("nav-vote", phase === "Vote");
|
showNav("nav-vote", phase === "Vote");
|
||||||
|
const playerCanMoveBackToSuggest =
|
||||||
|
!isAdmin && phase === "Vote" && state.hasJoker;
|
||||||
const jokerBtn = $("open-joker-modal");
|
const jokerBtn = $("open-joker-modal");
|
||||||
if (jokerBtn) {
|
if (jokerBtn) {
|
||||||
const showJoker = phase === "Vote" && state.hasJoker;
|
const showJoker = false;
|
||||||
jokerBtn.classList.toggle("hidden", !showJoker);
|
jokerBtn.classList.toggle("hidden", !showJoker);
|
||||||
jokerBtn.disabled = !showJoker;
|
jokerBtn.disabled = !showJoker;
|
||||||
}
|
}
|
||||||
@@ -242,11 +245,14 @@ export function updatePhaseNav() {
|
|||||||
renderAdminLinker();
|
renderAdminLinker();
|
||||||
updateMissingBadgeFromDom();
|
updateMissingBadgeFromDom();
|
||||||
|
|
||||||
const backButtons = ["nav-vote-prev"];
|
const votePrev = $("nav-vote-prev");
|
||||||
backButtons.forEach((id) => {
|
if (votePrev) {
|
||||||
const btn = $(id);
|
const canUseBack = isAdmin || playerCanMoveBackToSuggest;
|
||||||
if (btn) btn.classList.toggle("hidden", !isAdmin);
|
votePrev.classList.toggle("hidden", !canUseBack);
|
||||||
});
|
votePrev.textContent = playerCanMoveBackToSuggest
|
||||||
|
? t("nav.backToSuggestOnce")
|
||||||
|
: t("nav.prev");
|
||||||
|
}
|
||||||
|
|
||||||
const suggestNext = $("nav-suggest-next");
|
const suggestNext = $("nav-suggest-next");
|
||||||
if (suggestNext) {
|
if (suggestNext) {
|
||||||
@@ -268,8 +274,10 @@ export function updatePhaseNav() {
|
|||||||
: t("nav.next");
|
: t("nav.next");
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminResultsToggle = $("results-open");
|
const adminResultsToggle = $("results-open-toggle");
|
||||||
if (adminResultsToggle) {
|
if (adminResultsToggle) {
|
||||||
adminResultsToggle.checked = !!state.resultsOpen;
|
adminResultsToggle.textContent = state.resultsOpen
|
||||||
|
? t("admin.resultsOpenButtonDisable")
|
||||||
|
: t("admin.resultsOpenButtonEnable");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user