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>
133 lines
6.4 KiB
JavaScript
133 lines
6.4 KiB
JavaScript
import { useEffect, useState } from 'react'
|
||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||
import { api } from '../../api/client.js'
|
||
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
|
||
import CreateGameAccountForm from '../../components/CreateGameAccountForm.jsx'
|
||
|
||
// Public, token-gated invite acceptance (/invite/:token). Validates the invite,
|
||
// lets the invitee set a username + password (their email + role are pre-assigned),
|
||
// creates the account at that role and logs them in. For a player invite it then
|
||
// offers the built-in "create game account" step before sending them to the portal.
|
||
export default function AcceptInvite() {
|
||
const { token } = useParams()
|
||
const navigate = useNavigate()
|
||
const { refresh } = useAuth()
|
||
|
||
const [invite, setInvite] = useState(null) // { email, role }
|
||
const [loadErr, setLoadErr] = useState('')
|
||
const [signupOk, setSignupOk] = useState(false)
|
||
|
||
const [username, setUsername] = useState('')
|
||
const [password, setPassword] = useState('')
|
||
const [company, setCompany] = useState('') // honeypot
|
||
const [error, setError] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
const [accepted, setAccepted] = useState(false)
|
||
|
||
useEffect(() => {
|
||
let active = true
|
||
api.getInvite(token)
|
||
.then((iv) => active && setInvite(iv))
|
||
.catch((err) => active && setLoadErr(err.status === 404 ? 'This invitation is invalid or has expired.' : 'Could not load this invitation.'))
|
||
api.publicSettings()
|
||
.then((s) => active && setSignupOk(Boolean(s?.gameAccountSignup)))
|
||
.catch(() => {})
|
||
return () => { active = false }
|
||
}, [token])
|
||
|
||
const dest = invite && invite.role === 'player' ? '/player' : '/admin'
|
||
|
||
async function onSubmit(e) {
|
||
e.preventDefault()
|
||
setError('')
|
||
if (username.trim().length < 3) return setError('Username must be at least 3 characters.')
|
||
if (password.length < 8) return setError('Password must be at least 8 characters.')
|
||
setBusy(true)
|
||
try {
|
||
await api.acceptInvite(token, username.trim(), password, { company })
|
||
await refresh() // pull the freshly-issued session into context
|
||
setAccepted(true)
|
||
// Staff invites are web-only — no game step; go straight in.
|
||
if (!(invite.role === 'player' && signupOk)) navigate(dest, { replace: true })
|
||
} catch (err) {
|
||
if (err.status === 409) setError('That username is already taken, or the invite was already used.')
|
||
else if (err.status === 404) setError('This invitation is invalid or has expired.')
|
||
else if (err.status === 400) setError(err.message || 'Please check your details and try again.')
|
||
else setError('Could not accept the invitation right now.')
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
// ── Loading / invalid ─────────────────────────────────────────────────────
|
||
if (loadErr) {
|
||
return (
|
||
<PlayerShell subtitle="Invitation">
|
||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', textAlign: 'center', lineHeight: 1.6 }}>{loadErr}</p>
|
||
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0' }}>
|
||
<Link to="/account/login" style={{ color: 'var(--accent)', textDecoration: 'none' }}>Go to sign in</Link>
|
||
</p>
|
||
</PlayerShell>
|
||
)
|
||
}
|
||
if (!invite) {
|
||
return (
|
||
<PlayerShell subtitle="Invitation">
|
||
<div style={{ display: 'grid', placeItems: 'center', padding: 20 }}><span className="spin" /></div>
|
||
</PlayerShell>
|
||
)
|
||
}
|
||
|
||
// ── Accepted: optional game-account step (player invites) ──────────────────
|
||
if (accepted) {
|
||
return (
|
||
<PlayerShell subtitle="Set up your game account">
|
||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.9rem', lineHeight: 1.6 }}>
|
||
Your account is ready. Create a game account now to play, or skip and do it later from your portal.
|
||
</p>
|
||
<CreateGameAccountForm
|
||
submit={api.player.shard.createAccount}
|
||
onCreated={() => navigate('/player', { replace: true })}
|
||
/>
|
||
<p className="sans" style={{ textAlign: 'center', margin: '18px 0 0' }}>
|
||
<button type="button" onClick={() => navigate('/player', { replace: true })} className="btn" style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer' }}>
|
||
Skip for now →
|
||
</button>
|
||
</p>
|
||
</PlayerShell>
|
||
)
|
||
}
|
||
|
||
// ── Accept form ────────────────────────────────────────────────────────────
|
||
return (
|
||
<PlayerShell subtitle="Accept your invitation">
|
||
<p className="sans" style={{ marginTop: 0, marginBottom: 18, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||
You’ve been invited as <strong style={{ color: 'var(--head)' }}>{invite.role}</strong>
|
||
{invite.email ? <> for <strong style={{ color: 'var(--head)' }}>{invite.email}</strong></> : null}. Choose a username and password to finish.
|
||
</p>
|
||
<form onSubmit={onSubmit}>
|
||
<label style={{ display: 'block', marginBottom: 16 }}>
|
||
<span className="field-label">Username</span>
|
||
<input type="text" autoComplete="username" autoFocus value={username} onChange={(e) => setUsername(e.target.value)} className="input" />
|
||
</label>
|
||
<label style={{ display: 'block', marginBottom: 22 }}>
|
||
<span className="field-label">Password</span>
|
||
<input type="password" autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} className="input" />
|
||
</label>
|
||
<div style={honeypotStyle} aria-hidden="true">
|
||
<label>
|
||
Company
|
||
<input type="text" name="company" tabIndex={-1} autoComplete="off" value={company} onChange={(e) => setCompany(e.target.value)} />
|
||
</label>
|
||
</div>
|
||
|
||
{error && <p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center' }}>{error}</p>}
|
||
|
||
<button type="submit" disabled={busy} className="btn btn-primary" style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}>
|
||
{busy ? 'Creating…' : 'Accept & create account'}
|
||
</button>
|
||
</form>
|
||
</PlayerShell>
|
||
)
|
||
}
|