From 74d2ead9588e85279a3f0760120a56a706990836 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 03:05:43 -0500 Subject: [PATCH] Let staff link their own characters + share the game-accounts UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3 --- client/src/App.jsx | 4 + client/src/api/client.js | 8 + client/src/components/GameAccounts.jsx | 156 ++++++++++++++ client/src/routes/admin/AdminLayout.jsx | 8 +- .../src/routes/admin/views/AdminCharacter.jsx | 27 +++ .../routes/admin/views/AdminCharacters.jsx | 15 ++ client/src/routes/player/PlayerCharacters.jsx | 163 +------------- server/src/router/v1/admin/admin.routes.js | 50 +++++ server/swagger/swagger-output.json | 201 ++++++++++++++++++ 9 files changed, 473 insertions(+), 159 deletions(-) create mode 100644 client/src/components/GameAccounts.jsx create mode 100644 client/src/routes/admin/views/AdminCharacter.jsx create mode 100644 client/src/routes/admin/views/AdminCharacters.jsx 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 ( +
+ + + {msg && {msg}} + {error && {error}} +
+ ) +} + +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.

+ +
+ ) + } + 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) => ( +
+
+ {a.account} +
+ +
+ ))} +
+
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 ( -
- - - {msg && {msg}} - {error && {error}} -
- ) -} - -// 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.

- -
- ) - } - 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) => ( -
-
- {a.account} -
- -
- ))} -
- -
-
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": [