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 98893b1..929fb1d 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 @@ -1279,6 +1280,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 e106dc2..25a0265 100644 --- a/DorkNet.Server/Data/DorkNetDbContext.cs +++ b/DorkNet.Server/Data/DorkNetDbContext.cs @@ -61,6 +61,8 @@ public class DorkNetDbContext(DbContextOptions options) : DbCo public DbSet CommunityBoardRows => Set(); public DbSet LoadingScreenTips => Set(); public DbSet ServerSettings => 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/Program.cs b/DorkNet.Server/Program.cs index b2b066b..ae1c521 100644 --- a/DorkNet.Server/Program.cs +++ b/DorkNet.Server/Program.cs @@ -138,6 +138,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // Singletons — these own connectionless state (Redis-backed or // process-local) and don't need a per-request scope. builder.Services.AddSingleton(); @@ -494,6 +495,35 @@ using (var scope = app.Services.CreateScope()) { db.Database.EnsureCreated(); ApplySqliteCompatibilityPatches(db); + // New tables that post-date this DB's EnsureCreated snapshot — + // EnsureCreated never revisits an existing file, so create them + // idempotently (matches the Postgres patch block below). + await db.Database.ExecuteSqlRawAsync( + @"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 + );"); + await db.Database.ExecuteSqlRawAsync( + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_SignupCodes_Code"" ON ""SignupCodes"" (""Code"");"); + await db.Database.ExecuteSqlRawAsync( + @"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 + );"); + await db.Database.ExecuteSqlRawAsync( + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_PendingDevices_DeviceId"" ON ""PendingDevices"" (""DeviceId"");"); } else { @@ -793,6 +823,36 @@ using (var scope = app.Services.CreateScope()) ""UpdatedAt"" timestamp with time zone NOT NULL DEFAULT now() );"); + // 2026-05-31 — Signup codes + pending-device capture for the + // admin-issued invite flow (site /join). New tables, so + // EnsureCreated-skipped on existing DBs. + await RunPatchAsync("SignupCodes table", + @"CREATE TABLE IF NOT EXISTS ""SignupCodes"" ( + ""Id"" bigserial 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 + );"); + await RunPatchAsync("SignupCodes Code unique index", + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_SignupCodes_Code"" ON ""SignupCodes"" (""Code"");"); + await RunPatchAsync("PendingDevices table", + @"CREATE TABLE IF NOT EXISTS ""PendingDevices"" ( + ""Id"" bigserial 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() + );"); + await RunPatchAsync("PendingDevices DeviceId unique index", + @"CREATE UNIQUE INDEX IF NOT EXISTS ""IX_PendingDevices_DeviceId"" ON ""PendingDevices"" (""DeviceId"");"); + await conn.CloseAsync(); } 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/admin-ui/src/App.tsx b/DorkNet.Server/admin-ui/src/App.tsx index 88679fb..2ec29ad 100644 --- a/DorkNet.Server/admin-ui/src/App.tsx +++ b/DorkNet.Server/admin-ui/src/App.tsx @@ -22,6 +22,7 @@ import { Passwords } from './pages/Passwords'; import { Grants } from './pages/Grants'; import { Storage } from './pages/Storage'; import { Settings } from './pages/Settings'; +import { SignupCodes } from './pages/SignupCodes'; export default function App() { return ( @@ -56,6 +57,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 8786244..7f42d22 100644 --- a/DorkNet.Server/admin-ui/src/components/Sidebar.tsx +++ b/DorkNet.Server/admin-ui/src/components/Sidebar.tsx @@ -44,6 +44,7 @@ const groups: Array<{ title: string; items: Array<{ to: string; label: string; i { to: '/passwords', label: 'Reset passwords', icon: , new: true }, { to: '/grants', label: 'Grant maker pen', icon: , new: true }, { to: '/storage', label: 'Storage backfill', 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 a1037d8..d8d8c79 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,18 @@ Compared to `december-2020-12-18`, this branch's server is closer to the original 2020-Q1 wire shape — fewer endpoints, simpler entities, and the multi-step migrations haven't been consolidated. +### 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. The `SignupCodes` / +`PendingDevices` tables post-date the migration chain and are created by +an idempotent `CREATE TABLE IF NOT EXISTS` step at boot (Program.cs), so +existing SQLite/Postgres DBs pick them up without a new migration. + --- ## Contributing