Files
website/client/src/api/client.js
Claude 3ef1c8e438
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m37s
PR Checks / client-build (pull_request) Successful in 10m18s
PR Checks / bot-install (pull_request) Successful in 9m22s
feat(provisioning): admin game-signup mode setting, invite link option, staff self-create
Follow-ups from live testing:

- Game-account creation is now an admin Settings control (disabled / website /
  hybrid / game) instead of a hidden on/off flag. The site offers creation for
  website+hybrid; help text notes the shard's SignupMode (Bridge.cfg) has the final
  say. game_account_signup setting widened to a 4-value enum + validated on save.
- Invites: the accept link is ALWAYS returned and shown with a Copy button, and a
  "Email the invitation" toggle lets an admin create a link-only invite (no email)
  or email it. Backend takes sendEmail (default true) and always returns acceptUrl.
- Staff can create a game account from their own /admin/characters page too
  (POST /admin/shard/account → the shared createGameAccount controller), so the
  form is reachable in both the player and admin portals.

Note: the admin Houses view (/admin/houses) already worked; the earlier failure
was a stale Vite HMR state for the new route (needs a hard refresh).

Client build clean; server routes load; swagger regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:08:59 -05:00

337 lines
17 KiB
JavaScript

// Thin fetch wrapper. Always sends cookies (httpOnly JWT) and talks to the
// same-origin API (/api/v1) — proxied to the Express server in dev.
const BASE = '/api/v1'
class ApiError extends Error {
constructor(status, message, body) {
super(message)
this.status = status
this.body = body
}
}
async function req(path, { method = 'GET', body, headers, raw } = {}) {
const opts = { method, credentials: 'include', headers: { ...headers } }
if (body !== undefined) {
if (raw) {
opts.body = body // FormData — let the browser set the content-type
} else {
opts.headers['Content-Type'] = 'application/json'
opts.body = JSON.stringify(body)
}
}
const res = await fetch(BASE + path, opts)
const text = await res.text()
const data = text ? safeParse(text) : null
if (!res.ok) {
const message = (data && data.message) || res.statusText || 'Request failed'
throw new ApiError(res.status, message, data)
}
return data
}
function safeParse(text) {
try {
return JSON.parse(text)
} catch {
return text
}
}
export const api = {
// ----- auth -----
me: () => req('/auth/me'),
// `extra` carries the honeypot field (and any future login fields).
login: (username, password, extra = {}) =>
req('/auth/login', { method: 'POST', body: { username, password, ...extra } }),
// Public self-registration (player accounts). `extra` carries the honeypot +
// optional email. Returns { user } and sets the session cookie on success.
register: (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) =>
req('/auth/login/totp', { method: 'POST', body: { challenge, code } }),
// Second factor for an SSO login (challenge is held in an httpOnly cookie set by
// the callback, so only the code is sent). Returns { user, returnTo }.
ssoLoginTotp: (code) => req('/auth/sso/totp', { method: 'POST', body: { code } }),
logout: () => req('/auth/logout', { method: 'POST' }),
// Public SSO provider discovery — drives the login-page provider buttons.
authProviders: () => req('/auth/providers'),
// ----- public -----
publicSettings: () => req('/public/settings'),
status: () => req('/public/status'),
posts: (category) => req(`/public/posts/${category}`),
post: (category, idOrSlug) => req(`/public/posts/${category}/${idOrSlug}`),
wiki: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.category) qs.set('category', opts.category)
if (opts.tag) qs.set('tag', opts.tag)
if (opts.q) qs.set('q', opts.q)
const s = qs.toString()
return req(`/public/wiki${s ? `?${s}` : ''}`)
},
wikiCategories: () => req('/public/wiki/categories'),
wikiTags: () => req('/public/wiki/tags'),
wikiPage: (slug) => req(`/public/wiki/${slug}`),
// CMS pages (block-based). Published-only for the public; a draft-preview link
// is fetched by id + token.
page: (slug) => req(`/public/pages/${slug}`),
pagePreview: (id, token) => req(`/public/pages/${id}/preview/${token}`),
contact: (payload) => req('/public/contact', { method: 'POST', body: payload }),
// ----- shard live data (uo-link) -----
// Token-free, same-origin reads backed by the ingested feed + a cached live
// character round-trip. shardStreamUrl is the SSE endpoint for useShardFeed.
shard: {
status: () => req('/public/shard/status'),
feed: (opts = {}) => {
const qs = new URLSearchParams()
if (opts.kind) qs.set('kind', opts.kind)
if (opts.limit) qs.set('limit', opts.limit)
const s = qs.toString()
return req(`/public/shard/feed${s ? `?${s}` : ''}`)
},
economy: (limit) => req(`/public/shard/economy${limit ? `?limit=${limit}` : ''}`),
online: () => req('/public/shard/online'),
idoc: () => req('/public/shard/idoc'),
champs: () => req('/public/shard/champs'),
// Protocol 2.0 boards.
guilds: () => req('/public/shard/guilds'),
governors: () => req('/public/shard/governors'),
governorHistory: (city, limit) =>
req(`/public/shard/governors/${encodeURIComponent(city)}/history${limit ? `?limit=${limit}` : ''}`),
presence: () => req('/public/shard/presence'),
houses: () => req('/public/shard/houses'),
},
// Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is
// fetch-only, so SSE subscribers build the URL from here. The admin stream
// carries every kind (incl. audit/cheat) and needs the staff session cookie.
shardStreamUrl: `${BASE}/public/shard/stream`,
adminShardStreamUrl: `${BASE}/admin/uo-link/stream`,
// ----- admin -----
admin: {
dashboard: () => req('/admin/dashboard'),
setSiteMode: (mode) => req('/admin/site-mode', { method: 'PUT', body: { mode } }),
listPosts: (category) => req(`/admin/posts${category ? `?category=${category}` : ''}`),
getPost: (id) => req(`/admin/posts/${id}`),
createPost: (data) => req('/admin/posts', { method: 'POST', body: data }),
updatePost: (id, data) => req(`/admin/posts/${id}`, { method: 'PUT', body: data }),
deletePost: (id) => req(`/admin/posts/${id}`, { method: 'DELETE' }),
publishPost: (id, published) =>
req(`/admin/posts/${id}/publish`, { method: 'PATCH', body: { published } }),
// News announcement pipeline (town crier + Discord) status + per-leg retry.
getAnnounce: (id) => req(`/admin/posts/${id}/announce`),
retryAnnounceLeg: (id, leg) =>
req(`/admin/posts/${id}/announce/retry`, { method: 'POST', body: { leg } }),
uploadImage: (file) => {
const fd = new FormData()
fd.append('image', file)
return req('/admin/posts/upload', { method: 'POST', body: fd, raw: true })
},
// Generalized upload for rich-text editors → { url }.
upload: (file) => {
const fd = new FormData()
fd.append('image', file)
return req('/admin/uploads', { method: 'POST', body: fd, raw: true })
},
// ----- CMS pages (block-based page builder) -----
listPages: () => req('/admin/pages'),
getPage: (id) => req(`/admin/pages/${id}`),
createPage: (data) => req('/admin/pages', { method: 'POST', body: data }),
updatePage: (id, data) => req(`/admin/pages/${id}`, { method: 'PATCH', body: data }),
deletePage: (id) => req(`/admin/pages/${id}`, { method: 'DELETE' }),
unprotectPage: (id, password) =>
req(`/admin/pages/${id}/unprotect`, { method: 'POST', body: { password } }),
createPagePreview: (id) => req(`/admin/pages/${id}/preview`, { method: 'POST' }),
listWiki: (params = '') => req(`/admin/wiki${params}`),
getWiki: (slug) => req(`/admin/wiki/${slug}`),
createWiki: (data) => req('/admin/wiki', { method: 'POST', body: data }),
updateWiki: (slug, data) => req(`/admin/wiki/${slug}`, { method: 'PUT', body: data }),
publishWiki: (slug, published) =>
req(`/admin/wiki/${slug}/publish`, { method: 'PATCH', body: { published } }),
deleteWiki: (slug) => req(`/admin/wiki/${slug}`, { method: 'DELETE' }),
listWikiRevisions: (slug) => req(`/admin/wiki/${slug}/revisions`),
getWikiRevision: (slug, id) => req(`/admin/wiki/${slug}/revisions/${id}`),
restoreWikiRevision: (slug, id) =>
req(`/admin/wiki/${slug}/revisions/${id}/restore`, { method: 'POST' }),
listWikiTags: () => req('/admin/wiki/tags'),
listWikiCategories: () => req('/admin/wiki/categories'),
createWikiCategory: (data) => req('/admin/wiki/categories', { method: 'POST', body: data }),
updateWikiCategory: (id, data) =>
req(`/admin/wiki/categories/${id}`, { method: 'PUT', body: data }),
deleteWikiCategory: (id) => req(`/admin/wiki/categories/${id}`, { method: 'DELETE' }),
getSettings: () => req('/admin/settings'),
updateSettings: (obj) => req('/admin/settings', { method: 'PUT', body: obj }),
activity: (limit = 50) => req(`/admin/activity?limit=${limit}`),
botActivity: () => req('/admin/bot-activity'),
unbanIp: (ip) => req('/admin/bot-activity/unban', { method: 'POST', body: { ip } }),
listUsers: () => req('/admin/users'),
getUser: (id) => req(`/admin/users/${id}`),
createUser: (data) => req('/admin/users', { method: 'POST', body: data }),
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
// Email invites.
listInvites: () => req('/admin/invites'),
createInvite: (email, role, sendEmail = true) =>
req('/admin/invites', { method: 'POST', body: { email, role, sendEmail } }),
revokeInvite: (id) => req(`/admin/invites/${id}`, { method: 'DELETE' }),
// A single user's shard (uo-link) footprint, scoped to their linked accounts.
// accounts/sales/houses/online are user-scoped endpoints; roster/vendors/char
// reuse the admin-bypass /admin/shard/* endpoints (which already read any
// account) so the shared GameAccounts component works unchanged.
userShard: (id) => ({
accounts: () => req(`/admin/users/${id}/shard/accounts`),
roster: (account) => req(`/admin/shard/roster/${encodeURIComponent(account)}`),
vendors: (account) => req(`/admin/shard/vendors/${encodeURIComponent(account)}`),
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
sales: () => req(`/admin/users/${id}/shard/sales`),
houses: () => req(`/admin/users/${id}/shard/houses`),
online: () => req(`/admin/users/${id}/shard/online`),
standing: () => req(`/admin/users/${id}/shard/standing`),
unlink: (account) => req(`/admin/users/${id}/shard/link/${encodeURIComponent(account)}`, { method: 'DELETE' }),
}),
// ----- moderation dashboard (admin + moderator) -----
modSummary: () => req('/admin/moderation/stats/summary'),
modRecent: (params = {}) => {
const qs = new URLSearchParams()
if (params.type) qs.set('type', params.type)
if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset)
const s = qs.toString()
return req(`/admin/moderation/recent${s ? `?${s}` : ''}`)
},
modSearch: (q) => req(`/admin/moderation/search?q=${encodeURIComponent(q)}`),
modMembers: (params = {}) => {
const qs = new URLSearchParams()
if (params.type) qs.set('type', params.type)
if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset)
const s = qs.toString()
return req(`/admin/moderation/members${s ? `?${s}` : ''}`)
},
modFilterHits: (params = {}) => {
const qs = new URLSearchParams()
if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset)
const s = qs.toString()
return req(`/admin/moderation/filter-hits${s ? `?${s}` : ''}`)
},
modSpamHits: (params = {}) => {
const qs = new URLSearchParams()
if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset)
const s = qs.toString()
return req(`/admin/moderation/spam-hits${s ? `?${s}` : ''}`)
},
modUser: (discordId) => req(`/admin/moderation/user/${discordId}`),
modUserActions: (discordId, params = {}) => {
const qs = new URLSearchParams()
if (params.type) qs.set('type', params.type)
if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset)
const s = qs.toString()
return req(`/admin/moderation/user/${discordId}/actions${s ? `?${s}` : ''}`)
},
modUserNotes: (discordId) => req(`/admin/moderation/user/${discordId}/notes`),
addModNote: (discordId, data) =>
req(`/admin/moderation/user/${discordId}/notes`, { method: 'POST', body: data }),
// ----- account security (self-service 2FA) -----
getAccount: () => req('/admin/account'),
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
totpEnable: (code) => req('/admin/account/totp/enable', { method: 'POST', body: { code } }),
totpDisable: (code) => req('/admin/account/totp/disable', { method: 'POST', body: { code } }),
// ----- linked SSO identities (self-service) -----
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)}`),
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
sales: () => req('/admin/shard/sales'),
houses: () => req('/admin/shard/houses'), // full registry (admin/moderator)
createAccount: (account, password) =>
req('/admin/shard/account', { method: 'POST', body: { account, password } }),
},
// ----- auth providers / SSO config (admin only) -----
listAuthProviders: () => req('/admin/auth/providers'),
createAuthProvider: (data) => req('/admin/auth/providers', { method: 'POST', body: data }),
updateAuthProvider: (id, data) => req(`/admin/auth/providers/${id}`, { method: 'PUT', body: data }),
deleteAuthProvider: (id) => req(`/admin/auth/providers/${id}`, { method: 'DELETE' }),
// ----- Discord bot control (admin only) -----
getDiscordBotConfig: () => req('/admin/discord-bot/config'),
saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }),
// ----- uo-link sidecar control (admin only) -----
getUoLinkConfig: () => req('/admin/uo-link/config'),
saveUoLinkConfig: (data) => req('/admin/uo-link/config', { method: 'PUT', body: data }),
postTownCrier: (data) => req('/admin/uo-link/towncrier', { method: 'POST', body: data }),
deleteTownCrier: (id) => req(`/admin/uo-link/towncrier/${encodeURIComponent(id)}`, { method: 'DELETE' }),
// ----- in-game staff operations: write plane + support queue (admin/moderator) -----
// `actor` is stamped server-side from the session — never sent from here.
shardOps: {
kick: (data) => req('/admin/shard/kick', { method: 'POST', body: data }),
ban: (data) => req('/admin/shard/ban', { method: 'POST', body: data }),
unban: (account) => req('/admin/shard/unban', { method: 'POST', body: { account } }),
broadcast: (data) => req('/admin/shard/broadcast', { method: 'POST', body: data }),
pages: () => req('/admin/shard/pages'),
respondPage: (id, data) =>
req(`/admin/shard/pages/${encodeURIComponent(id)}/respond`, { method: 'POST', body: data }),
closePage: (id) => req(`/admin/shard/pages/${encodeURIComponent(id)}/close`, { method: 'POST' }),
audit: (limit) => req(`/admin/shard/audit${limit ? `?limit=${limit}` : ''}`),
},
// ----- Email delivery / Gmail OAuth2 (admin only) -----
getEmailConfig: () => req('/admin/email/config'),
saveEmailConfig: (data) => req('/admin/email/config', { method: 'PUT', body: data }),
emailConnectUrl: () => req('/admin/email/connect/start'),
testEmail: (to) => req('/admin/email/test', { method: 'POST', body: { to } }),
disconnectEmail: () => req('/admin/email/disconnect', { method: 'POST' }),
},
// ----- player self-service (role: 'player') -----
// Mirrors the admin account methods but self-scoped under /player. The change
// endpoints re-issue the session cookie server-side, so the caller stays signed in.
player: {
getAccount: () => req('/player/account'),
changeUsername: (username) =>
req('/player/account/username', { method: 'PATCH', body: { username } }),
changePassword: (newPassword, currentPassword) =>
req('/player/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }),
totpSetup: () => req('/player/account/totp/setup', { method: 'POST' }),
totpEnable: (code) => req('/player/account/totp/enable', { method: 'POST', body: { code } }),
totpDisable: (code) => req('/player/account/totp/disable', { method: 'POST', body: { code } }),
linkedIdentities: () => req('/player/account/identities'),
unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }),
// ----- game account linking (uo-link) -----
shard: {
link: (code) => req('/player/shard/link', { method: 'POST', body: { code } }),
accounts: () => req('/player/shard/accounts'),
roster: (account) => req(`/player/shard/roster/${encodeURIComponent(account)}`),
vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`),
char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`),
sales: () => req('/player/shard/sales'),
houses: () => req('/player/shard/houses'), // the caller's own houses
createAccount: (account, password) =>
req('/player/shard/account', { method: 'POST', body: { account, password } }),
},
},
}
export { ApiError }