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>
183 lines
8.1 KiB
JavaScript
183 lines
8.1 KiB
JavaScript
import { useMemo } from 'react'
|
||
import { useParams, Link } from 'react-router-dom'
|
||
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||
import { useAsync } from '../../../lib/useAsync.js'
|
||
import { dateTime, ago } from '../../../lib/format.js'
|
||
import { api } from '../../../api/client.js'
|
||
import CharacterStats from '../../../components/CharacterStats.jsx'
|
||
import GameAccounts from '../../../components/GameAccounts.jsx'
|
||
import VendorSales from '../../../components/VendorSales.jsx'
|
||
|
||
// Admin read-only view of one user's shard (uo-link) footprint: linked game
|
||
// accounts + character rosters, currently-online characters, houses (incl.
|
||
// IDOC) and recent vendor sales — everything scoped to that user's accounts.
|
||
// Reached from the Users table's "View" action; Edit stays a separate modal.
|
||
|
||
const ROLE_BADGE = {
|
||
admin: 'badge-admin',
|
||
editor: 'badge-editor',
|
||
moderator: 'badge-moderator',
|
||
player: 'badge-player',
|
||
}
|
||
|
||
function SectionTitle({ children }) {
|
||
return (
|
||
<div className="field-label" style={{ marginBottom: 12, marginTop: 4 }}>
|
||
{children}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Currently-online characters on the user's accounts, with where they are. The
|
||
// per-character Online/Offline badge lives in the roster; this adds location.
|
||
function OnlineNow({ scope }) {
|
||
const { data } = useAsync(() => scope.online(), [scope])
|
||
if (!data) return null
|
||
return (
|
||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||
<SectionTitle>Online now</SectionTitle>
|
||
{data.length === 0 ? (
|
||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No characters online right now.</p>
|
||
) : (
|
||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||
{data.map((c) => (
|
||
<li key={c.serial} className="sans" style={{ display: 'flex', justifyContent: 'space-between', gap: 12, fontSize: '0.9rem', color: 'var(--ink)' }}>
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7fd0a4', boxShadow: '0 0 6px #7fd0a4', flex: 'none' }} />
|
||
<span style={{ color: 'var(--head)' }}>{c.name || '(unnamed)'}</span>
|
||
</span>
|
||
<span className="dim" style={{ flex: 'none', fontSize: '0.8rem' }}>
|
||
{c.map != null ? `map ${c.map} · ${c.x}, ${c.y}` : '—'}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
// Shard "standing": city governorships held and guilds led by this user's
|
||
// accounts (both reliable current-state lookups). Renders nothing when empty.
|
||
function Standing({ scope }) {
|
||
const { data } = useAsync(() => scope.standing(), [scope])
|
||
if (!data) return null
|
||
const govs = data.governorOf || []
|
||
const guilds = data.guildsLed || []
|
||
if (govs.length === 0 && guilds.length === 0) return null
|
||
return (
|
||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||
<SectionTitle>Standing</SectionTitle>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||
{govs.map((g) => (
|
||
<span key={`gov-${g.city}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid #c9a24b55', color: '#c9a24b' }}>
|
||
Governor of {g.city}
|
||
</span>
|
||
))}
|
||
{guilds.map((g) => (
|
||
<span key={`guild-${g.id}`} className="sans" style={{ fontSize: '0.78rem', padding: '4px 10px', borderRadius: 999, border: '1px solid var(--accent)', color: 'var(--accent)' }}>
|
||
Guildmaster{g.abbr ? `, [${g.abbr}]` : ''} {g.name}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
// Houses owned by the user's accounts, IDOC first (flagged).
|
||
function Houses({ scope }) {
|
||
const { data } = useAsync(() => scope.houses(), [scope])
|
||
if (!data) return null
|
||
return (
|
||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
||
<SectionTitle>Houses</SectionTitle>
|
||
{data.length === 0 ? (
|
||
<p className="sans dim" style={{ margin: 0, fontSize: '0.86rem' }}>No houses recorded for this user’s accounts.</p>
|
||
) : (
|
||
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||
{data.map((h) => (
|
||
<li
|
||
key={h.serial}
|
||
style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'baseline', padding: '12px 14px', border: '1px solid var(--line)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}
|
||
>
|
||
<div style={{ minWidth: 0 }}>
|
||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.95rem' }}>
|
||
{h.name || 'Unnamed house'}
|
||
{h.isIdoc && <span className="badge" style={{ marginLeft: 8, background: '#5b2020', color: '#f0c8c2' }}>IDOC</span>}
|
||
</div>
|
||
<div className="sans dim" style={{ fontSize: '0.78rem', marginTop: 2 }}>
|
||
{h.region || (h.map != null ? `map ${h.map}` : 'unknown')}
|
||
{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
|
||
{h.ownerAcct ? ` · ${h.ownerAcct}` : ''}
|
||
{(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''}
|
||
</div>
|
||
</div>
|
||
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}>
|
||
{(h.decay || h.stage) ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.decay || h.stage}</div> : null}
|
||
{h.price != null ? <div style={{ fontVariantNumeric: 'tabular-nums' }}>{Number(h.price).toLocaleString()} gp</div> : null}
|
||
{h.lastRefreshed ? <div>refreshed {ago(h.lastRefreshed)}</div> : null}
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function ShardSections({ scope }) {
|
||
return (
|
||
<>
|
||
<CharacterStats scope={scope} />
|
||
<SectionTitle>Linked accounts & characters</SectionTitle>
|
||
<GameAccounts scope={scope} readOnly moderation onUnlink={scope.unlink} charTo={(serial) => `/admin/characters/${serial}`} />
|
||
<Standing scope={scope} />
|
||
<OnlineNow scope={scope} />
|
||
<Houses scope={scope} />
|
||
<VendorSales fetchSales={scope.sales} />
|
||
</>
|
||
)
|
||
}
|
||
|
||
export default function UserDetail() {
|
||
const { id } = useParams()
|
||
// Memoize so the child components' effects (keyed on `scope`) don't refetch
|
||
// on every render.
|
||
const scope = useMemo(() => api.admin.userShard(id), [id])
|
||
const { loading, error, data: user } = useAsync(() => api.admin.getUser(id), [id])
|
||
|
||
if (loading) return <Loading />
|
||
if (error) return <ErrorState message="Could not load this user." />
|
||
|
||
return (
|
||
<section>
|
||
<Link to="/admin/users" className="link-accent" style={{ fontSize: '0.85rem' }}>
|
||
← Back to users
|
||
</Link>
|
||
|
||
{/* Header */}
|
||
<div style={{ padding: 22, border: '1px solid var(--line)', borderRadius: 12, background: 'var(--panel-grad)', margin: '12px 0 24px' }}>
|
||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
|
||
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)' }}>
|
||
{user.username}
|
||
</span>
|
||
<span className={`badge ${ROLE_BADGE[user.role] || 'badge-editor'}`}>{user.role}</span>
|
||
<span
|
||
className="sans"
|
||
style={{ fontSize: '0.82rem', color: user.status && user.status !== 'active' ? '#d98b84' : 'var(--muted)' }}
|
||
>
|
||
{user.status || 'active'}
|
||
</span>
|
||
</div>
|
||
<div className="sans dim" style={{ display: 'flex', gap: 18, marginTop: 10, flexWrap: 'wrap', fontSize: '0.8rem' }}>
|
||
{user.email && <span>{user.email}</span>}
|
||
<span>Last login: {user.last_login_at ? dateTime(user.last_login_at) : 'never'}</span>
|
||
{user.created_at && <span>Joined: {dateTime(user.created_at)}</span>}
|
||
</div>
|
||
</div>
|
||
|
||
<ShardSections scope={scope} />
|
||
</section>
|
||
)
|
||
}
|