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

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