diff --git a/client/src/App.jsx b/client/src/App.jsx
index 9149a78..5963be8 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -36,6 +36,8 @@ import ActivityAdmin from './routes/admin/views/ActivityAdmin.jsx'
import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx'
import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.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 UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
@@ -118,6 +120,8 @@ export default function App() {
} />
} />
} />
+ } />
+ } />
} />
} />
} />
diff --git a/client/src/api/client.js b/client/src/api/client.js
index 648cf04..34f9409 100644
--- a/client/src/api/client.js
+++ b/client/src/api/client.js
@@ -214,6 +214,14 @@ export const api = {
linkedIdentities: () => req('/admin/account/identities'),
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) -----
listAuthProviders: () => req('/admin/auth/providers'),
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
diff --git a/client/src/components/GameAccounts.jsx b/client/src/components/GameAccounts.jsx
new file mode 100644
index 0000000..c4617e7
--- /dev/null
+++ b/client/src/components/GameAccounts.jsx
@@ -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 (
+
+ )
+}
+
+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 (
+
+
The game server is restarting — try again shortly.
+
Retry
+
+ )
+ }
+ if (error) return {error}
+ if (!roster) return Loading…
+
+ const chars = roster.chars || []
+ if (chars.length === 0) return No characters on this account.
+
+ return (
+
+ {chars.map((c) => (
+
+
+ {(c.name || '?').charAt(0)}
+
+
+
{c.name}
+
{c.online ? 'Online' : 'Offline'}
+
+
›
+
+ ))}
+
+ )
+}
+
+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
+ if (!accounts) return
+
+ // Not linked yet — prompt to link.
+ if (accounts.length === 0) {
+ return (
+
+
Link your game account
+
+ You haven’t linked a game account yet. In game, type [link to get a
+ one-time code, then enter it below to see your characters, stats, skills and vendors here.
+
+
+
+ )
+ }
+
+ // Linked — characters grouped by account.
+ return (
+
+ {accounts.map((a) => (
+
+ ))}
+
+ Link another account
+
+
+
+ )
+}
diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx
index 4c6358b..30bdd32 100644
--- a/client/src/routes/admin/AdminLayout.jsx
+++ b/client/src/routes/admin/AdminLayout.jsx
@@ -79,6 +79,7 @@ const NAV = [
},
{
items: [
+ { to: '/admin/characters', label: 'My Characters', icon: IconShard },
{ to: '/admin/account', label: 'Account', icon: IconUser },
],
},
@@ -98,6 +99,7 @@ const TITLES = {
'/admin/bot-activity': 'Web Bot Activity',
'/admin/discord-bot': 'Discord Bot',
'/admin/shard': 'Shard (uo-link)',
+ '/admin/characters': 'My Characters',
'/admin/auth-providers': 'Authentication',
'/admin/users': 'Users',
'/admin/account': 'Account Security',
@@ -123,7 +125,11 @@ export default function AdminLayout() {
const location = useLocation()
const title =
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.
const wide = location.pathname === '/admin/hero'
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
diff --git a/client/src/routes/admin/views/AdminCharacter.jsx b/client/src/routes/admin/views/AdminCharacter.jsx
new file mode 100644
index 0000000..1767a84
--- /dev/null
+++ b/client/src/routes/admin/views/AdminCharacter.jsx
@@ -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 (
+
+
+
+ ← Back to my characters
+
+
+ {loading &&
}
+ {restarting &&
}
+ {error && !restarting &&
}
+ {!loading && !error && data &&
}
+
+ )
+}
diff --git a/client/src/routes/admin/views/AdminCharacters.jsx b/client/src/routes/admin/views/AdminCharacters.jsx
new file mode 100644
index 0000000..b2a7ec2
--- /dev/null
+++ b/client/src/routes/admin/views/AdminCharacters.jsx
@@ -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 (
+
+
+ Link your own game account to view your characters, stats, skills and vendors.
+
+ `/admin/characters/${serial}`} />
+
+ )
+}
diff --git a/client/src/routes/player/PlayerCharacters.jsx b/client/src/routes/player/PlayerCharacters.jsx
index 07cf03a..bc48dee 100644
--- a/client/src/routes/player/PlayerCharacters.jsx
+++ b/client/src/routes/player/PlayerCharacters.jsx
@@ -1,166 +1,13 @@
-import { useCallback, useEffect, useState } from 'react'
-import { Link } from 'react-router-dom'
-import { Loading, ErrorState } from '../../components/PageState.jsx'
+import GameAccounts from '../../components/GameAccounts.jsx'
import { api } from '../../api/client.js'
-// One-time-code linking form (shared by the empty state and "add another").
-function LinkForm({ 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 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 (
-
- )
-}
-
-// 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 (
-
-
The game server is restarting — try again shortly.
-
Retry
-
- )
- }
- if (error) return {error}
- if (!roster) return Loading…
-
- const chars = roster.chars || []
- if (chars.length === 0) return No characters on this account.
-
- return (
-
- {chars.map((c) => (
-
-
- {(c.name || '?').charAt(0)}
-
-
-
{c.name}
-
{c.online ? 'Online' : 'Offline'}
-
-
›
-
- ))}
-
- )
-}
-
+// The logged-in player's characters. Shows the link prompt when no game account
+// is linked, otherwise their characters grouped by account (shared component).
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
- if (!accounts) return
-
- // Not linked yet — prompt to link.
- if (accounts.length === 0) {
- return (
-
-
Your characters
-
- You haven’t linked a game account yet. Link one to see your characters, stats, skills and vendors here.
-
-
-
Link your game account
-
- In game, type [link to get a one-time code, then enter it below.
-
-
-
-
- )
- }
-
- // Linked — show characters grouped by account.
return (
-
-
Your characters
-
-
-
- {accounts.map((a) => (
-
- ))}
-
-
-
- Link another account
-
-
+
Your characters
+
`/player/char/${serial}`} />
)
}
diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js
index 91e60ff..3134e0a 100644
--- a/server/src/router/v1/admin/admin.routes.js
+++ b/server/src/router/v1/admin/admin.routes.js
@@ -12,6 +12,7 @@ const authProviders = require('./authProviders.controller')
const discordBot = require('./discordBot.controller')
const emailConfig = require('./emailConfig.controller')
const uoLink = require('./uoLink.controller')
+const selfShard = require('../player/shard.controller')
const moderation = require('./moderation.controller')
const pagesCtrl = require('./pages.controller')
const { isLoggedIn, requireRole } = require('../../../utils/auth')
@@ -110,6 +111,55 @@ adminRouter.delete(
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) ───────────────────────────────
const UPLOAD_DIR =
process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', '..', '..', 'uploads')
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index f91b8b0..6978c0f 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -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": {
"get": {
"tags": [