feat(provisioning): provisioning UI — signup, invites, accept page, unlink

Phase 6: the UI for the Phase 5 provisioning backend.

- CreateGameAccountForm: reusable game-account form (own username + password),
  mapping the sidecar errors (409/429/403/503) to friendly messages. Wired into
  GameAccounts (self-serve) — shown alongside the [link flow when the
  game_account_signup flag is on (exposed via public settings), so a registered
  player can create + link a game account from their portal.
- Admin Invites view (/admin/invites, admin-only): send an invite at a chosen
  access level, list invites with status, revoke pending ones. When email isn't
  configured the create response's accept link is surfaced to copy manually.
- Public accept page (/invite/:token): validates the invite, sets username +
  password (email + role pre-assigned), creates the account at that role and logs
  in; for a player invite it then offers the built-in "create game account" step
  before the portal. Honeypot-guarded like registration.
- Admin unlink wired into UserDetail via GameAccounts (per-account Unlink button,
  confirm + reconcile).
- Backend: expose gameAccountSignup availability in public settings.

Client build clean; server 193/193.

Refs .plans/protocol2-integration.md (Phase 6). Completes the Protocol 2.0/2.1 integration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 16:06:01 -05:00
parent 91c206bf76
commit 2976d5982f
9 changed files with 442 additions and 11 deletions

View File

@@ -0,0 +1,69 @@
import { useState } from 'react'
// Reusable "create a game account" form (its own username + password — the game
// client credentials, distinct from the website login). Calls `submit(account,
// password)` which should POST /player/shard/account; on success calls onCreated.
// Used by the player portal (self-serve) and the invite-accept page alike.
export default function CreateGameAccountForm({ submit, onCreated, compact = false }) {
const [account, setAccount] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
async function onSubmit(e) {
e.preventDefault()
setMsg(''); setError('')
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/.test(account)) {
return setError('Account name must be 330 letters, numbers, . _ or -.')
}
if (password.length < 8) return setError('Password must be at least 8 characters.')
setBusy(true)
try {
await submit(account, password)
setMsg(`Game account “${account}” created and linked.`)
setAccount(''); setPassword('')
if (onCreated) await onCreated()
} catch (err) {
if (err.status === 409) setError('That account name is already taken.')
else if (err.status === 429) setError('The account limit for your network has been reached.')
else if (err.status === 403) setError('Game-account signup is not available right now.')
else if (err.status === 503) setError('The game server is unavailable — try again shortly.')
else setError(err.message || 'Could not create the account right now.')
} finally {
setBusy(false)
}
}
return (
<form onSubmit={onSubmit}>
{!compact && (
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Choose the username and password youll type into the game client. These are your
<strong style={{ color: 'var(--head)' }}> game</strong> credentials separate from your website login.
</p>
)}
<label style={{ display: 'block', marginBottom: 14 }}>
<span className="field-label">Game account name</span>
<input
type="text" autoComplete="off" value={account}
onChange={(e) => setAccount(e.target.value)} className="input" placeholder="e.g. darrow"
/>
</label>
<label style={{ display: 'block', marginBottom: 16 }}>
<span className="field-label">Game password</span>
<input
type="password" autoComplete="new-password" value={password}
onChange={(e) => setPassword(e.target.value)} className="input"
/>
</label>
{error && <p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
{msg && <p className="sans" style={{ margin: '0 0 12px', color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</p>}
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Creating…' : 'Create game account'}
</button>
</form>
)
}