feat(provisioning): provisioning UI — signup, invites, accept page, unlink

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>
This commit is contained in:
2026-07-17 16:06:01 -05:00
parent 91c206bf76
commit 2976d5982f
9 changed files with 442 additions and 11 deletions

View File

@@ -46,6 +46,7 @@ import AdminCharacter from './routes/admin/views/AdminCharacter.jsx'
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx' import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx' import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
import UserDetail from './routes/admin/views/UserDetail.jsx' import UserDetail from './routes/admin/views/UserDetail.jsx'
import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx'
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx' import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
import Moderation from './routes/admin/views/Moderation.jsx' import Moderation from './routes/admin/views/Moderation.jsx'
import ModerationUser from './routes/admin/views/ModerationUser.jsx' import ModerationUser from './routes/admin/views/ModerationUser.jsx'
@@ -53,6 +54,7 @@ import ModerationUser from './routes/admin/views/ModerationUser.jsx'
// Player portal // Player portal
import PlayerLogin from './routes/player/PlayerLogin.jsx' import PlayerLogin from './routes/player/PlayerLogin.jsx'
import PlayerRegister from './routes/player/PlayerRegister.jsx' import PlayerRegister from './routes/player/PlayerRegister.jsx'
import AcceptInvite from './routes/player/AcceptInvite.jsx'
import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx' import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx'
import PlayerCharacters from './routes/player/PlayerCharacters.jsx' import PlayerCharacters from './routes/player/PlayerCharacters.jsx'
import PlayerCharacter from './routes/player/PlayerCharacter.jsx' import PlayerCharacter from './routes/player/PlayerCharacter.jsx'
@@ -147,6 +149,7 @@ export default function App() {
<Route path="auth-providers" element={<AuthProvidersAdmin />} /> <Route path="auth-providers" element={<AuthProvidersAdmin />} />
<Route path="users" element={<UsersAdmin />} /> <Route path="users" element={<UsersAdmin />} />
<Route path="users/:id" element={<UserDetail />} /> <Route path="users/:id" element={<UserDetail />} />
<Route path="invites" element={<InvitesAdmin />} />
<Route path="account" element={<AccountAdmin />} /> <Route path="account" element={<AccountAdmin />} />
<Route path="*" element={<Navigate to="/admin" replace />} /> <Route path="*" element={<Navigate to="/admin" replace />} />
</Route> </Route>
@@ -154,6 +157,7 @@ export default function App() {
{/* Player portal */} {/* Player portal */}
<Route path="/account/login" element={<PlayerLogin />} /> <Route path="/account/login" element={<PlayerLogin />} />
<Route path="/account/register" element={<PlayerRegister />} /> <Route path="/account/register" element={<PlayerRegister />} />
<Route path="/invite/:token" element={<AcceptInvite />} />
<Route <Route
element={ element={
<RequirePlayer> <RequirePlayer>

View File

@@ -48,6 +48,10 @@ export const api = {
// optional email. Returns { user } and sets the session cookie on success. // optional email. Returns { user } and sets the session cookie on success.
register: (username, password, extra = {}) => register: (username, password, extra = {}) =>
req('/auth/register', { method: 'POST', body: { username, password, ...extra } }), req('/auth/register', { method: 'POST', body: { username, password, ...extra } }),
// Email invites (public, token-gated accept).
getInvite: (token) => req(`/auth/invite/${encodeURIComponent(token)}`),
acceptInvite: (token, username, password, extra = {}) =>
req(`/auth/invite/${encodeURIComponent(token)}/accept`, { method: 'POST', body: { username, password, ...extra } }),
loginTotp: (challenge, code) => loginTotp: (challenge, code) =>
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }), req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by // Second factor for an SSO login (challenge is held in an httpOnly cookie set by
@@ -171,6 +175,10 @@ export const api = {
createUser: (data) => req('/admin/users', { method: 'POST', body: data }), createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }), updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }), deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
// Email invites.
listInvites: () => req('/admin/invites'),
createInvite: (email, role) => req('/admin/invites', { method: 'POST', body: { email, role } }),
revokeInvite: (id) => req(`/admin/invites/${id}`, { method: 'DELETE' }),
// A single user's shard (uo-link) footprint, scoped to their linked accounts. // A single user's shard (uo-link) footprint, scoped to their linked accounts.
// accounts/sales/houses/online are user-scoped endpoints; roster/vendors/char // accounts/sales/houses/online are user-scoped endpoints; roster/vendors/char
// reuse the admin-bypass /admin/shard/* endpoints (which already read any // reuse the admin-bypass /admin/shard/* endpoints (which already read any
@@ -184,6 +192,7 @@ export const api = {
houses: () => req(`/admin/users/${id}/shard/houses`), houses: () => req(`/admin/users/${id}/shard/houses`),
online: () => req(`/admin/users/${id}/shard/online`), online: () => req(`/admin/users/${id}/shard/online`),
standing: () => req(`/admin/users/${id}/shard/standing`), standing: () => req(`/admin/users/${id}/shard/standing`),
unlink: (account) => req(`/admin/users/${id}/shard/link/${encodeURIComponent(account)}`, { method: 'DELETE' }),
}), }),
// ----- moderation dashboard (admin + moderator) ----- // ----- moderation dashboard (admin + moderator) -----
@@ -313,6 +322,8 @@ export const api = {
vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`), vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`),
char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`), char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`),
sales: () => req('/player/shard/sales'), sales: () => req('/player/shard/sales'),
createAccount: (account, password) =>
req('/player/shard/account', { method: 'POST', body: { account, password } }),
}, },
}, },
} }

View File

@@ -0,0 +1,69 @@
import { useState } from 'react'
// Reusable "create a game account" form (its own username + password — the game
// client credentials, distinct from the website login). Calls `submit(account,
// password)` which should POST /player/shard/account; on success calls onCreated.
// Used by the player portal (self-serve) and the invite-accept page alike.
export default function CreateGameAccountForm({ submit, onCreated, compact = false }) {
const [account, setAccount] = useState('')
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [msg, setMsg] = useState('')
const [error, setError] = useState('')
async function onSubmit(e) {
e.preventDefault()
setMsg(''); setError('')
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/.test(account)) {
return setError('Account name must be 330 letters, numbers, . _ or -.')
}
if (password.length < 8) return setError('Password must be at least 8 characters.')
setBusy(true)
try {
await submit(account, password)
setMsg(`Game account “${account}” created and linked.`)
setAccount(''); setPassword('')
if (onCreated) await onCreated()
} catch (err) {
if (err.status === 409) setError('That account name is already taken.')
else if (err.status === 429) setError('The account limit for your network has been reached.')
else if (err.status === 403) setError('Game-account signup is not available right now.')
else if (err.status === 503) setError('The game server is unavailable — try again shortly.')
else setError(err.message || 'Could not create the account right now.')
} finally {
setBusy(false)
}
}
return (
<form onSubmit={onSubmit}>
{!compact && (
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
Choose the username and password youll type into the game client. These are your
<strong style={{ color: 'var(--head)' }}> game</strong> credentials separate from your website login.
</p>
)}
<label style={{ display: 'block', marginBottom: 14 }}>
<span className="field-label">Game account name</span>
<input
type="text" autoComplete="off" value={account}
onChange={(e) => setAccount(e.target.value)} className="input" placeholder="e.g. darrow"
/>
</label>
<label style={{ display: 'block', marginBottom: 16 }}>
<span className="field-label">Game password</span>
<input
type="password" autoComplete="new-password" value={password}
onChange={(e) => setPassword(e.target.value)} className="input"
/>
</label>
{error && <p className="sans" style={{ margin: '0 0 12px', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
{msg && <p className="sans" style={{ margin: '0 0 12px', color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</p>}
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Creating…' : 'Create game account'}
</button>
</form>
)
}

View File

@@ -2,6 +2,8 @@ import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Loading, ErrorState } from './PageState.jsx' import { Loading, ErrorState } from './PageState.jsx'
import ShardAccountActions from './ShardAccountActions.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 // Shared game-account linking + character roster, used by both the player portal
// (/player) and the staff account page (/admin/account). `scope` is the api // (/player) and the staff account page (/admin/account). `scope` is the api
@@ -108,9 +110,37 @@ function AccountRoster({ scope, account, charTo }) {
) )
} }
export default function GameAccounts({ scope, charTo, readOnly = false, moderation = false }) { // 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 [accounts, setAccounts] = useState(null)
const [error, setError] = useState('') 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 () => { const load = useCallback(async () => {
setError('') setError('')
@@ -122,6 +152,17 @@ export default function GameAccounts({ scope, charTo, readOnly = false, moderati
}, [scope, readOnly]) }, [scope, readOnly])
useEffect(() => { load() }, [load]) 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 (error) return <ErrorState message={error} />
if (!accounts) return <Loading /> if (!accounts) return <Loading />
@@ -138,13 +179,21 @@ export default function GameAccounts({ scope, charTo, readOnly = false, moderati
) )
} }
return ( return (
<div className="panel" style={{ padding: 22 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="field-label" style={{ marginBottom: 8 }}>Link your game account</div> <div className="panel" style={{ padding: 22 }}>
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}> <div className="field-label" style={{ marginBottom: 8 }}>Link your game account</div>
You havent linked a game account yet. In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a <p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
one-time code, then enter it below to see your characters, stats, skills and vendors here. Already play? In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a
</p> one-time code, then enter it below to see your characters, stats, skills and vendors here.
<LinkForm scope={scope} onLinked={load} /> </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> </div>
) )
} }
@@ -154,8 +203,11 @@ export default function GameAccounts({ scope, charTo, readOnly = false, moderati
<div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
{accounts.map((a) => ( {accounts.map((a) => (
<section key={a.account}> <section key={a.account}>
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 12 }}>
{a.account} <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> </div>
{moderation && <ShardAccountActions account={a.account} style={{ marginBottom: 12 }} />} {moderation && <ShardAccountActions account={a.account} style={{ marginBottom: 12 }} />}
<AccountRoster scope={scope} account={a.account} charTo={charTo} /> <AccountRoster scope={scope} account={a.account} charTo={charTo} />
@@ -165,6 +217,12 @@ export default function GameAccounts({ scope, charTo, readOnly = false, moderati
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 20 }}> <section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 20 }}>
<div className="field-label" style={{ marginBottom: 10 }}>Link another account</div> <div className="field-label" style={{ marginBottom: 10 }}>Link another account</div>
<LinkForm scope={scope} onLinked={load} compact /> <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> </section>
)} )}
</div> </div>

View File

@@ -70,6 +70,7 @@ const NAV = [
title: 'System', title: 'System',
items: [ items: [
{ to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] }, { to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] },
{ to: '/admin/invites', label: 'Invites', icon: IconUsers, roles: ['admin'] },
{ to: '/admin/settings', label: 'Settings', icon: IconGear, roles: ['admin'] }, { to: '/admin/settings', label: 'Settings', icon: IconGear, roles: ['admin'] },
{ to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] }, { to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] },
{ to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] }, { to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] },
@@ -104,6 +105,7 @@ const TITLES = {
'/admin/characters': 'My Characters', '/admin/characters': 'My Characters',
'/admin/auth-providers': 'Authentication', '/admin/auth-providers': 'Authentication',
'/admin/users': 'Users', '/admin/users': 'Users',
'/admin/invites': 'Invites',
'/admin/account': 'Account Security', '/admin/account': 'Account Security',
} }

View File

@@ -0,0 +1,152 @@
import { useCallback, useEffect, useState } from 'react'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
// Admin email invites: send an invite at a chosen access level, see recent
// invites and their status, revoke pending ones. When email delivery isn't
// configured the create response hands back the accept link to copy manually.
const ROLES = ['player', 'moderator', 'editor', 'admin']
const ROLE_BADGE = { admin: 'badge-admin', editor: 'badge-editor', moderator: 'badge-moderator', player: 'badge-player' }
const STATUS_COLOR = { pending: 'var(--accent)', accepted: '#7fd0a4', revoked: 'var(--muted)' }
function CreateInvite({ onCreated }) {
const [email, setEmail] = useState('')
const [role, setRole] = useState('player')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [result, setResult] = useState(null) // { emailed, acceptUrl }
async function submit(e) {
e.preventDefault()
setError(''); setResult(null)
if (!email.trim()) return setError('Enter an email address.')
setBusy(true)
try {
const res = await api.admin.createInvite(email.trim(), role)
setResult(res)
setEmail('')
await onCreated()
} catch (err) {
setError(err.message || 'Could not create the invite.')
} finally {
setBusy(false)
}
}
return (
<div className="panel" style={{ padding: 22, marginBottom: 22 }}>
<div className="field-label" style={{ marginBottom: 10 }}>Invite someone</div>
<form onSubmit={submit} style={{ display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<label style={{ flex: '1 1 240px' }}>
<span className="field-label">Email</span>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input" placeholder="person@example.com" />
</label>
<label>
<span className="field-label">Access level</span>
<select value={role} onChange={(e) => setRole(e.target.value)} className="select">
{ROLES.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</label>
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Sending…' : 'Send invite'}
</button>
</form>
{error && <p className="sans" style={{ margin: '12px 0 0', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
{result && (
<div style={{ marginTop: 14 }}>
{result.emailed ? (
<p className="sans" style={{ margin: 0, color: '#7fd0a4', fontSize: '0.86rem' }}>Invitation emailed.</p>
) : (
<div className="sans" style={{ fontSize: '0.84rem', color: 'var(--muted)' }}>
<p style={{ margin: '0 0 6px', color: '#e0b070' }}>
Email isnt configured{result.emailError ? ` (${result.emailError})` : ''} share this single-use link:
</p>
<code style={{ display: 'block', wordBreak: 'break-all', color: 'var(--head)', background: 'var(--panel-flat)', padding: '8px 10px', borderRadius: 6, border: '1px solid var(--line)' }}>
{result.acceptUrl}
</code>
</div>
)}
</div>
)}
</div>
)
}
export default function InvitesAdmin() {
const [invites, setInvites] = useState(null)
const [error, setError] = useState('')
const load = useCallback(async () => {
setError('')
try {
setInvites(await api.admin.listInvites())
} catch {
setError('Could not load invites.')
}
}, [])
useEffect(() => { load() }, [load])
async function revoke(id) {
if (!window.confirm('Revoke this pending invitation?')) return
try {
await api.admin.revokeInvite(id)
await load()
} catch {
/* surfaced by the row staying; keep it simple */
}
}
if (error) return <ErrorState message={error} />
return (
<section>
<CreateInvite onCreated={load} />
{!invites ? (
<Loading />
) : (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Email</th>
<th className="adm-th">Role</th>
<th className="adm-th">Status</th>
<th className="adm-th">Expires</th>
<th className="adm-th">Created</th>
<th className="adm-th" />
</tr>
</thead>
<tbody>
{invites.length === 0 && (
<tr><td className="adm-td" colSpan={6} style={{ color: 'var(--muted)' }}>No invites yet.</td></tr>
)}
{invites.map((iv) => {
const status = iv.status === 'pending' && iv.expired ? 'expired' : iv.status
return (
<tr key={iv.id}>
<td className="adm-td" style={{ color: 'var(--text)' }}>{iv.email}</td>
<td className="adm-td"><span className={`badge ${ROLE_BADGE[iv.role] || 'badge-editor'}`}>{iv.role}</span></td>
<td className="adm-td" style={{ color: STATUS_COLOR[iv.status] || 'var(--muted)', textTransform: 'capitalize' }}>{status}</td>
<td className="adm-td dim">{dateTime(iv.expiresAt)}</td>
<td className="adm-td dim">{dateTime(iv.createdAt)}</td>
<td className="adm-td" style={{ textAlign: 'right' }}>
{iv.status === 'pending' && (
<button type="button" className="pill" style={{ fontSize: '0.72rem', color: '#d98b84', borderColor: '#5b2020' }} onClick={() => revoke(iv.id)}>
Revoke
</button>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</section>
)
}

View File

@@ -130,7 +130,7 @@ function ShardSections({ scope }) {
<> <>
<CharacterStats scope={scope} /> <CharacterStats scope={scope} />
<SectionTitle>Linked accounts &amp; characters</SectionTitle> <SectionTitle>Linked accounts &amp; characters</SectionTitle>
<GameAccounts scope={scope} readOnly moderation charTo={(serial) => `/admin/characters/${serial}`} /> <GameAccounts scope={scope} readOnly moderation onUnlink={scope.unlink} charTo={(serial) => `/admin/characters/${serial}`} />
<Standing scope={scope} /> <Standing scope={scope} />
<OnlineNow scope={scope} /> <OnlineNow scope={scope} />
<Houses scope={scope} /> <Houses scope={scope} />

View File

@@ -0,0 +1,132 @@
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 }}>
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>
)
}

View File

@@ -71,6 +71,9 @@ async function getPublic() {
// page show/hide the password form and SSO buttons. // page show/hide the password form and SSO buttons.
const mode = REGISTRATION_MODES.includes(all[REGISTRATION_KEY]) ? all[REGISTRATION_KEY] : 'disabled' const mode = REGISTRATION_MODES.includes(all[REGISTRATION_KEY]) ? all[REGISTRATION_KEY] : 'disabled'
out.registration = registrationFlags(mode) out.registration = registrationFlags(mode)
// Whether the site offers game-account creation (the shard's own mode still has
// the final say when the call is made). Lets the portal show/hide the form.
out.gameAccountSignup = all[GAME_SIGNUP_KEY] === 'enabled'
return out return out
} }