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:
Alexa
2026-05-31 20:00:35 +03:00
parent 18989fa6ec
commit 3480ca21fa
18 changed files with 830 additions and 0 deletions
@@ -19,6 +19,7 @@ public class AccountApiController(
DorkNetDbContext db,
NotificationService notifications,
ServerSettingsService settings,
SignupCodeService signupCodes,
ILogger<AccountApiController> 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,
@@ -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",
@@ -36,6 +36,7 @@ public class AdminController(
PlayerPresenceService playerPresence,
PlayerLogService playerLog,
ServerSettingsService serverSettings,
SignupCodeService signupCodes,
IObjectStorage storage,
DomainConfig domain,
ILogger<AdminController> adminLogger) : ControllerBase
@@ -1456,6 +1457,67 @@ public class AdminController(
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 ────────────────────────────────────────────────────────
[HttpGet("audit")]
@@ -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) ─────────────────────────────────────
/// <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 });
}
}
+2
View File
@@ -72,6 +72,8 @@ public class DorkNetDbContext(DbContextOptions<DorkNetDbContext> options) : DbCo
public DbSet<ClubCategoryTagEntity> ClubCategoryTags => Set<ClubCategoryTagEntity>();
public DbSet<ClubCategoryAssignmentEntity> ClubCategoryAssignments => Set<ClubCategoryAssignmentEntity>();
public DbSet<ClubSubscriptionEntity> ClubSubscriptions => Set<ClubSubscriptionEntity>();
public DbSet<SignupCodeEntity> SignupCodes => Set<SignupCodeEntity>();
public DbSet<PendingDeviceEntity> PendingDevices => Set<PendingDeviceEntity>();
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();
}
// 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;
}
/// <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
/// cutover scenarios so legacy DBs boot cleanly without manual SQL:
/// <list type="number">
@@ -127,6 +127,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<ClubService>();
services.AddScoped<LevelService>();
services.AddScoped<StoreService>();
services.AddScoped<SignupCodeService>();
// Singleton — the protobuf blob is identical for every room and built
// once at startup; no need to rebuild per request.
services.AddSingleton<RoomDataBlobService>();
+2
View File
@@ -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() {
<Route path="passwords" element={<Passwords />} />
<Route path="grants" element={<Grants />} />
<Route path="settings" element={<Settings />} />
<Route path="signup-codes" element={<SignupCodes />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</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: '/passwords', label: 'Reset passwords', icon: <KeyRound />, 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 },
],
},
@@ -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>
);
}
+2
View File
@@ -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() {
<Route path="/players" element={<Players />} />
<Route path="/players/:id" element={<PlayerProfile />} />
<Route path="/rooms" element={<Rooms />} />
<Route path="/join" element={<Join />} />
{/* Legacy paths from the vanilla-JS feed.localhost site */}
<Route path="/p/:id" element={<RedirectPhoto />} />
<Route path="/u/:id" element={<RedirectPlayer />} />
@@ -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() {
+23
View File
@@ -26,3 +26,26 @@ export async function get<T = unknown>(path: string): Promise<T> {
if (res.status === 204) return undefined as 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>;
}
+162
View File
@@ -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>
);
}
+16
View File
@@ -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