Add owner role and admin management controls
This commit is contained in:
6
API.md
6
API.md
@@ -5,14 +5,16 @@ Auth and admin-sensitive routes are rate-limited and return HTTP `429` on excess
|
|||||||
|
|
||||||
## Auth
|
## Auth
|
||||||
POST /api/auth/register — accepts optional `adminKey` to set `IsAdmin=true` only for bootstrap of the first admin account
|
POST /api/auth/register — accepts optional `adminKey` to set `IsAdmin=true` only for bootstrap of the first admin account
|
||||||
|
GET /api/auth/options — `{ ownerExists }` for registration UX (hide admin-key input after owner bootstrap)
|
||||||
POST /api/auth/login
|
POST /api/auth/login
|
||||||
POST /api/auth/logout
|
POST /api/auth/logout
|
||||||
Display names are set during registration and are immutable afterward.
|
Display names are set during registration and are immutable afterward.
|
||||||
Passwords must be 8-128 chars and contain uppercase, lowercase, number, and symbol.
|
Passwords must be 8-128 chars and contain uppercase, lowercase, number, and symbol.
|
||||||
|
The first account created with a valid `adminKey` becomes both `IsAdmin=true` and `IsOwner=true`.
|
||||||
|
|
||||||
## State (requires auth)
|
## State (requires auth)
|
||||||
GET /api/state — returns currentPhase (for caller), votesFinal, resultsOpen, updatedAt, counts (players/suggestions/votes)
|
GET /api/state — returns currentPhase (for caller), votesFinal, resultsOpen, updatedAt, counts (players/suggestions/votes)
|
||||||
GET /api/me — id, displayName, username, isAdmin, currentPhase, votesFinal
|
GET /api/me — id, displayName, username, isAdmin, isOwner, 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)
|
||||||
@@ -38,11 +40,13 @@ POST /api/admin/results — `{ resultsOpen: bool }` locks/unlocks results and al
|
|||||||
GET /api/admin/vote-status — readiness overview (who finalized)
|
GET /api/admin/vote-status — readiness overview (who finalized)
|
||||||
POST /api/admin/joker — `{ playerId }` grants a vote-phase joker to the target player
|
POST /api/admin/joker — `{ playerId }` grants a vote-phase joker to the target player
|
||||||
POST /api/admin/player-phase — `{ playerId, phase }`; currently supports Vote→Suggest transitions only
|
POST /api/admin/player-phase — `{ playerId, phase }`; currently supports Vote→Suggest transitions only
|
||||||
|
POST /api/admin/player-admin — `{ playerId, isAdmin }`; grant/revoke admin role for non-owner accounts
|
||||||
DELETE /api/admin/players/{playerId} — `{ password }`; deletes player account plus their suggestions/votes
|
DELETE /api/admin/players/{playerId} — `{ password }`; deletes player account plus their suggestions/votes
|
||||||
POST /api/admin/link-suggestions — `{ sourceSuggestionId, targetSuggestionId }`; merges vote groups during Vote, clears votes in the linked group, unfinalizes **all** players
|
POST /api/admin/link-suggestions — `{ sourceSuggestionId, targetSuggestionId }`; merges vote groups during Vote, clears votes in the linked group, unfinalizes **all** players
|
||||||
POST /api/admin/unlink-suggestions — `{ suggestionId }`; breaks links, clears votes for that group, unfinalizes **all** players
|
POST /api/admin/unlink-suggestions — `{ suggestionId }`; breaks links, clears votes for that group, unfinalizes **all** players
|
||||||
POST /api/admin/reset — `{ password }`; clear suggestions/votes, keep players, reset phases/vote-final flags
|
POST /api/admin/reset — `{ password }`; clear suggestions/votes, keep players, reset phases/vote-final flags
|
||||||
POST /api/admin/factory-reset — `{ password }`; wipe players, suggestions, votes, state
|
POST /api/admin/factory-reset — `{ password }`; wipe players, suggestions, votes, state
|
||||||
|
Owner restrictions: owner role/admin status cannot be changed, and owner account cannot be deleted.
|
||||||
|
|
||||||
## Security Defaults
|
## Security Defaults
|
||||||
- Security headers are set on all responses (`CSP`, `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`).
|
- Security headers are set on all responses (`CSP`, `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`).
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public record ResultsOpenRequest(bool ResultsOpen);
|
|||||||
|
|
||||||
public record VoteFinalizeRequest(bool Final);
|
public record VoteFinalizeRequest(bool Final);
|
||||||
|
|
||||||
public record VoteStatusDto(Guid PlayerId, string Name, string Username, Phase Phase, bool Finalized, bool HasJoker, int SuggestionCount, IReadOnlyList<string> SuggestionTitles);
|
public record VoteStatusDto(Guid PlayerId, string Name, string Username, Phase Phase, bool Finalized, bool HasJoker, bool IsAdmin, bool IsOwner, int SuggestionCount, IReadOnlyList<string> SuggestionTitles);
|
||||||
|
|
||||||
public record LinkSuggestionsRequest(int SourceSuggestionId, int TargetSuggestionId);
|
public record LinkSuggestionsRequest(int SourceSuggestionId, int TargetSuggestionId);
|
||||||
|
|
||||||
@@ -21,5 +21,6 @@ public record UnlinkSuggestionsRequest(int SuggestionId);
|
|||||||
public record GrantJokerRequest(Guid PlayerId);
|
public record GrantJokerRequest(Guid PlayerId);
|
||||||
|
|
||||||
public record SetPlayerPhaseRequest(Guid PlayerId, Phase Phase);
|
public record SetPlayerPhaseRequest(Guid PlayerId, Phase Phase);
|
||||||
|
public record SetPlayerAdminRequest(Guid PlayerId, bool IsAdmin);
|
||||||
|
|
||||||
public record AdminPasswordRequest(string Password);
|
public record AdminPasswordRequest(string Password);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public record AdminResultsStateResponse(bool ResultsOpen, DateTimeOffset Updated
|
|||||||
public record AdminGrantJokerResponse(Guid Id, bool HasJoker);
|
public record AdminGrantJokerResponse(Guid Id, bool HasJoker);
|
||||||
|
|
||||||
public record AdminSetPlayerPhaseResponse(Guid PlayerId, Phase CurrentPhase, bool VotesFinal);
|
public record AdminSetPlayerPhaseResponse(Guid PlayerId, Phase CurrentPhase, bool VotesFinal);
|
||||||
|
public record AdminSetPlayerAdminResponse(Guid PlayerId, bool IsAdmin);
|
||||||
|
|
||||||
public record AdminDeletePlayerResponse(Guid DeletedPlayerId);
|
public record AdminDeletePlayerResponse(Guid DeletedPlayerId);
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ public record ResultItemDto(
|
|||||||
);
|
);
|
||||||
|
|
||||||
public record AuthSessionResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin);
|
public record AuthSessionResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin);
|
||||||
|
public record AuthOptionsResponse(bool OwnerExists);
|
||||||
|
|
||||||
public record StateSummaryResponse(
|
public record StateSummaryResponse(
|
||||||
Phase CurrentPhase,
|
Phase CurrentPhase,
|
||||||
@@ -75,6 +77,7 @@ public record MeResponse(
|
|||||||
string Username,
|
string Username,
|
||||||
string? DisplayName,
|
string? DisplayName,
|
||||||
bool IsAdmin,
|
bool IsAdmin,
|
||||||
|
bool IsOwner,
|
||||||
Phase CurrentPhase,
|
Phase CurrentPhase,
|
||||||
bool VotesFinal,
|
bool VotesFinal,
|
||||||
bool HasJoker
|
bool HasJoker
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
|||||||
builder.Property(p => p.PasswordHash).IsRequired();
|
builder.Property(p => p.PasswordHash).IsRequired();
|
||||||
builder.Property(p => p.PasswordSalt).IsRequired();
|
builder.Property(p => p.PasswordSalt).IsRequired();
|
||||||
builder.Property(p => p.IsAdmin).HasDefaultValue(false);
|
builder.Property(p => p.IsAdmin).HasDefaultValue(false);
|
||||||
|
builder.Property(p => p.IsOwner).HasDefaultValue(false);
|
||||||
builder.Property(p => p.HasJoker).HasDefaultValue(false);
|
builder.Property(p => p.HasJoker).HasDefaultValue(false);
|
||||||
builder.Property(p => p.CurrentPhase).HasDefaultValue(Phase.Suggest);
|
builder.Property(p => p.CurrentPhase).HasDefaultValue(Phase.Suggest);
|
||||||
builder.Property(p => p.VotesFinal).HasDefaultValue(false);
|
builder.Property(p => p.VotesFinal).HasDefaultValue(false);
|
||||||
|
|||||||
251
Data/Migrations/20260208175912_AddOwnerRole.Designer.cs
generated
Normal file
251
Data/Migrations/20260208175912_AddOwnerRole.Designer.cs
generated
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using GameList.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GameList.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260208175912_AddOwnerRole")]
|
||||||
|
partial class AddOwnerRole
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||||
|
|
||||||
|
modelBuilder.Entity("GameList.Domain.AppState", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("ResultsOpen")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("AppState");
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
ResultsOpen = false,
|
||||||
|
UpdatedAt = new DateTimeOffset(new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GameList.Domain.Player", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("CurrentPhase")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(16)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("HasJoker")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<bool>("IsAdmin")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<bool>("IsOwner")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(24)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<byte[]>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB");
|
||||||
|
|
||||||
|
b.Property<byte[]>("PasswordSalt")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(24)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("VotesFinal")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedUsername")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Players");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("GameUrl")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Genre")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("MaxPlayers")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int?>("MinPlayers")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int?>("ParentSuggestionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<Guid>("PlayerId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ScreenshotUrl")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("YoutubeUrl")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ParentSuggestionId");
|
||||||
|
|
||||||
|
b.HasIndex("PlayerId");
|
||||||
|
|
||||||
|
b.ToTable("Suggestions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GameList.Domain.Vote", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("PlayerId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Score")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("SuggestionId")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SuggestionId");
|
||||||
|
|
||||||
|
b.HasIndex("PlayerId", "SuggestionId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Votes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("GameList.Domain.Suggestion", "ParentSuggestion")
|
||||||
|
.WithMany("LinkedSuggestions")
|
||||||
|
.HasForeignKey("ParentSuggestionId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("GameList.Domain.Player", "Player")
|
||||||
|
.WithMany("Suggestions")
|
||||||
|
.HasForeignKey("PlayerId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("ParentSuggestion");
|
||||||
|
|
||||||
|
b.Navigation("Player");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GameList.Domain.Vote", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("GameList.Domain.Player", "Player")
|
||||||
|
.WithMany("Votes")
|
||||||
|
.HasForeignKey("PlayerId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("GameList.Domain.Suggestion", "Suggestion")
|
||||||
|
.WithMany("Votes")
|
||||||
|
.HasForeignKey("SuggestionId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Player");
|
||||||
|
|
||||||
|
b.Navigation("Suggestion");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GameList.Domain.Player", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Suggestions");
|
||||||
|
|
||||||
|
b.Navigation("Votes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("LinkedSuggestions");
|
||||||
|
|
||||||
|
b.Navigation("Votes");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
Data/Migrations/20260208175912_AddOwnerRole.cs
Normal file
42
Data/Migrations/20260208175912_AddOwnerRole.cs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GameList.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddOwnerRole : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsOwner",
|
||||||
|
table: "Players",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.Sql(
|
||||||
|
"""
|
||||||
|
UPDATE Players
|
||||||
|
SET IsOwner = 1
|
||||||
|
WHERE Id = (
|
||||||
|
SELECT Id
|
||||||
|
FROM Players
|
||||||
|
WHERE IsAdmin = 1
|
||||||
|
ORDER BY CreatedAt, Id
|
||||||
|
LIMIT 1
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsOwner",
|
||||||
|
table: "Players");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,6 +70,11 @@ namespace GameList.Data.Migrations
|
|||||||
.HasColumnType("INTEGER")
|
.HasColumnType("INTEGER")
|
||||||
.HasDefaultValue(false);
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<bool>("IsOwner")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
b.Property<DateTimeOffset?>("LastLoginAt")
|
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public class Player
|
|||||||
|
|
||||||
public DateTimeOffset? LastLoginAt { get; set; }
|
public DateTimeOffset? LastLoginAt { get; set; }
|
||||||
public bool IsAdmin { get; set; }
|
public bool IsAdmin { get; set; }
|
||||||
|
public bool IsOwner { get; set; }
|
||||||
public Phase CurrentPhase { get; set; } = Phase.Suggest;
|
public Phase CurrentPhase { get; set; } = Phase.Suggest;
|
||||||
public bool VotesFinal { get; set; }
|
public bool VotesFinal { get; set; }
|
||||||
public bool HasJoker { get; set; }
|
public bool HasJoker { get; set; }
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public static class AdminEndpoints
|
|||||||
admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, AdminWorkflowService service) => await service.GrantJokerAsync(request.PlayerId));
|
admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, AdminWorkflowService service) => await service.GrantJokerAsync(request.PlayerId));
|
||||||
|
|
||||||
admin.MapPost("/player-phase", async ([FromBody] SetPlayerPhaseRequest request, AdminWorkflowService service) => await service.SetPlayerPhaseAsync(request.PlayerId, request.Phase));
|
admin.MapPost("/player-phase", async ([FromBody] SetPlayerPhaseRequest request, AdminWorkflowService service) => await service.SetPlayerPhaseAsync(request.PlayerId, request.Phase));
|
||||||
|
admin.MapPost("/player-admin", async ([FromBody] SetPlayerAdminRequest request, AdminWorkflowService service) => await service.SetPlayerAdminAsync(request.PlayerId, request.IsAdmin));
|
||||||
|
|
||||||
admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, [FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
|
admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, [FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -42,7 +42,17 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
|||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(p => p.Suggestions)
|
.Include(p => p.Suggestions)
|
||||||
.OrderBy(p => p.DisplayName ?? p.Username)
|
.OrderBy(p => p.DisplayName ?? p.Username)
|
||||||
.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.IsAdmin,
|
||||||
|
p.IsOwner,
|
||||||
|
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.Finalized).Select(v => v.Name).ToList();
|
||||||
@@ -87,6 +97,21 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
|||||||
return Results.Ok(new AdminSetPlayerPhaseResponse(player.Id, player.CurrentPhase, player.VotesFinal));
|
return Results.Ok(new AdminSetPlayerPhaseResponse(player.Id, player.CurrentPhase, player.VotesFinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IResult> SetPlayerAdminAsync(Guid playerId, bool isAdmin)
|
||||||
|
{
|
||||||
|
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
|
||||||
|
if (player is null)
|
||||||
|
return EndpointHelpers.NotFoundError("Player not found.");
|
||||||
|
|
||||||
|
if (player.IsOwner)
|
||||||
|
return EndpointHelpers.BadRequestError("Owner permissions cannot be changed.");
|
||||||
|
|
||||||
|
player.IsAdmin = isAdmin;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Results.Ok(new AdminSetPlayerAdminResponse(player.Id, player.IsAdmin));
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IResult> DeletePlayerAsync(Guid playerId, Guid adminPlayerId, string password, HttpContext ctx)
|
public async Task<IResult> DeletePlayerAsync(Guid playerId, Guid adminPlayerId, string password, HttpContext ctx)
|
||||||
{
|
{
|
||||||
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx);
|
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx);
|
||||||
@@ -96,6 +121,8 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
|||||||
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.");
|
||||||
|
if (player.IsOwner)
|
||||||
|
return EndpointHelpers.BadRequestError("Owner account cannot be deleted.");
|
||||||
|
|
||||||
await using var tx = await db.Database.BeginTransactionAsync();
|
await using var tx = await db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ public static class AuthEndpoints
|
|||||||
{
|
{
|
||||||
var group = app.MapGroup("/api/auth").RequireRateLimiting("auth-sensitive");
|
var group = app.MapGroup("/api/auth").RequireRateLimiting("auth-sensitive");
|
||||||
|
|
||||||
|
group.MapGet("/options", async (AppDbContext db) =>
|
||||||
|
{
|
||||||
|
var ownerExists = await db.Players.AsNoTracking().AnyAsync(p => p.IsOwner);
|
||||||
|
return Results.Ok(new AuthOptionsResponse(ownerExists));
|
||||||
|
});
|
||||||
|
|
||||||
group.MapPost("/register", async ([FromBody] RegisterRequest request, HttpContext ctx, AppDbContext db, IConfiguration config, AuthAttemptMonitor authAttemptMonitor) =>
|
group.MapPost("/register", async ([FromBody] RegisterRequest request, HttpContext ctx, AppDbContext db, IConfiguration config, AuthAttemptMonitor authAttemptMonitor) =>
|
||||||
{
|
{
|
||||||
if (!AuthValidator.TryValidateRegistration(request, out var validated, out var registrationError))
|
if (!AuthValidator.TryValidateRegistration(request, out var validated, out var registrationError))
|
||||||
@@ -37,15 +43,16 @@ public static class AuthEndpoints
|
|||||||
return EndpointHelpers.BadRequestError("Invalid admin key.");
|
return EndpointHelpers.BadRequestError("Invalid admin key.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var adminExists = await db.Players.AnyAsync(p => p.IsAdmin);
|
var ownerExists = await db.Players.AsNoTracking().AnyAsync(p => p.IsOwner);
|
||||||
if (adminExists)
|
if (ownerExists)
|
||||||
{
|
{
|
||||||
authAttemptMonitor.RecordFailure(ctx, "auth-register-admin", validated.NormalizedUsername, "bootstrap-admin-disabled");
|
authAttemptMonitor.RecordFailure(ctx, "auth-register-admin", validated.NormalizedUsername, "bootstrap-admin-disabled");
|
||||||
return EndpointHelpers.BadRequestError("Admin registration via admin key is disabled after the first admin account.");
|
return EndpointHelpers.BadRequestError("Admin registration via admin key is disabled once an owner account exists.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var isAdmin = wantsAdmin;
|
var isAdmin = wantsAdmin;
|
||||||
|
var isOwner = wantsAdmin;
|
||||||
|
|
||||||
var player = new Player
|
var player = new Player
|
||||||
{
|
{
|
||||||
@@ -56,6 +63,7 @@ public static class AuthEndpoints
|
|||||||
PasswordSalt = salt,
|
PasswordSalt = salt,
|
||||||
DisplayName = validated.DisplayName,
|
DisplayName = validated.DisplayName,
|
||||||
IsAdmin = isAdmin,
|
IsAdmin = isAdmin,
|
||||||
|
IsOwner = isOwner,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
LastLoginAt = DateTimeOffset.UtcNow
|
LastLoginAt = DateTimeOffset.UtcNow
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ internal sealed class StateWorkflowService(AppDbContext db)
|
|||||||
player.Username,
|
player.Username,
|
||||||
player.DisplayName,
|
player.DisplayName,
|
||||||
player.IsAdmin,
|
player.IsAdmin,
|
||||||
|
player.IsOwner,
|
||||||
phase,
|
phase,
|
||||||
player.VotesFinal,
|
player.VotesFinal,
|
||||||
player.HasJoker
|
player.HasJoker
|
||||||
|
|||||||
@@ -94,6 +94,82 @@ public class AdminTests
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Admin_can_grant_and_revoke_admin_for_non_owner_accounts()
|
||||||
|
{
|
||||||
|
await using var factory = new TestWebApplicationFactory();
|
||||||
|
var owner = factory.CreateClientWithCookies();
|
||||||
|
await owner.RegisterAsync("owner", admin: true);
|
||||||
|
var player = factory.CreateClientWithCookies();
|
||||||
|
await player.RegisterAsync("player");
|
||||||
|
var playerId = await player.GetProfileIdAsync();
|
||||||
|
|
||||||
|
var grant = await owner.PostAsJsonAsync("/api/admin/player-admin", new
|
||||||
|
{
|
||||||
|
playerId = playerId,
|
||||||
|
isAdmin = true
|
||||||
|
});
|
||||||
|
grant.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
await factory.WithDbContextAsync(async db =>
|
||||||
|
{
|
||||||
|
var promoted = await db.Players.AsNoTracking().SingleAsync(p => p.Id == playerId);
|
||||||
|
Assert.True(promoted.IsAdmin);
|
||||||
|
Assert.False(promoted.IsOwner);
|
||||||
|
});
|
||||||
|
|
||||||
|
var revoke = await owner.PostAsJsonAsync("/api/admin/player-admin", new
|
||||||
|
{
|
||||||
|
playerId = playerId,
|
||||||
|
isAdmin = false
|
||||||
|
});
|
||||||
|
revoke.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
await factory.WithDbContextAsync(async db =>
|
||||||
|
{
|
||||||
|
var demoted = await db.Players.AsNoTracking().SingleAsync(p => p.Id == playerId);
|
||||||
|
Assert.False(demoted.IsAdmin);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Owner_admin_role_cannot_be_changed_or_deleted()
|
||||||
|
{
|
||||||
|
await using var factory = new TestWebApplicationFactory();
|
||||||
|
var owner = factory.CreateClientWithCookies();
|
||||||
|
await owner.RegisterAsync("owner", admin: true);
|
||||||
|
var ownerId = await owner.GetProfileIdAsync();
|
||||||
|
|
||||||
|
var toggleOwner = await owner.PostAsJsonAsync("/api/admin/player-admin", new
|
||||||
|
{
|
||||||
|
playerId = ownerId,
|
||||||
|
isAdmin = false
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, toggleOwner.StatusCode);
|
||||||
|
|
||||||
|
var deleteOwner = await owner.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{ownerId}")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new { password = AdminPassword })
|
||||||
|
});
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, deleteOwner.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Set_player_admin_returns_not_found_for_unknown_player()
|
||||||
|
{
|
||||||
|
await using var factory = new TestWebApplicationFactory();
|
||||||
|
var owner = factory.CreateClientWithCookies();
|
||||||
|
await owner.RegisterAsync("owner", admin: true);
|
||||||
|
|
||||||
|
var response = await owner.PostAsJsonAsync("/api/admin/player-admin", new
|
||||||
|
{
|
||||||
|
playerId = Guid.NewGuid(),
|
||||||
|
isAdmin = true
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Admin_player_phase_requires_vote_phase_and_suggest_target()
|
public async Task Admin_player_phase_requires_vote_phase_and_suggest_target()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -117,6 +117,13 @@ public class AuthTests
|
|||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
Assert.True(json.GetProperty("isAdmin").GetBoolean());
|
Assert.True(json.GetProperty("isAdmin").GetBoolean());
|
||||||
|
|
||||||
|
await factory.WithDbContextAsync(async db =>
|
||||||
|
{
|
||||||
|
var owner = await db.Players.AsNoTracking().SingleAsync(p => p.Username == "adminuser");
|
||||||
|
Assert.True(owner.IsOwner);
|
||||||
|
Assert.True(owner.IsAdmin);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -133,7 +140,23 @@ public class AuthTests
|
|||||||
Assert.Equal(HttpStatusCode.BadRequest, secondAdmin.StatusCode);
|
Assert.Equal(HttpStatusCode.BadRequest, secondAdmin.StatusCode);
|
||||||
|
|
||||||
var body = await secondAdmin.Content.ReadFromJsonAsync<JsonElement>();
|
var body = await secondAdmin.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
Assert.Equal("Admin registration via admin key is disabled after the first admin account.", body.GetProperty("error").GetString());
|
Assert.Equal("Admin registration via admin key is disabled once an owner account exists.", body.GetProperty("error").GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Auth_options_reports_owner_existence()
|
||||||
|
{
|
||||||
|
await using var factory = new TestWebApplicationFactory();
|
||||||
|
var client = factory.CreateClientWithCookies();
|
||||||
|
|
||||||
|
var before = await client.GetFromJsonAsync<JsonElement>("/api/auth/options");
|
||||||
|
Assert.False(before.GetProperty("ownerExists").GetBoolean());
|
||||||
|
|
||||||
|
var ownerRegister = await client.RegisterAsync("owner", admin: true);
|
||||||
|
ownerRegister.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var after = await client.GetFromJsonAsync<JsonElement>("/api/auth/options");
|
||||||
|
Assert.True(after.GetProperty("ownerExists").GetBoolean());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
|
|||||||
|
|
||||||
- Authentication: username/password with HttpOnly `player` cookie.
|
- Authentication: username/password with HttpOnly `player` cookie.
|
||||||
- Admin authorization: authenticated account with `IsAdmin=true`.
|
- Admin authorization: authenticated account with `IsAdmin=true`.
|
||||||
|
- Owner model: first valid admin-key registration becomes `owner`; admins can grant/revoke admin role for non-owner accounts.
|
||||||
- Gameplay phases: `Suggest`, `Vote`, `Results`.
|
- Gameplay phases: `Suggest`, `Vote`, `Results`.
|
||||||
- Storage: SQLite database under `App_Data/gamelist.db`.
|
- Storage: SQLite database under `App_Data/gamelist.db`.
|
||||||
- Security defaults: rate-limited auth/admin routes, baseline browser security headers, production HTTPS+HSTS enforcement.
|
- Security defaults: rate-limited auth/admin routes, baseline browser security headers, production HTTPS+HSTS enforcement.
|
||||||
|
|||||||
5
TESTS.md
5
TESTS.md
@@ -33,7 +33,8 @@ stateDiagram-v2
|
|||||||
### 1) Authentication & Identity
|
### 1) Authentication & Identity
|
||||||
- Register success (player, admin key path) issues cookie, trims fields, stores normalized username, hashes password.
|
- Register success (player, admin key path) issues cookie, trims fields, stores normalized username, hashes password.
|
||||||
- Register rejects missing/long username, weak password policy violations, missing display name, duplicate username, bad admin key, >24 chars username, >16 display name.
|
- Register rejects missing/long username, weak password policy violations, missing display name, duplicate username, bad admin key, >24 chars username, >16 display name.
|
||||||
- Bootstrap-admin key path only works until the first admin account exists.
|
- Bootstrap-admin key path only works until the owner account exists; bootstrap admin is marked as owner.
|
||||||
|
- `/api/auth/options` reports owner presence for registration UI behavior.
|
||||||
- Login success updates LastLoginAt and sets DisplayName if null; rejects wrong password/username; enforces length limits.
|
- Login success updates LastLoginAt and sets DisplayName if null; rejects wrong password/username; enforces length limits.
|
||||||
- Logout clears cookie.
|
- Logout clears cookie.
|
||||||
- EnsurePlayerExistsMiddleware: signed cookie for deleted player returns 401 and clears auth.
|
- EnsurePlayerExistsMiddleware: signed cookie for deleted player returns 401 and clears auth.
|
||||||
@@ -70,7 +71,9 @@ stateDiagram-v2
|
|||||||
- GET /admin/vote-status returns list ordered by display/username with suggestion counts, finalized flag, joker flag; ready/waiting derived correctly.
|
- GET /admin/vote-status returns list ordered by display/username with suggestion counts, finalized flag, joker flag; ready/waiting derived correctly.
|
||||||
- POST /admin/joker grants joker only when target in Vote; resets VotesFinal for target.
|
- POST /admin/joker grants joker only when target in Vote; resets VotesFinal for target.
|
||||||
- POST /admin/player-phase allows Vote->Suggest transitions only; rejects other targets/current phases; clears target VotesFinal.
|
- POST /admin/player-phase allows Vote->Suggest transitions only; rejects other targets/current phases; clears target VotesFinal.
|
||||||
|
- POST /admin/player-admin grants/revokes admin role for non-owner accounts; owner role cannot be changed.
|
||||||
- DELETE /admin/players/{id}: requires valid admin password; removes player, cascades suggestions, breaks links to their suggestions, deletes related votes, wrapped in transaction.
|
- DELETE /admin/players/{id}: requires valid admin password; removes player, cascades suggestions, breaks links to their suggestions, deletes related votes, wrapped in transaction.
|
||||||
|
- Owner account cannot be deleted.
|
||||||
- POST /admin/link-suggestions: only in Vote; errors on same ids/already linked/not found; re-parents groups correctly; deletes votes for affected group and unfinalizes affected players.
|
- POST /admin/link-suggestions: only in Vote; errors on same ids/already linked/not found; re-parents groups correctly; deletes votes for affected group and unfinalizes affected players.
|
||||||
- POST /admin/unlink-suggestions: only in Vote; clears parents for group, deletes votes in group, unfinalizes affected players; no-op safe when missing.
|
- POST /admin/unlink-suggestions: only in Vote; clears parents for group, deletes votes in group, unfinalizes affected players; no-op safe when missing.
|
||||||
- POST /admin/reset: requires valid admin password; wipes suggestions/votes, resets phases to Suggest, clears votesFinal/hasJoker, closes results, updates timestamp.
|
- POST /admin/reset: requires valid admin password; wipes suggestions/votes, resets phases to Suggest, clears votesFinal/hasJoker, closes results, updates timestamp.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Dein Anzeigename ist erforderlich ‒ er erscheint neben all deinen Vorschlägen
|
|||||||
### Brauche ich Admin-Rechte?
|
### Brauche ich Admin-Rechte?
|
||||||
|
|
||||||
Wenn du einen **Admin-Schlüssel** erhalten hast, gib ihn bei der Registrierung ein. Ist der Schlüssel ungültig, wird die Anfrage abgelehnt. Die Admin-Schlüssel-Registrierung ist nur verfügbar, bis das erste Admin-Konto erstellt wurde. Admin-Rechte können später nicht über die öffentliche Registrierung hinzugefügt werden.
|
Wenn du einen **Admin-Schlüssel** erhalten hast, gib ihn bei der Registrierung ein. Ist der Schlüssel ungültig, wird die Anfrage abgelehnt. Die Admin-Schlüssel-Registrierung ist nur verfügbar, bis das erste Admin-Konto erstellt wurde. Admin-Rechte können später nicht über die öffentliche Registrierung hinzugefügt werden.
|
||||||
|
Sobald ein Owner-Konto existiert, wird das Admin-Schlüssel-Feld in der Registrierung nicht mehr angezeigt.
|
||||||
|
|
||||||
## Phasen im Überblick
|
## Phasen im Überblick
|
||||||
|
|
||||||
@@ -152,6 +153,7 @@ Nein. Vorschläge und Bewertungen sind schreibgeschützt. Wende dich bei Bedarf
|
|||||||
- Joker während der Abstimmung vergeben
|
- Joker während der Abstimmung vergeben
|
||||||
- Einen Bewerter zurück in die Vorschlagsphase setzen (stärker als ein Joker; sparsam einsetzen)
|
- 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)
|
- Ergebniszugriff mit einem einzelnen Button umschalten (Beschriftung wechselt je nach Zustand)
|
||||||
|
- Admin-Rechte für Nicht-Owner-Konten in der Spielertabelle vergeben oder entziehen
|
||||||
- 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)
|
||||||
@@ -163,6 +165,7 @@ Nein. Vorschläge und Bewertungen sind schreibgeschützt. Wende dich bei Bedarf
|
|||||||
### Was können Admin-Konten nicht tun?
|
### Was können Admin-Konten nicht tun?
|
||||||
|
|
||||||
- Einzelne Spielerbewertungen einsehen
|
- Einzelne Spielerbewertungen einsehen
|
||||||
|
- Owner-Rechte entziehen oder das Owner-Konto löschen
|
||||||
|
|
||||||
Die Abstimmung bleibt anonym und fair.
|
Die Abstimmung bleibt anonym und fair.
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ Your display name is required ‒ it appears next to all of your suggestions and
|
|||||||
|
|
||||||
If you've been given an **admin key**, enter it during registration. If the key is invalid, the request is rejected.
|
If you've been given an **admin key**, enter it during registration. If the key is invalid, the request is rejected.
|
||||||
Admin-key bootstrap is only available until the first admin account exists. Admin access cannot be added later. To become an admin afterward, an existing admin must create/manage access outside the public registration flow.
|
Admin-key bootstrap is only available until the first admin account exists. Admin access cannot be added later. To become an admin afterward, an existing admin must create/manage access outside the public registration flow.
|
||||||
|
Once an owner account exists, the registration form no longer shows the admin-key field.
|
||||||
|
|
||||||
## Phases at a Glance
|
## Phases at a Glance
|
||||||
|
|
||||||
@@ -156,6 +157,7 @@ No. Suggestions and votes are read-only. Contact an admin for assistance.
|
|||||||
- Grant jokers during Vote
|
- Grant jokers during Vote
|
||||||
- Move a voter back to Suggest (stronger than a joker; use sparingly)
|
- Move a voter back to Suggest (stronger than a joker; use sparingly)
|
||||||
- Toggle results access with a single button (label switches by current state)
|
- Toggle results access with a single button (label switches by current state)
|
||||||
|
- Grant or revoke admin role for any non-owner account from the player table
|
||||||
- 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)
|
||||||
@@ -167,6 +169,7 @@ No. Suggestions and votes are read-only. Contact an admin for assistance.
|
|||||||
### What can't admin accounts do?
|
### What can't admin accounts do?
|
||||||
|
|
||||||
- View individual player votes
|
- View individual player votes
|
||||||
|
- Revoke owner permissions or delete the owner account
|
||||||
|
|
||||||
Voting remains anonymous and fair.
|
Voting remains anonymous and fair.
|
||||||
|
|
||||||
|
|||||||
@@ -124,13 +124,16 @@
|
|||||||
"admin.playerStatus": "Status",
|
"admin.playerStatus": "Status",
|
||||||
"admin.playerGames": "Games",
|
"admin.playerGames": "Games",
|
||||||
"admin.playerJoker": "Joker",
|
"admin.playerJoker": "Joker",
|
||||||
|
"admin.playerAdmin": "Admin",
|
||||||
"admin.playerDelete": "Delete",
|
"admin.playerDelete": "Delete",
|
||||||
|
"admin.owner": "owner",
|
||||||
"admin.grantJokerChip": "Grant",
|
"admin.grantJokerChip": "Grant",
|
||||||
"admin.statusSuggesting": "Suggesting",
|
"admin.statusSuggesting": "Suggesting",
|
||||||
"admin.statusVoting": "Voting",
|
"admin.statusVoting": "Voting",
|
||||||
"admin.statusFinished": "Finished",
|
"admin.statusFinished": "Finished",
|
||||||
"admin.statusMoveToSuggest": "Move to Suggest",
|
"admin.statusMoveToSuggest": "Move to Suggest",
|
||||||
"admin.statusUpdated": "Player phase updated",
|
"admin.statusUpdated": "Player phase updated",
|
||||||
|
"admin.roleUpdated": "Admin role updated",
|
||||||
"admin.deleteTitle": "Delete account?",
|
"admin.deleteTitle": "Delete account?",
|
||||||
"admin.deleteBody": "Delete player \"{name}\" and all their games and votes? This cannot be undone.",
|
"admin.deleteBody": "Delete player \"{name}\" and all their games and votes? This cannot be undone.",
|
||||||
"admin.deleteConfirm": "Delete",
|
"admin.deleteConfirm": "Delete",
|
||||||
@@ -294,13 +297,16 @@
|
|||||||
"admin.playerStatus": "Status",
|
"admin.playerStatus": "Status",
|
||||||
"admin.playerGames": "Spiele",
|
"admin.playerGames": "Spiele",
|
||||||
"admin.playerJoker": "Joker",
|
"admin.playerJoker": "Joker",
|
||||||
|
"admin.playerAdmin": "Admin",
|
||||||
"admin.playerDelete": "Löschen",
|
"admin.playerDelete": "Löschen",
|
||||||
|
"admin.owner": "owner",
|
||||||
"admin.grantJokerChip": "Joker",
|
"admin.grantJokerChip": "Joker",
|
||||||
"admin.statusSuggesting": "Vorschlagen",
|
"admin.statusSuggesting": "Vorschlagen",
|
||||||
"admin.statusVoting": "Bewerten",
|
"admin.statusVoting": "Bewerten",
|
||||||
"admin.statusFinished": "Fertig",
|
"admin.statusFinished": "Fertig",
|
||||||
"admin.statusMoveToSuggest": "Zur Vorschlagsphase",
|
"admin.statusMoveToSuggest": "Zur Vorschlagsphase",
|
||||||
"admin.statusUpdated": "Spielerphase aktualisiert",
|
"admin.statusUpdated": "Spielerphase aktualisiert",
|
||||||
|
"admin.roleUpdated": "Admin-Rolle aktualisiert",
|
||||||
"admin.deleteTitle": "Konto löschen?",
|
"admin.deleteTitle": "Konto löschen?",
|
||||||
"admin.deleteBody": "Spieler \"{name}\" samt Spielen und Stimmen löschen? Dies kann nicht rückgängig gemacht werden.",
|
"admin.deleteBody": "Spieler \"{name}\" samt Spielen und Stimmen löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||||
"admin.deleteConfirm": "Löschen",
|
"admin.deleteConfirm": "Löschen",
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
<span class="label" data-i18n="auth.displayName">Display name (shows to group)</span>
|
<span class="label" data-i18n="auth.displayName">Display name (shows to group)</span>
|
||||||
<input id="register-displayName" name="displayName" maxlength="16" required />
|
<input id="register-displayName" name="displayName" maxlength="16" required />
|
||||||
</label>
|
</label>
|
||||||
<label class="stack">
|
<label class="stack" id="register-admin-key-field">
|
||||||
<span class="label" data-i18n="auth.adminKey">Admin key (optional)</span>
|
<span class="label" data-i18n="auth.adminKey">Admin key (optional)</span>
|
||||||
<input id="register-adminkey" name="adminKey" type="password" maxlength="128" />
|
<input id="register-adminkey" name="adminKey" type="password" maxlength="128" />
|
||||||
</label>
|
</label>
|
||||||
@@ -172,6 +172,7 @@
|
|||||||
<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">Joker</th>
|
||||||
|
<th data-i18n="admin.playerAdmin">Admin</th>
|
||||||
<th data-i18n="admin.playerDelete">Delete</th>
|
<th data-i18n="admin.playerDelete">Delete</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -72,6 +72,21 @@ export function renderAdminVoteStatus() {
|
|||||||
jokerButton.textContent = v.hasJoker ? "🎟" : t("admin.grantJokerChip");
|
jokerButton.textContent = v.hasJoker ? "🎟" : t("admin.grantJokerChip");
|
||||||
jokerCell.appendChild(jokerButton);
|
jokerCell.appendChild(jokerButton);
|
||||||
|
|
||||||
|
const adminCell = document.createElement("td");
|
||||||
|
if (v.isOwner) {
|
||||||
|
const ownerLabel = document.createElement("span");
|
||||||
|
ownerLabel.className = "muted small";
|
||||||
|
ownerLabel.textContent = t("admin.owner");
|
||||||
|
adminCell.appendChild(ownerLabel);
|
||||||
|
} else {
|
||||||
|
const adminCheckbox = document.createElement("input");
|
||||||
|
adminCheckbox.type = "checkbox";
|
||||||
|
adminCheckbox.dataset.setPlayerAdmin = v.playerId;
|
||||||
|
adminCheckbox.checked = !!v.isAdmin;
|
||||||
|
adminCheckbox.setAttribute("aria-label", t("admin.playerAdmin"));
|
||||||
|
adminCell.appendChild(adminCheckbox);
|
||||||
|
}
|
||||||
|
|
||||||
const deleteCell = document.createElement("td");
|
const deleteCell = document.createElement("td");
|
||||||
const deleteButton = document.createElement("button");
|
const deleteButton = document.createElement("button");
|
||||||
deleteButton.className = "chip danger-chip";
|
deleteButton.className = "chip danger-chip";
|
||||||
@@ -87,6 +102,7 @@ export function renderAdminVoteStatus() {
|
|||||||
statusCell,
|
statusCell,
|
||||||
countCell,
|
countCell,
|
||||||
jokerCell,
|
jokerCell,
|
||||||
|
adminCell,
|
||||||
deleteCell,
|
deleteCell,
|
||||||
);
|
);
|
||||||
table.appendChild(tr);
|
table.appendChild(tr);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ async function request(path, { method = "GET", body } = {}) {
|
|||||||
export const api = {
|
export const api = {
|
||||||
state: () => request("/api/state"),
|
state: () => request("/api/state"),
|
||||||
me: () => request("/api/me"),
|
me: () => request("/api/me"),
|
||||||
|
authOptions: () => request("/api/auth/options"),
|
||||||
register: (payload) => request("/api/auth/register", { method: "POST", body: payload }),
|
register: (payload) => request("/api/auth/register", { method: "POST", body: payload }),
|
||||||
login: (payload) => request("/api/auth/login", { method: "POST", body: payload }),
|
login: (payload) => request("/api/auth/login", { method: "POST", body: payload }),
|
||||||
logout: () => request("/api/auth/logout", { method: "POST" }),
|
logout: () => request("/api/auth/logout", { method: "POST" }),
|
||||||
@@ -61,6 +62,11 @@ export const adminApi = {
|
|||||||
factoryReset: (password) =>
|
factoryReset: (password) =>
|
||||||
request("/api/admin/factory-reset", { method: "POST", body: { password } }),
|
request("/api/admin/factory-reset", { method: "POST", body: { password } }),
|
||||||
grantJoker: (playerId) => request("/api/admin/joker", { method: "POST", body: { playerId } }),
|
grantJoker: (playerId) => request("/api/admin/joker", { method: "POST", body: { playerId } }),
|
||||||
|
setPlayerAdmin: (playerId, isAdmin) =>
|
||||||
|
request("/api/admin/player-admin", {
|
||||||
|
method: "POST",
|
||||||
|
body: { playerId, isAdmin },
|
||||||
|
}),
|
||||||
setPlayerPhase: (playerId, phase) =>
|
setPlayerPhase: (playerId, phase) =>
|
||||||
request("/api/admin/player-phase", { method: "POST", body: { playerId, phase } }),
|
request("/api/admin/player-phase", { method: "POST", body: { playerId, phase } }),
|
||||||
deletePlayer: (playerId, password) =>
|
deletePlayer: (playerId, password) =>
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ function setupPlayerTableActions(runSerializedRefresh) {
|
|||||||
const playerTable = $("admin-player-table");
|
const playerTable = $("admin-player-table");
|
||||||
if (!playerTable) return;
|
if (!playerTable) return;
|
||||||
const phaseSelectSelector = "[data-set-player-phase]";
|
const phaseSelectSelector = "[data-set-player-phase]";
|
||||||
|
const adminCheckboxSelector = "[data-set-player-admin]";
|
||||||
|
|
||||||
playerTable.addEventListener("focusin", (e) => {
|
playerTable.addEventListener("focusin", (e) => {
|
||||||
if (e.target.matches?.(phaseSelectSelector)) {
|
if (e.target.matches?.(phaseSelectSelector)) {
|
||||||
@@ -144,6 +145,25 @@ function setupPlayerTableActions(runSerializedRefresh) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
playerTable.addEventListener("change", async (e) => {
|
playerTable.addEventListener("change", async (e) => {
|
||||||
|
const adminCheckbox = e.target.closest(adminCheckboxSelector);
|
||||||
|
if (adminCheckbox) {
|
||||||
|
const playerId = adminCheckbox.dataset.setPlayerAdmin;
|
||||||
|
if (!playerId) return;
|
||||||
|
const previous = !adminCheckbox.checked;
|
||||||
|
adminCheckbox.disabled = true;
|
||||||
|
try {
|
||||||
|
await adminApi.setPlayerAdmin(playerId, adminCheckbox.checked);
|
||||||
|
toast(t("admin.roleUpdated"));
|
||||||
|
await runSerializedRefresh();
|
||||||
|
} catch (err) {
|
||||||
|
adminCheckbox.checked = previous;
|
||||||
|
toast(err.message, true);
|
||||||
|
} finally {
|
||||||
|
adminCheckbox.disabled = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const select = e.target.closest(phaseSelectSelector);
|
const select = e.target.closest(phaseSelectSelector);
|
||||||
if (!select) return;
|
if (!select) return;
|
||||||
const playerId = select.dataset.setPlayerPhase;
|
const playerId = select.dataset.setPlayerPhase;
|
||||||
|
|||||||
@@ -46,6 +46,29 @@ function setupAuthModeToggle() {
|
|||||||
setAuthMode(state.authMode);
|
setAuthMode(state.authMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyRegistrationOptions(ownerExists) {
|
||||||
|
state.ownerExists = !!ownerExists;
|
||||||
|
const adminKeyField = $("register-admin-key-field");
|
||||||
|
const adminKeyInput = $("register-adminkey");
|
||||||
|
if (!adminKeyField || !adminKeyInput) return;
|
||||||
|
|
||||||
|
const hideAdminKeyInput = state.ownerExists;
|
||||||
|
adminKeyField.classList.toggle("hidden", hideAdminKeyInput);
|
||||||
|
adminKeyInput.disabled = hideAdminKeyInput;
|
||||||
|
if (hideAdminKeyInput) {
|
||||||
|
adminKeyInput.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshRegistrationOptions() {
|
||||||
|
try {
|
||||||
|
const options = await api.authOptions();
|
||||||
|
applyRegistrationOptions(options?.ownerExists);
|
||||||
|
} catch {
|
||||||
|
applyRegistrationOptions(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setupLoginUserEditingHint() {
|
function setupLoginUserEditingHint() {
|
||||||
const loginUser = $("login-username");
|
const loginUser = $("login-username");
|
||||||
if (!loginUser) return;
|
if (!loginUser) return;
|
||||||
@@ -121,6 +144,7 @@ function setupRegisterFormHandlers({
|
|||||||
return toast(t("auth.cookieRequired"), true);
|
return toast(t("auth.cookieRequired"), true);
|
||||||
try {
|
try {
|
||||||
await api.register({ username, password, displayName, adminKey });
|
await api.register({ username, password, displayName, adminKey });
|
||||||
|
await refreshRegistrationOptions();
|
||||||
setConsent();
|
setConsent();
|
||||||
toggleConsentRows();
|
toggleConsentRows();
|
||||||
setSavedUsername(username);
|
setSavedUsername(username);
|
||||||
@@ -152,6 +176,7 @@ function setupLogoutHandler() {
|
|||||||
clearUserState();
|
clearUserState();
|
||||||
state.isAuthenticated = false;
|
state.isAuthenticated = false;
|
||||||
setAuthUI(false);
|
setAuthUI(false);
|
||||||
|
await refreshRegistrationOptions();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +203,7 @@ function setupSuggestionEntryButtons() {
|
|||||||
|
|
||||||
export function setupAuthHandlers({ runSerializedRefresh }) {
|
export function setupAuthHandlers({ runSerializedRefresh }) {
|
||||||
setupAuthModeToggle();
|
setupAuthModeToggle();
|
||||||
|
refreshRegistrationOptions();
|
||||||
const consent = setupConsentRows();
|
const consent = setupConsentRows();
|
||||||
setupLoginUserEditingHint();
|
setupLoginUserEditingHint();
|
||||||
setupLoginFormHandlers({ ...consent, runSerializedRefresh });
|
setupLoginFormHandlers({ ...consent, runSerializedRefresh });
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export const state = {
|
export const state = {
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
ownerExists: false,
|
||||||
authMode: "login",
|
authMode: "login",
|
||||||
me: null,
|
me: null,
|
||||||
phase: null,
|
phase: null,
|
||||||
@@ -19,6 +20,7 @@ export const state = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function clearUserState() {
|
export function clearUserState() {
|
||||||
|
state.ownerExists = false;
|
||||||
state.me = null;
|
state.me = null;
|
||||||
state.phase = null;
|
state.phase = null;
|
||||||
state.prevPhase = null;
|
state.prevPhase = null;
|
||||||
|
|||||||
Reference in New Issue
Block a user