Add owner role and admin management controls

This commit is contained in:
2026-02-08 19:01:58 +01:00
parent 97f1b30b75
commit 1c59d68a50
25 changed files with 540 additions and 9 deletions

6
API.md
View File

@@ -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`).

View File

@@ -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);

View File

@@ -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

View File

@@ -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);

View 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
}
}
}

View 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");
}
}
}

View File

@@ -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");

View File

@@ -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; }

View File

@@ -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) =>
{ {

View File

@@ -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();

View File

@@ -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
}; };

View File

@@ -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

View File

@@ -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()
{ {

View File

@@ -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]

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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",

View File

@@ -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>

View File

@@ -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);

View File

@@ -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) =>

View File

@@ -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;

View File

@@ -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 });

View File

@@ -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;