From 60191a1fe3b0ff1c3ee699aad00fb7b9e7c8d5da Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Thu, 29 Jan 2026 01:14:53 +0100 Subject: [PATCH] Add admin accounts and streamlined header UI --- API.md | 10 +- Contracts/AuthRequests.cs | 2 +- Data/AppDbContext.cs | 1 + ...60129001158_AddIsAdminToPlayer.Designer.cs | 211 ++++++++++++++++++ .../20260129001158_AddIsAdminToPlayer.cs | 29 +++ Data/Migrations/AppDbContextModelSnapshot.cs | 5 + Domain/Player.cs | 1 + Endpoints/AdminEndpoints.cs | 6 +- Endpoints/AuthEndpoints.cs | 17 +- Endpoints/EndpointHelpers.cs | 5 +- Endpoints/StateEndpoints.cs | 2 +- SPEC.md | 2 +- wwwroot/app.js | 76 ++----- wwwroot/index.html | 18 +- wwwroot/js/api.js | 14 +- wwwroot/styles.css | 22 +- 16 files changed, 311 insertions(+), 110 deletions(-) create mode 100644 Data/Migrations/20260129001158_AddIsAdminToPlayer.Designer.cs create mode 100644 Data/Migrations/20260129001158_AddIsAdminToPlayer.cs diff --git a/API.md b/API.md index d1d5c30..be7bef7 100644 --- a/API.md +++ b/API.md @@ -7,11 +7,13 @@ POST /api/auth/register POST /api/auth/login POST /api/auth/logout +- Register accepts optional `adminKey`; when it matches `ADMIN_PASSWORD`, the account is marked `IsAdmin=true` and can use admin APIs. + ## State GET /api/state (public) ## Player (requires auth) -GET /api/me +GET /api/me (returns id, displayName, username, isAdmin) POST /api/me/name ## Suggestions (requires auth + phase gating) @@ -27,7 +29,9 @@ POST /api/votes ## Results (requires auth + phase gating) GET /api/results -## Admin (admin key header/query required) +## Admin (requires admin account or admin key) POST /api/admin/phase POST /api/admin/reset -POST /api/admin/factory-reset +POST /api/admin/factory-reset + +Admin APIs accept either an authenticated admin user (cookie) or, for compatibility, `X-Admin-Key`/`key` matching `ADMIN_PASSWORD`. diff --git a/Contracts/AuthRequests.cs b/Contracts/AuthRequests.cs index a0a73bd..af7d2fa 100644 --- a/Contracts/AuthRequests.cs +++ b/Contracts/AuthRequests.cs @@ -1,4 +1,4 @@ namespace GameList.Contracts; -public record RegisterRequest(string Username, string Password, string? DisplayName); +public record RegisterRequest(string Username, string Password, string? DisplayName, string? AdminKey); public record LoginRequest(string Username, string Password); diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index 587ad2e..70f68ea 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -25,6 +25,7 @@ public class AppDbContext : DbContext builder.HasIndex(p => p.NormalizedUsername).IsUnique(); builder.Property(p => p.PasswordHash).IsRequired(); builder.Property(p => p.PasswordSalt).IsRequired(); + builder.Property(p => p.IsAdmin).HasDefaultValue(false); builder.HasMany(p => p.Suggestions) .WithOne(s => s.Player!) .HasForeignKey(s => s.PlayerId) diff --git a/Data/Migrations/20260129001158_AddIsAdminToPlayer.Designer.cs b/Data/Migrations/20260129001158_AddIsAdminToPlayer.Designer.cs new file mode 100644 index 0000000..e2b9d4f --- /dev/null +++ b/Data/Migrations/20260129001158_AddIsAdminToPlayer.Designer.cs @@ -0,0 +1,211 @@ +// +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("20260129001158_AddIsAdminToPlayer")] + partial class AddIsAdminToPlayer + { + /// + 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("CurrentPhase") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AppState"); + + b.HasData( + new + { + Id = 1, + CurrentPhase = 0, + 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("DisplayName") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("PasswordSalt") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + 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("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("TEXT"); + + b.Property("ScreenshotUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("YoutubeUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + 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.Player", "Player") + .WithMany("Suggestions") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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("Votes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data/Migrations/20260129001158_AddIsAdminToPlayer.cs b/Data/Migrations/20260129001158_AddIsAdminToPlayer.cs new file mode 100644 index 0000000..cc3ff53 --- /dev/null +++ b/Data/Migrations/20260129001158_AddIsAdminToPlayer.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GameList.Data.Migrations +{ + /// + public partial class AddIsAdminToPlayer : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsAdmin", + table: "Players", + type: "INTEGER", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsAdmin", + table: "Players"); + } + } +} diff --git a/Data/Migrations/AppDbContextModelSnapshot.cs b/Data/Migrations/AppDbContextModelSnapshot.cs index 805088a..c84ec05 100644 --- a/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/Data/Migrations/AppDbContextModelSnapshot.cs @@ -55,6 +55,11 @@ namespace GameList.Data.Migrations .HasMaxLength(64) .HasColumnType("TEXT"); + b.Property("IsAdmin") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + b.Property("LastLoginAt") .HasColumnType("TEXT"); diff --git a/Domain/Player.cs b/Domain/Player.cs index b288761..21ac961 100644 --- a/Domain/Player.cs +++ b/Domain/Player.cs @@ -19,6 +19,7 @@ public class Player public byte[] PasswordSalt { get; set; } = Array.Empty(); public DateTimeOffset? LastLoginAt { get; set; } + public bool IsAdmin { get; set; } public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; diff --git a/Endpoints/AdminEndpoints.cs b/Endpoints/AdminEndpoints.cs index d9698e2..bb57e4d 100644 --- a/Endpoints/AdminEndpoints.cs +++ b/Endpoints/AdminEndpoints.cs @@ -13,7 +13,7 @@ public static class AdminEndpoints admin.MapPost("/phase", async ([FromBody] Contracts.PhaseRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) => { - if (!EndpointHelpers.IsAuthorized(ctx, config)) return Results.Unauthorized(); + if (!await EndpointHelpers.IsAdmin(ctx, db, config)) return Results.Unauthorized(); var state = await db.AppState.FirstAsync(); state.CurrentPhase = request.Phase; @@ -24,7 +24,7 @@ public static class AdminEndpoints admin.MapPost("/reset", async (HttpContext ctx, AppDbContext db, IConfiguration config) => { - if (!EndpointHelpers.IsAuthorized(ctx, config)) return Results.Unauthorized(); + if (!await EndpointHelpers.IsAdmin(ctx, db, config)) return Results.Unauthorized(); await db.Votes.ExecuteDeleteAsync(); await db.Suggestions.ExecuteDeleteAsync(); @@ -39,7 +39,7 @@ public static class AdminEndpoints admin.MapPost("/factory-reset", async (HttpContext ctx, AppDbContext db, IConfiguration config) => { - if (!EndpointHelpers.IsAuthorized(ctx, config)) return Results.Unauthorized(); + if (!await EndpointHelpers.IsAdmin(ctx, db, config)) return Results.Unauthorized(); await using var tx = await db.Database.BeginTransactionAsync(); diff --git a/Endpoints/AuthEndpoints.cs b/Endpoints/AuthEndpoints.cs index 2545ae5..cfb786c 100644 --- a/Endpoints/AuthEndpoints.cs +++ b/Endpoints/AuthEndpoints.cs @@ -14,7 +14,7 @@ public static class AuthEndpoints { var group = app.MapGroup("/api/auth"); - group.MapPost("/register", async ([FromBody] RegisterRequest request, HttpContext ctx, AppDbContext db) => + group.MapPost("/register", async ([FromBody] RegisterRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) => { var username = request.Username?.Trim(); if (string.IsNullOrWhiteSpace(username) || username.Length > 64) @@ -24,6 +24,8 @@ public static class AuthEndpoints return Results.BadRequest(new { error = "Password is required." }); var displayName = EndpointHelpers.TrimTo(request.DisplayName, 64); + if (string.IsNullOrWhiteSpace(displayName)) + return Results.BadRequest(new { error = "Display name is required." }); var normalized = username.ToLowerInvariant(); var exists = await db.Players.AnyAsync(p => p.NormalizedUsername == normalized); @@ -31,6 +33,10 @@ public static class AuthEndpoints return Results.Conflict(new { error = "Username already taken." }); var (hash, salt) = PasswordHasher.HashPassword(request.Password); + var adminKey = EndpointHelpers.TrimTo(request.AdminKey, 128); + var expectedAdminKey = config["ADMIN_PASSWORD"]; + var isAdmin = !string.IsNullOrWhiteSpace(expectedAdminKey) && adminKey == expectedAdminKey; + var player = new Player { Id = Guid.NewGuid(), @@ -39,6 +45,7 @@ public static class AuthEndpoints PasswordHash = hash, PasswordSalt = salt, DisplayName = displayName, + IsAdmin = isAdmin, CreatedAt = DateTimeOffset.UtcNow, LastLoginAt = DateTimeOffset.UtcNow }; @@ -48,7 +55,7 @@ public static class AuthEndpoints PlayerIdentityExtensions.IssuePlayerCookie(ctx, player.Id); - return Results.Ok(new { player.Id, player.Username, player.DisplayName }); + return Results.Ok(new { player.Id, player.Username, player.DisplayName, player.IsAdmin }); }); group.MapPost("/login", async ([FromBody] LoginRequest request, HttpContext ctx, AppDbContext db) => @@ -62,12 +69,16 @@ public static class AuthEndpoints if (player == null || !PasswordHasher.Verify(request.Password, player.PasswordHash, player.PasswordSalt)) return Results.Json(new { error = "Invalid username or password." }, statusCode: StatusCodes.Status401Unauthorized); + if (string.IsNullOrWhiteSpace(player.DisplayName)) + { + player.DisplayName = player.Username; + } player.LastLoginAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(); PlayerIdentityExtensions.IssuePlayerCookie(ctx, player.Id); - return Results.Ok(new { player.Id, player.Username, player.DisplayName }); + return Results.Ok(new { player.Id, player.Username, player.DisplayName, player.IsAdmin }); }); group.MapPost("/logout", (HttpContext ctx) => diff --git a/Endpoints/EndpointHelpers.cs b/Endpoints/EndpointHelpers.cs index 937f3d6..5a94e54 100644 --- a/Endpoints/EndpointHelpers.cs +++ b/Endpoints/EndpointHelpers.cs @@ -34,8 +34,11 @@ internal static class EndpointHelpers ? t[..Math.Min(t.Length, max)] : null; - public static bool IsAuthorized(HttpContext ctx, IConfiguration config) + public static async Task IsAdmin(HttpContext ctx, AppDbContext db, IConfiguration config) { + var player = await GetAuthenticatedPlayer(ctx, db); + if (player?.IsAdmin == true) return true; + var provided = ctx.Request.Headers["X-Admin-Key"].FirstOrDefault() ?? ctx.Request.Query["key"].FirstOrDefault(); var expected = config["ADMIN_PASSWORD"]; diff --git a/Endpoints/StateEndpoints.cs b/Endpoints/StateEndpoints.cs index 372325f..9e5fa17 100644 --- a/Endpoints/StateEndpoints.cs +++ b/Endpoints/StateEndpoints.cs @@ -27,7 +27,7 @@ public static class StateEndpoints { var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return Results.Unauthorized(); - return Results.Ok(new { player.Id, player.DisplayName }); + return Results.Ok(new { player.Id, player.DisplayName, player.Username, player.IsAdmin }); }); app.MapPost("/api/me/name", async ([FromBody] SetNameRequest request, HttpContext ctx, AppDbContext db) => diff --git a/SPEC.md b/SPEC.md index 7523c8c..54b71a3 100644 --- a/SPEC.md +++ b/SPEC.md @@ -10,7 +10,7 @@ A micro web app for a closed Discord group (4–8 players) to decide what co-op ## MVP Scope - Single shared instance - Username/password login (cookie holds auth token after register/login) -- Organizer-controlled phase switching +- Organizer-controlled phase switching (admin accounts flagged via admin key at registration) ## Suggest Phase - Up to 3 suggestions per player diff --git a/wwwroot/app.js b/wwwroot/app.js index b35ee4f..4b8de6e 100644 --- a/wwwroot/app.js +++ b/wwwroot/app.js @@ -30,7 +30,7 @@ function setAuthUI(isAuthed) { [main, statusBar].forEach(el => el?.classList.toggle("hidden", !isAuthed)); if (authCard) authCard.classList.toggle("hidden", isAuthed); const adminToggle = $("admin-toggle"); - if (adminToggle) adminToggle.classList.toggle("hidden", !isAuthed); + if (adminToggle) adminToggle.classList.toggle("hidden", !isAuthed || !state.me?.isAdmin); } function setAuthMode(mode) { @@ -71,13 +71,9 @@ async function loadState() { state.phase = stateData.currentPhase; state.counts = stateData; setAuthUI(true); + renderWelcome(); renderPhasePill(); renderCounts(); - const nameInput = $("name-input"); - if (nameInput && !nameInput.dataset.userEditing) { - nameInput.value = me.displayName || ""; - } - applyNameRequirementUI(); } async function loadSuggestData() { @@ -120,7 +116,6 @@ function renderPhasePill() { if (phaseSelect && !phaseSelect.dataset.userEditing) { phaseSelect.value = state.phase || "Suggest"; } - applyNameRequirementUI(); } function renderCounts() { @@ -128,6 +123,13 @@ function renderCounts() { $("counts").textContent = `Players: ${state.counts.players} • Suggestions: ${state.counts.suggestions} • Votes: ${state.counts.votes}`; } +function renderWelcome() { + const el = $("welcome-text"); + if (!el) return; + const name = state.me?.displayName?.trim() || state.me?.username || "Player"; + el.textContent = `Welcome, ${name}!`; +} + function renderMySuggestions() { const wrap = $("my-suggestions"); if (!wrap) return; @@ -258,9 +260,10 @@ function setupHandlers() { const username = $("register-username").value.trim(); const password = $("register-password").value; const displayName = $("register-displayName").value.trim(); + const adminKey = $("register-adminkey").value.trim(); if (!username || !password) return toast("Username and password required", true); try { - await api.register({ username, password, displayName }); + await api.register({ username, password, displayName, adminKey }); state.isAuthenticated = true; setAuthUI(true); await refreshPhaseData(); @@ -272,28 +275,6 @@ function setupHandlers() { }); } - const nameInput = $("name-input"); - if (nameInput) { - ["focus", "input"].forEach(evt => { - nameInput.addEventListener(evt, () => { nameInput.dataset.userEditing = "1"; }); - }); - nameInput.addEventListener("blur", () => { nameInput.dataset.userEditing = ""; }); - } - - $("save-name").addEventListener("click", async () => { - const name = nameInput.value.trim(); - if (!name) return toast("Name required", true); - try { - const me = await api.setName(name); - state.me = me; - nameInput.dataset.userEditing = ""; - toast("Saved name"); - applyNameRequirementUI(); - } catch (err) { - toast(err.message, true); - } - }); - $("suggest-form").addEventListener("submit", async (e) => { e.preventDefault(); const form = e.target; @@ -311,9 +292,8 @@ function setupHandlers() { $("set-phase").addEventListener("click", async () => { const phase = $("phase-select").value; - const adminKey = $("admin-key").value; try { - await adminApi.setPhase(phase, adminKey); + await adminApi.setPhase(phase); toast("Phase updated"); state.phase = phase; $("phase-select").dataset.userEditing = ""; @@ -334,7 +314,8 @@ function setupHandlers() { const logoutBtn = $("logout"); if (logoutBtn) { - logoutBtn.addEventListener("click", async () => { + logoutBtn.addEventListener("click", async (e) => { + e.preventDefault(); try { await api.logout(); } catch (err) { @@ -357,9 +338,8 @@ function setupHandlers() { } async function adminAction(fn, successMessage) { - const adminKey = $("admin-key").value; try { - await fn(adminKey); + await fn(); toast(successMessage); await refreshPhaseData(); } catch (err) { @@ -437,32 +417,6 @@ function openLightbox(url, title) { document.body.appendChild(overlay); } -function applyNameRequirementUI() { - if (!state.isAuthenticated) return; - const requiresName = !state.me?.displayName?.trim(); - const warning = $("name-warning"); - if (warning) warning.classList.toggle("hidden", !requiresName); - - const suggestForm = $("suggest-form"); - if (suggestForm) { - suggestForm.querySelectorAll("input,textarea,button").forEach(el => { - if (el.id === "save-name") return; - el.disabled = requiresName; - }); - suggestForm.classList.toggle("disabled-form", requiresName); - } - - const voteList = $("vote-list"); - if (voteList) { - voteList.querySelectorAll("input[type=range]").forEach(el => el.disabled = requiresName); - voteList.classList.toggle("disabled-form", requiresName); - } - - if (requiresName && state.phase !== "Suggest") { - toast("Enter a name to continue.", true); - } -} - async function main() { setupHandlers(); try { diff --git a/wwwroot/index.html b/wwwroot/index.html index b5a8604..9922946 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -22,20 +22,18 @@
-
- - - - - +
+ Welcome! + Logout
-
+
Loading… @@ -89,10 +87,6 @@

Admin

-