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

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> 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);

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")
.HasDefaultValue(false);
b.Property<bool>("IsOwner")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("TEXT");

View File

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

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("/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) =>
{

View File

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

View File

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

View File

@@ -33,6 +33,7 @@ internal sealed class StateWorkflowService(AppDbContext db)
player.Username,
player.DisplayName,
player.IsAdmin,
player.IsOwner,
phase,
player.VotesFinal,
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]
public async Task Admin_player_phase_requires_vote_phase_and_suggest_target()
{

View File

@@ -117,6 +117,13 @@ public class AuthTests
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
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<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]

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

View File

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

View File

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

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

View File

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

View File

@@ -62,7 +62,7 @@
<span class="label" data-i18n="auth.displayName">Display name (shows to group)</span>
<input id="register-displayName" name="displayName" maxlength="16" required />
</label>
<label class="stack">
<label class="stack" id="register-admin-key-field">
<span class="label" data-i18n="auth.adminKey">Admin key (optional)</span>
<input id="register-adminkey" name="adminKey" type="password" maxlength="128" />
</label>
@@ -172,6 +172,7 @@
<th data-i18n="admin.playerStatus">Status</th>
<th data-i18n="admin.playerGames">Games</th>
<th data-i18n="admin.playerJoker">Joker</th>
<th data-i18n="admin.playerAdmin">Admin</th>
<th data-i18n="admin.playerDelete">Delete</th>
</tr>
</thead>

View File

@@ -72,6 +72,21 @@ export function renderAdminVoteStatus() {
jokerButton.textContent = v.hasJoker ? "🎟" : t("admin.grantJokerChip");
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 deleteButton = document.createElement("button");
deleteButton.className = "chip danger-chip";
@@ -87,6 +102,7 @@ export function renderAdminVoteStatus() {
statusCell,
countCell,
jokerCell,
adminCell,
deleteCell,
);
table.appendChild(tr);

View File

@@ -34,6 +34,7 @@ async function request(path, { method = "GET", body } = {}) {
export const api = {
state: () => request("/api/state"),
me: () => request("/api/me"),
authOptions: () => request("/api/auth/options"),
register: (payload) => request("/api/auth/register", { method: "POST", body: payload }),
login: (payload) => request("/api/auth/login", { method: "POST", body: payload }),
logout: () => request("/api/auth/logout", { method: "POST" }),
@@ -61,6 +62,11 @@ export const adminApi = {
factoryReset: (password) =>
request("/api/admin/factory-reset", { method: "POST", body: { password } }),
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) =>
request("/api/admin/player-phase", { method: "POST", body: { playerId, phase } }),
deletePlayer: (playerId, password) =>

View File

@@ -127,6 +127,7 @@ function setupPlayerTableActions(runSerializedRefresh) {
const playerTable = $("admin-player-table");
if (!playerTable) return;
const phaseSelectSelector = "[data-set-player-phase]";
const adminCheckboxSelector = "[data-set-player-admin]";
playerTable.addEventListener("focusin", (e) => {
if (e.target.matches?.(phaseSelectSelector)) {
@@ -144,6 +145,25 @@ function setupPlayerTableActions(runSerializedRefresh) {
});
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);
if (!select) return;
const playerId = select.dataset.setPlayerPhase;

View File

@@ -46,6 +46,29 @@ function setupAuthModeToggle() {
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() {
const loginUser = $("login-username");
if (!loginUser) return;
@@ -121,6 +144,7 @@ function setupRegisterFormHandlers({
return toast(t("auth.cookieRequired"), true);
try {
await api.register({ username, password, displayName, adminKey });
await refreshRegistrationOptions();
setConsent();
toggleConsentRows();
setSavedUsername(username);
@@ -152,6 +176,7 @@ function setupLogoutHandler() {
clearUserState();
state.isAuthenticated = false;
setAuthUI(false);
await refreshRegistrationOptions();
});
}
@@ -178,6 +203,7 @@ function setupSuggestionEntryButtons() {
export function setupAuthHandlers({ runSerializedRefresh }) {
setupAuthModeToggle();
refreshRegistrationOptions();
const consent = setupConsentRows();
setupLoginUserEditingHint();
setupLoginFormHandlers({ ...consent, runSerializedRefresh });

View File

@@ -1,5 +1,6 @@
export const state = {
isAuthenticated: false,
ownerExists: false,
authMode: "login",
me: null,
phase: null,
@@ -19,6 +20,7 @@ export const state = {
};
export function clearUserState() {
state.ownerExists = false;
state.me = null;
state.phase = null;
state.prevPhase = null;