The three things core owes the client half before it can leave, all additive, all MODULE_API 1.2.0 → 1.3.0. `player.invite.accepted` is the third extension slot. Core's invite page owned a UO game-account step — it read a `gameAccountSignup` flag out of core's own settings and posted to a shard route — and an invite is a core concept that staff receive too, so the page stays and its optional next step becomes a slot. Named for the place, like the other two. Whether there is a step at all is the filling module's call, made from data core does not have; core keeps the shell, the skip control and the destination. `icon` on a nav item, because without it the six extracted UO rows would have been the only text-only entries in a sidebar where every other row has a glyph. Core supplies no fallback — an invented one is core making a presentation choice for content it knows nothing about. `icon` was already among the fields an override may not touch, so the concept predates a module being able to send one. `api.BASE` was in §3.5 from the first draft and never actually published. `request` is fetch-only, so an EventSource builds its own URL, and the shard's live feed is two of them; the alternative is a module hardcoding `/api/v1`, which asserts something about core that core has not promised. `AcceptInvite` is the one legitimate reader of `extensionFor` outside Slot.jsx: the answer decides a NAVIGATION, not a decoration. Decoration goes inside `<Slot wrap>`, which is why `hasExtension` stayed deleted. Co-Authored-By: Claude <noreply@anthropic.com>
150 lines
7.5 KiB
JavaScript
150 lines
7.5 KiB
JavaScript
import { useCallback, 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 Slot from '../../modules/Slot.jsx'
|
||
import { extensionFor } from '../../modules/registry.js'
|
||
|
||
// 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 there may then be one more step, supplied by an installed
|
||
// module through the `player.invite.accepted` slot: core rendered a UO
|
||
// game-account form here itself until Phase 3 slice 3, reading a
|
||
// `gameAccountSignup` flag out of its own settings and posting to a shard route.
|
||
// Neither of those is core's. What core keeps is the shell, the skip control and
|
||
// the destination; whether there is a step at all is the module's call, made
|
||
// from data core does not have.
|
||
export default function AcceptInvite() {
|
||
const { token } = useParams()
|
||
const navigate = useNavigate()
|
||
const { refresh } = useAuth()
|
||
|
||
const [invite, setInvite] = useState(null) // fields email and role
|
||
const [loadErr, setLoadErr] = useState('')
|
||
|
||
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.'))
|
||
return () => { active = false }
|
||
}, [token])
|
||
|
||
const dest = invite && invite.role === 'player' ? '/player' : '/admin'
|
||
|
||
// Whether anything is installed that wants the post-acceptance step. Read
|
||
// rather than rendered blind because it decides a NAVIGATION, not just what
|
||
// appears: with nothing filled there is no screen to show, so the invitee goes
|
||
// straight to their destination. This is the one legitimate reason to ask
|
||
// whether a slot is filled — the answer changes control flow, not decoration
|
||
// (decoration goes inside `<Slot wrap>`, which is why `hasExtension` is gone).
|
||
const hasNextStep = Boolean(extensionFor('player.invite.accepted'))
|
||
const finish = useCallback(() => navigate('/player', { replace: true }), [navigate])
|
||
|
||
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 go straight in, and so does a player invite when nothing
|
||
// is installed that has a step to offer.
|
||
if (!(invite.role === 'player' && hasNextStep)) 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: a module's optional next step (player invites) ───────────────
|
||
//
|
||
// Only reachable when the slot is filled — `onSubmit` navigates away otherwise
|
||
// — so there is no empty-shell case to guard here.
|
||
//
|
||
// The subtitle is core's and says nothing about what the step is: naming it
|
||
// would be core describing content it does not own, and the wrong description
|
||
// is worse than a general one. "Skip" stays core's too, because where it goes
|
||
// is core's decision, and it is rendered outside the slot deliberately — an
|
||
// extension that throws must not take the way out with it.
|
||
if (accepted) {
|
||
return (
|
||
<PlayerShell subtitle="One more step">
|
||
<Slot name="player.invite.accepted" onDone={finish} />
|
||
<p className="sans" style={{ textAlign: 'center', margin: '18px 0 0' }}>
|
||
<button type="button" onClick={finish} 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>
|
||
)
|
||
}
|