Let staff link their own characters + share the game-accounts UI
- Backend: /admin/shard/{link,accounts,roster/:account,vendors/:account} —
staff self-service, reusing the player/shard controller (it keys off
req.user.id, so the same handlers serve any logged-in role). Swagger under
Admin · Account; spec regenerated.
- components/GameAccounts.jsx: the link-prompt + character-roster UI extracted
into one reusable component parametrized by an api scope and a charTo(serial)
route builder.
- PlayerCharacters now renders it (player scope → /player/char/:serial).
- Admin: "My Characters" nav item + /admin/characters (AdminCharacters) and
/admin/characters/:serial (AdminCharacter, in-shell sheet), using the admin
self-service scope. api.admin.shard.* added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
This commit is contained in:
@@ -36,6 +36,8 @@ import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
|
|||||||
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
|
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
|
||||||
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
|
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
|
||||||
import ShardAdmin from './routes/admin/views/ShardAdmin.jsx'
|
import ShardAdmin from './routes/admin/views/ShardAdmin.jsx'
|
||||||
|
import AdminCharacters from './routes/admin/views/AdminCharacters.jsx'
|
||||||
|
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 AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||||
@@ -118,6 +120,8 @@ export default function App() {
|
|||||||
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||||
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
||||||
<Route path="shard" element={<ShardAdmin />} />
|
<Route path="shard" element={<ShardAdmin />} />
|
||||||
|
<Route path="characters" element={<AdminCharacters />} />
|
||||||
|
<Route path="characters/:serial" element={<AdminCharacter />} />
|
||||||
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
|
<Route path="auth-providers" element={<AuthProvidersAdmin />} />
|
||||||
<Route path="users" element={<UsersAdmin />} />
|
<Route path="users" element={<UsersAdmin />} />
|
||||||
<Route path="account" element={<AccountAdmin />} />
|
<Route path="account" element={<AccountAdmin />} />
|
||||||
|
|||||||
@@ -214,6 +214,14 @@ export const api = {
|
|||||||
linkedIdentities: () => req('/admin/account/identities'),
|
linkedIdentities: () => req('/admin/account/identities'),
|
||||||
unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }),
|
unlinkIdentity: (provider) => req(`/admin/account/identities/${provider}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// ----- game account linking (self-service, staff) -----
|
||||||
|
shard: {
|
||||||
|
link: (code) => req('/admin/shard/link', { method: 'POST', body: { code } }),
|
||||||
|
accounts: () => req('/admin/shard/accounts'),
|
||||||
|
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
|
||||||
|
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
|
||||||
|
},
|
||||||
|
|
||||||
// ----- auth providers / SSO config (admin only) -----
|
// ----- auth providers / SSO config (admin only) -----
|
||||||
listAuthProviders: () => req('/admin/auth/providers'),
|
listAuthProviders: () => req('/admin/auth/providers'),
|
||||||
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
|
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
|
||||||
|
|||||||
156
client/src/components/GameAccounts.jsx
Normal file
156
client/src/components/GameAccounts.jsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { Loading, ErrorState } from './PageState.jsx'
|
||||||
|
|
||||||
|
// Shared game-account linking + character roster, used by both the player portal
|
||||||
|
// (/player) and the staff account page (/admin/account). `scope` is the api
|
||||||
|
// object with { link, accounts, roster } (player or admin self-service); `charTo`
|
||||||
|
// maps a serial to the route for that character's sheet.
|
||||||
|
|
||||||
|
function LinkForm({ scope, onLinked, compact }) {
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [msg, setMsg] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
async function submit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setMsg(''); setError('')
|
||||||
|
if (!code.trim()) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const { account } = await scope.link(code.trim())
|
||||||
|
setMsg(`Linked ${account}.`)
|
||||||
|
setCode('')
|
||||||
|
await onLinked()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || 'Could not link that code.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap', marginTop: compact ? 0 : 6 }}>
|
||||||
|
<label style={{ display: 'block' }}>
|
||||||
|
{!compact && <span className="field-label">Link code</span>}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||||
|
className="input"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="AB12CD"
|
||||||
|
style={{ maxWidth: 180, textTransform: 'uppercase', letterSpacing: '0.12em' }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
|
||||||
|
{busy ? 'Linking…' : 'Link account'}
|
||||||
|
</button>
|
||||||
|
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
||||||
|
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountRoster({ scope, account, charTo }) {
|
||||||
|
const [roster, setRoster] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [unavailable, setUnavailable] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError(''); setUnavailable(false)
|
||||||
|
try {
|
||||||
|
setRoster(await scope.roster(account))
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 503) setUnavailable(true)
|
||||||
|
else setError(err.message || 'Could not load this account.')
|
||||||
|
}
|
||||||
|
}, [scope, account])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
if (unavailable) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="sans" style={{ margin: '0 0 8px', color: '#e0b070', fontSize: '0.85rem' }}>The game server is restarting — try again shortly.</p>
|
||||||
|
<button className="pill" onClick={load}>Retry</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (error) return <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
|
||||||
|
if (!roster) return <p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>Loading…</p>
|
||||||
|
|
||||||
|
const chars = roster.chars || []
|
||||||
|
if (chars.length === 0) return <p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>No characters on this account.</p>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid-2" style={{ gap: 12 }}>
|
||||||
|
{chars.map((c) => (
|
||||||
|
<Link
|
||||||
|
key={c.serial}
|
||||||
|
to={charTo(c.serial)}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px', border: '1px solid var(--line)', borderRadius: 10, textDecoration: 'none', background: 'rgba(255,255,255,0.02)' }}
|
||||||
|
>
|
||||||
|
<span style={{ flex: 'none', width: 40, height: 40, borderRadius: '50%', background: 'linear-gradient(180deg,#2a3a52,#1a2536)', border: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#d8e2ef', fontSize: '1rem', textTransform: 'uppercase' }}>
|
||||||
|
{(c.name || '?').charAt(0)}
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="display" style={{ color: 'var(--head)', fontSize: '1.02rem' }}>{c.name}</div>
|
||||||
|
<div className="sans" style={{ fontSize: '0.76rem', color: c.online ? '#7fd0a4' : 'var(--muted)' }}>{c.online ? 'Online' : 'Offline'}</div>
|
||||||
|
</div>
|
||||||
|
<span className="sans dim" style={{ fontSize: '1.1rem' }}>›</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GameAccounts({ scope, charTo }) {
|
||||||
|
const [accounts, setAccounts] = useState(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
setAccounts(await scope.accounts())
|
||||||
|
} catch {
|
||||||
|
setError('Could not load your game accounts.')
|
||||||
|
}
|
||||||
|
}, [scope])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
if (error) return <ErrorState message={error} />
|
||||||
|
if (!accounts) return <Loading />
|
||||||
|
|
||||||
|
// Not linked yet — prompt to link.
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="panel" style={{ padding: 22 }}>
|
||||||
|
<div className="field-label" style={{ marginBottom: 8 }}>Link your game account</div>
|
||||||
|
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||||
|
You haven’t linked a game account yet. In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a
|
||||||
|
one-time code, then enter it below to see your characters, stats, skills and vendors here.
|
||||||
|
</p>
|
||||||
|
<LinkForm scope={scope} onLinked={load} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linked — characters grouped by account.
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<section key={a.account}>
|
||||||
|
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
||||||
|
{a.account}
|
||||||
|
</div>
|
||||||
|
<AccountRoster scope={scope} account={a.account} charTo={charTo} />
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 20 }}>
|
||||||
|
<div className="field-label" style={{ marginBottom: 10 }}>Link another account</div>
|
||||||
|
<LinkForm scope={scope} onLinked={load} compact />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -79,6 +79,7 @@ const NAV = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
items: [
|
items: [
|
||||||
|
{ to: '/admin/characters', label: 'My Characters', icon: IconShard },
|
||||||
{ to: '/admin/account', label: 'Account', icon: IconUser },
|
{ to: '/admin/account', label: 'Account', icon: IconUser },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -98,6 +99,7 @@ const TITLES = {
|
|||||||
'/admin/bot-activity': 'Web Bot Activity',
|
'/admin/bot-activity': 'Web Bot Activity',
|
||||||
'/admin/discord-bot': 'Discord Bot',
|
'/admin/discord-bot': 'Discord Bot',
|
||||||
'/admin/shard': 'Shard (uo-link)',
|
'/admin/shard': 'Shard (uo-link)',
|
||||||
|
'/admin/characters': 'My Characters',
|
||||||
'/admin/auth-providers': 'Authentication',
|
'/admin/auth-providers': 'Authentication',
|
||||||
'/admin/users': 'Users',
|
'/admin/users': 'Users',
|
||||||
'/admin/account': 'Account Security',
|
'/admin/account': 'Account Security',
|
||||||
@@ -123,7 +125,11 @@ export default function AdminLayout() {
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const title =
|
const title =
|
||||||
TITLES[location.pathname] ||
|
TITLES[location.pathname] ||
|
||||||
(location.pathname.startsWith('/admin/moderation') ? 'Moderation' : 'Admin')
|
(location.pathname.startsWith('/admin/moderation')
|
||||||
|
? 'Moderation'
|
||||||
|
: location.pathname.startsWith('/admin/characters')
|
||||||
|
? 'My Characters'
|
||||||
|
: 'Admin')
|
||||||
// The hero canvas editor needs room — let it use the full content width.
|
// The hero canvas editor needs room — let it use the full content width.
|
||||||
const wide = location.pathname === '/admin/hero'
|
const wide = location.pathname === '/admin/hero'
|
||||||
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
|
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
|
||||||
|
|||||||
27
client/src/routes/admin/views/AdminCharacter.jsx
Normal file
27
client/src/routes/admin/views/AdminCharacter.jsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
||||||
|
import CharacterSheet from '../../../components/CharacterSheet.jsx'
|
||||||
|
import { useAsync } from '../../../lib/useAsync.js'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
// A staff member's character sheet inside the admin shell. Character data is
|
||||||
|
// public MMO data, so it uses the same cached public endpoint.
|
||||||
|
export default function AdminCharacter() {
|
||||||
|
const { serial } = useParams()
|
||||||
|
const { loading, error, data } = useAsync(() => api.shard.char(serial), [serial])
|
||||||
|
const restarting = error && error.status === 503
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 760 }}>
|
||||||
|
<p style={{ margin: '0 0 18px' }}>
|
||||||
|
<Link to="/admin/characters" className="sans" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.86rem' }}>
|
||||||
|
← Back to my characters
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
{loading && <Loading />}
|
||||||
|
{restarting && <ErrorState message="The game server is restarting — try again shortly." />}
|
||||||
|
{error && !restarting && <ErrorState message="Could not load that character right now." />}
|
||||||
|
{!loading && !error && data && <CharacterSheet char={data} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
client/src/routes/admin/views/AdminCharacters.jsx
Normal file
15
client/src/routes/admin/views/AdminCharacters.jsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import GameAccounts from '../../../components/GameAccounts.jsx'
|
||||||
|
import { api } from '../../../api/client.js'
|
||||||
|
|
||||||
|
// Staff link their OWN in-game account and view their characters — the same
|
||||||
|
// shared component players use, pointed at the staff self-service endpoints.
|
||||||
|
export default function AdminCharacters() {
|
||||||
|
return (
|
||||||
|
<section style={{ maxWidth: 760 }}>
|
||||||
|
<p className="sans" style={{ marginTop: 0, marginBottom: 22, color: 'var(--muted)', fontSize: '0.92rem', lineHeight: 1.6 }}>
|
||||||
|
Link your own game account to view your characters, stats, skills and vendors.
|
||||||
|
</p>
|
||||||
|
<GameAccounts scope={api.admin.shard} charTo={(serial) => `/admin/characters/${serial}`} />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,166 +1,13 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import GameAccounts from '../../components/GameAccounts.jsx'
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
|
||||||
import { api } from '../../api/client.js'
|
import { api } from '../../api/client.js'
|
||||||
|
|
||||||
// One-time-code linking form (shared by the empty state and "add another").
|
// The logged-in player's characters. Shows the link prompt when no game account
|
||||||
function LinkForm({ onLinked, compact }) {
|
// is linked, otherwise their characters grouped by account (shared component).
|
||||||
const [code, setCode] = useState('')
|
|
||||||
const [busy, setBusy] = useState(false)
|
|
||||||
const [msg, setMsg] = useState('')
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
|
|
||||||
async function submit(e) {
|
|
||||||
e.preventDefault()
|
|
||||||
setMsg(''); setError('')
|
|
||||||
if (!code.trim()) return
|
|
||||||
setBusy(true)
|
|
||||||
try {
|
|
||||||
const { account } = await api.player.shard.link(code.trim())
|
|
||||||
setMsg(`Linked ${account}.`)
|
|
||||||
setCode('')
|
|
||||||
await onLinked()
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message || 'Could not link that code.')
|
|
||||||
} finally {
|
|
||||||
setBusy(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={submit} style={{ display: 'flex', gap: 10, alignItems: 'flex-end', flexWrap: 'wrap', marginTop: compact ? 0 : 6 }}>
|
|
||||||
<label style={{ display: 'block' }}>
|
|
||||||
{!compact && <span className="field-label">Link code</span>}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={code}
|
|
||||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
|
||||||
className="input"
|
|
||||||
autoComplete="off"
|
|
||||||
placeholder="AB12CD"
|
|
||||||
style={{ maxWidth: 180, textTransform: 'uppercase', letterSpacing: '0.12em' }}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button type="submit" disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
|
|
||||||
{busy ? 'Linking…' : 'Link account'}
|
|
||||||
</button>
|
|
||||||
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
|
|
||||||
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Roster of one linked account → character cards linking to the sheet.
|
|
||||||
function AccountRoster({ account }) {
|
|
||||||
const [roster, setRoster] = useState(null)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
const [unavailable, setUnavailable] = useState(false)
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setError(''); setUnavailable(false)
|
|
||||||
try {
|
|
||||||
setRoster(await api.player.shard.roster(account))
|
|
||||||
} catch (err) {
|
|
||||||
if (err.status === 503) setUnavailable(true)
|
|
||||||
else setError(err.message || 'Could not load this account.')
|
|
||||||
}
|
|
||||||
}, [account])
|
|
||||||
useEffect(() => { load() }, [load])
|
|
||||||
|
|
||||||
if (unavailable) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p className="sans" style={{ margin: '0 0 8px', color: '#e0b070', fontSize: '0.85rem' }}>The game server is restarting — try again shortly.</p>
|
|
||||||
<button className="pill" onClick={load}>Retry</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (error) return <p className="sans" style={{ margin: 0, color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>
|
|
||||||
if (!roster) return <p className="sans dim" style={{ margin: 0, fontSize: '0.82rem' }}>Loading…</p>
|
|
||||||
|
|
||||||
const chars = roster.chars || []
|
|
||||||
if (chars.length === 0) return <p className="sans dim" style={{ margin: 0, fontSize: '0.84rem' }}>No characters on this account.</p>
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid-2" style={{ gap: 12 }}>
|
|
||||||
{chars.map((c) => (
|
|
||||||
<Link
|
|
||||||
key={c.serial}
|
|
||||||
to={`/player/char/${c.serial}`}
|
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '14px 16px', border: '1px solid var(--line)', borderRadius: 10, textDecoration: 'none', background: 'rgba(255,255,255,0.02)' }}
|
|
||||||
>
|
|
||||||
<span style={{ flex: 'none', width: 40, height: 40, borderRadius: '50%', background: 'linear-gradient(180deg,#2a3a52,#1a2536)', border: '1px solid var(--line)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#d8e2ef', fontSize: '1rem', textTransform: 'uppercase' }}>
|
|
||||||
{(c.name || '?').charAt(0)}
|
|
||||||
</span>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div className="display" style={{ color: 'var(--head)', fontSize: '1.02rem' }}>{c.name}</div>
|
|
||||||
<div className="sans" style={{ fontSize: '0.76rem', color: c.online ? '#7fd0a4' : 'var(--muted)' }}>{c.online ? 'Online' : 'Offline'}</div>
|
|
||||||
</div>
|
|
||||||
<span className="sans dim" style={{ fontSize: '1.1rem' }}>›</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PlayerCharacters() {
|
export default function PlayerCharacters() {
|
||||||
const [accounts, setAccounts] = useState(null)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setError('')
|
|
||||||
try {
|
|
||||||
setAccounts(await api.player.shard.accounts())
|
|
||||||
} catch {
|
|
||||||
setError('Could not load your game accounts.')
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
useEffect(() => { load() }, [load])
|
|
||||||
|
|
||||||
if (error) return <ErrorState message={error} />
|
|
||||||
if (!accounts) return <Loading />
|
|
||||||
|
|
||||||
// Not linked yet — prompt to link.
|
|
||||||
if (accounts.length === 0) {
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="display" style={{ margin: '0 0 6px', fontSize: '1.6rem', color: 'var(--head)' }}>Your characters</h1>
|
<h1 className="display" style={{ margin: '0 0 18px', fontSize: '1.6rem', color: 'var(--head)' }}>Your characters</h1>
|
||||||
<p className="sans" style={{ margin: '0 0 22px', color: 'var(--muted)', fontSize: '0.92rem', lineHeight: 1.6 }}>
|
<GameAccounts scope={api.player.shard} charTo={(serial) => `/player/char/${serial}`} />
|
||||||
You haven’t linked a game account yet. Link one to see your characters, stats, skills and vendors here.
|
|
||||||
</p>
|
|
||||||
<div className="panel" style={{ padding: 22 }}>
|
|
||||||
<div className="field-label" style={{ marginBottom: 8 }}>Link your game account</div>
|
|
||||||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
|
||||||
In game, type <code style={{ color: 'var(--head)' }}>[link</code> to get a one-time code, then enter it below.
|
|
||||||
</p>
|
|
||||||
<LinkForm onLinked={load} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Linked — show characters grouped by account.
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 20 }}>
|
|
||||||
<h1 className="display" style={{ margin: 0, fontSize: '1.6rem', color: 'var(--head)' }}>Your characters</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
|
|
||||||
{accounts.map((a) => (
|
|
||||||
<section key={a.account}>
|
|
||||||
<div className="sans" style={{ color: 'var(--accent)', fontSize: '0.7rem', letterSpacing: '0.12em', textTransform: 'uppercase', marginBottom: 12 }}>
|
|
||||||
{a.account}
|
|
||||||
</div>
|
|
||||||
<AccountRoster account={a.account} />
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section style={{ borderTop: '1px solid var(--line-soft)', marginTop: 30, paddingTop: 22 }}>
|
|
||||||
<div className="field-label" style={{ marginBottom: 10 }}>Link another account</div>
|
|
||||||
<LinkForm onLinked={load} compact />
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const authProviders = require('./authProviders.controller')
|
|||||||
const discordBot = require('./discordBot.controller')
|
const discordBot = require('./discordBot.controller')
|
||||||
const emailConfig = require('./emailConfig.controller')
|
const emailConfig = require('./emailConfig.controller')
|
||||||
const uoLink = require('./uoLink.controller')
|
const uoLink = require('./uoLink.controller')
|
||||||
|
const selfShard = require('../player/shard.controller')
|
||||||
const moderation = require('./moderation.controller')
|
const moderation = require('./moderation.controller')
|
||||||
const pagesCtrl = require('./pages.controller')
|
const pagesCtrl = require('./pages.controller')
|
||||||
const { isLoggedIn, requireRole } = require('../../../utils/auth')
|
const { isLoggedIn, requireRole } = require('../../../utils/auth')
|
||||||
@@ -110,6 +111,55 @@ adminRouter.delete(
|
|||||||
account.unlinkIdentity,
|
account.unlinkIdentity,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Game account linking (self-service, any staff role) ───────────────
|
||||||
|
// Staff link their OWN in-game account here, exactly like players do under
|
||||||
|
// /player/shard. The controller keys off req.user.id, so the same handlers work.
|
||||||
|
const SHARD_ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/
|
||||||
|
adminRouter.post(
|
||||||
|
'/shard/link',
|
||||||
|
// #swagger.tags = ['Admin · Account']
|
||||||
|
// #swagger.summary = 'Link an in-game account with a one-time code (self)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkRequest" } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Linked', content: { "application/json": { schema: { $ref: "#/components/schemas/ShardLinkResult" } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Unknown or expired code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
body('code').isString().trim().isLength({ min: 4, max: 32 }),
|
||||||
|
validate,
|
||||||
|
selfShard.link,
|
||||||
|
)
|
||||||
|
adminRouter.get(
|
||||||
|
'/shard/accounts',
|
||||||
|
// #swagger.tags = ['Admin · Account']
|
||||||
|
// #swagger.summary = 'List the caller’s linked game accounts (self)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
|
||||||
|
selfShard.listAccounts,
|
||||||
|
)
|
||||||
|
adminRouter.get(
|
||||||
|
'/shard/roster/:account',
|
||||||
|
// #swagger.tags = ['Admin · Account']
|
||||||
|
// #swagger.summary = 'Character roster for a linked account (self)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Account roster', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('account').matches(SHARD_ACCOUNT_RE),
|
||||||
|
validate,
|
||||||
|
selfShard.roster,
|
||||||
|
)
|
||||||
|
adminRouter.get(
|
||||||
|
'/shard/vendors/:account',
|
||||||
|
// #swagger.tags = ['Admin · Account']
|
||||||
|
// #swagger.summary = 'Player vendors for a linked account (self)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'A game account linked to the caller.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Vendor snapshot', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Account not linked to the caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('account').matches(SHARD_ACCOUNT_RE),
|
||||||
|
validate,
|
||||||
|
selfShard.vendors,
|
||||||
|
)
|
||||||
|
|
||||||
// ── Image uploads (screenshots/gallery) ───────────────────────────────
|
// ── Image uploads (screenshots/gallery) ───────────────────────────────
|
||||||
const UPLOAD_DIR =
|
const UPLOAD_DIR =
|
||||||
process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', '..', '..', 'uploads')
|
process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', '..', '..', 'uploads')
|
||||||
|
|||||||
@@ -1886,6 +1886,207 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/admin/shard/link": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Admin · Account"
|
||||||
|
],
|
||||||
|
"summary": "Link an in-game account with a one-time code (self)",
|
||||||
|
"description": "",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Linked",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ShardLinkResult"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Unknown or expired code",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"description": "Bad Gateway"
|
||||||
|
},
|
||||||
|
"503": {
|
||||||
|
"description": "Service Unavailable"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ShardLinkRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/admin/shard/accounts": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Admin · Account"
|
||||||
|
],
|
||||||
|
"summary": "List the caller’s linked game accounts (self)",
|
||||||
|
"description": "",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Linked accounts",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ShardLink"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/admin/shard/roster/{account}": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Admin · Account"
|
||||||
|
],
|
||||||
|
"summary": "Character roster for a linked account (self)",
|
||||||
|
"description": "",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "account",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "A game account linked to the caller."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Account roster",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Account not linked to the caller",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/admin/shard/vendors/{account}": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Admin · Account"
|
||||||
|
],
|
||||||
|
"summary": "Player vendors for a linked account (self)",
|
||||||
|
"description": "",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "account",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "A game account linked to the caller."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Vendor snapshot",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Account not linked to the caller",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/admin/dashboard": {
|
"/api/v1/admin/dashboard": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
Reference in New Issue
Block a user