diff --git a/API.md b/API.md index 2124753..8e41245 100644 --- a/API.md +++ b/API.md @@ -5,14 +5,16 @@ Auth and admin-sensitive routes are rate-limited and return HTTP `429` on excess ## Auth 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/logout Display names are set during registration and are immutable afterward. 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) 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) 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) 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-admin — `{ playerId, isAdmin }`; grant/revoke admin role for non-owner accounts DELETE /api/admin/players/{playerId} — `{ password }`; deletes player account plus their suggestions/votes POST /api/admin/link-suggestions — `{ sourceSuggestionId, targetSuggestionId }`; merges vote groups during Vote, clears votes in the linked group, unfinalizes **all** players POST /api/admin/unlink-suggestions — `{ suggestionId }`; breaks links, clears votes for that group, unfinalizes **all** players POST /api/admin/reset — `{ password }`; clear suggestions/votes, keep players, reset phases/vote-final flags 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 headers are set on all responses (`CSP`, `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`). diff --git a/Contracts/Dtos.cs b/Contracts/Dtos.cs index ffdbd82..21268d9 100644 --- a/Contracts/Dtos.cs +++ b/Contracts/Dtos.cs @@ -12,7 +12,7 @@ public record ResultsOpenRequest(bool ResultsOpen); public record VoteFinalizeRequest(bool Final); -public record VoteStatusDto(Guid PlayerId, string Name, string Username, Phase Phase, bool Finalized, bool HasJoker, int SuggestionCount, IReadOnlyList SuggestionTitles); +public record VoteStatusDto(Guid PlayerId, string Name, string Username, Phase Phase, bool Finalized, bool HasJoker, bool IsAdmin, bool IsOwner, int SuggestionCount, IReadOnlyList SuggestionTitles); public record LinkSuggestionsRequest(int SourceSuggestionId, int TargetSuggestionId); @@ -21,5 +21,6 @@ public record UnlinkSuggestionsRequest(int SuggestionId); public record GrantJokerRequest(Guid PlayerId); public record SetPlayerPhaseRequest(Guid PlayerId, Phase Phase); +public record SetPlayerAdminRequest(Guid PlayerId, bool IsAdmin); public record AdminPasswordRequest(string Password); diff --git a/Contracts/Responses.cs b/Contracts/Responses.cs index e8dccc6..254ae03 100644 --- a/Contracts/Responses.cs +++ b/Contracts/Responses.cs @@ -25,6 +25,7 @@ public record AdminResultsStateResponse(bool ResultsOpen, DateTimeOffset Updated public record AdminGrantJokerResponse(Guid Id, bool HasJoker); public record AdminSetPlayerPhaseResponse(Guid PlayerId, Phase CurrentPhase, bool VotesFinal); +public record AdminSetPlayerAdminResponse(Guid PlayerId, bool IsAdmin); 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 AuthOptionsResponse(bool OwnerExists); public record StateSummaryResponse( Phase CurrentPhase, @@ -75,6 +77,7 @@ public record MeResponse( string Username, string? DisplayName, bool IsAdmin, + bool IsOwner, Phase CurrentPhase, bool VotesFinal, bool HasJoker diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index 5ebdffb..768558e 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -22,6 +22,7 @@ public class AppDbContext(DbContextOptions options) : DbContext(op builder.Property(p => p.PasswordHash).IsRequired(); builder.Property(p => p.PasswordSalt).IsRequired(); 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.CurrentPhase).HasDefaultValue(Phase.Suggest); builder.Property(p => p.VotesFinal).HasDefaultValue(false); diff --git a/Data/Migrations/20260208175912_AddOwnerRole.Designer.cs b/Data/Migrations/20260208175912_AddOwnerRole.Designer.cs new file mode 100644 index 0000000..9d0cda4 --- /dev/null +++ b/Data/Migrations/20260208175912_AddOwnerRole.Designer.cs @@ -0,0 +1,251 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ResultsOpen") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentPhase") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("DisplayName") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("HasJoker") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("IsAdmin") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("IsOwner") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("PasswordSalt") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("VotesFinal") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("GameList.Domain.Suggestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("GameUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("Genre") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxPlayers") + .HasColumnType("INTEGER"); + + b.Property("MinPlayers") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ParentSuggestionId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("TEXT"); + + b.Property("ScreenshotUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("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 + } + } +} diff --git a/Data/Migrations/20260208175912_AddOwnerRole.cs b/Data/Migrations/20260208175912_AddOwnerRole.cs new file mode 100644 index 0000000..757be24 --- /dev/null +++ b/Data/Migrations/20260208175912_AddOwnerRole.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GameList.Data.Migrations +{ + /// + public partial class AddOwnerRole : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + 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 + ); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsOwner", + table: "Players"); + } + } +} diff --git a/Data/Migrations/AppDbContextModelSnapshot.cs b/Data/Migrations/AppDbContextModelSnapshot.cs index cd37628..88e7b95 100644 --- a/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/Data/Migrations/AppDbContextModelSnapshot.cs @@ -70,6 +70,11 @@ namespace GameList.Data.Migrations .HasColumnType("INTEGER") .HasDefaultValue(false); + b.Property("IsOwner") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + b.Property("LastLoginAt") .HasColumnType("TEXT"); diff --git a/Domain/Player.cs b/Domain/Player.cs index bcdee37..8378c44 100644 --- a/Domain/Player.cs +++ b/Domain/Player.cs @@ -20,6 +20,7 @@ public class Player public DateTimeOffset? LastLoginAt { get; set; } public bool IsAdmin { get; set; } + public bool IsOwner { get; set; } public Phase CurrentPhase { get; set; } = Phase.Suggest; public bool VotesFinal { get; set; } public bool HasJoker { get; set; } diff --git a/Endpoints/AdminEndpoints.cs b/Endpoints/AdminEndpoints.cs index 829065e..06e387f 100644 --- a/Endpoints/AdminEndpoints.cs +++ b/Endpoints/AdminEndpoints.cs @@ -19,6 +19,7 @@ public static class AdminEndpoints 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-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) => { diff --git a/Endpoints/AdminWorkflowService.cs b/Endpoints/AdminWorkflowService.cs index 67a6108..7b141c6 100644 --- a/Endpoints/AdminWorkflowService.cs +++ b/Endpoints/AdminWorkflowService.cs @@ -42,7 +42,17 @@ internal sealed class AdminWorkflowService(AppDbContext db) .AsNoTracking() .Include(p => p.Suggestions) .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(); 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)); } + public async Task 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 DeletePlayerAsync(Guid playerId, Guid adminPlayerId, string password, HttpContext 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); if (player is null) 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(); diff --git a/Endpoints/AuthEndpoints.cs b/Endpoints/AuthEndpoints.cs index c5993c0..e2c124f 100644 --- a/Endpoints/AuthEndpoints.cs +++ b/Endpoints/AuthEndpoints.cs @@ -14,6 +14,12 @@ public static class AuthEndpoints { 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) => { if (!AuthValidator.TryValidateRegistration(request, out var validated, out var registrationError)) @@ -37,15 +43,16 @@ public static class AuthEndpoints return EndpointHelpers.BadRequestError("Invalid admin key."); } - var adminExists = await db.Players.AnyAsync(p => p.IsAdmin); - if (adminExists) + var ownerExists = await db.Players.AsNoTracking().AnyAsync(p => p.IsOwner); + if (ownerExists) { 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 isOwner = wantsAdmin; var player = new Player { @@ -56,6 +63,7 @@ public static class AuthEndpoints PasswordSalt = salt, DisplayName = validated.DisplayName, IsAdmin = isAdmin, + IsOwner = isOwner, CreatedAt = DateTimeOffset.UtcNow, LastLoginAt = DateTimeOffset.UtcNow }; diff --git a/Endpoints/StateWorkflowService.cs b/Endpoints/StateWorkflowService.cs index 8e11142..e3c005d 100644 --- a/Endpoints/StateWorkflowService.cs +++ b/Endpoints/StateWorkflowService.cs @@ -33,6 +33,7 @@ internal sealed class StateWorkflowService(AppDbContext db) player.Username, player.DisplayName, player.IsAdmin, + player.IsOwner, phase, player.VotesFinal, player.HasJoker diff --git a/GameList.Tests/AdminTests.cs b/GameList.Tests/AdminTests.cs index ea3113e..662505c 100644 --- a/GameList.Tests/AdminTests.cs +++ b/GameList.Tests/AdminTests.cs @@ -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] public async Task Admin_player_phase_requires_vote_phase_and_suggest_target() { diff --git a/GameList.Tests/AuthTests.cs b/GameList.Tests/AuthTests.cs index 516ab29..7a2a8d3 100644 --- a/GameList.Tests/AuthTests.cs +++ b/GameList.Tests/AuthTests.cs @@ -117,6 +117,13 @@ public class AuthTests response.EnsureSuccessStatusCode(); var json = await response.Content.ReadFromJsonAsync(); 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] @@ -133,7 +140,23 @@ public class AuthTests Assert.Equal(HttpStatusCode.BadRequest, secondAdmin.StatusCode); var body = await secondAdmin.Content.ReadFromJsonAsync(); - 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("/api/auth/options"); + Assert.False(before.GetProperty("ownerExists").GetBoolean()); + + var ownerRegister = await client.RegisterAsync("owner", admin: true); + ownerRegister.EnsureSuccessStatusCode(); + + var after = await client.GetFromJsonAsync("/api/auth/options"); + Assert.True(after.GetProperty("ownerExists").GetBoolean()); } [Fact] diff --git a/README.md b/README.md index 7e23b5a..87e0774 100644 --- a/README.md +++ b/README.md @@ -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. - 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`. - Storage: SQLite database under `App_Data/gamelist.db`. - Security defaults: rate-limited auth/admin routes, baseline browser security headers, production HTTPS+HSTS enforcement. diff --git a/TESTS.md b/TESTS.md index 1cfd947..a1ff9a4 100644 --- a/TESTS.md +++ b/TESTS.md @@ -33,7 +33,8 @@ stateDiagram-v2 ### 1) Authentication & Identity - 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. -- 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. - Logout clears cookie. - 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. - 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-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. +- 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/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. diff --git a/wwwroot/data/i18n/faq/de.md b/wwwroot/data/i18n/faq/de.md index 184766b..053a499 100644 --- a/wwwroot/data/i18n/faq/de.md +++ b/wwwroot/data/i18n/faq/de.md @@ -14,6 +14,7 @@ Dein Anzeigename ist erforderlich ‒ er erscheint neben all deinen Vorschlägen ### 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. +Sobald ein Owner-Konto existiert, wird das Admin-Schlüssel-Feld in der Registrierung nicht mehr angezeigt. ## 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 - 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) +- Admin-Rechte für Nicht-Owner-Konten in der Spielertabelle vergeben oder entziehen - Doppelte Vorschläge verknüpfen oder trennen - Vorschläge löschen - 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? - Einzelne Spielerbewertungen einsehen +- Owner-Rechte entziehen oder das Owner-Konto löschen Die Abstimmung bleibt anonym und fair. diff --git a/wwwroot/data/i18n/faq/en.md b/wwwroot/data/i18n/faq/en.md index 853c868..de62a0a 100644 --- a/wwwroot/data/i18n/faq/en.md +++ b/wwwroot/data/i18n/faq/en.md @@ -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. 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 @@ -156,6 +157,7 @@ No. Suggestions and votes are read-only. Contact an admin for assistance. - Grant jokers during Vote - Move a voter back to Suggest (stronger than a joker; use sparingly) - Toggle results access with a single button (label switches by current state) +- Grant or revoke admin role for any non-owner account from the player table - Link or unlink duplicate suggestions - Delete suggestions - 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? - View individual player votes +- Revoke owner permissions or delete the owner account Voting remains anonymous and fair. diff --git a/wwwroot/data/i18n/translations.json b/wwwroot/data/i18n/translations.json index 07a60c5..51a31ce 100644 --- a/wwwroot/data/i18n/translations.json +++ b/wwwroot/data/i18n/translations.json @@ -124,13 +124,16 @@ "admin.playerStatus": "Status", "admin.playerGames": "Games", "admin.playerJoker": "Joker", + "admin.playerAdmin": "Admin", "admin.playerDelete": "Delete", + "admin.owner": "owner", "admin.grantJokerChip": "Grant", "admin.statusSuggesting": "Suggesting", "admin.statusVoting": "Voting", "admin.statusFinished": "Finished", "admin.statusMoveToSuggest": "Move to Suggest", "admin.statusUpdated": "Player phase updated", + "admin.roleUpdated": "Admin role updated", "admin.deleteTitle": "Delete account?", "admin.deleteBody": "Delete player \"{name}\" and all their games and votes? This cannot be undone.", "admin.deleteConfirm": "Delete", @@ -294,13 +297,16 @@ "admin.playerStatus": "Status", "admin.playerGames": "Spiele", "admin.playerJoker": "Joker", + "admin.playerAdmin": "Admin", "admin.playerDelete": "Löschen", + "admin.owner": "owner", "admin.grantJokerChip": "Joker", "admin.statusSuggesting": "Vorschlagen", "admin.statusVoting": "Bewerten", "admin.statusFinished": "Fertig", "admin.statusMoveToSuggest": "Zur Vorschlagsphase", "admin.statusUpdated": "Spielerphase aktualisiert", + "admin.roleUpdated": "Admin-Rolle aktualisiert", "admin.deleteTitle": "Konto löschen?", "admin.deleteBody": "Spieler \"{name}\" samt Spielen und Stimmen löschen? Dies kann nicht rückgängig gemacht werden.", "admin.deleteConfirm": "Löschen", diff --git a/wwwroot/index.html b/wwwroot/index.html index 421cbb4..ab63a91 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -62,7 +62,7 @@ Display name (shows to group) -