Files
website/client/src/routes/player/AcceptInvite.jsx
wtclaude 5b5006c365 feat(modules): a third slot, nav icons, and api.BASE (phase 3, slice 3)
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>
2026-08-11 18:39:36 -05:00

150 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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