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:
69
client/src/components/CreateGameAccountForm.jsx
Normal file
69
client/src/components/CreateGameAccountForm.jsx
Normal 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 3–30 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 you’ll 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>
|
||||
)
|
||||
}
|
||||
@@ -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 haven’t 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>
|
||||
|
||||
Reference in New Issue
Block a user