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] 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);
IQueryable<StoreItemEntity> q = db.StoreItems;
if (!string.IsNullOrWhiteSpace(storefront)) q = q.Where(i => i.Storefront == storefront);
@@ -267,16 +267,15 @@ public class PublicSiteController(
}).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,
/// 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>
/// minting a username/password 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);
var result = await signupCodes.RedeemAsync(body.Code, body.Username, body.Password);
if (!result.Ok)
return BadRequest(new { ok = false, error = result.Error });
return Ok(new { ok = true, username = result.Username, playerId = result.PlayerId });
+99
View File
@@ -1773,6 +1773,105 @@
"item_outfit_type": 0,
"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_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
/// 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.
/// descriptor, and can have an optional expiry. Redemption mints a normal
/// username/password account.
/// </summary>
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);
/// <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)
/// <summary>Redeem a code: validate it, then mint a password account
/// with the requested username. The code is consumed atomically with
/// account creation.</summary>
public async Task<RedeemResult> RedeemAsync(string? rawCode, string? rawUsername, string? rawPassword)
{
var code = NormalizeCode(rawCode);
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();
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);
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))
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,
var player = await players.CreateNewAccountAsync(
deviceId: $"join-{Guid.NewGuid():N}",
platform: 0,
platformId: null,
displayName: username);
player.PasswordHash = BCrypt.Net.BCrypt.HashPassword(password, workFactor: 11);
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);
}
+7 -5
View File
@@ -69,12 +69,14 @@ export function Store() {
const [bulkBusy, setBulkBusy] = useState(false);
const toast = useToast();
// Server-side pagination is overkill for this dataset (low hundreds
// of rows typically); pull a generous slice and filter / sort in the
// browser so the storefront/category dropdowns don't trigger network
// round-trips on every change.
// Pull the whole catalog and filter / sort in the browser so the
// storefront/category dropdowns don't trigger network round-trips on
// every change. The wardrobe + color-variant seed makes this ~800-1000
// 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();
qs.set('take', '500');
qs.set('take', '5000');
const { data, loading, error, refresh } = useApi<StoreItem[]>(`/storeitems?${qs}`);
const { data: storefrontData } = useApi<StorefrontDefinition[]>('/storefronts');
const storefronts = storefrontData ?? FALLBACK_STOREFRONTS;
+40 -71
View File
@@ -1,11 +1,5 @@
import { useEffect, useState } from 'react';
import { get, post } from '../lib/api';
interface PendingDevice {
deviceId: string;
platform: number;
lastSeenAt: string;
}
import { useState } from 'react';
import { post } from '../lib/api';
// Server error code -> friendly copy.
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.',
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.',
missing_password: 'Enter a password.',
password_too_short: 'Use at least 8 characters for your password.',
password_mismatch: 'The passwords do not match.',
};
export function Join() {
const [code, setCode] = useState('');
const [username, setUsername] = useState('');
const [deviceId, setDeviceId] = useState('');
const [pending, setPending] = useState<PendingDevice[]>([]);
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
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);
if (password !== confirmPassword) {
setErr(ERRORS.password_mismatch);
setBusy(false);
return;
}
try {
const res = await post<{ ok: boolean; username: string }>('/join/redeem', {
Code: code.trim(),
Username: username.trim(),
DeviceId: deviceId.trim(),
Password: password,
});
setDoneUsername(res.username);
} catch (e) {
@@ -66,8 +54,8 @@ export function Join() {
<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.
Your account is ready. Launch the game through the DorkNet launcher and sign in
with your username and password.
</p>
</div>
</div>
@@ -79,7 +67,7 @@ export function Join() {
<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.
Got a signup code? Redeem it here to create your account, then sign in from the game.
</p>
</div>
@@ -102,52 +90,33 @@ export function Join() {
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="2–24 chars: letters, numbers, _ or -"
autoComplete="username"
/>
</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>
<label className="block">
<span className="label">Password</span>
<input
className="input mt-1"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="at least 8 characters"
autoComplete="new-password"
/>
</label>
<label className="block">
<span className="label">Confirm password</span>
<input
className="input mt-1"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="type it again"
autoComplete="new-password"
/>
</label>
{err && (
<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,
target: 'es2022',
sourcemap: true,
rollupOptions: {
output: {
entryFileNames: 'assets/[name]-join-password-[hash].js',
chunkFileNames: 'assets/[name]-join-password-[hash].js',
},
},
},
server: {
port: 5174,