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>
231 lines
9.7 KiB
JavaScript
231 lines
9.7 KiB
JavaScript
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
|
||
// object with { link, accounts, roster } (player or admin self-service); `charTo`
|
||
// maps a serial to the route for that character's sheet. `readOnly` drops the
|
||
// link forms and self-voice copy for the admin case where staff view *another*
|
||
// user's accounts (no `scope.link`) at /admin/users/:id.
|
||
|
||
function LinkForm({ scope, onLinked, compact }) {
|
||
const [code, setCode] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
const [msg, setMsg] = useState('')
|
||
const [error, setError] = useState('')
|
||
|
||
async function submit(e) {
|
||
e.preventDefault()
|
||
setMsg(''); setError('')
|
||
if (!code.trim()) return
|
||
setBusy(true)
|
||
try {
|
||
const { account } = await scope.link(code.trim())
|
||
setMsg(`Linked ${account}.`)
|
||
setCode('')
|
||
await onLinked()
|
||
} catch (err) {
|
||
setError(err.message || 'Could not link that code.')
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<form onSubmit={submit} style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap', marginTop: compact ? 0 : 6 }}>
|
||
<label style={{ display: 'block' }}>
|
||
{!compact && <span className="field-label">Link code</span>}
|
||
<input
|
||
type="text"
|
||
value={code}
|
||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||
className="input"
|
||
autoComplete="off"
|
||
placeholder="AB12CD"
|
||
style={{ maxWidth: 180, textTransform: 'uppercase', letterSpacing: '0.12em' }}
|
||
/>
|
||
</label>
|
||
<button type="submit" disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
|
||
{busy ? 'Linking…' : 'Link account'}
|
||
</button>
|
||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||
</form>
|
||
)
|
||
}
|
||
|
||
function AccountRoster({ scope, account, charTo }) {
|
||
const [roster, setRoster] = useState(null)
|
||
const [error, setError] = useState('')
|
||
const [unavailable, setUnavailable] = useState(false)
|
||
|
||
const load = useCallback(async () => {
|
||
setError(''); setUnavailable(false)
|
||
try {
|
||
setRoster(await scope.roster(account))
|
||
} catch (err) {
|
||
if (err.status === 503) setUnavailable(true)
|
||
else setError(err.message || 'Could not load this account.')
|
||
}
|
||
}, [scope, account])
|
||
useEffect(() => { load() }, [load])
|
||
|
||
if (unavailable) {
|
||
return (
|
||
<div>
|
||
<p className="sans" style={{ margin: '0 0 8px', color: '#e0b070', fontSize: '0.85rem' }}>The game server is restarting — try again shortly.</p>
|
||
<button className="pill" onClick={load}>Retry</button>
|
||
</div>
|
||
)
|
||
}
|
||
if (error) return <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
|
||
if (!roster) return <p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>Loading…</p>
|
||
|
||
const chars = roster.chars || []
|
||
if (chars.length === 0) return <p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>No characters on this account.</p>
|
||
|
||
return (
|
||
<div className="grid-2" style={{ gap: 12 }}>
|
||
{chars.map((c) => (
|
||
<Link
|
||
key={c.serial}
|
||
to={charTo(c.serial)}
|
||
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px', border: '1px solid var(--line)', borderRadius: 10, textDecoration: 'none', background: 'rgba(255,255,255,0.02)' }}
|
||
>
|
||
<span style={{ flex: 'none', width: 40, height: 40, borderRadius: '50%', background: 'linear-gradient(180deg,#2a3a52,#1a2536)', border: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#d8e2ef', fontSize: '1rem', textTransform: 'uppercase' }}>
|
||
{(c.name || '?').charAt(0)}
|
||
</span>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div className="display" style={{ color: 'var(--head)', fontSize: '1.02rem' }}>{c.name}</div>
|
||
<div className="sans" style={{ fontSize: '0.76rem', color: c.online ? '#7fd0a4' : 'var(--muted)' }}>{c.online ? 'Online' : 'Offline'}</div>
|
||
</div>
|
||
<span className="sans dim" style={{ fontSize: '1.1rem' }}>›</span>
|
||
</Link>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 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('')
|
||
try {
|
||
setAccounts(await scope.accounts())
|
||
} catch {
|
||
setError(readOnly ? 'Could not load this user’s game accounts.' : 'Could not load your game accounts.')
|
||
}
|
||
}, [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 />
|
||
|
||
// No linked accounts. In read-only (admin viewing another user) this is just an
|
||
// empty state; otherwise it's the link-your-account prompt.
|
||
if (accounts.length === 0) {
|
||
if (readOnly) {
|
||
return (
|
||
<div className="panel" style={{ padding: 22 }}>
|
||
<p className="sans dim" style={{ margin: 0, fontSize: '0.88rem' }}>
|
||
This user has not linked a game account.
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|
||
return (
|
||
<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>
|
||
)
|
||
}
|
||
|
||
// Linked — characters grouped by account.
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
|
||
{accounts.map((a) => (
|
||
<section key={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} />
|
||
</section>
|
||
))}
|
||
{!readOnly && (
|
||
<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>
|
||
)
|
||
}
|