Add admin accounts and streamlined header UI
This commit is contained in:
8
API.md
8
API.md
@@ -7,11 +7,13 @@ POST /api/auth/register
|
|||||||
POST /api/auth/login
|
POST /api/auth/login
|
||||||
POST /api/auth/logout
|
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
|
## State
|
||||||
GET /api/state (public)
|
GET /api/state (public)
|
||||||
|
|
||||||
## Player (requires auth)
|
## Player (requires auth)
|
||||||
GET /api/me
|
GET /api/me (returns id, displayName, username, isAdmin)
|
||||||
POST /api/me/name
|
POST /api/me/name
|
||||||
|
|
||||||
## Suggestions (requires auth + phase gating)
|
## Suggestions (requires auth + phase gating)
|
||||||
@@ -27,7 +29,9 @@ POST /api/votes
|
|||||||
## Results (requires auth + phase gating)
|
## Results (requires auth + phase gating)
|
||||||
GET /api/results
|
GET /api/results
|
||||||
|
|
||||||
## Admin (admin key header/query required)
|
## Admin (requires admin account or admin key)
|
||||||
POST /api/admin/phase
|
POST /api/admin/phase
|
||||||
POST /api/admin/reset
|
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`.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace GameList.Contracts;
|
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);
|
public record LoginRequest(string Username, string Password);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public class AppDbContext : DbContext
|
|||||||
builder.HasIndex(p => p.NormalizedUsername).IsUnique();
|
builder.HasIndex(p => p.NormalizedUsername).IsUnique();
|
||||||
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.HasMany(p => p.Suggestions)
|
builder.HasMany(p => p.Suggestions)
|
||||||
.WithOne(s => s.Player!)
|
.WithOne(s => s.Player!)
|
||||||
.HasForeignKey(s => s.PlayerId)
|
.HasForeignKey(s => s.PlayerId)
|
||||||
|
|||||||
211
Data/Migrations/20260129001158_AddIsAdminToPlayer.Designer.cs
generated
Normal file
211
Data/Migrations/20260129001158_AddIsAdminToPlayer.Designer.cs
generated
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
// <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("20260129001158_AddIsAdminToPlayer")]
|
||||||
|
partial class AddIsAdminToPlayer
|
||||||
|
{
|
||||||
|
/// <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<int>("CurrentPhase")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAdmin")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedUsername")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<byte[]>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB");
|
||||||
|
|
||||||
|
b.Property<byte[]>("PasswordSalt")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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("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.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
Data/Migrations/20260129001158_AddIsAdminToPlayer.cs
Normal file
29
Data/Migrations/20260129001158_AddIsAdminToPlayer.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GameList.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddIsAdminToPlayer : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsAdmin",
|
||||||
|
table: "Players",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsAdmin",
|
||||||
|
table: "Players");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,11 @@ namespace GameList.Data.Migrations
|
|||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAdmin")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
b.Property<DateTimeOffset?>("LastLoginAt")
|
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public class Player
|
|||||||
public byte[] PasswordSalt { get; set; } = Array.Empty<byte>();
|
public byte[] PasswordSalt { get; set; } = Array.Empty<byte>();
|
||||||
|
|
||||||
public DateTimeOffset? LastLoginAt { get; set; }
|
public DateTimeOffset? LastLoginAt { get; set; }
|
||||||
|
public bool IsAdmin { get; set; }
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public static class AdminEndpoints
|
|||||||
|
|
||||||
admin.MapPost("/phase", async ([FromBody] Contracts.PhaseRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
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();
|
var state = await db.AppState.FirstAsync();
|
||||||
state.CurrentPhase = request.Phase;
|
state.CurrentPhase = request.Phase;
|
||||||
@@ -24,7 +24,7 @@ public static class AdminEndpoints
|
|||||||
|
|
||||||
admin.MapPost("/reset", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
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.Votes.ExecuteDeleteAsync();
|
||||||
await db.Suggestions.ExecuteDeleteAsync();
|
await db.Suggestions.ExecuteDeleteAsync();
|
||||||
@@ -39,7 +39,7 @@ public static class AdminEndpoints
|
|||||||
|
|
||||||
admin.MapPost("/factory-reset", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
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();
|
await using var tx = await db.Database.BeginTransactionAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ public static class AuthEndpoints
|
|||||||
{
|
{
|
||||||
var group = app.MapGroup("/api/auth");
|
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();
|
var username = request.Username?.Trim();
|
||||||
if (string.IsNullOrWhiteSpace(username) || username.Length > 64)
|
if (string.IsNullOrWhiteSpace(username) || username.Length > 64)
|
||||||
@@ -24,6 +24,8 @@ public static class AuthEndpoints
|
|||||||
return Results.BadRequest(new { error = "Password is required." });
|
return Results.BadRequest(new { error = "Password is required." });
|
||||||
|
|
||||||
var displayName = EndpointHelpers.TrimTo(request.DisplayName, 64);
|
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 normalized = username.ToLowerInvariant();
|
||||||
|
|
||||||
var exists = await db.Players.AnyAsync(p => p.NormalizedUsername == normalized);
|
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." });
|
return Results.Conflict(new { error = "Username already taken." });
|
||||||
|
|
||||||
var (hash, salt) = PasswordHasher.HashPassword(request.Password);
|
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
|
var player = new Player
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -39,6 +45,7 @@ public static class AuthEndpoints
|
|||||||
PasswordHash = hash,
|
PasswordHash = hash,
|
||||||
PasswordSalt = salt,
|
PasswordSalt = salt,
|
||||||
DisplayName = displayName,
|
DisplayName = displayName,
|
||||||
|
IsAdmin = isAdmin,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
LastLoginAt = DateTimeOffset.UtcNow
|
LastLoginAt = DateTimeOffset.UtcNow
|
||||||
};
|
};
|
||||||
@@ -48,7 +55,7 @@ public static class AuthEndpoints
|
|||||||
|
|
||||||
PlayerIdentityExtensions.IssuePlayerCookie(ctx, player.Id);
|
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) =>
|
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))
|
if (player == null || !PasswordHasher.Verify(request.Password, player.PasswordHash, player.PasswordSalt))
|
||||||
return Results.Json(new { error = "Invalid username or password." }, statusCode: StatusCodes.Status401Unauthorized);
|
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;
|
player.LastLoginAt = DateTimeOffset.UtcNow;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
PlayerIdentityExtensions.IssuePlayerCookie(ctx, player.Id);
|
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) =>
|
group.MapPost("/logout", (HttpContext ctx) =>
|
||||||
|
|||||||
@@ -34,8 +34,11 @@ internal static class EndpointHelpers
|
|||||||
? t[..Math.Min(t.Length, max)]
|
? t[..Math.Min(t.Length, max)]
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
public static bool IsAuthorized(HttpContext ctx, IConfiguration config)
|
public static async Task<bool> 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()
|
var provided = ctx.Request.Headers["X-Admin-Key"].FirstOrDefault()
|
||||||
?? ctx.Request.Query["key"].FirstOrDefault();
|
?? ctx.Request.Query["key"].FirstOrDefault();
|
||||||
var expected = config["ADMIN_PASSWORD"];
|
var expected = config["ADMIN_PASSWORD"];
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public static class StateEndpoints
|
|||||||
{
|
{
|
||||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||||
if (player is null) return Results.Unauthorized();
|
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) =>
|
app.MapPost("/api/me/name", async ([FromBody] SetNameRequest request, HttpContext ctx, AppDbContext db) =>
|
||||||
|
|||||||
2
SPEC.md
2
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
|
## MVP Scope
|
||||||
- Single shared instance
|
- Single shared instance
|
||||||
- Username/password login (cookie holds auth token after register/login)
|
- 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
|
## Suggest Phase
|
||||||
- Up to 3 suggestions per player
|
- Up to 3 suggestions per player
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ function setAuthUI(isAuthed) {
|
|||||||
[main, statusBar].forEach(el => el?.classList.toggle("hidden", !isAuthed));
|
[main, statusBar].forEach(el => el?.classList.toggle("hidden", !isAuthed));
|
||||||
if (authCard) authCard.classList.toggle("hidden", isAuthed);
|
if (authCard) authCard.classList.toggle("hidden", isAuthed);
|
||||||
const adminToggle = $("admin-toggle");
|
const adminToggle = $("admin-toggle");
|
||||||
if (adminToggle) adminToggle.classList.toggle("hidden", !isAuthed);
|
if (adminToggle) adminToggle.classList.toggle("hidden", !isAuthed || !state.me?.isAdmin);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAuthMode(mode) {
|
function setAuthMode(mode) {
|
||||||
@@ -71,13 +71,9 @@ async function loadState() {
|
|||||||
state.phase = stateData.currentPhase;
|
state.phase = stateData.currentPhase;
|
||||||
state.counts = stateData;
|
state.counts = stateData;
|
||||||
setAuthUI(true);
|
setAuthUI(true);
|
||||||
|
renderWelcome();
|
||||||
renderPhasePill();
|
renderPhasePill();
|
||||||
renderCounts();
|
renderCounts();
|
||||||
const nameInput = $("name-input");
|
|
||||||
if (nameInput && !nameInput.dataset.userEditing) {
|
|
||||||
nameInput.value = me.displayName || "";
|
|
||||||
}
|
|
||||||
applyNameRequirementUI();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSuggestData() {
|
async function loadSuggestData() {
|
||||||
@@ -120,7 +116,6 @@ function renderPhasePill() {
|
|||||||
if (phaseSelect && !phaseSelect.dataset.userEditing) {
|
if (phaseSelect && !phaseSelect.dataset.userEditing) {
|
||||||
phaseSelect.value = state.phase || "Suggest";
|
phaseSelect.value = state.phase || "Suggest";
|
||||||
}
|
}
|
||||||
applyNameRequirementUI();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCounts() {
|
function renderCounts() {
|
||||||
@@ -128,6 +123,13 @@ function renderCounts() {
|
|||||||
$("counts").textContent = `Players: ${state.counts.players} • Suggestions: ${state.counts.suggestions} • Votes: ${state.counts.votes}`;
|
$("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() {
|
function renderMySuggestions() {
|
||||||
const wrap = $("my-suggestions");
|
const wrap = $("my-suggestions");
|
||||||
if (!wrap) return;
|
if (!wrap) return;
|
||||||
@@ -258,9 +260,10 @@ function setupHandlers() {
|
|||||||
const username = $("register-username").value.trim();
|
const username = $("register-username").value.trim();
|
||||||
const password = $("register-password").value;
|
const password = $("register-password").value;
|
||||||
const displayName = $("register-displayName").value.trim();
|
const displayName = $("register-displayName").value.trim();
|
||||||
|
const adminKey = $("register-adminkey").value.trim();
|
||||||
if (!username || !password) return toast("Username and password required", true);
|
if (!username || !password) return toast("Username and password required", true);
|
||||||
try {
|
try {
|
||||||
await api.register({ username, password, displayName });
|
await api.register({ username, password, displayName, adminKey });
|
||||||
state.isAuthenticated = true;
|
state.isAuthenticated = true;
|
||||||
setAuthUI(true);
|
setAuthUI(true);
|
||||||
await refreshPhaseData();
|
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) => {
|
$("suggest-form").addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const form = e.target;
|
const form = e.target;
|
||||||
@@ -311,9 +292,8 @@ function setupHandlers() {
|
|||||||
|
|
||||||
$("set-phase").addEventListener("click", async () => {
|
$("set-phase").addEventListener("click", async () => {
|
||||||
const phase = $("phase-select").value;
|
const phase = $("phase-select").value;
|
||||||
const adminKey = $("admin-key").value;
|
|
||||||
try {
|
try {
|
||||||
await adminApi.setPhase(phase, adminKey);
|
await adminApi.setPhase(phase);
|
||||||
toast("Phase updated");
|
toast("Phase updated");
|
||||||
state.phase = phase;
|
state.phase = phase;
|
||||||
$("phase-select").dataset.userEditing = "";
|
$("phase-select").dataset.userEditing = "";
|
||||||
@@ -334,7 +314,8 @@ function setupHandlers() {
|
|||||||
|
|
||||||
const logoutBtn = $("logout");
|
const logoutBtn = $("logout");
|
||||||
if (logoutBtn) {
|
if (logoutBtn) {
|
||||||
logoutBtn.addEventListener("click", async () => {
|
logoutBtn.addEventListener("click", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
await api.logout();
|
await api.logout();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -357,9 +338,8 @@ function setupHandlers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function adminAction(fn, successMessage) {
|
async function adminAction(fn, successMessage) {
|
||||||
const adminKey = $("admin-key").value;
|
|
||||||
try {
|
try {
|
||||||
await fn(adminKey);
|
await fn();
|
||||||
toast(successMessage);
|
toast(successMessage);
|
||||||
await refreshPhaseData();
|
await refreshPhaseData();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -437,32 +417,6 @@ function openLightbox(url, title) {
|
|||||||
document.body.appendChild(overlay);
|
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() {
|
async function main() {
|
||||||
setupHandlers();
|
setupHandlers();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -22,20 +22,18 @@
|
|||||||
<form id="register-form" class="stack auth-form hidden" data-mode="register">
|
<form id="register-form" class="stack auth-form hidden" data-mode="register">
|
||||||
<input id="register-username" name="username" maxlength="64" placeholder="Username" autocomplete="username" required />
|
<input id="register-username" name="username" maxlength="64" placeholder="Username" autocomplete="username" required />
|
||||||
<input id="register-password" name="password" type="password" placeholder="Password" autocomplete="new-password" required />
|
<input id="register-password" name="password" type="password" placeholder="Password" autocomplete="new-password" required />
|
||||||
<input id="register-displayName" name="displayName" maxlength="64" placeholder="Display name (shows to group)" />
|
<input id="register-displayName" name="displayName" maxlength="64" placeholder="Display name (shows to group)" required />
|
||||||
|
<input id="register-adminkey" name="adminKey" type="password" maxlength="128" placeholder="Admin key (optional)" />
|
||||||
<button type="submit">Create account</button>
|
<button type="submit">Create account</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="status-bar">
|
<div class="status-bar">
|
||||||
<div class="name-bar">
|
<div class="status-left">
|
||||||
<label for="name-input" class="label">Name</label>
|
<span id="welcome-text">Welcome!</span>
|
||||||
<input id="name-input" maxlength="64" placeholder="Pick a name" />
|
<a id="logout" href="#" class="link inline-link">Logout</a>
|
||||||
<button id="save-name" class="ghost">Save</button>
|
|
||||||
<button id="logout" class="ghost">Logout</button>
|
|
||||||
<span class="hint warning hidden" id="name-warning">Name required</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="phase-bar">
|
<div class="status-right">
|
||||||
<span class="status-dot"></span>
|
<span class="status-dot"></span>
|
||||||
<span id="phase-pill">Loading…</span>
|
<span id="phase-pill">Loading…</span>
|
||||||
<span class="counts" id="counts">—</span>
|
<span class="counts" id="counts">—</span>
|
||||||
@@ -89,10 +87,6 @@
|
|||||||
<h3>Admin</h3>
|
<h3>Admin</h3>
|
||||||
<button id="admin-close" class="ghost">✕</button>
|
<button id="admin-close" class="ghost">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<label class="stack">
|
|
||||||
<span class="label">Admin key</span>
|
|
||||||
<input id="admin-key" type="password" placeholder="X-Admin-Key" />
|
|
||||||
</label>
|
|
||||||
<div class="stack horizontal">
|
<div class="stack horizontal">
|
||||||
<select id="phase-select">
|
<select id="phase-select">
|
||||||
<option>Suggest</option>
|
<option>Suggest</option>
|
||||||
|
|||||||
@@ -8,13 +8,10 @@ const autoBase = (() => {
|
|||||||
const basePath = metaBase || autoBase;
|
const basePath = metaBase || autoBase;
|
||||||
const withBase = (path) => `${basePath}${path}`;
|
const withBase = (path) => `${basePath}${path}`;
|
||||||
|
|
||||||
async function request(path, { method = "GET", body, adminKey } = {}) {
|
async function request(path, { method = "GET", body } = {}) {
|
||||||
const res = await fetch(withBase(path), {
|
const res = await fetch(withBase(path), {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: defaultHeaders,
|
||||||
...defaultHeaders,
|
|
||||||
...(adminKey ? { "X-Admin-Key": adminKey } : {})
|
|
||||||
},
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,7 +31,6 @@ async function request(path, { method = "GET", body, adminKey } = {}) {
|
|||||||
export const api = {
|
export const api = {
|
||||||
state: () => request("/api/state"),
|
state: () => request("/api/state"),
|
||||||
me: () => request("/api/me"),
|
me: () => request("/api/me"),
|
||||||
setName: (name) => request("/api/me/name", { method: "POST", body: { name } }),
|
|
||||||
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" }),
|
||||||
@@ -51,7 +47,7 @@ export const api = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const adminApi = {
|
export const adminApi = {
|
||||||
setPhase: (phase, adminKey) => request("/api/admin/phase", { method: "POST", body: { phase }, adminKey }),
|
setPhase: (phase) => request("/api/admin/phase", { method: "POST", body: { phase } }),
|
||||||
reset: (adminKey) => request("/api/admin/reset", { method: "POST", adminKey }),
|
reset: () => request("/api/admin/reset", { method: "POST" }),
|
||||||
factoryReset: (adminKey) => request("/api/admin/factory-reset", { method: "POST", adminKey }),
|
factoryReset: () => request("/api/admin/factory-reset", { method: "POST" }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,13 +10,18 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-bar {
|
.status-bar {
|
||||||
display: block;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
background: rgba(15, 23, 42, 0.8);
|
background: rgba(15, 23, 42, 0.8);
|
||||||
border: 1px solid #1f2937;
|
border: 1px solid #1f2937;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 10px 24px rgba(0,0,0,0.25);
|
box-shadow: 0 10px 24px rgba(0,0,0,0.25);
|
||||||
|
padding: 10px 14px;
|
||||||
}
|
}
|
||||||
|
.status-left, .status-right { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.inline-link { font-size: 14px; }
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
@@ -32,20 +37,7 @@ body {
|
|||||||
margin-left: 12px;
|
margin-left: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.phase-bar {
|
.phase-bar { display: none; }
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
}
|
|
||||||
.name-bar {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.name-bar input { width: 180px; }
|
|
||||||
.name-bar .hint { margin: 0; }
|
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
Reference in New Issue
Block a user