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 ``, 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 (

{loadErr}

Go to sign in

) } if (!invite) { return (
) } // ── 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 (

) } // ── Accept form ──────────────────────────────────────────────────────────── return (

You’ve been invited as {invite.role} {invite.email ? <> for {invite.email} : null}. Choose a username and password to finish.

{error &&

{error}

}
) }