feat(shard): Protocol 2.0 cross-links — titles, guild, governor, houses
Phase 3: surface the new board data on existing character/user pages. - Character sheet: render the char.profile titles block (fame/karma + skill + selected reward title; numeric clilocs skipped since the site has no cliloc table yet), plus "Guildmaster" and "Governor of <city>" chips. - Char profile enrichment (player/admin /shard/char/:serial, one shared path): attach guild + governorOf from our own boards. Guild is LEADERSHIP-ONLY — it's verifiable from current board state, whereas guessing membership from stale guild.join events risks showing a wrong guild, so we return null instead. - Admin user detail (/admin/users/:id): new "Standing" section (governorships held + guilds led) via GET /users/:id/shard/standing; Houses rows now show the registry fields (decay level, placement price, co-owner/friend counts) already returned by listHousesForAccounts. Server 179/179, client build clean, swagger regenerated. Refs .plans/protocol2-integration.md (Phase 3). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -183,6 +183,7 @@ export const api = {
|
|||||||
sales: () => req(`/admin/users/${id}/shard/sales`),
|
sales: () => req(`/admin/users/${id}/shard/sales`),
|
||||||
houses: () => req(`/admin/users/${id}/shard/houses`),
|
houses: () => req(`/admin/users/${id}/shard/houses`),
|
||||||
online: () => req(`/admin/users/${id}/shard/online`),
|
online: () => req(`/admin/users/${id}/shard/online`),
|
||||||
|
standing: () => req(`/admin/users/${id}/shard/standing`),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// ----- moderation dashboard (admin + moderator) -----
|
// ----- moderation dashboard (admin + moderator) -----
|
||||||
|
|||||||
@@ -10,6 +10,38 @@ import ShardAccountActions from './ShardAccountActions.jsx'
|
|||||||
|
|
||||||
const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
|
const RESIST_LABELS = { phys: 'Physical', fire: 'Fire', cold: 'Cold', pois: 'Poison', energy: 'Energy' }
|
||||||
|
|
||||||
|
// The char.profile `titles` block (Protocol 2.0). fameKarma/skill are already
|
||||||
|
// computed display strings; reward entries may be a cliloc NUMBER-as-string or a
|
||||||
|
// literal string. Without a cliloc table on the site we can only show literals, so
|
||||||
|
// numeric reward entries are skipped rather than shown as a raw number. Returns a
|
||||||
|
// de-duped list of human-readable title chips.
|
||||||
|
function displayTitles(titles) {
|
||||||
|
if (!titles) return []
|
||||||
|
const out = []
|
||||||
|
if (titles.fameKarma) out.push(titles.fameKarma)
|
||||||
|
if (titles.skill) out.push(titles.skill)
|
||||||
|
const reward = Array.isArray(titles.reward) ? titles.reward : []
|
||||||
|
const sel = typeof titles.selected === 'number' ? titles.selected : -1
|
||||||
|
// Prefer the selected reward title; fall back to the first literal one.
|
||||||
|
const candidate = sel >= 0 && sel < reward.length ? reward[sel] : reward.find((r) => r && !/^\d+$/.test(String(r)))
|
||||||
|
if (candidate && !/^\d+$/.test(String(candidate))) out.push(String(candidate))
|
||||||
|
return [...new Set(out.filter(Boolean))]
|
||||||
|
}
|
||||||
|
|
||||||
|
function TitleChip({ children, tone = 'var(--muted)' }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="sans"
|
||||||
|
style={{
|
||||||
|
fontSize: '0.72rem', padding: '3px 9px', borderRadius: 999,
|
||||||
|
border: `1px solid ${tone}55`, color: tone, whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function StatTile({ value, label }) {
|
function StatTile({ value, label }) {
|
||||||
return (
|
return (
|
||||||
<div className="panel" style={{ padding: '14px 12px', textAlign: 'center' }}>
|
<div className="panel" style={{ padding: '14px 12px', textAlign: 'center' }}>
|
||||||
@@ -64,6 +96,21 @@ export default function CharacterSheet({ char, moderation = false }) {
|
|||||||
<span className="sans dim" style={{ fontSize: '0.76rem', marginLeft: 'auto' }}>{char.serial}</span>
|
<span className="sans dim" style={{ fontSize: '0.76rem', marginLeft: 'auto' }}>{char.serial}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Titles + standing (guild led / governorship) — all optional */}
|
||||||
|
{(displayTitles(char.titles).length > 0 || char.guild || (char.governorOf && char.governorOf.length > 0)) && (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: -8 }}>
|
||||||
|
{char.governorOf && char.governorOf.map((city) => (
|
||||||
|
<TitleChip key={`gov-${city}`} tone="#c9a24b">Governor of {city}</TitleChip>
|
||||||
|
))}
|
||||||
|
{char.guild && (
|
||||||
|
<TitleChip tone="var(--accent)">
|
||||||
|
Guildmaster{char.guild.abbr ? `, [${char.guild.abbr}]` : ''} {char.guild.name}
|
||||||
|
</TitleChip>
|
||||||
|
)}
|
||||||
|
{displayTitles(char.titles).map((t) => <TitleChip key={t}>{t}</TitleChip>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Staff moderation for this character's account (self-gates to staff). */}
|
{/* Staff moderation for this character's account (self-gates to staff). */}
|
||||||
{moderation && char.acct && (
|
{moderation && char.acct && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: '12px 14px', border: '1px solid var(--line-soft)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: '12px 14px', border: '1px solid var(--line-soft)', borderRadius: 10, background: 'rgba(255,255,255,0.02)' }}>
|
||||||
|
|||||||
@@ -57,6 +57,33 @@ function OnlineNow({ scope }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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).
|
// Houses owned by the user's accounts, IDOC first (flagged).
|
||||||
function Houses({ scope }) {
|
function Houses({ scope }) {
|
||||||
const { data } = useAsync(() => scope.houses(), [scope])
|
const { data } = useAsync(() => scope.houses(), [scope])
|
||||||
@@ -82,10 +109,12 @@ function Houses({ scope }) {
|
|||||||
{h.region || (h.map != null ? `map ${h.map}` : 'unknown')}
|
{h.region || (h.map != null ? `map ${h.map}` : 'unknown')}
|
||||||
{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
|
{h.x != null ? ` · ${h.x}, ${h.y}` : ''}
|
||||||
{h.ownerAcct ? ` · ${h.ownerAcct}` : ''}
|
{h.ownerAcct ? ` · ${h.ownerAcct}` : ''}
|
||||||
|
{(h.coOwners || h.friends) ? ` · ${h.coOwners || 0} co-owners, ${h.friends || 0} friends` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}>
|
<div className="sans dim" style={{ flex: 'none', fontSize: '0.78rem', textAlign: 'right' }}>
|
||||||
{h.stage ? <div style={{ color: h.isIdoc ? '#e0928a' : 'var(--muted)' }}>{h.stage}</div> : null}
|
{(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}
|
{h.lastRefreshed ? <div>refreshed {ago(h.lastRefreshed)}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -102,6 +131,7 @@ function ShardSections({ scope }) {
|
|||||||
<CharacterStats scope={scope} />
|
<CharacterStats scope={scope} />
|
||||||
<SectionTitle>Linked accounts & characters</SectionTitle>
|
<SectionTitle>Linked accounts & characters</SectionTitle>
|
||||||
<GameAccounts scope={scope} readOnly moderation charTo={(serial) => `/admin/characters/${serial}`} />
|
<GameAccounts scope={scope} readOnly moderation charTo={(serial) => `/admin/characters/${serial}`} />
|
||||||
|
<Standing scope={scope} />
|
||||||
<OnlineNow scope={scope} />
|
<OnlineNow scope={scope} />
|
||||||
<Houses scope={scope} />
|
<Houses scope={scope} />
|
||||||
<VendorSales fetchSales={scope.sales} />
|
<VendorSales fetchSales={scope.sales} />
|
||||||
|
|||||||
@@ -189,6 +189,28 @@ const removeGuild = (id) => query('DELETE FROM shard_guilds WHERE id = ?', [id])
|
|||||||
const clearGuilds = () => query('DELETE FROM shard_guilds')
|
const clearGuilds = () => query('DELETE FROM shard_guilds')
|
||||||
const listGuilds = () => query(`SELECT ${GUILD_COLS} FROM shard_guilds ORDER BY name ASC`)
|
const listGuilds = () => query(`SELECT ${GUILD_COLS} FROM shard_guilds ORDER BY name ASC`)
|
||||||
|
|
||||||
|
// The guild an actor LEADS — matched on the current board (leader_serial or the
|
||||||
|
// linked leader_acct), so it reflects live state. Guild MEMBERSHIP for non-leaders
|
||||||
|
// is not modelled (the board carries only counts + leader), so we don't guess it.
|
||||||
|
const findGuildLedByActor = (serial, acct) =>
|
||||||
|
query(
|
||||||
|
`SELECT id, name, abbr, alliance, leader_name FROM shard_guilds
|
||||||
|
WHERE leader_serial = ? OR (leader_acct IS NOT NULL AND leader_acct = ?)
|
||||||
|
LIMIT 1`,
|
||||||
|
[serial ?? null, acct ?? null],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Guilds led by any of the given game accounts (admin: a user's linked accounts).
|
||||||
|
const listGuildsLedByAccounts = (accounts) =>
|
||||||
|
accounts.length === 0
|
||||||
|
? Promise.resolve([])
|
||||||
|
: query(
|
||||||
|
`SELECT id, name, abbr, alliance, leader_name FROM shard_guilds
|
||||||
|
WHERE leader_acct IN (${accounts.map(() => '?').join(', ')})
|
||||||
|
ORDER BY name ASC`,
|
||||||
|
accounts,
|
||||||
|
)
|
||||||
|
|
||||||
// ── Governor board + term history (Protocol 2.0) ───────────────────────────
|
// ── Governor board + term history (Protocol 2.0) ───────────────────────────
|
||||||
const GOV_COLS =
|
const GOV_COLS =
|
||||||
'city, governor_serial, governor_name, governor_acct, governor_web_id, elect_serial, elect_name, elect_acct, election_phase, candidates, auto_pick_at, payload, t, updated_at'
|
'city, governor_serial, governor_name, governor_acct, governor_web_id, elect_serial, elect_name, elect_acct, election_phase, candidates, auto_pick_at, payload, t, updated_at'
|
||||||
@@ -286,6 +308,8 @@ module.exports = {
|
|||||||
removeGuild,
|
removeGuild,
|
||||||
clearGuilds,
|
clearGuilds,
|
||||||
listGuilds,
|
listGuilds,
|
||||||
|
findGuildLedByActor,
|
||||||
|
listGuildsLedByAccounts,
|
||||||
upsertGovernor,
|
upsertGovernor,
|
||||||
listGovernors,
|
listGovernors,
|
||||||
listGovernorshipsByAccounts,
|
listGovernorshipsByAccounts,
|
||||||
|
|||||||
@@ -382,6 +382,22 @@ async function replaceGuilds(guilds) {
|
|||||||
for (const ev of guilds || []) await upsertGuild(ev)
|
for (const ev of guilds || []) await upsertGuild(ev)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The guild an actor leads (cross-link on the character sheet). Leadership only —
|
||||||
|
// see the db note; membership for rank-and-file isn't in the feed, so we return
|
||||||
|
// null rather than show a possibly-stale guess.
|
||||||
|
async function findGuildForActor({ serial, acct }) {
|
||||||
|
const rows = await db.findGuildLedByActor(serial ?? null, acct ?? null)
|
||||||
|
const g = rows[0]
|
||||||
|
if (!g) return null
|
||||||
|
return { id: g.id, name: g.name, abbr: g.abbr, alliance: g.alliance, role: 'leader' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guilds led by any of a user's linked accounts (admin user-detail cross-link).
|
||||||
|
async function listGuildsLedForAccounts(accounts) {
|
||||||
|
const rows = await db.listGuildsLedByAccounts(accounts)
|
||||||
|
return rows.map((g) => ({ id: g.id, name: g.name, abbr: g.abbr, alliance: g.alliance, leaderName: g.leader_name }))
|
||||||
|
}
|
||||||
|
|
||||||
// ── Town governors (Protocol 2.0) ──────────────────────────────────────────
|
// ── Town governors (Protocol 2.0) ──────────────────────────────────────────
|
||||||
// Upsert a city's governance snapshot (city.update) AND capture term history.
|
// Upsert a city's governance snapshot (city.update) AND capture term history.
|
||||||
// Term capture runs first (it reads the CURRENT open term to decide whether the
|
// Term capture runs first (it reads the CURRENT open term to decide whether the
|
||||||
@@ -543,6 +559,8 @@ module.exports = {
|
|||||||
clearGuilds,
|
clearGuilds,
|
||||||
listGuilds,
|
listGuilds,
|
||||||
replaceGuilds,
|
replaceGuilds,
|
||||||
|
findGuildForActor,
|
||||||
|
listGuildsLedForAccounts,
|
||||||
upsertGovernor,
|
upsertGovernor,
|
||||||
listGovernors,
|
listGovernors,
|
||||||
listGovernorshipsForAccounts,
|
listGovernorshipsForAccounts,
|
||||||
|
|||||||
@@ -1255,6 +1255,18 @@ adminRouter.get(
|
|||||||
validate,
|
validate,
|
||||||
usersShard.getOnline,
|
usersShard.getOnline,
|
||||||
)
|
)
|
||||||
|
adminRouter.get(
|
||||||
|
'/users/:id/shard/standing',
|
||||||
|
// #swagger.tags = ['Admin · Users']
|
||||||
|
// #swagger.summary = 'A user’s shard standing — governorships held and guilds led (admin only)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Standing { governorOf, guildsLed }', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('id').isInt(),
|
||||||
|
validate,
|
||||||
|
usersShard.getStanding,
|
||||||
|
)
|
||||||
|
|
||||||
// ── uo-link sidecar control (admin only) ──────────────────────────────────
|
// ── uo-link sidecar control (admin only) ──────────────────────────────────
|
||||||
// Connection config (base/ws URL + token + protocol + enabled) and the town
|
// Connection config (base/ws URL + token + protocol + enabled) and the town
|
||||||
|
|||||||
@@ -83,4 +83,22 @@ async function getOnline(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getUser, listAccounts, getSales, getHouses, getOnline }
|
// GET /admin/users/:id/shard/standing — the user's shard "standing" cross-links:
|
||||||
|
// city governorships they currently hold and guilds they lead. Both are reliable
|
||||||
|
// current-state lookups on the user's linked accounts.
|
||||||
|
async function getStanding(req, res) {
|
||||||
|
try {
|
||||||
|
const ctx = await accountsForUser(Number(req.params.id))
|
||||||
|
if (!ctx) return res.status(404).json({ message: 'Not found' })
|
||||||
|
const [governorOf, guildsLed] = await Promise.all([
|
||||||
|
shardState.listGovernorshipsForAccounts(ctx.accounts),
|
||||||
|
shardState.listGuildsLedForAccounts(ctx.accounts),
|
||||||
|
])
|
||||||
|
return res.json({ governorOf, guildsLed })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('getStanding', err)
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getUser, listAccounts, getSales, getHouses, getOnline, getStanding }
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
const uoLinkClient = require('../../../utils/uoLinkClient')
|
const uoLinkClient = require('../../../utils/uoLinkClient')
|
||||||
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
|
const shardLinks = require('../../../model/shardLinks/shardLinks.model')
|
||||||
|
const shardState = require('../../../model/shardState/shardState.model')
|
||||||
const { salesForAccounts } = require('../../../utils/shardSales')
|
const { salesForAccounts } = require('../../../utils/shardSales')
|
||||||
const activity = require('../../../model/activity/activity.model')
|
const activity = require('../../../model/activity/activity.model')
|
||||||
|
|
||||||
@@ -16,6 +17,24 @@ const log = require('../../../utils/logger')('player-shard')
|
|||||||
|
|
||||||
const SERIAL_RE = /^0x[0-9a-fA-F]+$/
|
const SERIAL_RE = /^0x[0-9a-fA-F]+$/
|
||||||
|
|
||||||
|
// Decorate a char.profile with cross-links from our own board data: the guild the
|
||||||
|
// character leads and any city governorship on its account. Best-effort — a
|
||||||
|
// failure here never fails the profile (it's a nicety, not the sheet).
|
||||||
|
async function enrichCharProfile(profile) {
|
||||||
|
if (!profile) return profile
|
||||||
|
try {
|
||||||
|
const guild = await shardState.findGuildForActor({ serial: profile.serial, acct: profile.acct })
|
||||||
|
if (guild) profile.guild = guild
|
||||||
|
if (profile.acct) {
|
||||||
|
const govs = await shardState.listGovernorshipsForAccounts([profile.acct])
|
||||||
|
if (govs.length) profile.governorOf = govs.map((g) => g.city)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('enrichCharProfile failed', { serial: profile.serial, message: err.message })
|
||||||
|
}
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
// POST /player/shard/link — confirm an in-game link code.
|
// POST /player/shard/link — confirm an in-game link code.
|
||||||
async function link(req, res) {
|
async function link(req, res) {
|
||||||
const { code } = req.body
|
const { code } = req.body
|
||||||
@@ -102,7 +121,7 @@ async function getChar(req, res) {
|
|||||||
const owns = acct ? await shardLinks.ownsAccount(acct, req.user.id) : false
|
const owns = acct ? await shardLinks.ownsAccount(acct, req.user.id) : false
|
||||||
if (!owns) return res.status(403).json({ message: 'That character is not on an account linked to you.' })
|
if (!owns) return res.status(403).json({ message: 'That character is not on an account linked to you.' })
|
||||||
}
|
}
|
||||||
return res.json(result.data)
|
return res.json(await enrichCharProfile(result.data))
|
||||||
}
|
}
|
||||||
if (result.status === 404) return res.status(404).json({ message: 'Character not found.' })
|
if (result.status === 404) return res.status(404).json({ message: 'Character not found.' })
|
||||||
if (result.status === 503 || result.status === 0) {
|
if (result.status === 503 || result.status === 0) {
|
||||||
|
|||||||
@@ -7091,6 +7091,63 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/admin/users/{id}/shard/standing": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Admin · Users"
|
||||||
|
],
|
||||||
|
"summary": "A user’s shard standing — governorships held and guilds led (admin only)",
|
||||||
|
"description": "",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"description": "User id."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Standing { governorOf, guildsLed }",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/admin/uo-link/config": {
|
"/api/v1/admin/uo-link/config": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
Reference in New Issue
Block a user