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
+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>
);
}