Enhance signup process: increase item fetch limit, update Join API to use password, and improve UI for account creation

This commit is contained in:
Alexa
2026-06-02 02:05:01 +03:00
parent e404ed8352
commit e18e167660
7 changed files with 175 additions and 108 deletions
@@ -2582,7 +2582,13 @@ public class AdminController(
[FromQuery] string? storefront, [FromQuery] string? category, [FromQuery] string? storefront, [FromQuery] string? category,
[FromQuery] int take = 200, [FromQuery] int skip = 0) [FromQuery] int take = 200, [FromQuery] int skip = 0)
{ {
take = Math.Clamp(take, 1, 500); // Ceiling raised from 500: the wardrobe + color-variant seed pushes
// the live catalog past 800 rows, all under storefront "main". A
// 500-row window (ordered Storefront→Category→Price) silently hid
// every item that sorted past it — e.g. torso SKUs like the Sea
// Captain Contest shirt — so the admin UI couldn't find them even
// by search (it filters the fetched page client-side).
take = Math.Clamp(take, 1, 5000);
skip = Math.Max(0, skip); skip = Math.Max(0, skip);
IQueryable<StoreItemEntity> q = db.StoreItems; IQueryable<StoreItemEntity> q = db.StoreItems;
if (!string.IsNullOrWhiteSpace(storefront)) q = q.Where(i => i.Storefront == storefront); if (!string.IsNullOrWhiteSpace(storefront)) q = q.Where(i => i.Storefront == storefront);
@@ -267,16 +267,15 @@ public class PublicSiteController(
}).ToList()); }).ToList());
} }
public sealed record JoinRedeemRequest(string? Code, string? Username, string? DeviceId); public sealed record JoinRedeemRequest(string? Code, string? Username, string? Password);
/// <summary>POST /api/site/v1/join/redeem — redeem a signup code, /// <summary>POST /api/site/v1/join/redeem — redeem a signup code,
/// minting an account bound to the supplied device id. On success the /// minting a username/password account. Returns {ok, error?,
/// player relaunches the game and the watch's device-id login finds /// username?}.</summary>
/// the new account. Returns {ok, error?, username?}.</summary>
[HttpPost("join/redeem")] [HttpPost("join/redeem")]
public async Task<IActionResult> JoinRedeem([FromBody] JoinRedeemRequest body) public async Task<IActionResult> JoinRedeem([FromBody] JoinRedeemRequest body)
{ {
var result = await signupCodes.RedeemAsync(body.Code, body.Username, body.DeviceId); var result = await signupCodes.RedeemAsync(body.Code, body.Username, body.Password);
if (!result.Ok) if (!result.Ok)
return BadRequest(new { ok = false, error = result.Error }); return BadRequest(new { ok = false, error = result.Error });
return Ok(new { ok = true, username = result.Username, playerId = result.PlayerId }); return Ok(new { ok = true, username = result.Username, playerId = result.PlayerId });
+99
View File
@@ -1773,6 +1773,105 @@
"item_outfit_type": 0, "item_outfit_type": 0,
"mask_guid": "a4e51b6b-fdfd-47dc-9d0e-f26f383e03e9" "mask_guid": "a4e51b6b-fdfd-47dc-9d0e-f26f383e03e9"
}, },
{
"swatch_name": "Hat_SeaCaptainDarkDarkGold_Swatch",
"swatch_guid": "70112887-dc8d-49d5-96ed-0845204e0b58",
"color": "DarkDarkGold",
"item_name": "(Hat_SeaCaptain)",
"item_guid": "f5c9743f-e77c-42f2-ba63-9843342cbf8e",
"item_outfit_type": 0,
"mask_guid": "a4e51b6b-fdfd-47dc-9d0e-f26f383e03e9"
},
{
"swatch_name": "Hat_SeaCaptainDarkGold_Swatch",
"swatch_guid": "f5403c89-9769-479b-8a05-c71607f9b189",
"color": "DarkGold",
"item_name": "(Hat_SeaCaptain)",
"item_guid": "f5c9743f-e77c-42f2-ba63-9843342cbf8e",
"item_outfit_type": 0,
"mask_guid": "a4e51b6b-fdfd-47dc-9d0e-f26f383e03e9"
},
{
"swatch_name": "Hat_SeaCaptainDarkSilver_Swatch",
"swatch_guid": "36e63797-5a42-4779-a5cc-ef5c2e7d17d4",
"color": "DarkSilver",
"item_name": "(Hat_SeaCaptain)",
"item_guid": "f5c9743f-e77c-42f2-ba63-9843342cbf8e",
"item_outfit_type": 0,
"mask_guid": "a4e51b6b-fdfd-47dc-9d0e-f26f383e03e9"
},
{
"swatch_name": "Hat_SeaCaptainFushia_Swatch",
"swatch_guid": "2738ab5f-8c94-46b3-bac6-4a8111c41c21",
"color": "Fushia",
"item_name": "(Hat_SeaCaptain)",
"item_guid": "f5c9743f-e77c-42f2-ba63-9843342cbf8e",
"item_outfit_type": 0,
"mask_guid": "a4e51b6b-fdfd-47dc-9d0e-f26f383e03e9"
},
{
"swatch_name": "Torso_SeaCaptainBlue_Swatch",
"swatch_guid": "27af51bc-8226-4911-8d47-22d28ea8e378",
"color": "Blue",
"item_name": "(SeaCaptainTorso)",
"item_guid": "0bc6bd1b-8b7e-4945-a1d6-c26b9c043318",
"item_outfit_type": 101,
"mask_guid": "8ec5de40-73fc-4f09-9bf3-ddf4701107bc"
},
{
"swatch_name": "Torso_SeaCaptainBlueDark_Swatch",
"swatch_guid": "7c1aaa7e-503b-46ef-b1c5-0c61a75b916e",
"color": "BlueDark",
"item_name": "(SeaCaptainTorso)",
"item_guid": "0bc6bd1b-8b7e-4945-a1d6-c26b9c043318",
"item_outfit_type": 101,
"mask_guid": "8ec5de40-73fc-4f09-9bf3-ddf4701107bc"
},
{
"swatch_name": "Torso_SeaCaptainBlueDark2_Swatch",
"swatch_guid": "3df5d13b-ba77-4b51-90b2-7a946e4d0be7",
"color": "BlueDark2",
"item_name": "(SeaCaptainTorso)",
"item_guid": "0bc6bd1b-8b7e-4945-a1d6-c26b9c043318",
"item_outfit_type": 101,
"mask_guid": "8ec5de40-73fc-4f09-9bf3-ddf4701107bc"
},
{
"swatch_name": "Torso_SeaCaptainBlueWhite_Swatch",
"swatch_guid": "857fdcd8-704f-43d0-92b9-bca26619b153",
"color": "BlueWhite",
"item_name": "(SeaCaptainTorso)",
"item_guid": "0bc6bd1b-8b7e-4945-a1d6-c26b9c043318",
"item_outfit_type": 101,
"mask_guid": "8ec5de40-73fc-4f09-9bf3-ddf4701107bc"
},
{
"swatch_name": "Torso_SeaCaptainBlackDark_Swatch",
"swatch_guid": "6145292a-9002-41c6-8cd5-43679b6f542f",
"color": "BlackDark",
"item_name": "(SeaCaptainTorso)",
"item_guid": "0bc6bd1b-8b7e-4945-a1d6-c26b9c043318",
"item_outfit_type": 101,
"mask_guid": "8ec5de40-73fc-4f09-9bf3-ddf4701107bc"
},
{
"swatch_name": "Torso_SeaCaptainDark_Swatch",
"swatch_guid": "b9f51861-ad10-4878-a9b1-d849bccf532a",
"color": "Dark",
"item_name": "(SeaCaptainTorso)",
"item_guid": "0bc6bd1b-8b7e-4945-a1d6-c26b9c043318",
"item_outfit_type": 101,
"mask_guid": "8ec5de40-73fc-4f09-9bf3-ddf4701107bc"
},
{
"swatch_name": "Torso_SeaCaptainWhiteDark_Swatch",
"swatch_guid": "b5d62931-e756-4a8a-8f3b-760beba49319",
"color": "WhiteDark",
"item_name": "(SeaCaptainTorso)",
"item_guid": "0bc6bd1b-8b7e-4945-a1d6-c26b9c043318",
"item_outfit_type": 101,
"mask_guid": "8ec5de40-73fc-4f09-9bf3-ddf4701107bc"
},
{ {
"swatch_name": "LaserTagGlove_Orange_Swatch", "swatch_name": "LaserTagGlove_Orange_Swatch",
"swatch_guid": "EC0ttvk6eU2HEMacLAx2Iw", "swatch_guid": "EC0ttvk6eU2HEMacLAx2Iw",
+12 -26
View File
@@ -10,10 +10,8 @@ namespace DorkNet.Server.Services;
/// Backs the admin-issued signup-code flow. While the server has signups /// 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 /// 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 /// the public <c>/join</c> page. Codes are single-use, carry an admin
/// descriptor, and can have an optional expiry. Redemption mints an /// descriptor, and can have an optional expiry. Redemption mints a normal
/// account bound to the device id the player's game client reported (see /// username/password account.
/// <see cref="PendingDeviceEntity"/>), so the next launch logs straight
/// in via the watch's device-id login.
/// </summary> /// </summary>
public class SignupCodeService(DorkNetDbContext db, PlayerService players) public class SignupCodeService(DorkNetDbContext db, PlayerService players)
{ {
@@ -61,10 +59,10 @@ public class SignupCodeService(DorkNetDbContext db, PlayerService players)
public sealed record RedeemResult(bool Ok, string? Error, long? PlayerId, string? Username); public sealed record RedeemResult(bool Ok, string? Error, long? PlayerId, string? Username);
/// <summary>Redeem a code: validate it, then mint an account bound to /// <summary>Redeem a code: validate it, then mint a password account
/// <paramref name="deviceId"/> with the requested username. The code /// with the requested username. The code is consumed atomically with
/// is consumed atomically with account creation.</summary> /// account creation.</summary>
public async Task<RedeemResult> RedeemAsync(string? rawCode, string? rawUsername, string? deviceId) public async Task<RedeemResult> RedeemAsync(string? rawCode, string? rawUsername, string? rawPassword)
{ {
var code = NormalizeCode(rawCode); var code = NormalizeCode(rawCode);
if (string.IsNullOrEmpty(code)) return new(false, "missing_code", null, null); if (string.IsNullOrEmpty(code)) return new(false, "missing_code", null, null);
@@ -72,7 +70,9 @@ public class SignupCodeService(DorkNetDbContext db, PlayerService players)
var username = (rawUsername ?? string.Empty).Trim(); var username = (rawUsername ?? string.Empty).Trim();
if (!IsValidUsername(username)) return new(false, "invalid_username", null, null); if (!IsValidUsername(username)) return new(false, "invalid_username", null, null);
if (string.IsNullOrWhiteSpace(deviceId)) return new(false, "missing_device", null, null); var password = rawPassword ?? string.Empty;
if (string.IsNullOrEmpty(password)) return new(false, "missing_password", null, null);
if (password.Length < 8) return new(false, "password_too_short", null, null);
var entity = await db.SignupCodes.FirstOrDefaultAsync(c => c.Code == code); var entity = await db.SignupCodes.FirstOrDefaultAsync(c => c.Code == code);
if (entity is null) return new(false, "invalid_code", null, null); if (entity is null) return new(false, "invalid_code", null, null);
@@ -83,31 +83,17 @@ public class SignupCodeService(DorkNetDbContext db, PlayerService players)
if (await db.Players.AnyAsync(p => p.Username == username)) if (await db.Players.AnyAsync(p => p.Username == username))
return new(false, "username_taken", null, null); return new(false, "username_taken", null, null);
// The device must not already own an account (otherwise the var player = await players.CreateNewAccountAsync(
// player would launch into the existing one, not this fresh deviceId: $"join-{Guid.NewGuid():N}",
// 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, platform: 0,
platformId: null, platformId: null,
displayName: username); displayName: username);
player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(password, workFactor: 11);
entity.RedeemedByPlayerId = player.Id; entity.RedeemedByPlayerId = player.Id;
entity.RedeemedAt = DateTime.UtcNow; entity.RedeemedAt = DateTime.UtcNow;
await db.SaveChangesAsync(); 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); return new(true, null, player.Id, player.Username);
} }
+7 -5
View File
@@ -69,12 +69,14 @@ export function Store() {
const [bulkBusy, setBulkBusy] = useState(false); const [bulkBusy, setBulkBusy] = useState(false);
const toast = useToast(); const toast = useToast();
// Server-side pagination is overkill for this dataset (low hundreds // Pull the whole catalog and filter / sort in the browser so the
// of rows typically); pull a generous slice and filter / sort in the // storefront/category dropdowns don't trigger network round-trips on
// browser so the storefront/category dropdowns don't trigger network // every change. The wardrobe + color-variant seed makes this ~800-1000
// round-trips on every change. // rows, so the slice must clear that — a 500 cap previously hid every
// item that sorted past row 500 (e.g. torso SKUs like the Sea Captain
// Contest shirt).
const qs = new URLSearchParams(); const qs = new URLSearchParams();
qs.set('take', '500'); qs.set('take', '5000');
const { data, loading, error, refresh } = useApi<StoreItem[]>(`/storeitems?${qs}`); const { data, loading, error, refresh } = useApi<StoreItem[]>(`/storeitems?${qs}`);
const { data: storefrontData } = useApi<StorefrontDefinition[]>('/storefronts'); const { data: storefrontData } = useApi<StorefrontDefinition[]>('/storefronts');
const storefronts = storefrontData ?? FALLBACK_STOREFRONTS; const storefronts = storefrontData ?? FALLBACK_STOREFRONTS;
+36 -67
View File
@@ -1,11 +1,5 @@
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { get, post } from '../lib/api'; import { post } from '../lib/api';
interface PendingDevice {
deviceId: string;
platform: number;
lastSeenAt: string;
}
// Server error code -> friendly copy. // Server error code -> friendly copy.
const ERRORS: Record<string, string> = { const ERRORS: Record<string, string> = {
@@ -16,40 +10,34 @@ const ERRORS: Record<string, string> = {
code_expired: 'That code has expired. Ask for a new one.', code_expired: 'That code has expired. Ask for a new one.',
invalid_username: 'Pick a username 2–24 characters long (letters, numbers, _ or -).', invalid_username: 'Pick a username 2–24 characters long (letters, numbers, _ or -).',
username_taken: 'That username is taken — try another.', username_taken: 'That username is taken — try another.',
missing_device: 'Pick or paste the device id from your game client.', missing_password: 'Enter a password.',
device_in_use: 'That device already has an account — just launch the game to log in.', password_too_short: 'Use at least 8 characters for your password.',
password_mismatch: 'The passwords do not match.',
}; };
export function Join() { export function Join() {
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [deviceId, setDeviceId] = useState(''); const [password, setPassword] = useState('');
const [pending, setPending] = useState<PendingDevice[]>([]); const [confirmPassword, setConfirmPassword] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [doneUsername, setDoneUsername] = 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) => { const submit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setBusy(true); setBusy(true);
setErr(null); setErr(null);
if (password !== confirmPassword) {
setErr(ERRORS.password_mismatch);
setBusy(false);
return;
}
try { try {
const res = await post<{ ok: boolean; username: string }>('/join/redeem', { const res = await post<{ ok: boolean; username: string }>('/join/redeem', {
Code: code.trim(), Code: code.trim(),
Username: username.trim(), Username: username.trim(),
DeviceId: deviceId.trim(), Password: password,
}); });
setDoneUsername(res.username); setDoneUsername(res.username);
} catch (e) { } catch (e) {
@@ -66,8 +54,8 @@ export function Join() {
<div className="card !p-6 text-center"> <div className="card !p-6 text-center">
<h1 className="text-2xl font-semibold text-ink-50">You're in, {doneUsername}! 🎉</h1> <h1 className="text-2xl font-semibold text-ink-50">You're in, {doneUsername}! 🎉</h1>
<p className="mt-3 text-sm text-ink-300"> <p className="mt-3 text-sm text-ink-300">
Your account is ready. Launch the game through the DorkNet launcher on this Your account is ready. Launch the game through the DorkNet launcher and sign in
same device and you'll be logged straight in — no signup step needed. with your username and password.
</p> </p>
</div> </div>
</div> </div>
@@ -79,7 +67,7 @@ export function Join() {
<div> <div>
<h1 className="text-2xl font-semibold text-ink-50">Join the server</h1> <h1 className="text-2xl font-semibold text-ink-50">Join the server</h1>
<p className="text-sm text-ink-400"> <p className="text-sm text-ink-400">
Got a signup code? Redeem it here to create your account, then launch the game. Got a signup code? Redeem it here to create your account, then sign in from the game.
</p> </p>
</div> </div>
@@ -102,52 +90,33 @@ export function Join() {
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
placeholder="2–24 chars: letters, numbers, _ or -" placeholder="2–24 chars: letters, numbers, _ or -"
autoComplete="username"
/> />
</label> </label>
<div className="block"> <label className="block">
<span className="label">Your device</span> <span className="label">Password</span>
{pending.length > 0 ? ( <input
<>
<select
className="input mt-1" className="input mt-1"
value={pending.some((d) => d.deviceId === deviceId) ? deviceId : '__manual__'} type="password"
onChange={(e) => setDeviceId(e.target.value === '__manual__' ? '' : e.target.value)} value={password}
> onChange={(e) => setPassword(e.target.value)}
{pending.map((d) => ( placeholder="at least 8 characters"
<option key={d.deviceId} value={d.deviceId}> autoComplete="new-password"
{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"
/> />
)} </label>
<p className="mt-1 text-[11px] text-ink-500">
These are devices that just tried to sign in from your network. Pick yours. <label className="block">
</p> <span className="label">Confirm password</span>
</>
) : (
<>
<input <input
className="input mt-1 font-mono text-xs" className="input mt-1"
value={deviceId} type="password"
onChange={(e) => setDeviceId(e.target.value)} value={confirmPassword}
placeholder="paste your device id" onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="type it again"
autoComplete="new-password"
/> />
<p className="mt-1 text-[11px] text-ink-500"> </label>
Launch the game once first — when it says signups are disabled, come back here
and your device should appear automatically.
</p>
</>
)}
</div>
{err && ( {err && (
<div className="rounded-lg border border-danger/30 bg-danger/5 px-3 py-2 text-sm text-danger">{err}</div> <div className="rounded-lg border border-danger/30 bg-danger/5 px-3 py-2 text-sm text-danger">{err}</div>
+6
View File
@@ -20,6 +20,12 @@ export default defineConfig({
emptyOutDir: true, emptyOutDir: true,
target: 'es2022', target: 'es2022',
sourcemap: true, sourcemap: true,
rollupOptions: {
output: {
entryFileNames: 'assets/[name]-join-password-[hash].js',
chunkFileNames: 'assets/[name]-join-password-[hash].js',
},
},
}, },
server: { server: {
port: 5174, port: 5174,