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

View File

@@ -2,6 +2,8 @@ import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Loading, ErrorState } from './PageState.jsx'
import ShardAccountActions from './ShardAccountActions.jsx'
import CreateGameAccountForm from './CreateGameAccountForm.jsx'
import { api } from '../api/client.js'
// Shared game-account linking + character roster, used by both the player portal
// (/player) and the staff account page (/admin/account). `scope` is the api
@@ -108,9 +110,37 @@ function AccountRoster({ scope, account, charTo }) {
)
}
export default function GameAccounts({ scope, charTo, readOnly = false, moderation = false }) {
// Compact per-account "Unlink" button for the admin (readOnly) view. Confirms,
// then calls onUnlink(account) and reloads. Errors surface inline.
function UnlinkButton({ account, onUnlink }) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
async function go() {
if (!window.confirm(`Unlink game account “${account}” from this user? Attribution stops immediately.`)) return
setBusy(true); setError('')
try {
await onUnlink(account)
} catch (err) {
setError(err.status === 403 ? 'Protected account — refused.' : err.status === 404 ? 'Not linked.' : (err.message || 'Could not unlink.'))
setBusy(false)
}
}
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<button type="button" onClick={go} disabled={busy} className="pill" style={{ fontSize: '0.72rem', color: '#d98b84', borderColor: '#5b2020' }}>
{busy ? 'Unlinking…' : 'Unlink'}
</button>
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.76rem' }}>{error}</span>}
</span>
)
}
export default function GameAccounts({ scope, charTo, readOnly = false, moderation = false, onUnlink = null }) {
const [accounts, setAccounts] = useState(null)
const [error, setError] = useState('')
// Whether the site currently offers game-account creation (public flag). Only
// relevant for the self-service (non-readOnly) view with a createAccount scope.
const [signupOk, setSignupOk] = useState(false)
const load = useCallback(async () => {
setError('')
@@ -122,6 +152,17 @@ export default function GameAccounts({ scope, charTo, readOnly = false, moderati
}, [scope, readOnly])
useEffect(() => { load() }, [load])
useEffect(() => {
if (readOnly || !scope.createAccount) return
let active = true
api.publicSettings()
.then((s) => active && setSignupOk(Boolean(s?.gameAccountSignup)))
.catch(() => {})
return () => { active = false }
}, [readOnly, scope])
const canCreate = !readOnly && Boolean(scope.createAccount) && signupOk
if (error) return <ErrorState message={error} />
if (!accounts) return <Loading />
@@ -138,13 +179,21 @@ export default function GameAccounts({ scope, charTo, readOnly = false, moderati
)
}
return (
<div className="panel" style={{ padding: 22 }}>
<div className="field-label" style={{ marginBottom: 8 }}>Link your game account</div>
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
You havent linked a game account yet. In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a
one-time code, then enter it below to see your characters, stats, skills and vendors here.
</p>
<LinkForm scope={scope} onLinked={load} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="panel" style={{ padding: 22 }}>
<div className="field-label" style={{ marginBottom: 8 }}>Link your game account</div>
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Already play? In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a
one-time code, then enter it below to see your characters, stats, skills and vendors here.
</p>
<LinkForm scope={scope} onLinked={load} />
</div>
{canCreate && (
<div className="panel" style={{ padding: 22 }}>
<div className="field-label" style={{ marginBottom: 8 }}>Create a new game account</div>
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} />
</div>
)}
</div>
)
}
@@ -154,8 +203,11 @@ export default function GameAccounts({ scope, charTo, readOnly = false, moderati
<div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
{accounts.map((a) => (
<section key={a.account}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
{a.account}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase' }}>
{a.account}
</div>
{onUnlink && <UnlinkButton account={a.account} onUnlink={async (acct) => { await onUnlink(acct); await load() }} />}
</div>
{moderation && <ShardAccountActions account={a.account} style={{ marginBottom: 12 }} />}
<AccountRoster scope={scope} account={a.account} charTo={charTo} />
@@ -165,6 +217,12 @@ export default function GameAccounts({ scope, charTo, readOnly = false, moderati
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 20 }}>
<div className="field-label" style={{ marginBottom: 10 }}>Link another account</div>
<LinkForm scope={scope} onLinked={load} compact />
{canCreate && (
<div style={{ marginTop: 20 }}>
<div className="field-label" style={{ marginBottom: 10 }}>Create another game account</div>
<CreateGameAccountForm submit={scope.createAccount} onCreated={load} compact />
</div>
)}
</section>
)}
</div>