From 3480ca21fa4c9fb73816df48b13afdd94797506e Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sun, 31 May 2026 20:00:35 +0300 Subject: [PATCH] signup codes: admin-issued invite codes + site /join redemption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While account creation is disabled, the only way in is now an admin-issued single-use signup code (Discord-invite style), redeemed on the public site's /join page. Because the 2020 watch login is device-id-bound with no code field, the flow captures it server-side instead: a new device refused at the signups-disabled gate (PlatformLogin / account create) is stashed in a PendingDevices table keyed by the real client IP (CF-Connecting-IP aware). The /join page then shows the player the device their own client just reported, they enter their code + username, and the account is minted bound to that device — so the next launch logs straight in via the existing device-id path. No watch changes. - SignupCodeEntity (code, descriptor, optional expiry, redeemer) + PendingDeviceEntity; idempotent CREATE TABLE in DatabaseBootstrap so both providers pick them up without a new migration. - SignupCodeService: generate / list / revoke / redeem + pending-device capture, single-use + expiry + revoke validation. - Admin: GET/POST signup-codes + revoke endpoints and a Signup codes admin-ui page (generate with descriptor/expiry, status table, revoke). - Public: anonymous /api/site/v1/join pending-devices + redeem, and a /join site page (+ header link). - README documents the flow + the post-Initial table-create approach. --- .../API/Account/AccountApiController.cs | 3 + .../V2/PlatformLoginController.cs | 5 + .../Controllers/Admin/AdminController.cs | 62 ++++++ .../Controllers/Site/PublicSiteController.cs | 38 ++++ DorkNet.Server/Data/DorkNetDbContext.cs | 2 + .../Data/Entities/PendingDeviceEntity.cs | 28 +++ .../Data/Entities/SignupCodeEntity.cs | 40 ++++ DorkNet.Server/Services/SignupCodeService.cs | 186 ++++++++++++++++++ DorkNet.Server/Startup/DatabaseBootstrap.cs | 79 ++++++++ .../Startup/ServiceCollectionExtensions.cs | 1 + DorkNet.Server/admin-ui/src/App.tsx | 2 + .../admin-ui/src/components/Sidebar.tsx | 1 + .../admin-ui/src/pages/SignupCodes.tsx | 179 +++++++++++++++++ DorkNet.Server/site/src/App.tsx | 2 + DorkNet.Server/site/src/components/Header.tsx | 1 + DorkNet.Server/site/src/lib/api.ts | 23 +++ DorkNet.Server/site/src/pages/Join.tsx | 162 +++++++++++++++ README.md | 16 ++ 18 files changed, 830 insertions(+) create mode 100644 DorkNet.Server/Data/Entities/PendingDeviceEntity.cs create mode 100644 DorkNet.Server/Data/Entities/SignupCodeEntity.cs create mode 100644 DorkNet.Server/Services/SignupCodeService.cs create mode 100644 DorkNet.Server/admin-ui/src/pages/SignupCodes.tsx create mode 100644 DorkNet.Server/site/src/pages/Join.tsx diff --git a/DorkNet.Server/Controllers/API/Account/AccountApiController.cs b/DorkNet.Server/Controllers/API/Account/AccountApiController.cs index 7bf38b4..68a9c34 100644 --- a/DorkNet.Server/Controllers/API/Account/AccountApiController.cs +++ b/DorkNet.Server/Controllers/API/Account/AccountApiController.cs @@ -19,6 +19,7 @@ public class AccountApiController( DorkNetDbContext db, NotificationService notifications, ServerSettingsService settings, + SignupCodeService signupCodes, ILogger logger) : ControllerBase { [HttpPost("api/account/v1/create")] @@ -37,6 +38,8 @@ public class AccountApiController( logger.LogInformation( "[api-account] api/account/v1/create refused — signups disabled (device={Device} platform={Platform})", deviceId, platform); + await signupCodes.RecordPendingDeviceAsync( + deviceId, platform, platformId, SignupCodeService.ClientIp(HttpContext)); return Ok(new CreateAccountResponse { Success = false, diff --git a/DorkNet.Server/Controllers/API/PlatformLogin/V2/PlatformLoginController.cs b/DorkNet.Server/Controllers/API/PlatformLogin/V2/PlatformLoginController.cs index 47e3060..dc3a8f8 100644 --- a/DorkNet.Server/Controllers/API/PlatformLogin/V2/PlatformLoginController.cs +++ b/DorkNet.Server/Controllers/API/PlatformLogin/V2/PlatformLoginController.cs @@ -28,6 +28,7 @@ public class PlatformLoginController( PlayerService playerService, AuthService authService, ServerSettingsService settings, + SignupCodeService signupCodes, DorkNetDbContext db) : ControllerBase { // Login.LoginToAccount → POST api/platformlogin/v2 / v5 @@ -55,6 +56,10 @@ public class PlatformLoginController( if (await settings.AreSignupsDisabledAsync() && await playerService.GetByDeviceAsync(DeviceId) is null) { + // Stash the refused device so the player can claim it on the + // /join page (matched by IP) with a signup code. + await signupCodes.RecordPendingDeviceAsync( + DeviceId, Platform, PlatformId, SignupCodeService.ClientIp(HttpContext)); return Unauthorized(new LoginResponse { Error = "signups_disabled", diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index f935332..e1d6c24 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -36,6 +36,7 @@ public class AdminController( PlayerPresenceService playerPresence, PlayerLogService playerLog, ServerSettingsService serverSettings, + SignupCodeService signupCodes, IObjectStorage storage, DomainConfig domain, ILogger adminLogger) : ControllerBase @@ -1456,6 +1457,67 @@ public class AdminController( return Ok(weekly); } + // ── Signup codes ───────────────────────────────────────────────────── + + /// GET api/admin/v1/signup-codes — every issued code + /// with its status (unused / redeemed-by-whom / revoked / expired) + /// for the admin invite tracker. + [HttpGet("signup-codes")] + public async Task GetSignupCodes([FromQuery] int take = 200) + { + var codes = await signupCodes.ListAsync(take); + var redeemerIds = codes.Where(c => c.RedeemedByPlayerId is not null) + .Select(c => c.RedeemedByPlayerId!.Value).Distinct().ToList(); + var redeemers = redeemerIds.Count == 0 + ? new Dictionary() + : await db.Players.Where(p => redeemerIds.Contains(p.Id)) + .ToDictionaryAsync(p => p.Id, p => p.Username); + + var now = DateTime.UtcNow; + return Ok(codes.Select(c => new + { + c.Id, + c.Code, + c.Descriptor, + c.CreatedAt, + c.ExpiresAt, + c.Revoked, + c.RedeemedAt, + c.RedeemedByPlayerId, + RedeemedByUsername = c.RedeemedByPlayerId is { } rid && redeemers.TryGetValue(rid, out var u) ? u : null, + Status = c.RedeemedByPlayerId is not null ? "redeemed" + : c.Revoked ? "revoked" + : c.ExpiresAt is { } e && e <= now ? "expired" + : "active", + })); + } + + public sealed record GenerateSignupCodeRequest(string? Descriptor, DateTime? ExpiresAt); + + /// POST api/admin/v1/signup-codes — mint a new + /// single-use code with an optional descriptor + expiry. + [HttpPost("signup-codes")] + public async Task GenerateSignupCode([FromBody] GenerateSignupCodeRequest body) + { + var expires = body.ExpiresAt is { } e ? DateTime.SpecifyKind(e, DateTimeKind.Utc) : (DateTime?)null; + var code = await signupCodes.GenerateAsync(body.Descriptor ?? string.Empty, expires, CurrentAdminId); + await LogAsync("signup_code_created", "signup_code", code.Id, code.Descriptor); + await db.SaveChangesAsync(); + return Ok(new { code.Id, code.Code, code.Descriptor, code.CreatedAt, code.ExpiresAt }); + } + + /// POST api/admin/v1/signup-codes/{id}/revoke — kill an + /// unused code. Already-redeemed codes can't be revoked. + [HttpPost("signup-codes/{id:long}/revoke")] + public async Task RevokeSignupCode(long id) + { + var ok = await signupCodes.RevokeAsync(id); + if (!ok) return BadRequest(new { Error = "cannot_revoke" }); + await LogAsync("signup_code_revoked", "signup_code", id, ""); + await db.SaveChangesAsync(); + return Ok(new { Revoked = true }); + } + // ── Audit log ──────────────────────────────────────────────────────── [HttpGet("audit")] diff --git a/DorkNet.Server/Controllers/Site/PublicSiteController.cs b/DorkNet.Server/Controllers/Site/PublicSiteController.cs index 03dfa3d..944da1e 100644 --- a/DorkNet.Server/Controllers/Site/PublicSiteController.cs +++ b/DorkNet.Server/Controllers/Site/PublicSiteController.cs @@ -27,6 +27,7 @@ namespace DorkNet.Server.Controllers.Site; public class PublicSiteController( DorkNetDbContext db, PlayerService players, + SignupCodeService signupCodes, DomainConfig domain) : ControllerBase { // ── Players ────────────────────────────────────────────────────── @@ -243,4 +244,41 @@ public class PublicSiteController( }; }).ToList(); } + + // ── Signup (code redemption) ───────────────────────────────────── + + /// GET /api/site/v1/join/pending-devices — device ids that + /// were refused account creation while signups are disabled, seen + /// from THIS caller's IP. Lets the /join page show a player the + /// device their own game client just reported so they don't have to + /// dig it out of Player.log. Best-effort: behind a proxy that + /// collapses client IPs this can be empty, in which case the player + /// pastes the id manually. + [HttpGet("join/pending-devices")] + public async Task JoinPendingDevices() + { + var ip = SignupCodeService.ClientIp(HttpContext); + var rows = await signupCodes.RecentPendingByIpAsync(ip); + return Ok(rows.Select(d => new + { + deviceId = d.DeviceId, + platform = d.Platform, + lastSeenAt = d.LastSeenAt, + }).ToList()); + } + + public sealed record JoinRedeemRequest(string? Code, string? Username, string? DeviceId); + + /// POST /api/site/v1/join/redeem — redeem a signup code, + /// minting an account bound to the supplied device id. On success the + /// player relaunches the game and the watch's device-id login finds + /// the new account. Returns {ok, error?, username?}. + [HttpPost("join/redeem")] + public async Task JoinRedeem([FromBody] JoinRedeemRequest body) + { + var result = await signupCodes.RedeemAsync(body.Code, body.Username, body.DeviceId); + if (!result.Ok) + return BadRequest(new { ok = false, error = result.Error }); + return Ok(new { ok = true, username = result.Username, playerId = result.PlayerId }); + } } diff --git a/DorkNet.Server/Data/DorkNetDbContext.cs b/DorkNet.Server/Data/DorkNetDbContext.cs index 2df811a..9a371a6 100644 --- a/DorkNet.Server/Data/DorkNetDbContext.cs +++ b/DorkNet.Server/Data/DorkNetDbContext.cs @@ -72,6 +72,8 @@ public class DorkNetDbContext(DbContextOptions options) : DbCo public DbSet ClubCategoryTags => Set(); public DbSet ClubCategoryAssignments => Set(); public DbSet ClubSubscriptions => Set(); + public DbSet SignupCodes => Set(); + public DbSet PendingDevices => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/DorkNet.Server/Data/Entities/PendingDeviceEntity.cs b/DorkNet.Server/Data/Entities/PendingDeviceEntity.cs new file mode 100644 index 0000000..9fbf81c --- /dev/null +++ b/DorkNet.Server/Data/Entities/PendingDeviceEntity.cs @@ -0,0 +1,28 @@ +namespace DorkNet.Server.Data.Entities; + +/// +/// A device that tried to log in / create an account while signups were +/// disabled and got refused (it has no account yet). Recorded so the +/// public /join page can show a player the device id their own +/// game client just reported — matched by request IP — instead of making +/// them dig the Unity deviceUniqueIdentifier out of Player.log by +/// hand. Purely a UX aid for the signup-code redeem flow; rows are +/// upserted by DeviceId and can be pruned freely. +/// +public class PendingDeviceEntity +{ + public long Id { get; set; } + + /// The Unity device id the refused client sent. + public string DeviceId { get; set; } = string.Empty; + + public int Platform { get; set; } + public string PlatformId { get; set; } = string.Empty; + + /// Last request IP the refusal came from — the /join page + /// matches on this so a player sees their own device. + public string? LastIp { get; set; } + + public DateTime FirstSeenAt { get; set; } = DateTime.UtcNow; + public DateTime LastSeenAt { get; set; } = DateTime.UtcNow; +} diff --git a/DorkNet.Server/Data/Entities/SignupCodeEntity.cs b/DorkNet.Server/Data/Entities/SignupCodeEntity.cs new file mode 100644 index 0000000..5df7237 --- /dev/null +++ b/DorkNet.Server/Data/Entities/SignupCodeEntity.cs @@ -0,0 +1,40 @@ +namespace DorkNet.Server.Data.Entities; + +/// +/// An admin-issued, single-use signup code (Discord-invite style). While +/// the server has signups disabled, the only way to mint a new account is +/// to redeem one of these on the public site's /join page. Each +/// code carries a free-text so admins can track +/// who they handed it to, an optional , and the +/// redemption result ( / +/// ). Codes can be revoked before use. +/// +public class SignupCodeEntity +{ + public long Id { get; set; } + + /// The opaque code string the player types on /join. + /// Uppercase, hyphenated, unambiguous alphabet (no 0/O/1/I). + public string Code { get; set; } = string.Empty; + + /// Free-text note for the admin — "who is this for". Shown + /// only in the admin panel, never to the redeemer. + public string Descriptor { get; set; } = string.Empty; + + /// Player id of the admin who generated the code (0 if + /// generated by a system/CLI path). + public long CreatedByPlayerId { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// Optional hard expiry. Null = never expires. + public DateTime? ExpiresAt { get; set; } + + /// Set once the code is redeemed; null while unused. + public long? RedeemedByPlayerId { get; set; } + public DateTime? RedeemedAt { get; set; } + + /// Admin kill switch — a revoked code can never be redeemed, + /// even if otherwise valid. + public bool Revoked { get; set; } +} diff --git a/DorkNet.Server/Services/SignupCodeService.cs b/DorkNet.Server/Services/SignupCodeService.cs new file mode 100644 index 0000000..4c17329 --- /dev/null +++ b/DorkNet.Server/Services/SignupCodeService.cs @@ -0,0 +1,186 @@ +using System.Security.Cryptography; +using DorkNet.Server.Data; +using DorkNet.Server.Data.Entities; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; + +namespace DorkNet.Server.Services; + +/// +/// Backs the admin-issued signup-code flow. While the server has signups +/// disabled, the only path to a new account is redeeming one of these on +/// the public /join page. Codes are single-use, carry an admin +/// descriptor, and can have an optional expiry. Redemption mints an +/// account bound to the device id the player's game client reported (see +/// ), so the next launch logs straight +/// in via the watch's device-id login. +/// +public class SignupCodeService(DorkNetDbContext db, PlayerService players) +{ + // Unambiguous alphabet — no 0/O/1/I/L so codes are easy to read aloud + // and retype. Format: XXXX-XXXX. + private const string Alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; + + public async Task GenerateAsync(string descriptor, DateTime? expiresAt, long createdByPlayerId) + { + string code; + do { code = NewCode(); } + while (await db.SignupCodes.AnyAsync(c => c.Code == code)); + + var entity = new SignupCodeEntity + { + Code = code, + Descriptor = (descriptor ?? string.Empty).Trim(), + CreatedByPlayerId = createdByPlayerId, + CreatedAt = DateTime.UtcNow, + ExpiresAt = expiresAt, + }; + db.SignupCodes.Add(entity); + await db.SaveChangesAsync(); + return entity; + } + + public async Task> ListAsync(int take = 200) => + await db.SignupCodes + .OrderByDescending(c => c.CreatedAt) + .Take(Math.Clamp(take, 1, 1000)) + .ToListAsync(); + + /// Revoke an unused code. Already-redeemed codes can't be + /// revoked (the account already exists); returns false in that + /// case. + public async Task RevokeAsync(long id) + { + var code = await db.SignupCodes.FirstOrDefaultAsync(c => c.Id == id); + if (code is null || code.RedeemedByPlayerId is not null) return false; + if (code.Revoked) return true; + code.Revoked = true; + await db.SaveChangesAsync(); + return true; + } + + public sealed record RedeemResult(bool Ok, string? Error, long? PlayerId, string? Username); + + /// Redeem a code: validate it, then mint an account bound to + /// with the requested username. The code + /// is consumed atomically with account creation. + public async Task RedeemAsync(string? rawCode, string? rawUsername, string? deviceId) + { + var code = NormalizeCode(rawCode); + if (string.IsNullOrEmpty(code)) return new(false, "missing_code", null, null); + + var username = (rawUsername ?? string.Empty).Trim(); + if (!IsValidUsername(username)) return new(false, "invalid_username", null, null); + + if (string.IsNullOrWhiteSpace(deviceId)) return new(false, "missing_device", null, null); + + var entity = await db.SignupCodes.FirstOrDefaultAsync(c => c.Code == code); + if (entity is null) return new(false, "invalid_code", null, null); + if (entity.Revoked) return new(false, "code_revoked", null, null); + if (entity.RedeemedByPlayerId is not null) return new(false, "code_used", null, null); + if (entity.ExpiresAt is { } exp && exp <= DateTime.UtcNow) return new(false, "code_expired", null, null); + + if (await db.Players.AnyAsync(p => p.Username == username)) + return new(false, "username_taken", null, null); + + // The device must not already own an account (otherwise the + // player would launch into the existing one, not this fresh + // account). Guides them to a different device / to just log in. + if (await players.GetByDeviceAsync(deviceId) is not null) + return new(false, "device_in_use", null, null); + + var player = await players.GetOrCreateByDeviceAsync( + deviceId: deviceId, + platform: 0, + platformId: null, + displayName: username); + + entity.RedeemedByPlayerId = player.Id; + entity.RedeemedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + + // The device is now bound to a real account — drop any pending + // refusal row so it stops showing on the /join picker. + var pending = await db.PendingDevices.FirstOrDefaultAsync(d => d.DeviceId == deviceId); + if (pending is not null) + { + db.PendingDevices.Remove(pending); + await db.SaveChangesAsync(); + } + + return new(true, null, player.Id, player.Username); + } + + /// Record a device that was refused account creation while + /// signups are disabled, so the /join page can surface it (matched by + /// IP) instead of making the player hunt for their device id. + /// Upserts by device id. + public async Task RecordPendingDeviceAsync(string? deviceId, int platform, string? platformId, string? ip) + { + if (string.IsNullOrWhiteSpace(deviceId)) return; + var existing = await db.PendingDevices.FirstOrDefaultAsync(d => d.DeviceId == deviceId); + var now = DateTime.UtcNow; + if (existing is null) + { + db.PendingDevices.Add(new PendingDeviceEntity + { + DeviceId = deviceId, + Platform = platform, + PlatformId = platformId ?? string.Empty, + LastIp = ip, + FirstSeenAt = now, + LastSeenAt = now, + }); + } + else + { + existing.Platform = platform; + if (!string.IsNullOrEmpty(platformId)) existing.PlatformId = platformId; + existing.LastIp = ip; + existing.LastSeenAt = now; + } + await db.SaveChangesAsync(); + } + + /// Devices recently refused from the given IP, newest first — + /// the /join picker shows these so the player recognises their own. + public async Task> RecentPendingByIpAsync(string? ip, int take = 10) + { + if (string.IsNullOrWhiteSpace(ip)) return new(); + return await db.PendingDevices + .Where(d => d.LastIp == ip) + .OrderByDescending(d => d.LastSeenAt) + .Take(Math.Clamp(take, 1, 50)) + .ToListAsync(); + } + + /// Best-effort real client IP. The game client and the + /// player's browser both sit behind the same Cloudflare edge / tunnel, + /// so RemoteIpAddress collapses every caller to one address; + /// prefer the CF-Connecting-IP / X-Forwarded-For header so the /join + /// picker can match a player to the device their own client + /// reported. + public static string? ClientIp(HttpContext ctx) + { + var cf = ctx.Request.Headers["CF-Connecting-IP"].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(cf)) return cf.Trim(); + var fwd = ctx.Request.Headers["X-Forwarded-For"].FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(fwd)) return fwd.Split(',')[0].Trim(); + return ctx.Connection.RemoteIpAddress?.ToString(); + } + + private static string NewCode() + { + Span buf = stackalloc char[9]; + for (var i = 0; i < 9; i++) + buf[i] = i == 4 ? '-' : Alphabet[RandomNumberGenerator.GetInt32(Alphabet.Length)]; + return new string(buf); + } + + private static string NormalizeCode(string? raw) => + (raw ?? string.Empty).Trim().ToUpperInvariant(); + + private static bool IsValidUsername(string username) => + username.Length is >= 2 and <= 24 + && username.All(ch => char.IsLetterOrDigit(ch) || ch is '_' or '-'); +} diff --git a/DorkNet.Server/Startup/DatabaseBootstrap.cs b/DorkNet.Server/Startup/DatabaseBootstrap.cs index 6e38b2b..d540c0c 100644 --- a/DorkNet.Server/Startup/DatabaseBootstrap.cs +++ b/DorkNet.Server/Startup/DatabaseBootstrap.cs @@ -93,6 +93,14 @@ public static class DatabaseBootstrap await conn.CloseAsync(); } + // Signup-code + pending-device tables. These post-date the + // consolidated Initial migration, and the Postgres path is + // EnsureCreated-only (no migration replay), so neither provider + // gets them automatically on an existing DB. Create them with + // idempotent raw SQL that works on a fresh DB too (CREATE TABLE + // IF NOT EXISTS no-ops when EnsureCreated already built them). + await EnsureSignupCodeTablesAsync(db); + // Coach system account at Player.Id=1. The RR-Original room seeder // below assigns CreatorPlayerId=1 to every canonical room // (RecCenter, Paintball, Dodgeball, etc.), so the FK target needs @@ -157,6 +165,77 @@ public static class DatabaseBootstrap Controllers.Health.HealthController.MigrationsComplete = true; } + /// Idempotently create the SignupCodes + PendingDevices + /// tables on whichever provider is in use. Safe to run every boot: + /// CREATE TABLE / INDEX IF NOT EXISTS no-op once they exist (whether + /// created here or by a fresh-DB EnsureCreated). + private static async Task EnsureSignupCodeTablesAsync(DorkNetDbContext db) + { + var pg = db.Database.IsNpgsql(); + var statements = pg + ? new[] + { + """ + CREATE TABLE IF NOT EXISTS "SignupCodes" ( + "Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "Code" text NOT NULL, + "Descriptor" text NOT NULL DEFAULT '', + "CreatedByPlayerId" bigint NOT NULL DEFAULT 0, + "CreatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "ExpiresAt" timestamp with time zone NULL, + "RedeemedByPlayerId" bigint NULL, + "RedeemedAt" timestamp with time zone NULL, + "Revoked" boolean NOT NULL DEFAULT false + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_SignupCodes_Code"" ON ""SignupCodes"" (""Code"");", + """ + CREATE TABLE IF NOT EXISTS "PendingDevices" ( + "Id" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + "DeviceId" text NOT NULL, + "Platform" integer NOT NULL DEFAULT 0, + "PlatformId" text NOT NULL DEFAULT '', + "LastIp" text NULL, + "FirstSeenAt" timestamp with time zone NOT NULL DEFAULT now(), + "LastSeenAt" timestamp with time zone NOT NULL DEFAULT now() + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_PendingDevices_DeviceId"" ON ""PendingDevices"" (""DeviceId"");", + } + : new[] + { + """ + CREATE TABLE IF NOT EXISTS "SignupCodes" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_SignupCodes" PRIMARY KEY AUTOINCREMENT, + "Code" TEXT NOT NULL, + "Descriptor" TEXT NOT NULL DEFAULT '', + "CreatedByPlayerId" INTEGER NOT NULL DEFAULT 0, + "CreatedAt" TEXT NOT NULL, + "ExpiresAt" TEXT NULL, + "RedeemedByPlayerId" INTEGER NULL, + "RedeemedAt" TEXT NULL, + "Revoked" INTEGER NOT NULL DEFAULT 0 + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_SignupCodes_Code"" ON ""SignupCodes"" (""Code"");", + """ + CREATE TABLE IF NOT EXISTS "PendingDevices" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_PendingDevices" PRIMARY KEY AUTOINCREMENT, + "DeviceId" TEXT NOT NULL, + "Platform" INTEGER NOT NULL DEFAULT 0, + "PlatformId" TEXT NOT NULL DEFAULT '', + "LastIp" TEXT NULL, + "FirstSeenAt" TEXT NOT NULL, + "LastSeenAt" TEXT NOT NULL + ); + """, + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_PendingDevices_DeviceId"" ON ""PendingDevices"" (""DeviceId"");", + }; + + foreach (var sql in statements) + await db.Database.ExecuteSqlRawAsync(sql); + } + /// SQLite migration-history reconciliation. Handles two /// cutover scenarios so legacy DBs boot cleanly without manual SQL: /// diff --git a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs index 74e013d..5a12084 100644 --- a/DorkNet.Server/Startup/ServiceCollectionExtensions.cs +++ b/DorkNet.Server/Startup/ServiceCollectionExtensions.cs @@ -127,6 +127,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Singleton — the protobuf blob is identical for every room and built // once at startup; no need to rebuild per request. services.AddSingleton(); diff --git a/DorkNet.Server/admin-ui/src/App.tsx b/DorkNet.Server/admin-ui/src/App.tsx index 32b7da7..6a67d20 100644 --- a/DorkNet.Server/admin-ui/src/App.tsx +++ b/DorkNet.Server/admin-ui/src/App.tsx @@ -21,6 +21,7 @@ import { Gift } from './pages/Gift'; import { Passwords } from './pages/Passwords'; import { Grants } from './pages/Grants'; import { Settings } from './pages/Settings'; +import { SignupCodes } from './pages/SignupCodes'; export default function App() { return ( @@ -54,6 +55,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/DorkNet.Server/admin-ui/src/components/Sidebar.tsx b/DorkNet.Server/admin-ui/src/components/Sidebar.tsx index 3292e9b..da6068e 100644 --- a/DorkNet.Server/admin-ui/src/components/Sidebar.tsx +++ b/DorkNet.Server/admin-ui/src/components/Sidebar.tsx @@ -43,6 +43,7 @@ const groups: Array<{ title: string; items: Array<{ to: string; label: string; i { to: '/gift', label: 'Gift items', icon: , new: true }, { to: '/passwords', label: 'Reset passwords', icon: , new: true }, { to: '/grants', label: 'Grant maker pen', icon: , new: true }, + { to: '/signup-codes', label: 'Signup codes', icon: , new: true }, { to: '/settings', label: 'Server settings', icon: , new: true }, ], }, diff --git a/DorkNet.Server/admin-ui/src/pages/SignupCodes.tsx b/DorkNet.Server/admin-ui/src/pages/SignupCodes.tsx new file mode 100644 index 0000000..2bfabbc --- /dev/null +++ b/DorkNet.Server/admin-ui/src/pages/SignupCodes.tsx @@ -0,0 +1,179 @@ +import { useEffect, useState } from 'react'; +import { api, get } from '../lib/api'; +import { PageHeader } from '../components/PageHeader'; +import { useToast } from '../components/Toast'; +import { Plus, RefreshCw } from '../components/Icons'; + +interface SignupCode { + id: number; + code: string; + descriptor: string; + createdAt: string; + expiresAt: string | null; + revoked: boolean; + redeemedAt: string | null; + redeemedByPlayerId: number | null; + redeemedByUsername: string | null; + status: 'active' | 'redeemed' | 'revoked' | 'expired'; +} + +const STATUS_BADGE: Record = { + active: 'badge-online', + redeemed: 'badge-admin', + revoked: 'badge-banned', + expired: 'badge-neutral', +}; + +export function SignupCodes() { + const [codes, setCodes] = useState(null); + const [err, setErr] = useState(null); + const [descriptor, setDescriptor] = useState(''); + const [expiresAt, setExpiresAt] = useState(''); + const [busy, setBusy] = useState(false); + const toast = useToast(); + + const load = async () => { + try { + setCodes(await get('/signup-codes')); + setErr(null); + } catch (e) { + setErr((e as Error).message); + } + }; + + useEffect(() => { void load(); }, []); + + const generate = async () => { + setBusy(true); + try { + const created = await api<{ code: string }>('/signup-codes', { + method: 'POST', + body: { + Descriptor: descriptor.trim(), + ExpiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, + }, + }); + setDescriptor(''); + setExpiresAt(''); + toast.push(`Code ${created.code} created.`, 'success'); + await load(); + } catch (e) { + toast.push((e as Error).message, 'error'); + } finally { + setBusy(false); + } + }; + + const revoke = async (id: number, code: string) => { + if (!confirm(`Revoke code ${code}? It can no longer be redeemed.`)) return; + try { + await api(`/signup-codes/${id}/revoke`, { method: 'POST' }); + toast.push('Code revoked.', 'success'); + await load(); + } catch (e) { + toast.push((e as Error).message, 'error'); + } + }; + + const copy = async (code: string) => { + try { + await navigator.clipboard.writeText(code); + toast.push('Code copied.', 'success'); + } catch { + toast.push('Copy failed — select it manually.', 'error'); + } + }; + + return ( +
+ + + Refresh + + } + /> + + {err && ( +
{err}
+ )} + +
+

Generate a code

+
+ + +
+
+ +
+
+ +
+ {!codes && !err &&
Loading…
} + {codes && codes.length === 0 && ( +
No codes yet — generate one above.
+ )} + {codes && codes.length > 0 && ( + + + + + + + + + + + + + {codes.map((c) => ( + + + + + + + + + ))} + +
CodeDescriptorStatusRedeemed byExpires
{c.code}{c.descriptor || —}{c.status} + {c.redeemedByUsername + ? {c.redeemedByUsername} ({c.redeemedByPlayerId}) + : —} + + {c.expiresAt ? new Date(c.expiresAt).toLocaleString() : never} + + + {c.status === 'active' && ( + + )} +
+ )} +
+
+ ); +} diff --git a/DorkNet.Server/site/src/App.tsx b/DorkNet.Server/site/src/App.tsx index ea462c1..5feab6e 100644 --- a/DorkNet.Server/site/src/App.tsx +++ b/DorkNet.Server/site/src/App.tsx @@ -6,6 +6,7 @@ import { PhotoDetail } from './pages/PhotoDetail'; import { Players } from './pages/Players'; import { PlayerProfile } from './pages/PlayerProfile'; import { Rooms } from './pages/Rooms'; +import { Join } from './pages/Join'; import { NotFound } from './pages/NotFound'; export function App() { @@ -20,6 +21,7 @@ export function App() { } /> } /> } /> + } /> {/* Legacy paths from the vanilla-JS feed.localhost site */} } /> } /> diff --git a/DorkNet.Server/site/src/components/Header.tsx b/DorkNet.Server/site/src/components/Header.tsx index 87f01a5..a5ae394 100644 --- a/DorkNet.Server/site/src/components/Header.tsx +++ b/DorkNet.Server/site/src/components/Header.tsx @@ -6,6 +6,7 @@ const NAV = [ { to: '/feed', label: 'Feed' }, { to: '/players', label: 'Players' }, { to: '/rooms', label: 'Rooms' }, + { to: '/join', label: 'Join' }, ]; export function Header() { diff --git a/DorkNet.Server/site/src/lib/api.ts b/DorkNet.Server/site/src/lib/api.ts index cd5a771..a928f6c 100644 --- a/DorkNet.Server/site/src/lib/api.ts +++ b/DorkNet.Server/site/src/lib/api.ts @@ -26,3 +26,26 @@ export async function get(path: string): Promise { if (res.status === 204) return undefined as T; return res.json() as Promise; } + +// POST with a JSON body. Used by the /join signup-code flow — the only +// write path the otherwise read-only public site has. On a non-2xx the +// server's { error } string (e.g. "code_expired") is surfaced as the +// thrown ApiError message so the page can map it to friendly copy. +export async function post(path: string, body: unknown): Promise { + const res = await fetch(BASE + path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + let msg = `${res.status} ${res.statusText}`; + try { + const b = await res.json(); + if (b?.error) msg = b.error; + else if (b?.message) msg = b.message; + } catch { /* keep status text */ } + throw new ApiError(res.status, msg); + } + if (res.status === 204) return undefined as T; + return res.json() as Promise; +} diff --git a/DorkNet.Server/site/src/pages/Join.tsx b/DorkNet.Server/site/src/pages/Join.tsx new file mode 100644 index 0000000..2ed39c2 --- /dev/null +++ b/DorkNet.Server/site/src/pages/Join.tsx @@ -0,0 +1,162 @@ +import { useEffect, useState } from 'react'; +import { get, post } from '../lib/api'; + +interface PendingDevice { + deviceId: string; + platform: number; + lastSeenAt: string; +} + +// Server error code -> friendly copy. +const ERRORS: Record = { + missing_code: 'Enter your signup code.', + invalid_code: "That code isn't valid. Double-check it with whoever gave it to you.", + code_revoked: 'That code has been revoked. Ask for a new one.', + code_used: 'That code has already been used.', + code_expired: 'That code has expired. Ask for a new one.', + invalid_username: 'Pick a username 2–24 characters long (letters, numbers, _ or -).', + username_taken: 'That username is taken — try another.', + missing_device: 'Pick or paste the device id from your game client.', + device_in_use: 'That device already has an account — just launch the game to log in.', +}; + +export function Join() { + const [code, setCode] = useState(''); + const [username, setUsername] = useState(''); + const [deviceId, setDeviceId] = useState(''); + const [pending, setPending] = useState([]); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + const [doneUsername, setDoneUsername] = useState(null); + + // Pull the devices our caller's IP was recently refused from, so the + // player can pick the one their own game client reported instead of + // hunting for the Unity device id by hand. + useEffect(() => { + get('/join/pending-devices') + .then((rows) => { + setPending(rows); + if (rows.length > 0) setDeviceId((cur) => cur || rows[0].deviceId); + }) + .catch(() => { /* picker is a convenience; manual paste still works */ }); + }, []); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setBusy(true); + setErr(null); + try { + const res = await post<{ ok: boolean; username: string }>('/join/redeem', { + Code: code.trim(), + Username: username.trim(), + DeviceId: deviceId.trim(), + }); + setDoneUsername(res.username); + } catch (e) { + const key = (e as Error).message; + setErr(ERRORS[key] ?? key); + } finally { + setBusy(false); + } + }; + + if (doneUsername) { + return ( +
+
+

You're in, {doneUsername}! 🎉

+

+ Your account is ready. Launch the game through the DorkNet launcher on this + same device and you'll be logged straight in — no signup step needed. +

+
+
+ ); + } + + return ( +
+
+

Join the server

+

+ Got a signup code? Redeem it here to create your account, then launch the game. +

+
+ +
+ + + + +
+ Your device + {pending.length > 0 ? ( + <> + + {!pending.some((d) => d.deviceId === deviceId) && ( + setDeviceId(e.target.value)} + placeholder="paste your device id" + /> + )} +

+ These are devices that just tried to sign in from your network. Pick yours. +

+ + ) : ( + <> + setDeviceId(e.target.value)} + placeholder="paste your device id" + /> +

+ Launch the game once first — when it says signups are disabled, come back here + and your device should appear automatically. +

+ + )} +
+ + {err && ( +
{err}
+ )} + + +
+
+ ); +} diff --git a/README.md b/README.md index 3369618..b90e9b4 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,22 @@ go in [`DorkNet.Server/Data/LegacyUpgrades.cs`](DorkNet.Server/Data/LegacyUpgrad See [`DorkNet.Server/Data/MIGRATIONS.md`](DorkNet.Server/Data/MIGRATIONS.md) for the full discipline. +Tables added after the consolidated `Initial` migration (e.g. +`SignupCodes` / `PendingDevices`) are created by an idempotent +`CREATE TABLE IF NOT EXISTS` step in +[`Startup/DatabaseBootstrap.cs`](DorkNet.Server/Startup/DatabaseBootstrap.cs) +so both providers pick them up on existing DBs without a new migration +(the Postgres path is `EnsureCreated`-only and never replays migrations). + +### Signup codes + +When account creation is disabled (admin **Server settings**), the only +way in is an admin-issued single-use **signup code**: generate one in the +admin panel's **Signup codes** page (with a descriptor + optional expiry), +hand it to the player, and they redeem it on the site's **`/join`** page — +which creates their account bound to the device their game client +reported, so the next launch logs straight in. + --- ## Contributing