mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
signup codes: admin-issued invite codes + site /join redemption
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.
This commit is contained in:
@@ -19,6 +19,7 @@ public class AccountApiController(
|
|||||||
DorkNetDbContext db,
|
DorkNetDbContext db,
|
||||||
NotificationService notifications,
|
NotificationService notifications,
|
||||||
ServerSettingsService settings,
|
ServerSettingsService settings,
|
||||||
|
SignupCodeService signupCodes,
|
||||||
ILogger<AccountApiController> logger) : ControllerBase
|
ILogger<AccountApiController> logger) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpPost("api/account/v1/create")]
|
[HttpPost("api/account/v1/create")]
|
||||||
@@ -37,6 +38,8 @@ public class AccountApiController(
|
|||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"[api-account] api/account/v1/create refused — signups disabled (device={Device} platform={Platform})",
|
"[api-account] api/account/v1/create refused — signups disabled (device={Device} platform={Platform})",
|
||||||
deviceId, platform);
|
deviceId, platform);
|
||||||
|
await signupCodes.RecordPendingDeviceAsync(
|
||||||
|
deviceId, platform, platformId, SignupCodeService.ClientIp(HttpContext));
|
||||||
return Ok(new CreateAccountResponse
|
return Ok(new CreateAccountResponse
|
||||||
{
|
{
|
||||||
Success = false,
|
Success = false,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public class PlatformLoginController(
|
|||||||
PlayerService playerService,
|
PlayerService playerService,
|
||||||
AuthService authService,
|
AuthService authService,
|
||||||
ServerSettingsService settings,
|
ServerSettingsService settings,
|
||||||
|
SignupCodeService signupCodes,
|
||||||
DorkNetDbContext db) : ControllerBase
|
DorkNetDbContext db) : ControllerBase
|
||||||
{
|
{
|
||||||
// Login.LoginToAccount → POST api/platformlogin/v2 / v5
|
// Login.LoginToAccount → POST api/platformlogin/v2 / v5
|
||||||
@@ -55,6 +56,10 @@ public class PlatformLoginController(
|
|||||||
if (await settings.AreSignupsDisabledAsync()
|
if (await settings.AreSignupsDisabledAsync()
|
||||||
&& await playerService.GetByDeviceAsync(DeviceId) is null)
|
&& 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
|
return Unauthorized(new LoginResponse
|
||||||
{
|
{
|
||||||
Error = "signups_disabled",
|
Error = "signups_disabled",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ public class AdminController(
|
|||||||
PlayerPresenceService playerPresence,
|
PlayerPresenceService playerPresence,
|
||||||
PlayerLogService playerLog,
|
PlayerLogService playerLog,
|
||||||
ServerSettingsService serverSettings,
|
ServerSettingsService serverSettings,
|
||||||
|
SignupCodeService signupCodes,
|
||||||
IObjectStorage storage,
|
IObjectStorage storage,
|
||||||
DomainConfig domain,
|
DomainConfig domain,
|
||||||
ILogger<AdminController> adminLogger) : ControllerBase
|
ILogger<AdminController> adminLogger) : ControllerBase
|
||||||
@@ -1456,6 +1457,67 @@ public class AdminController(
|
|||||||
return Ok(weekly);
|
return Ok(weekly);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Signup codes ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>GET <c>api/admin/v1/signup-codes</c> — every issued code
|
||||||
|
/// with its status (unused / redeemed-by-whom / revoked / expired)
|
||||||
|
/// for the admin invite tracker.</summary>
|
||||||
|
[HttpGet("signup-codes")]
|
||||||
|
public async Task<ActionResult> 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<long, string>()
|
||||||
|
: 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);
|
||||||
|
|
||||||
|
/// <summary>POST <c>api/admin/v1/signup-codes</c> — mint a new
|
||||||
|
/// single-use code with an optional descriptor + expiry.</summary>
|
||||||
|
[HttpPost("signup-codes")]
|
||||||
|
public async Task<ActionResult> 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>POST <c>api/admin/v1/signup-codes/{id}/revoke</c> — kill an
|
||||||
|
/// unused code. Already-redeemed codes can't be revoked.</summary>
|
||||||
|
[HttpPost("signup-codes/{id:long}/revoke")]
|
||||||
|
public async Task<ActionResult> 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 ────────────────────────────────────────────────────────
|
// ── Audit log ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[HttpGet("audit")]
|
[HttpGet("audit")]
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ namespace DorkNet.Server.Controllers.Site;
|
|||||||
public class PublicSiteController(
|
public class PublicSiteController(
|
||||||
DorkNetDbContext db,
|
DorkNetDbContext db,
|
||||||
PlayerService players,
|
PlayerService players,
|
||||||
|
SignupCodeService signupCodes,
|
||||||
DomainConfig domain) : ControllerBase
|
DomainConfig domain) : ControllerBase
|
||||||
{
|
{
|
||||||
// ── Players ──────────────────────────────────────────────────────
|
// ── Players ──────────────────────────────────────────────────────
|
||||||
@@ -243,4 +244,41 @@ public class PublicSiteController(
|
|||||||
};
|
};
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Signup (code redemption) ─────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
[HttpGet("join/pending-devices")]
|
||||||
|
public async Task<IActionResult> 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);
|
||||||
|
|
||||||
|
/// <summary>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?}.</summary>
|
||||||
|
[HttpPost("join/redeem")]
|
||||||
|
public async Task<IActionResult> 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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
|
|||||||
public DbSet<ClubCategoryTagEntity> ClubCategoryTags => Set<ClubCategoryTagEntity>();
|
public DbSet<ClubCategoryTagEntity> ClubCategoryTags => Set<ClubCategoryTagEntity>();
|
||||||
public DbSet<ClubCategoryAssignmentEntity> ClubCategoryAssignments => Set<ClubCategoryAssignmentEntity>();
|
public DbSet<ClubCategoryAssignmentEntity> ClubCategoryAssignments => Set<ClubCategoryAssignmentEntity>();
|
||||||
public DbSet<ClubSubscriptionEntity> ClubSubscriptions => Set<ClubSubscriptionEntity>();
|
public DbSet<ClubSubscriptionEntity> ClubSubscriptions => Set<ClubSubscriptionEntity>();
|
||||||
|
public DbSet<SignupCodeEntity> SignupCodes => Set<SignupCodeEntity>();
|
||||||
|
public DbSet<PendingDeviceEntity> PendingDevices => Set<PendingDeviceEntity>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
namespace DorkNet.Server.Data.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>/join</c> 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 <c>deviceUniqueIdentifier</c> out of Player.log by
|
||||||
|
/// hand. Purely a UX aid for the signup-code redeem flow; rows are
|
||||||
|
/// upserted by <c>DeviceId</c> and can be pruned freely.
|
||||||
|
/// </summary>
|
||||||
|
public class PendingDeviceEntity
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The Unity device id the refused client sent.</summary>
|
||||||
|
public string DeviceId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int Platform { get; set; }
|
||||||
|
public string PlatformId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Last request IP the refusal came from — the /join page
|
||||||
|
/// matches on this so a player sees their own device.</summary>
|
||||||
|
public string? LastIp { get; set; }
|
||||||
|
|
||||||
|
public DateTime FirstSeenAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime LastSeenAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
namespace DorkNet.Server.Data.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>/join</c> page. Each
|
||||||
|
/// code carries a free-text <see cref="Descriptor"/> so admins can track
|
||||||
|
/// who they handed it to, an optional <see cref="ExpiresAt"/>, and the
|
||||||
|
/// redemption result (<see cref="RedeemedByPlayerId"/> /
|
||||||
|
/// <see cref="RedeemedAt"/>). Codes can be revoked before use.
|
||||||
|
/// </summary>
|
||||||
|
public class SignupCodeEntity
|
||||||
|
{
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The opaque code string the player types on /join.
|
||||||
|
/// Uppercase, hyphenated, unambiguous alphabet (no 0/O/1/I).</summary>
|
||||||
|
public string Code { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Free-text note for the admin — "who is this for". Shown
|
||||||
|
/// only in the admin panel, never to the redeemer.</summary>
|
||||||
|
public string Descriptor { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Player id of the admin who generated the code (0 if
|
||||||
|
/// generated by a system/CLI path).</summary>
|
||||||
|
public long CreatedByPlayerId { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>Optional hard expiry. Null = never expires.</summary>
|
||||||
|
public DateTime? ExpiresAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Set once the code is redeemed; null while unused.</summary>
|
||||||
|
public long? RedeemedByPlayerId { get; set; }
|
||||||
|
public DateTime? RedeemedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Admin kill switch — a revoked code can never be redeemed,
|
||||||
|
/// even if otherwise valid.</summary>
|
||||||
|
public bool Revoked { get; set; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>/join</c> 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
|
||||||
|
/// <see cref="PendingDeviceEntity"/>), so the next launch logs straight
|
||||||
|
/// in via the watch's device-id login.
|
||||||
|
/// </summary>
|
||||||
|
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<SignupCodeEntity> 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<List<SignupCodeEntity>> ListAsync(int take = 200) =>
|
||||||
|
await db.SignupCodes
|
||||||
|
.OrderByDescending(c => c.CreatedAt)
|
||||||
|
.Take(Math.Clamp(take, 1, 1000))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
/// <summary>Revoke an unused code. Already-redeemed codes can't be
|
||||||
|
/// revoked (the account already exists); returns false in that
|
||||||
|
/// case.</summary>
|
||||||
|
public async Task<bool> 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);
|
||||||
|
|
||||||
|
/// <summary>Redeem a code: validate it, then mint an account bound to
|
||||||
|
/// <paramref name="deviceId"/> with the requested username. The code
|
||||||
|
/// is consumed atomically with account creation.</summary>
|
||||||
|
public async Task<RedeemResult> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Devices recently refused from the given IP, newest first —
|
||||||
|
/// the /join picker shows these so the player recognises their own.</summary>
|
||||||
|
public async Task<List<PendingDeviceEntity>> 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Best-effort real client IP. The game client and the
|
||||||
|
/// player's browser both sit behind the same Cloudflare edge / tunnel,
|
||||||
|
/// so <c>RemoteIpAddress</c> 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.</summary>
|
||||||
|
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<char> 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 '-');
|
||||||
|
}
|
||||||
@@ -93,6 +93,14 @@ public static class DatabaseBootstrap
|
|||||||
await conn.CloseAsync();
|
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
|
// Coach system account at Player.Id=1. The RR-Original room seeder
|
||||||
// below assigns CreatorPlayerId=1 to every canonical room
|
// below assigns CreatorPlayerId=1 to every canonical room
|
||||||
// (RecCenter, Paintball, Dodgeball, etc.), so the FK target needs
|
// (RecCenter, Paintball, Dodgeball, etc.), so the FK target needs
|
||||||
@@ -157,6 +165,77 @@ public static class DatabaseBootstrap
|
|||||||
Controllers.Health.HealthController.MigrationsComplete = true;
|
Controllers.Health.HealthController.MigrationsComplete = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>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).</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>SQLite migration-history reconciliation. Handles two
|
/// <summary>SQLite migration-history reconciliation. Handles two
|
||||||
/// cutover scenarios so legacy DBs boot cleanly without manual SQL:
|
/// cutover scenarios so legacy DBs boot cleanly without manual SQL:
|
||||||
/// <list type="number">
|
/// <list type="number">
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<ClubService>();
|
services.AddScoped<ClubService>();
|
||||||
services.AddScoped<LevelService>();
|
services.AddScoped<LevelService>();
|
||||||
services.AddScoped<StoreService>();
|
services.AddScoped<StoreService>();
|
||||||
|
services.AddScoped<SignupCodeService>();
|
||||||
// Singleton — the protobuf blob is identical for every room and built
|
// Singleton — the protobuf blob is identical for every room and built
|
||||||
// once at startup; no need to rebuild per request.
|
// once at startup; no need to rebuild per request.
|
||||||
services.AddSingleton<RoomDataBlobService>();
|
services.AddSingleton<RoomDataBlobService>();
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { Gift } from './pages/Gift';
|
|||||||
import { Passwords } from './pages/Passwords';
|
import { Passwords } from './pages/Passwords';
|
||||||
import { Grants } from './pages/Grants';
|
import { Grants } from './pages/Grants';
|
||||||
import { Settings } from './pages/Settings';
|
import { Settings } from './pages/Settings';
|
||||||
|
import { SignupCodes } from './pages/SignupCodes';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -54,6 +55,7 @@ export default function App() {
|
|||||||
<Route path="passwords" element={<Passwords />} />
|
<Route path="passwords" element={<Passwords />} />
|
||||||
<Route path="grants" element={<Grants />} />
|
<Route path="grants" element={<Grants />} />
|
||||||
<Route path="settings" element={<Settings />} />
|
<Route path="settings" element={<Settings />} />
|
||||||
|
<Route path="signup-codes" element={<SignupCodes />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const groups: Array<{ title: string; items: Array<{ to: string; label: string; i
|
|||||||
{ to: '/gift', label: 'Gift items', icon: <Gift />, new: true },
|
{ to: '/gift', label: 'Gift items', icon: <Gift />, new: true },
|
||||||
{ to: '/passwords', label: 'Reset passwords', icon: <KeyRound />, new: true },
|
{ to: '/passwords', label: 'Reset passwords', icon: <KeyRound />, new: true },
|
||||||
{ to: '/grants', label: 'Grant maker pen', icon: <Wrench />, new: true },
|
{ to: '/grants', label: 'Grant maker pen', icon: <Wrench />, new: true },
|
||||||
|
{ to: '/signup-codes', label: 'Signup codes', icon: <KeyRound />, new: true },
|
||||||
{ to: '/settings', label: 'Server settings', icon: <Settings />, new: true },
|
{ to: '/settings', label: 'Server settings', icon: <Settings />, new: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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<SignupCode['status'], string> = {
|
||||||
|
active: 'badge-online',
|
||||||
|
redeemed: 'badge-admin',
|
||||||
|
revoked: 'badge-banned',
|
||||||
|
expired: 'badge-neutral',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SignupCodes() {
|
||||||
|
const [codes, setCodes] = useState<SignupCode[] | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [descriptor, setDescriptor] = useState('');
|
||||||
|
const [expiresAt, setExpiresAt] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
setCodes(await get<SignupCode[]>('/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 (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Signup codes"
|
||||||
|
blurb="Single-use invite codes. Hand one to a player; they redeem it on the site's /join page to create an account while signups are disabled."
|
||||||
|
actions={
|
||||||
|
<button onClick={load} className="btn-secondary text-xs">
|
||||||
|
<RefreshCw />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{err && (
|
||||||
|
<div className="card border-danger/30 bg-danger/5 px-4 py-3 text-sm text-danger mb-4">{err}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card !p-5 max-w-2xl">
|
||||||
|
<h2 className="text-sm font-semibold text-ink-50">Generate a code</h2>
|
||||||
|
<div className="mt-3 grid gap-3 md:grid-cols-[1fr_auto]">
|
||||||
|
<label className="block">
|
||||||
|
<span className="label">Descriptor (who is this for?)</span>
|
||||||
|
<input
|
||||||
|
className="input mt-1"
|
||||||
|
value={descriptor}
|
||||||
|
onChange={(e) => setDescriptor(e.target.value)}
|
||||||
|
placeholder="e.g. Phil's friend Dana"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className="label">Expires (optional)</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
className="input mt-1"
|
||||||
|
value={expiresAt}
|
||||||
|
onChange={(e) => setExpiresAt(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<button onClick={generate} disabled={busy} className="btn-primary text-xs">
|
||||||
|
<Plus />
|
||||||
|
{busy ? 'Generating…' : 'Generate code'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card !p-0 mt-5 overflow-hidden">
|
||||||
|
{!codes && !err && <div className="p-6 text-center text-xs text-ink-400">Loading…</div>}
|
||||||
|
{codes && codes.length === 0 && (
|
||||||
|
<div className="p-6 text-center text-xs text-ink-400">No codes yet — generate one above.</div>
|
||||||
|
)}
|
||||||
|
{codes && codes.length > 0 && (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-left text-[11px] uppercase tracking-wide text-ink-500 border-b border-ink-800">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2">Code</th>
|
||||||
|
<th className="px-4 py-2">Descriptor</th>
|
||||||
|
<th className="px-4 py-2">Status</th>
|
||||||
|
<th className="px-4 py-2">Redeemed by</th>
|
||||||
|
<th className="px-4 py-2">Expires</th>
|
||||||
|
<th className="px-4 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{codes.map((c) => (
|
||||||
|
<tr key={c.id} className="border-b border-ink-900/60">
|
||||||
|
<td className="px-4 py-2 font-mono text-ink-100">{c.code}</td>
|
||||||
|
<td className="px-4 py-2 text-ink-300">{c.descriptor || <span className="text-ink-600">—</span>}</td>
|
||||||
|
<td className="px-4 py-2"><span className={STATUS_BADGE[c.status]}>{c.status}</span></td>
|
||||||
|
<td className="px-4 py-2 text-ink-300">
|
||||||
|
{c.redeemedByUsername
|
||||||
|
? <span>{c.redeemedByUsername} <span className="text-ink-600">({c.redeemedByPlayerId})</span></span>
|
||||||
|
: <span className="text-ink-600">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-ink-400 text-xs">
|
||||||
|
{c.expiresAt ? new Date(c.expiresAt).toLocaleString() : <span className="text-ink-600">never</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right whitespace-nowrap">
|
||||||
|
<button onClick={() => copy(c.code)} className="btn-ghost !px-2 !py-1 text-xs">Copy</button>
|
||||||
|
{c.status === 'active' && (
|
||||||
|
<button onClick={() => revoke(c.id, c.code)} className="btn-ghost !px-2 !py-1 text-xs text-danger">Revoke</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { PhotoDetail } from './pages/PhotoDetail';
|
|||||||
import { Players } from './pages/Players';
|
import { Players } from './pages/Players';
|
||||||
import { PlayerProfile } from './pages/PlayerProfile';
|
import { PlayerProfile } from './pages/PlayerProfile';
|
||||||
import { Rooms } from './pages/Rooms';
|
import { Rooms } from './pages/Rooms';
|
||||||
|
import { Join } from './pages/Join';
|
||||||
import { NotFound } from './pages/NotFound';
|
import { NotFound } from './pages/NotFound';
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
@@ -20,6 +21,7 @@ export function App() {
|
|||||||
<Route path="/players" element={<Players />} />
|
<Route path="/players" element={<Players />} />
|
||||||
<Route path="/players/:id" element={<PlayerProfile />} />
|
<Route path="/players/:id" element={<PlayerProfile />} />
|
||||||
<Route path="/rooms" element={<Rooms />} />
|
<Route path="/rooms" element={<Rooms />} />
|
||||||
|
<Route path="/join" element={<Join />} />
|
||||||
{/* Legacy paths from the vanilla-JS feed.localhost site */}
|
{/* Legacy paths from the vanilla-JS feed.localhost site */}
|
||||||
<Route path="/p/:id" element={<RedirectPhoto />} />
|
<Route path="/p/:id" element={<RedirectPhoto />} />
|
||||||
<Route path="/u/:id" element={<RedirectPlayer />} />
|
<Route path="/u/:id" element={<RedirectPlayer />} />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const NAV = [
|
|||||||
{ to: '/feed', label: 'Feed' },
|
{ to: '/feed', label: 'Feed' },
|
||||||
{ to: '/players', label: 'Players' },
|
{ to: '/players', label: 'Players' },
|
||||||
{ to: '/rooms', label: 'Rooms' },
|
{ to: '/rooms', label: 'Rooms' },
|
||||||
|
{ to: '/join', label: 'Join' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
|
|||||||
@@ -26,3 +26,26 @@ export async function get<T = unknown>(path: string): Promise<T> {
|
|||||||
if (res.status === 204) return undefined as T;
|
if (res.status === 204) return undefined as T;
|
||||||
return res.json() as Promise<T>;
|
return res.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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<T = unknown>(path: string, body: unknown): Promise<T> {
|
||||||
|
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<T>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<string, string> = {
|
||||||
|
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<PendingDevice[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [doneUsername, setDoneUsername] = useState<string | null>(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<PendingDevice[]>('/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 (
|
||||||
|
<div className="max-w-lg mx-auto space-y-4">
|
||||||
|
<div className="card !p-6 text-center">
|
||||||
|
<h1 className="text-2xl font-semibold text-ink-50">You're in, {doneUsername}! 🎉</h1>
|
||||||
|
<p className="mt-3 text-sm text-ink-300">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-lg mx-auto space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-ink-50">Join the server</h1>
|
||||||
|
<p className="text-sm text-ink-400">
|
||||||
|
Got a signup code? Redeem it here to create your account, then launch the game.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={submit} className="card !p-5 space-y-4">
|
||||||
|
<label className="block">
|
||||||
|
<span className="label">Signup code</span>
|
||||||
|
<input
|
||||||
|
className="input mt-1 font-mono tracking-wider"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||||
|
placeholder="XXXX-XXXX"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block">
|
||||||
|
<span className="label">Choose a username</span>
|
||||||
|
<input
|
||||||
|
className="input mt-1"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="2–24 chars: letters, numbers, _ or -"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="block">
|
||||||
|
<span className="label">Your device</span>
|
||||||
|
{pending.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
className="input mt-1"
|
||||||
|
value={pending.some((d) => d.deviceId === deviceId) ? deviceId : '__manual__'}
|
||||||
|
onChange={(e) => setDeviceId(e.target.value === '__manual__' ? '' : e.target.value)}
|
||||||
|
>
|
||||||
|
{pending.map((d) => (
|
||||||
|
<option key={d.deviceId} value={d.deviceId}>
|
||||||
|
{d.deviceId.slice(0, 16)}… · last seen {new Date(d.lastSeenAt).toLocaleTimeString()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
<option value="__manual__">Enter device id manually…</option>
|
||||||
|
</select>
|
||||||
|
{!pending.some((d) => d.deviceId === deviceId) && (
|
||||||
|
<input
|
||||||
|
className="input mt-2 font-mono text-xs"
|
||||||
|
value={deviceId}
|
||||||
|
onChange={(e) => setDeviceId(e.target.value)}
|
||||||
|
placeholder="paste your device id"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-[11px] text-ink-500">
|
||||||
|
These are devices that just tried to sign in from your network. Pick yours.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
className="input mt-1 font-mono text-xs"
|
||||||
|
value={deviceId}
|
||||||
|
onChange={(e) => setDeviceId(e.target.value)}
|
||||||
|
placeholder="paste your device id"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[11px] text-ink-500">
|
||||||
|
Launch the game once first — when it says signups are disabled, come back here
|
||||||
|
and your device should appear automatically.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && (
|
||||||
|
<div className="rounded-lg border border-danger/30 bg-danger/5 px-3 py-2 text-sm text-danger">{err}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit" disabled={busy} className="btn-primary text-sm w-full justify-center">
|
||||||
|
{busy ? 'Creating your account…' : 'Create my account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)
|
See [`DorkNet.Server/Data/MIGRATIONS.md`](DorkNet.Server/Data/MIGRATIONS.md)
|
||||||
for the full discipline.
|
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
|
## Contributing
|
||||||
|
|||||||
Reference in New Issue
Block a user