Add username/password auth and login UI
This commit is contained in:
21
API.md
21
API.md
@@ -1,28 +1,33 @@
|
|||||||
# API Contract (MVP)
|
# API Contract (Auth-enabled)
|
||||||
|
|
||||||
All endpoints are JSON. Player identity comes from HttpOnly cookie `player`.
|
All endpoints are JSON. Most routes require the HttpOnly cookie `player`, which is issued after successful register/login. Legacy player rows are given `legacy-xxxxxxxx` usernames during migration; they must register/login to get a valid auth cookie.
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
POST /api/auth/register
|
||||||
|
POST /api/auth/login
|
||||||
|
POST /api/auth/logout
|
||||||
|
|
||||||
## State
|
## State
|
||||||
GET /api/state
|
GET /api/state (public)
|
||||||
|
|
||||||
## Player
|
## Player (requires auth)
|
||||||
GET /api/me
|
GET /api/me
|
||||||
POST /api/me/name
|
POST /api/me/name
|
||||||
|
|
||||||
## Suggestions
|
## Suggestions (requires auth + phase gating)
|
||||||
GET /api/suggestions/mine
|
GET /api/suggestions/mine
|
||||||
POST /api/suggestions
|
POST /api/suggestions
|
||||||
DELETE /api/suggestions/{id}
|
DELETE /api/suggestions/{id}
|
||||||
GET /api/suggestions/all
|
GET /api/suggestions/all
|
||||||
|
|
||||||
## Votes
|
## Votes (requires auth + phase gating)
|
||||||
GET /api/votes/mine
|
GET /api/votes/mine
|
||||||
POST /api/votes
|
POST /api/votes
|
||||||
|
|
||||||
## Results
|
## Results (requires auth + phase gating)
|
||||||
GET /api/results
|
GET /api/results
|
||||||
|
|
||||||
## Admin
|
## Admin (admin key header/query required)
|
||||||
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
|
||||||
|
|||||||
4
Contracts/AuthRequests.cs
Normal file
4
Contracts/AuthRequests.cs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
namespace GameList.Contracts;
|
||||||
|
|
||||||
|
public record RegisterRequest(string Username, string Password, string? DisplayName);
|
||||||
|
public record LoginRequest(string Username, string Password);
|
||||||
@@ -20,6 +20,11 @@ public class AppDbContext : DbContext
|
|||||||
{
|
{
|
||||||
builder.HasKey(p => p.Id);
|
builder.HasKey(p => p.Id);
|
||||||
builder.Property(p => p.DisplayName).HasMaxLength(64);
|
builder.Property(p => p.DisplayName).HasMaxLength(64);
|
||||||
|
builder.Property(p => p.Username).IsRequired().HasMaxLength(64);
|
||||||
|
builder.Property(p => p.NormalizedUsername).IsRequired().HasMaxLength(64);
|
||||||
|
builder.HasIndex(p => p.NormalizedUsername).IsUnique();
|
||||||
|
builder.Property(p => p.PasswordHash).IsRequired();
|
||||||
|
builder.Property(p => p.PasswordSalt).IsRequired();
|
||||||
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)
|
||||||
|
|||||||
206
Data/Migrations/20260128235657_AddAuthToPlayers.Designer.cs
generated
Normal file
206
Data/Migrations/20260128235657_AddAuthToPlayers.Designer.cs
generated
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
// <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("20260128235657_AddAuthToPlayers")]
|
||||||
|
partial class AddAuthToPlayers
|
||||||
|
{
|
||||||
|
/// <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<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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
96
Data/Migrations/20260128235657_AddAuthToPlayers.cs
Normal file
96
Data/Migrations/20260128235657_AddAuthToPlayers.cs
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace GameList.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAuthToPlayers : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||||
|
name: "LastLoginAt",
|
||||||
|
table: "Players",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "NormalizedUsername",
|
||||||
|
table: "Players",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 64,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<byte[]>(
|
||||||
|
name: "PasswordHash",
|
||||||
|
table: "Players",
|
||||||
|
type: "BLOB",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new byte[0]);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<byte[]>(
|
||||||
|
name: "PasswordSalt",
|
||||||
|
table: "Players",
|
||||||
|
type: "BLOB",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new byte[0]);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Username",
|
||||||
|
table: "Players",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 64,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE Players
|
||||||
|
SET Username = 'legacy-' || substr(Id,1,8),
|
||||||
|
NormalizedUsername = lower('legacy-' || substr(Id,1,8))
|
||||||
|
WHERE coalesce(Username,'') = '' OR coalesce(NormalizedUsername,'') = '';
|
||||||
|
|
||||||
|
UPDATE Players
|
||||||
|
SET PasswordHash = X'', PasswordSalt = X''
|
||||||
|
WHERE (PasswordHash IS NULL OR length(PasswordHash)=0) OR (PasswordSalt IS NULL OR length(PasswordSalt)=0);
|
||||||
|
");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Players_NormalizedUsername",
|
||||||
|
table: "Players",
|
||||||
|
column: "NormalizedUsername",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_Players_NormalizedUsername",
|
||||||
|
table: "Players");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LastLoginAt",
|
||||||
|
table: "Players");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "NormalizedUsername",
|
||||||
|
table: "Players");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PasswordHash",
|
||||||
|
table: "Players");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PasswordSalt",
|
||||||
|
table: "Players");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Username",
|
||||||
|
table: "Players");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,8 +55,32 @@ namespace GameList.Data.Migrations
|
|||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
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.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedUsername")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("Players");
|
b.ToTable("Players");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ public class Player
|
|||||||
[MaxLength(64)]
|
[MaxLength(64)]
|
||||||
public string? DisplayName { get; set; }
|
public string? DisplayName { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(64)]
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[MaxLength(64)]
|
||||||
|
public string NormalizedUsername { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public byte[] PasswordHash { get; set; } = Array.Empty<byte>();
|
||||||
|
public byte[] PasswordSalt { get; set; } = Array.Empty<byte>();
|
||||||
|
|
||||||
|
public DateTimeOffset? LastLoginAt { get; set; }
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
public ICollection<Suggestion> Suggestions { get; set; } = new List<Suggestion>();
|
public ICollection<Suggestion> Suggestions { get; set; } = new List<Suggestion>();
|
||||||
|
|||||||
79
Endpoints/AuthEndpoints.cs
Normal file
79
Endpoints/AuthEndpoints.cs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
using GameList.Contracts;
|
||||||
|
using GameList.Data;
|
||||||
|
using GameList.Domain;
|
||||||
|
using GameList.Infrastructure;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace GameList.Endpoints;
|
||||||
|
|
||||||
|
public static class AuthEndpoints
|
||||||
|
{
|
||||||
|
public static void MapAuthEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/auth");
|
||||||
|
|
||||||
|
group.MapPost("/register", async ([FromBody] RegisterRequest request, HttpContext ctx, AppDbContext db) =>
|
||||||
|
{
|
||||||
|
var username = request.Username?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(username) || username.Length > 64)
|
||||||
|
return Results.BadRequest(new { error = "Username is required and must be <= 64 characters." });
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Password))
|
||||||
|
return Results.BadRequest(new { error = "Password is required." });
|
||||||
|
|
||||||
|
var displayName = EndpointHelpers.TrimTo(request.DisplayName, 64);
|
||||||
|
var normalized = username.ToLowerInvariant();
|
||||||
|
|
||||||
|
var exists = await db.Players.AnyAsync(p => p.NormalizedUsername == normalized);
|
||||||
|
if (exists)
|
||||||
|
return Results.Conflict(new { error = "Username already taken." });
|
||||||
|
|
||||||
|
var (hash, salt) = PasswordHasher.HashPassword(request.Password);
|
||||||
|
var player = new Player
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Username = username,
|
||||||
|
NormalizedUsername = normalized,
|
||||||
|
PasswordHash = hash,
|
||||||
|
PasswordSalt = salt,
|
||||||
|
DisplayName = displayName,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
LastLoginAt = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Players.Add(player);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
PlayerIdentityExtensions.IssuePlayerCookie(ctx, player.Id);
|
||||||
|
|
||||||
|
return Results.Ok(new { player.Id, player.Username, player.DisplayName });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPost("/login", async ([FromBody] LoginRequest request, HttpContext ctx, AppDbContext db) =>
|
||||||
|
{
|
||||||
|
var username = request.Username?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password))
|
||||||
|
return Results.BadRequest(new { error = "Username and password are required." });
|
||||||
|
|
||||||
|
var normalized = username.ToLowerInvariant();
|
||||||
|
var player = await db.Players.FirstOrDefaultAsync(p => p.NormalizedUsername == normalized);
|
||||||
|
if (player == null || !PasswordHasher.Verify(request.Password, player.PasswordHash, player.PasswordSalt))
|
||||||
|
return Results.Json(new { error = "Invalid username or password." }, statusCode: StatusCodes.Status401Unauthorized);
|
||||||
|
|
||||||
|
player.LastLoginAt = DateTimeOffset.UtcNow;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
PlayerIdentityExtensions.IssuePlayerCookie(ctx, player.Id);
|
||||||
|
|
||||||
|
return Results.Ok(new { player.Id, player.Username, player.DisplayName });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPost("/logout", (HttpContext ctx) =>
|
||||||
|
{
|
||||||
|
PlayerIdentityExtensions.ClearPlayerCookie(ctx);
|
||||||
|
return Results.NoContent();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,20 +7,15 @@ namespace GameList.Endpoints;
|
|||||||
|
|
||||||
internal static class EndpointHelpers
|
internal static class EndpointHelpers
|
||||||
{
|
{
|
||||||
public static async Task<Player> GetOrCreatePlayer(HttpContext ctx, AppDbContext db)
|
public static async Task<Player?> GetAuthenticatedPlayer(HttpContext ctx, AppDbContext db)
|
||||||
{
|
{
|
||||||
if (!ctx.Items.TryGetValue(Infrastructure.PlayerIdentityExtensions.PlayerCookieName, out var value) || value is not Guid playerId)
|
if (!ctx.Items.TryGetValue(Infrastructure.PlayerIdentityExtensions.PlayerCookieName, out var value) || value is not Guid playerId)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Player cookie missing.");
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var existing = await db.Players.FindAsync(playerId);
|
var existing = await db.Players.FindAsync(playerId);
|
||||||
if (existing != null) return existing;
|
return existing;
|
||||||
|
|
||||||
var player = new Player { Id = playerId };
|
|
||||||
db.Players.Add(player);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
return player;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<Phase> GetPhase(AppDbContext db)
|
public static async Task<Phase> GetPhase(AppDbContext db)
|
||||||
|
|||||||
@@ -8,12 +8,15 @@ public static class ResultsEndpoints
|
|||||||
{
|
{
|
||||||
public static void MapResultsEndpoints(this IEndpointRouteBuilder app)
|
public static void MapResultsEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
app.MapGet("/api/results", async (AppDbContext db) =>
|
app.MapGet("/api/results", async (HttpContext ctx, AppDbContext db) =>
|
||||||
{
|
{
|
||||||
var phase = await EndpointHelpers.GetPhase(db);
|
var phase = await EndpointHelpers.GetPhase(db);
|
||||||
if (phase != Phase.Results)
|
if (phase != Phase.Results)
|
||||||
return EndpointHelpers.PhaseMismatch(Phase.Results, phase);
|
return EndpointHelpers.PhaseMismatch(Phase.Results, phase);
|
||||||
|
|
||||||
|
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||||
|
if (player is null) return Results.Unauthorized();
|
||||||
|
|
||||||
var results = await db.Suggestions.AsNoTracking()
|
var results = await db.Suggestions.AsNoTracking()
|
||||||
.Include(s => s.Player)
|
.Include(s => s.Player)
|
||||||
.Include(s => s.Votes)
|
.Include(s => s.Votes)
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ public static class StateEndpoints
|
|||||||
|
|
||||||
app.MapGet("/api/me", async (HttpContext ctx, AppDbContext db) =>
|
app.MapGet("/api/me", async (HttpContext ctx, AppDbContext db) =>
|
||||||
{
|
{
|
||||||
var player = await EndpointHelpers.GetOrCreatePlayer(ctx, db);
|
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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,7 +37,9 @@ public static class StateEndpoints
|
|||||||
return Results.BadRequest(new { error = "Name is required and must be <= 64 characters." });
|
return Results.BadRequest(new { error = "Name is required and must be <= 64 characters." });
|
||||||
}
|
}
|
||||||
|
|
||||||
var player = await EndpointHelpers.GetOrCreatePlayer(ctx, db);
|
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||||
|
if (player is null) return Results.Unauthorized();
|
||||||
|
|
||||||
player.DisplayName = request.Name.Trim();
|
player.DisplayName = request.Name.Trim();
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Results.Ok(new { player.Id, player.DisplayName });
|
return Results.Ok(new { player.Id, player.DisplayName });
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ public static class SuggestEndpoints
|
|||||||
if (phase != Phase.Suggest)
|
if (phase != Phase.Suggest)
|
||||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||||
|
|
||||||
var player = await EndpointHelpers.GetOrCreatePlayer(ctx, db);
|
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||||
|
if (player is null) return Results.Unauthorized();
|
||||||
var mine = await db.Suggestions.AsNoTracking()
|
var mine = await db.Suggestions.AsNoTracking()
|
||||||
.Where(s => s.PlayerId == player.Id)
|
.Where(s => s.PlayerId == player.Id)
|
||||||
.Select(s => new
|
.Select(s => new
|
||||||
@@ -50,7 +51,8 @@ public static class SuggestEndpoints
|
|||||||
return Results.BadRequest(new { error = "Name is required and must be <= 100 characters." });
|
return Results.BadRequest(new { error = "Name is required and must be <= 100 characters." });
|
||||||
}
|
}
|
||||||
|
|
||||||
var player = await EndpointHelpers.GetOrCreatePlayer(ctx, db);
|
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||||
|
if (player is null) return Results.Unauthorized();
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(player.DisplayName))
|
if (string.IsNullOrWhiteSpace(player.DisplayName))
|
||||||
{
|
{
|
||||||
@@ -86,7 +88,8 @@ public static class SuggestEndpoints
|
|||||||
if (phase != Phase.Suggest)
|
if (phase != Phase.Suggest)
|
||||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||||
|
|
||||||
var player = await EndpointHelpers.GetOrCreatePlayer(ctx, db);
|
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||||
|
if (player is null) return Results.Unauthorized();
|
||||||
var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id && s.PlayerId == player.Id);
|
var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id && s.PlayerId == player.Id);
|
||||||
if (suggestion == null)
|
if (suggestion == null)
|
||||||
return Results.NotFound(new { error = "Suggestion not found." });
|
return Results.NotFound(new { error = "Suggestion not found." });
|
||||||
@@ -96,12 +99,15 @@ public static class SuggestEndpoints
|
|||||||
return Results.NoContent();
|
return Results.NoContent();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.MapGet("/api/suggestions/all", async (AppDbContext db) =>
|
app.MapGet("/api/suggestions/all", async (HttpContext ctx, AppDbContext db) =>
|
||||||
{
|
{
|
||||||
var phase = await EndpointHelpers.GetPhase(db);
|
var phase = await EndpointHelpers.GetPhase(db);
|
||||||
if (phase < Phase.Reveal)
|
if (phase < Phase.Reveal)
|
||||||
return EndpointHelpers.PhaseMismatch(Phase.Reveal, phase);
|
return EndpointHelpers.PhaseMismatch(Phase.Reveal, phase);
|
||||||
|
|
||||||
|
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||||
|
if (player is null) return Results.Unauthorized();
|
||||||
|
|
||||||
var all = await db.Suggestions.AsNoTracking()
|
var all = await db.Suggestions.AsNoTracking()
|
||||||
.Include(s => s.Player)
|
.Include(s => s.Player)
|
||||||
.Select(s => new
|
.Select(s => new
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ public static class VoteEndpoints
|
|||||||
if (phase != Phase.Vote)
|
if (phase != Phase.Vote)
|
||||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||||
|
|
||||||
var player = await EndpointHelpers.GetOrCreatePlayer(ctx, db);
|
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||||
|
if (player is null) return Results.Unauthorized();
|
||||||
var votes = await db.Votes.AsNoTracking()
|
var votes = await db.Votes.AsNoTracking()
|
||||||
.Where(v => v.PlayerId == player.Id)
|
.Where(v => v.PlayerId == player.Id)
|
||||||
.Select(v => new { v.SuggestionId, v.Score })
|
.Select(v => new { v.SuggestionId, v.Score })
|
||||||
@@ -34,7 +35,8 @@ public static class VoteEndpoints
|
|||||||
if (request.Score is < 0 or > 10)
|
if (request.Score is < 0 or > 10)
|
||||||
return Results.BadRequest(new { error = "Score must be between 0 and 10." });
|
return Results.BadRequest(new { error = "Score must be between 0 and 10." });
|
||||||
|
|
||||||
var player = await EndpointHelpers.GetOrCreatePlayer(ctx, db);
|
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||||
|
if (player is null) return Results.Unauthorized();
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(player.DisplayName))
|
if (string.IsNullOrWhiteSpace(player.DisplayName))
|
||||||
return Results.BadRequest(new { error = "Set a display name before voting." });
|
return Results.BadRequest(new { error = "Set a display name before voting." });
|
||||||
|
|||||||
38
Infrastructure/PasswordHasher.cs
Normal file
38
Infrastructure/PasswordHasher.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace GameList.Infrastructure;
|
||||||
|
|
||||||
|
public static class PasswordHasher
|
||||||
|
{
|
||||||
|
private const int SaltSize = 16;
|
||||||
|
private const int KeySize = 32;
|
||||||
|
private const int Iterations = 100_000;
|
||||||
|
|
||||||
|
public static (byte[] Hash, byte[] Salt) HashPassword(string password)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(password))
|
||||||
|
throw new ArgumentException("Password required", nameof(password));
|
||||||
|
|
||||||
|
var salt = RandomNumberGenerator.GetBytes(SaltSize);
|
||||||
|
var hash = PBKDF2(password, salt);
|
||||||
|
return (hash, salt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool Verify(string password, byte[] hash, byte[] salt)
|
||||||
|
{
|
||||||
|
if (hash is null || salt is null || hash.Length == 0 || salt.Length == 0) return false;
|
||||||
|
var computed = PBKDF2(password, salt);
|
||||||
|
return CryptographicOperations.FixedTimeEquals(computed, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] PBKDF2(string password, byte[] salt)
|
||||||
|
{
|
||||||
|
return Rfc2898DeriveBytes.Pbkdf2(
|
||||||
|
Encoding.UTF8.GetBytes(password),
|
||||||
|
salt,
|
||||||
|
Iterations,
|
||||||
|
HashAlgorithmName.SHA256,
|
||||||
|
KeySize);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,41 @@ public static class PlayerIdentityExtensions
|
|||||||
var pathBase = ctx.Request.PathBase.HasValue ? ctx.Request.PathBase.Value : "/";
|
var pathBase = ctx.Request.PathBase.HasValue ? ctx.Request.PathBase.Value : "/";
|
||||||
var isHttps = string.Equals(ctx.Request.Scheme, "https", StringComparison.OrdinalIgnoreCase);
|
var isHttps = string.Equals(ctx.Request.Scheme, "https", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
var cookieOptions = new CookieOptions
|
Guid playerId;
|
||||||
|
if (!ctx.Request.Cookies.TryGetValue(PlayerCookieName, out var value) || !Guid.TryParse(value, out playerId))
|
||||||
|
{
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
IssuePlayerCookie(ctx, playerId);
|
||||||
|
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void IssuePlayerCookie(HttpContext ctx, Guid playerId)
|
||||||
|
{
|
||||||
|
var options = BuildCookieOptions(ctx);
|
||||||
|
ctx.Response.Cookies.Append(PlayerCookieName, playerId.ToString(), options);
|
||||||
|
ctx.Items[PlayerCookieName] = playerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void ClearPlayerCookie(HttpContext ctx)
|
||||||
|
{
|
||||||
|
var options = BuildCookieOptions(ctx);
|
||||||
|
options.Expires = DateTimeOffset.UtcNow.AddDays(-1);
|
||||||
|
ctx.Response.Cookies.Append(PlayerCookieName, string.Empty, options);
|
||||||
|
ctx.Items.Remove(PlayerCookieName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CookieOptions BuildCookieOptions(HttpContext ctx)
|
||||||
|
{
|
||||||
|
var pathBase = ctx.Request.PathBase.HasValue ? ctx.Request.PathBase.Value : "/";
|
||||||
|
var isHttps = string.Equals(ctx.Request.Scheme, "https", StringComparison.OrdinalIgnoreCase);
|
||||||
|
return new CookieOptions
|
||||||
{
|
{
|
||||||
HttpOnly = true,
|
HttpOnly = true,
|
||||||
SameSite = SameSiteMode.Strict,
|
SameSite = SameSiteMode.Strict,
|
||||||
@@ -22,20 +56,6 @@ public static class PlayerIdentityExtensions
|
|||||||
Expires = DateTimeOffset.UtcNow.AddYears(1),
|
Expires = DateTimeOffset.UtcNow.AddYears(1),
|
||||||
Path = pathBase
|
Path = pathBase
|
||||||
};
|
};
|
||||||
|
|
||||||
Guid playerId;
|
|
||||||
if (!ctx.Request.Cookies.TryGetValue(PlayerCookieName, out var value) || !Guid.TryParse(value, out playerId))
|
|
||||||
{
|
|
||||||
playerId = Guid.NewGuid();
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.Response.Cookies.Append(PlayerCookieName, playerId.ToString(), cookieOptions);
|
|
||||||
ctx.Items[PlayerCookieName] = playerId;
|
|
||||||
|
|
||||||
await next();
|
|
||||||
});
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IApplicationBuilder UseGlobalExceptionLogging(this IApplicationBuilder app)
|
public static IApplicationBuilder UseGlobalExceptionLogging(this IApplicationBuilder app)
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ app.UseStaticFiles();
|
|||||||
app.UsePlayerIdentity();
|
app.UsePlayerIdentity();
|
||||||
|
|
||||||
app.MapHealthChecks();
|
app.MapHealthChecks();
|
||||||
|
app.MapAuthEndpoints();
|
||||||
app.MapStateEndpoints();
|
app.MapStateEndpoints();
|
||||||
app.MapSuggestEndpoints();
|
app.MapSuggestEndpoints();
|
||||||
app.MapVoteEndpoints();
|
app.MapVoteEndpoints();
|
||||||
|
|||||||
2
SPEC.md
2
SPEC.md
@@ -9,7 +9,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
|
||||||
- Anonymous join via cookie
|
- Username/password login (cookie holds auth token after register/login)
|
||||||
- Organizer-controlled phase switching
|
- Organizer-controlled phase switching
|
||||||
|
|
||||||
## Suggest Phase
|
## Suggest Phase
|
||||||
|
|||||||
117
wwwroot/app.js
117
wwwroot/app.js
@@ -1,6 +1,8 @@
|
|||||||
import { api, adminApi } from "./js/api.js";
|
import { api, adminApi } from "./js/api.js";
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
|
isAuthenticated: false,
|
||||||
|
authMode: "login",
|
||||||
me: null,
|
me: null,
|
||||||
phase: null,
|
phase: null,
|
||||||
counts: null,
|
counts: null,
|
||||||
@@ -21,11 +23,54 @@ function toast(msg, isError = false) {
|
|||||||
setTimeout(() => toastEl.classList.add("hidden"), 2000);
|
setTimeout(() => toastEl.classList.add("hidden"), 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setAuthUI(isAuthed) {
|
||||||
|
const main = document.querySelector("main");
|
||||||
|
const statusBar = document.querySelector(".status-bar");
|
||||||
|
const authCard = $("auth-card");
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAuthMode(mode) {
|
||||||
|
state.authMode = mode;
|
||||||
|
document.querySelectorAll(".auth-form").forEach(form => {
|
||||||
|
form.classList.toggle("hidden", form.dataset.mode !== mode);
|
||||||
|
});
|
||||||
|
document.querySelectorAll("[data-auth-tab]").forEach(btn => {
|
||||||
|
btn.classList.toggle("active", btn.dataset.authTab === mode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearUserState() {
|
||||||
|
state.me = null;
|
||||||
|
state.phase = null;
|
||||||
|
state.counts = null;
|
||||||
|
state.mySuggestions = [];
|
||||||
|
state.allSuggestions = [];
|
||||||
|
state.myVotes = [];
|
||||||
|
state.results = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthError(err) {
|
||||||
|
if (err?.status === 401) {
|
||||||
|
clearUserState();
|
||||||
|
state.isAuthenticated = false;
|
||||||
|
setAuthUI(false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
toast(err?.message || "Unexpected error", true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadState() {
|
async function loadState() {
|
||||||
const [me, stateData] = await Promise.all([api.me(), api.state()]);
|
const [me, stateData] = await Promise.all([api.me(), api.state()]);
|
||||||
|
state.isAuthenticated = true;
|
||||||
state.me = me;
|
state.me = me;
|
||||||
state.phase = stateData.currentPhase;
|
state.phase = stateData.currentPhase;
|
||||||
state.counts = stateData;
|
state.counts = stateData;
|
||||||
|
setAuthUI(true);
|
||||||
renderPhasePill();
|
renderPhasePill();
|
||||||
renderCounts();
|
renderCounts();
|
||||||
const nameInput = $("name-input");
|
const nameInput = $("name-input");
|
||||||
@@ -181,6 +226,52 @@ function renderResults() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setupHandlers() {
|
function setupHandlers() {
|
||||||
|
document.querySelectorAll("[data-auth-tab]").forEach(btn => {
|
||||||
|
btn.addEventListener("click", () => setAuthMode(btn.dataset.authTab));
|
||||||
|
});
|
||||||
|
setAuthMode(state.authMode);
|
||||||
|
|
||||||
|
const loginForm = $("login-form");
|
||||||
|
if (loginForm) {
|
||||||
|
loginForm.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const username = $("login-username").value.trim();
|
||||||
|
const password = $("login-password").value;
|
||||||
|
if (!username || !password) return toast("Username and password required", true);
|
||||||
|
try {
|
||||||
|
await api.login({ username, password });
|
||||||
|
state.isAuthenticated = true;
|
||||||
|
setAuthUI(true);
|
||||||
|
await refreshPhaseData();
|
||||||
|
toast("Logged in");
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.status === 401) return toast("Invalid username or password", true);
|
||||||
|
if (handleAuthError(err)) return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const registerForm = $("register-form");
|
||||||
|
if (registerForm) {
|
||||||
|
registerForm.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const username = $("register-username").value.trim();
|
||||||
|
const password = $("register-password").value;
|
||||||
|
const displayName = $("register-displayName").value.trim();
|
||||||
|
if (!username || !password) return toast("Username and password required", true);
|
||||||
|
try {
|
||||||
|
await api.register({ username, password, displayName });
|
||||||
|
state.isAuthenticated = true;
|
||||||
|
setAuthUI(true);
|
||||||
|
await refreshPhaseData();
|
||||||
|
toast("Registered");
|
||||||
|
} catch (err) {
|
||||||
|
if (handleAuthError(err)) return;
|
||||||
|
toast(err.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const nameInput = $("name-input");
|
const nameInput = $("name-input");
|
||||||
if (nameInput) {
|
if (nameInput) {
|
||||||
["focus", "input"].forEach(evt => {
|
["focus", "input"].forEach(evt => {
|
||||||
@@ -241,6 +332,20 @@ function setupHandlers() {
|
|||||||
$("reset").addEventListener("click", () => adminAction(adminApi.reset, "Reset complete"));
|
$("reset").addEventListener("click", () => adminAction(adminApi.reset, "Reset complete"));
|
||||||
$("factory-reset").addEventListener("click", () => adminAction(adminApi.factoryReset, "Factory reset complete"));
|
$("factory-reset").addEventListener("click", () => adminAction(adminApi.factoryReset, "Factory reset complete"));
|
||||||
|
|
||||||
|
const logoutBtn = $("logout");
|
||||||
|
if (logoutBtn) {
|
||||||
|
logoutBtn.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await api.logout();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, true);
|
||||||
|
}
|
||||||
|
clearUserState();
|
||||||
|
state.isAuthenticated = false;
|
||||||
|
setAuthUI(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const adminToggle = $("admin-toggle");
|
const adminToggle = $("admin-toggle");
|
||||||
const adminCard = $("admin-card");
|
const adminCard = $("admin-card");
|
||||||
const adminClose = $("admin-close");
|
const adminClose = $("admin-close");
|
||||||
@@ -263,8 +368,13 @@ async function adminAction(fn, successMessage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshPhaseData() {
|
async function refreshPhaseData() {
|
||||||
|
try {
|
||||||
await loadState();
|
await loadState();
|
||||||
await Promise.all([loadSuggestData(), loadRevealData(), loadVoteData(), loadResults()]);
|
await Promise.all([loadSuggestData(), loadRevealData(), loadVoteData(), loadResults()]);
|
||||||
|
} catch (err) {
|
||||||
|
if (handleAuthError(err)) return;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCard(s, { showAuthor = false, allowDelete = false }) {
|
function buildCard(s, { showAuthor = false, allowDelete = false }) {
|
||||||
@@ -328,6 +438,7 @@ function openLightbox(url, title) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyNameRequirementUI() {
|
function applyNameRequirementUI() {
|
||||||
|
if (!state.isAuthenticated) return;
|
||||||
const requiresName = !state.me?.displayName?.trim();
|
const requiresName = !state.me?.displayName?.trim();
|
||||||
const warning = $("name-warning");
|
const warning = $("name-warning");
|
||||||
if (warning) warning.classList.toggle("hidden", !requiresName);
|
if (warning) warning.classList.toggle("hidden", !requiresName);
|
||||||
@@ -359,7 +470,11 @@ async function main() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.message, true);
|
toast(err.message, true);
|
||||||
}
|
}
|
||||||
setInterval(refreshPhaseData, 4000);
|
setInterval(() => {
|
||||||
|
refreshPhaseData().catch(err => {
|
||||||
|
if (!handleAuthError(err)) toast(err.message, true);
|
||||||
|
});
|
||||||
|
}, 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main();
|
||||||
|
|||||||
@@ -9,11 +9,30 @@
|
|||||||
<meta name="app-base" content="">
|
<meta name="app-base" content="">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<section class="card hidden" id="auth-card">
|
||||||
|
<div class="stack horizontal">
|
||||||
|
<button class="ghost active" data-auth-tab="login" type="button">Log in</button>
|
||||||
|
<button class="ghost" data-auth-tab="register" type="button">Register</button>
|
||||||
|
</div>
|
||||||
|
<form id="login-form" class="stack auth-form" data-mode="login">
|
||||||
|
<input id="login-username" name="username" maxlength="64" placeholder="Username" autocomplete="username" required />
|
||||||
|
<input id="login-password" name="password" type="password" placeholder="Password" autocomplete="current-password" required />
|
||||||
|
<button type="submit">Log in</button>
|
||||||
|
</form>
|
||||||
|
<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-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)" />
|
||||||
|
<button type="submit">Create account</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="status-bar">
|
<div class="status-bar">
|
||||||
<div class="name-bar">
|
<div class="name-bar">
|
||||||
<label for="name-input" class="label">Name</label>
|
<label for="name-input" class="label">Name</label>
|
||||||
<input id="name-input" maxlength="64" placeholder="Pick a name" />
|
<input id="name-input" maxlength="64" placeholder="Pick a name" />
|
||||||
<button id="save-name" class="ghost">Save</button>
|
<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>
|
<span class="hint warning hidden" id="name-warning">Name required</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="phase-bar">
|
<div class="phase-bar">
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ async function request(path, { method = "GET", body, adminKey } = {}) {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
msg = data.error || JSON.stringify(data);
|
msg = data.error || JSON.stringify(data);
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
throw new Error(msg);
|
const err = new Error(msg);
|
||||||
|
err.status = res.status;
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
return res.status === 204 ? null : res.json();
|
return res.status === 204 ? null : res.json();
|
||||||
}
|
}
|
||||||
@@ -33,6 +35,9 @@ 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 } }),
|
setName: (name) => request("/api/me/name", { method: "POST", body: { name } }),
|
||||||
|
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" }),
|
||||||
|
|
||||||
mySuggestions: () => request("/api/suggestions/mine"),
|
mySuggestions: () => request("/api/suggestions/mine"),
|
||||||
createSuggestion: (payload) => request("/api/suggestions", { method: "POST", body: payload }),
|
createSuggestion: (payload) => request("/api/suggestions", { method: "POST", body: payload }),
|
||||||
|
|||||||
@@ -245,6 +245,9 @@ input[type="range"].full-slider::-moz-range-track {
|
|||||||
}
|
}
|
||||||
.toast.error { background: #dc2626; }
|
.toast.error { background: #dc2626; }
|
||||||
|
|
||||||
|
.auth-card .active { font-weight: 700; }
|
||||||
|
.auth-form { margin-top: 8px; }
|
||||||
|
|
||||||
.admin-toggle {
|
.admin-toggle {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 18px;
|
bottom: 18px;
|
||||||
|
|||||||
Reference in New Issue
Block a user