diff --git a/DorkNet.Server/Controllers/Admin/AdminController.cs b/DorkNet.Server/Controllers/Admin/AdminController.cs index 42fd877..c848e85 100644 --- a/DorkNet.Server/Controllers/Admin/AdminController.cs +++ b/DorkNet.Server/Controllers/Admin/AdminController.cs @@ -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 q = db.StoreItems; if (!string.IsNullOrWhiteSpace(storefront)) q = q.Where(i => i.Storefront == storefront); diff --git a/DorkNet.Server/Controllers/Site/PublicSiteController.cs b/DorkNet.Server/Controllers/Site/PublicSiteController.cs index 944da1e..838533c 100644 --- a/DorkNet.Server/Controllers/Site/PublicSiteController.cs +++ b/DorkNet.Server/Controllers/Site/PublicSiteController.cs @@ -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); /// 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?}. + /// minting a username/password 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); + 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 }); diff --git a/DorkNet.Server/Data/color_variants.json b/DorkNet.Server/Data/color_variants.json index 5297865..5ee3cf8 100644 --- a/DorkNet.Server/Data/color_variants.json +++ b/DorkNet.Server/Data/color_variants.json @@ -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", diff --git a/DorkNet.Server/Services/SignupCodeService.cs b/DorkNet.Server/Services/SignupCodeService.cs index 4c17329..6512d35 100644 --- a/DorkNet.Server/Services/SignupCodeService.cs +++ b/DorkNet.Server/Services/SignupCodeService.cs @@ -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 /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. +/// descriptor, and can have an optional expiry. Redemption mints a normal +/// username/password account. /// 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); - /// 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) + /// Redeem a code: validate it, then mint a password account + /// with the requested username. The code is consumed atomically with + /// account creation. + public async Task 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); } diff --git a/DorkNet.Server/admin-ui/src/pages/Store.tsx b/DorkNet.Server/admin-ui/src/pages/Store.tsx index 8112fa9..9bf1fbc 100644 --- a/DorkNet.Server/admin-ui/src/pages/Store.tsx +++ b/DorkNet.Server/admin-ui/src/pages/Store.tsx @@ -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(`/storeitems?${qs}`); const { data: storefrontData } = useApi('/storefronts'); const storefronts = storefrontData ?? FALLBACK_STOREFRONTS; diff --git a/DorkNet.Server/site/src/pages/Join.tsx b/DorkNet.Server/site/src/pages/Join.tsx index 2ed39c2..cb62ca5 100644 --- a/DorkNet.Server/site/src/pages/Join.tsx +++ b/DorkNet.Server/site/src/pages/Join.tsx @@ -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 = { @@ -16,40 +10,34 @@ const ERRORS: Record = { 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([]); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = 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); + 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() {

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. + Your account is ready. Launch the game through the DorkNet launcher and sign in + with your username and password.

@@ -79,7 +67,7 @@ export function Join() {

Join the server

- 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.

@@ -102,52 +90,33 @@ export function Join() { value={username} onChange={(e) => setUsername(e.target.value)} placeholder="2–24 chars: letters, numbers, _ or -" + autoComplete="username" /> -
- 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/DorkNet.Server/site/vite.config.ts b/DorkNet.Server/site/vite.config.ts index 0674b7e..f912e70 100644 --- a/DorkNet.Server/site/vite.config.ts +++ b/DorkNet.Server/site/vite.config.ts @@ -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,