refactor(server): split admin users, account, invites and auth providers into capability routers
First of the five domain-split PRs in docs/website/API_V2_PLAN.md § Phase 2. Pure
mechanical re-wiring: routes move between files, no handler, gate, validator or
annotation changes, and not one URL moves.
New src/router/v1/admin/index.js owns the two things the group shares — the
`noindex, isLoggedIn, staffOnly` gate and the mount table — and declares no routes
itself. The gate sits ahead of every mount so a capability router extracted in a
later PR cannot silently ship without it. Four capability routers mount at the
prefix they already owned inside the monolith:
account.router.js 6 routes -> /admin/account (self-service, no adminOnly)
users.router.js 15 routes -> /admin/users (adminOnly, router-level)
invites.router.js 3 routes -> /admin/invites (adminOnly, per-route)
authProviders.router.js 4 routes -> /admin/auth (adminOnly, per-route)
admin.routes.js keeps the other 82 (6+15+3+4+82 = the 110 inventoried admin
routes) and is mounted last at the group root; none of the four prefixes appears
in it, so nothing depends on mount ordering. It disappears when PR 5 lands.
Handlers still live in admin.controller.js and usersShard.controller.js — this
re-wires routes, not logic. `adminOnly` moves with the routes that use it, and
`usersRouter.use(adminOnly)` is exactly equivalent to the old
`adminRouter.use('/users', adminOnly)` now that the router is mounted at /users.
All three generated gates are zero-diff:
routes.manifest.json unchanged (200 public + 2 internal)
routes.guards.json unchanged — no route lost or gained a gate
swagger-output.json unchanged, byte-for-byte
The spec staying byte-identical depends on the path normalization landed in the
preceding commit; without it the four collection routes would have documented as
/api/v1/admin/{users,invites,account}/ with a trailing slash.
Server tests green (434/434).
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
87
server/src/router/v1/admin/account.router.js
Normal file
87
server/src/router/v1/admin/account.router.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
// Admin · Account — self-service account security for staff.
|
||||||
|
//
|
||||||
|
// Mounted at /api/v1/admin/account by admin/index.js, which already applied
|
||||||
|
// `noindex, isLoggedIn, staffOnly`. Deliberately NOT behind adminOnly: an editor
|
||||||
|
// or moderator manages their own 2FA and linked identities here, exactly as a
|
||||||
|
// player does under /player. Every handler keys off req.user.id.
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
const { body, param } = require('express-validator')
|
||||||
|
|
||||||
|
const account = require('./account.controller')
|
||||||
|
const validate = require('../../../middleware/validate')
|
||||||
|
|
||||||
|
const accountRouter = express.Router()
|
||||||
|
|
||||||
|
accountRouter.get(
|
||||||
|
'/',
|
||||||
|
// #swagger.tags = ['Admin · Account']
|
||||||
|
// #swagger.summary = 'Get the current account (self)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'The account', content: { "application/json": { schema: { $ref: "#/components/schemas/AccountStatus" } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
account.getAccount,
|
||||||
|
)
|
||||||
|
accountRouter.post(
|
||||||
|
'/totp/setup',
|
||||||
|
// #swagger.tags = ['Admin · Account']
|
||||||
|
// #swagger.summary = 'Begin 2FA enrollment (returns secret + QR)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'otpauth URL and QR data to scan', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpSetup" } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
account.totpSetup,
|
||||||
|
)
|
||||||
|
accountRouter.post(
|
||||||
|
'/totp/enable',
|
||||||
|
// #swagger.tags = ['Admin · Account']
|
||||||
|
// #swagger.summary = 'Enable 2FA by confirming a code'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: '2FA enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Setup not started, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
body('code').isString().trim().isLength({ min: 6, max: 8 }),
|
||||||
|
validate,
|
||||||
|
account.totpEnable,
|
||||||
|
)
|
||||||
|
accountRouter.post(
|
||||||
|
'/totp/disable',
|
||||||
|
// #swagger.tags = ['Admin · Account']
|
||||||
|
// #swagger.summary = 'Disable 2FA by confirming a code'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: '2FA disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Not enabled, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
body('code').isString().trim().isLength({ min: 6, max: 8 }),
|
||||||
|
validate,
|
||||||
|
account.totpDisable,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Linked SSO identities (self-service — any logged-in role manages their own).
|
||||||
|
accountRouter.get(
|
||||||
|
'/identities',
|
||||||
|
// #swagger.tags = ['Admin · Account']
|
||||||
|
// #swagger.summary = 'List linked SSO identities (self)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Linked identities', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/LinkedIdentity" } } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
account.listIdentities,
|
||||||
|
)
|
||||||
|
accountRouter.delete(
|
||||||
|
'/identities/:provider',
|
||||||
|
// #swagger.tags = ['Admin · Account']
|
||||||
|
// #swagger.summary = 'Unlink an SSO identity (self)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { $ref: "#/components/schemas/UnlinkedFlag" } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'No linked account for that provider', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('provider').matches(/^[a-z0-9-]+$/),
|
||||||
|
validate,
|
||||||
|
account.unlinkIdentity,
|
||||||
|
)
|
||||||
|
|
||||||
|
module.exports = accountRouter
|
||||||
@@ -1,3 +1,14 @@
|
|||||||
|
// Residual /admin routes — the capabilities not yet carved into their own
|
||||||
|
// router file (docs/website/API_V2_PLAN.md § Phase 2). Mounted at the root of
|
||||||
|
// /api/v1/admin by admin/index.js, *after* the extracted capability routers and
|
||||||
|
// behind the shared `noindex, isLoggedIn, staffOnly` gate it owns, so the URLs
|
||||||
|
// here are unchanged from when this file held all 110 admin routes.
|
||||||
|
//
|
||||||
|
// Already extracted: users, account, invites, auth/providers.
|
||||||
|
// Still here: shard, dashboard, site-mode, posts, uploads, wiki, pages,
|
||||||
|
// settings, activity, bot-activity, discord-bot, email, moderation, uo-link.
|
||||||
|
// This file disappears when the last group moves.
|
||||||
|
|
||||||
const express = require('express')
|
const express = require('express')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
@@ -6,32 +17,19 @@ const multer = require('multer')
|
|||||||
const { body, param } = require('express-validator')
|
const { body, param } = require('express-validator')
|
||||||
|
|
||||||
const ctrl = require('./admin.controller')
|
const ctrl = require('./admin.controller')
|
||||||
const account = require('./account.controller')
|
|
||||||
const botActivity = require('./botActivity.controller')
|
const botActivity = require('./botActivity.controller')
|
||||||
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 shardOps = require('./shardOps.controller')
|
const shardOps = require('./shardOps.controller')
|
||||||
const usersShard = require('./usersShard.controller')
|
|
||||||
const invites = require('./invites.controller')
|
|
||||||
const selfShard = require('../player/shard.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 { requireRole } = require('../../../utils/auth')
|
||||||
const noindex = require('../../../middleware/noindex')
|
|
||||||
const validate = require('../../../middleware/validate')
|
const validate = require('../../../middleware/validate')
|
||||||
|
|
||||||
const adminRouter = express.Router()
|
const adminRouter = express.Router()
|
||||||
|
|
||||||
// Every admin route requires auth, a STAFF role, and is kept out of search
|
|
||||||
// indexes. The staff gate matters now that `player` is a logged-in-but-untrusted
|
|
||||||
// role: without it, the editor-tier routes below (dashboard, posts, wiki,
|
|
||||||
// uploads) that are only guarded by isLoggedIn would be reachable by players.
|
|
||||||
// Players get 403 here and use the self-scoped /player group instead.
|
|
||||||
const staffOnly = requireRole('admin', 'editor', 'moderator')
|
|
||||||
adminRouter.use(noindex, isLoggedIn, staffOnly)
|
|
||||||
|
|
||||||
// Admin-only gate. Editors may manage content (posts/wiki), but user
|
// Admin-only gate. Editors may manage content (posts/wiki), but user
|
||||||
// management, site mode, and settings are restricted to the admin role.
|
// management, site mode, and settings are restricted to the admin role.
|
||||||
const adminOnly = requireRole('admin')
|
const adminOnly = requireRole('admin')
|
||||||
@@ -41,79 +39,6 @@ const adminOnly = requireRole('admin')
|
|||||||
// admin check inside the controller.
|
// admin check inside the controller.
|
||||||
const modAccess = requireRole('admin', 'moderator')
|
const modAccess = requireRole('admin', 'moderator')
|
||||||
|
|
||||||
// ── Account security (self-service, any logged-in role) ───────────────
|
|
||||||
// Not behind adminOnly: an editor manages their own 2FA too.
|
|
||||||
adminRouter.get(
|
|
||||||
'/account',
|
|
||||||
// #swagger.tags = ['Admin · Account']
|
|
||||||
// #swagger.summary = 'Get the current account (self)'
|
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
||||||
/* #swagger.responses[200] = { description: 'The account', content: { "application/json": { schema: { $ref: "#/components/schemas/AccountStatus" } } } } */
|
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
account.getAccount,
|
|
||||||
)
|
|
||||||
adminRouter.post(
|
|
||||||
'/account/totp/setup',
|
|
||||||
// #swagger.tags = ['Admin · Account']
|
|
||||||
// #swagger.summary = 'Begin 2FA enrollment (returns secret + QR)'
|
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
||||||
/* #swagger.responses[200] = { description: 'otpauth URL and QR data to scan', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpSetup" } } } } */
|
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
account.totpSetup,
|
|
||||||
)
|
|
||||||
adminRouter.post(
|
|
||||||
'/account/totp/enable',
|
|
||||||
// #swagger.tags = ['Admin · Account']
|
|
||||||
// #swagger.summary = 'Enable 2FA by confirming a code'
|
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
||||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */
|
|
||||||
/* #swagger.responses[200] = { description: '2FA enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */
|
|
||||||
/* #swagger.responses[400] = { description: 'Setup not started, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
body('code').isString().trim().isLength({ min: 6, max: 8 }),
|
|
||||||
validate,
|
|
||||||
account.totpEnable,
|
|
||||||
)
|
|
||||||
adminRouter.post(
|
|
||||||
'/account/totp/disable',
|
|
||||||
// #swagger.tags = ['Admin · Account']
|
|
||||||
// #swagger.summary = 'Disable 2FA by confirming a code'
|
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
||||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/TotpCodeRequest" } } } } */
|
|
||||||
/* #swagger.responses[200] = { description: '2FA disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/TotpState" } } } } */
|
|
||||||
/* #swagger.responses[400] = { description: 'Not enabled, or invalid code', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
body('code').isString().trim().isLength({ min: 6, max: 8 }),
|
|
||||||
validate,
|
|
||||||
account.totpDisable,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Linked SSO identities (self-service — any logged-in role manages their own).
|
|
||||||
adminRouter.get(
|
|
||||||
'/account/identities',
|
|
||||||
// #swagger.tags = ['Admin · Account']
|
|
||||||
// #swagger.summary = 'List linked SSO identities (self)'
|
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
||||||
/* #swagger.responses[200] = { description: 'Linked identities', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/LinkedIdentity" } } } } } */
|
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
account.listIdentities,
|
|
||||||
)
|
|
||||||
adminRouter.delete(
|
|
||||||
'/account/identities/:provider',
|
|
||||||
// #swagger.tags = ['Admin · Account']
|
|
||||||
// #swagger.summary = 'Unlink an SSO identity (self)'
|
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
||||||
// #swagger.parameters['provider'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
|
|
||||||
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { $ref: "#/components/schemas/UnlinkedFlag" } } } } */
|
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[404] = { description: 'No linked account for that provider', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
param('provider').matches(/^[a-z0-9-]+$/),
|
|
||||||
validate,
|
|
||||||
account.unlinkIdentity,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Game account linking (self-service, any staff role) ───────────────
|
// ── Game account linking (self-service, any staff role) ───────────────
|
||||||
// Staff link their OWN in-game account here, exactly like players do under
|
// 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.
|
// /player/shard. The controller keys off req.user.id, so the same handlers work.
|
||||||
@@ -979,89 +904,6 @@ adminRouter.post(
|
|||||||
emailConfig.disconnect,
|
emailConfig.disconnect,
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Authentication providers / SSO (admin only) ───────────────────────
|
|
||||||
adminRouter.get(
|
|
||||||
'/auth/providers',
|
|
||||||
// #swagger.tags = ['Admin · Auth Providers']
|
|
||||||
// #swagger.summary = 'List configured SSO providers (admin only)'
|
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
||||||
/* #swagger.responses[200] = { description: 'Providers (secrets stripped)', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ProviderConfig" } } } } } */
|
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
adminOnly,
|
|
||||||
authProviders.list,
|
|
||||||
)
|
|
||||||
adminRouter.post(
|
|
||||||
'/auth/providers',
|
|
||||||
// #swagger.tags = ['Admin · Auth Providers']
|
|
||||||
// #swagger.summary = 'Create a custom SSO provider (admin only)'
|
|
||||||
// #swagger.description = 'Built-in providers (google, discord) are configured via PUT, not created here.'
|
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
||||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderCreateRequest" } } } } */
|
|
||||||
/* #swagger.responses[201] = { description: 'Created provider', content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderConfig" } } } } */
|
|
||||||
/* #swagger.responses[400] = { description: 'Validation error, or a built-in/invalid kind', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[409] = { description: 'Provider id already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
adminOnly,
|
|
||||||
body('id').matches(/^[a-z0-9-]+$/),
|
|
||||||
body('kind').isIn(['oidc', 'oauth2']),
|
|
||||||
body('name').isString().trim().notEmpty().isLength({ max: 80 }),
|
|
||||||
body('enabled').optional().isBoolean(),
|
|
||||||
body('clientId').optional({ values: 'falsy' }).isString(),
|
|
||||||
body('secret').optional({ values: 'falsy' }).isString(),
|
|
||||||
body('authorizeUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
|
||||||
body('tokenUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
|
||||||
body('userinfoUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
|
||||||
body('scopes').optional({ values: 'falsy' }).isString().isLength({ max: 500 }),
|
|
||||||
body('priority').optional().isInt(),
|
|
||||||
validate,
|
|
||||||
authProviders.create,
|
|
||||||
)
|
|
||||||
adminRouter.put(
|
|
||||||
'/auth/providers/:id',
|
|
||||||
// #swagger.tags = ['Admin · Auth Providers']
|
|
||||||
// #swagger.summary = 'Update an SSO provider (admin only)'
|
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
||||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
|
|
||||||
/* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderCreateRequest" } } } } */
|
|
||||||
/* #swagger.responses[200] = { description: 'Updated provider', content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderConfig" } } } } */
|
|
||||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[404] = { description: 'Provider not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
adminOnly,
|
|
||||||
param('id').matches(/^[a-z0-9-]+$/),
|
|
||||||
body('name').optional().isString().trim().notEmpty().isLength({ max: 80 }),
|
|
||||||
body('enabled').optional().isBoolean(),
|
|
||||||
body('clientId').optional({ values: 'falsy' }).isString(),
|
|
||||||
body('secret').optional({ values: 'falsy' }).isString(),
|
|
||||||
body('authorizeUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
|
||||||
body('tokenUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
|
||||||
body('userinfoUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
|
||||||
body('scopes').optional({ values: 'falsy' }).isString().isLength({ max: 500 }),
|
|
||||||
body('priority').optional().isInt(),
|
|
||||||
validate,
|
|
||||||
authProviders.update,
|
|
||||||
)
|
|
||||||
adminRouter.delete(
|
|
||||||
'/auth/providers/:id',
|
|
||||||
// #swagger.tags = ['Admin · Auth Providers']
|
|
||||||
// #swagger.summary = 'Delete a custom SSO provider (admin only)'
|
|
||||||
// #swagger.description = 'Built-in providers cannot be deleted — disable them instead.'
|
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
||||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
|
|
||||||
/* #swagger.responses[200] = { description: 'Deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedFlag" } } } } */
|
|
||||||
/* #swagger.responses[400] = { description: 'Built-in provider cannot be deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[404] = { description: 'Provider not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
adminOnly,
|
|
||||||
param('id').matches(/^[a-z0-9-]+$/),
|
|
||||||
validate,
|
|
||||||
authProviders.remove,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Moderation dashboard (admin + moderator) ──────────────────────────
|
// ── Moderation dashboard (admin + moderator) ──────────────────────────
|
||||||
// Read-only views over the bot's mod_actions log, plus staff notes. The whole
|
// Read-only views over the bot's mod_actions log, plus staff notes. The whole
|
||||||
// sub-path is gated for the moderator role (admins included).
|
// sub-path is gated for the moderator role (admins included).
|
||||||
@@ -1214,270 +1056,6 @@ adminRouter.get(
|
|||||||
moderation.getUserAppeals,
|
moderation.getUserAppeals,
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── User management (admin only) ──────────────────────────────────────
|
|
||||||
adminRouter.use('/users', adminOnly)
|
|
||||||
adminRouter.get(
|
|
||||||
'/users',
|
|
||||||
// #swagger.tags = ['Admin · Users']
|
|
||||||
// #swagger.summary = 'List users (admin only)'
|
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
||||||
/* #swagger.responses[200] = { description: 'Users', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/User" } } } } } */
|
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
ctrl.listUsers,
|
|
||||||
)
|
|
||||||
adminRouter.post(
|
|
||||||
'/users',
|
|
||||||
// #swagger.tags = ['Admin · Users']
|
|
||||||
// #swagger.summary = 'Create a user (admin only)'
|
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
||||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UserCreateRequest" } } } } */
|
|
||||||
/* #swagger.responses[201] = { description: 'Created user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */
|
|
||||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
body('username').isString().trim().isLength({ min: 3, max: 32 }),
|
|
||||||
body('password').isString().isLength({ min: 8, max: 64 }),
|
|
||||||
body('role').optional().isIn(['admin', 'editor', 'moderator', 'player']),
|
|
||||||
body('status').optional().isIn(['active', 'disabled', 'banned', 'pending']),
|
|
||||||
body('email').optional({ values: 'falsy' }).isEmail().isLength({ max: 255 }),
|
|
||||||
validate,
|
|
||||||
ctrl.createUser,
|
|
||||||
)
|
|
||||||
adminRouter.put(
|
|
||||||
'/users/:id',
|
|
||||||
// #swagger.tags = ['Admin · Users']
|
|
||||||
// #swagger.summary = 'Update a user (admin only)'
|
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
||||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
|
||||||
/* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/UserCreateRequest" } } } } */
|
|
||||||
/* #swagger.responses[200] = { description: 'Updated user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */
|
|
||||||
/* #swagger.responses[400] = { description: 'Validation error, or cannot demote the last admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
param('id').isInt(),
|
|
||||||
body('username').optional().isString().trim().isLength({ min: 3, max: 32 }),
|
|
||||||
body('password').optional().isString().isLength({ min: 8, max: 64 }),
|
|
||||||
body('role').optional().isIn(['admin', 'editor', 'moderator', 'player']),
|
|
||||||
body('status').optional().isIn(['active', 'disabled', 'banned', 'pending']),
|
|
||||||
body('email').optional({ values: 'null' }).isEmail().isLength({ max: 255 }),
|
|
||||||
validate,
|
|
||||||
ctrl.updateUser,
|
|
||||||
)
|
|
||||||
adminRouter.delete(
|
|
||||||
'/users/:id',
|
|
||||||
// #swagger.tags = ['Admin · Users']
|
|
||||||
// #swagger.summary = 'Delete a user (admin only)'
|
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
||||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
|
||||||
/* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */
|
|
||||||
/* #swagger.responses[400] = { description: 'Cannot delete your own account or the last admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
param('id').isInt(),
|
|
||||||
validate,
|
|
||||||
ctrl.deleteUser,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── A user's trusted devices & MFA (admin only) ───────────────────────
|
|
||||||
adminRouter.get(
|
|
||||||
'/users/:id/trusted-devices',
|
|
||||||
// #swagger.tags = ['Admin · Users']
|
|
||||||
// #swagger.summary = 'List a user’s trusted devices (admin only)'
|
|
||||||
// #swagger.description = 'Active (unrevoked, unexpired) trusted devices for the target user — the browsers/apps allowed to skip that user’s TOTP step. Never returns tokens.'
|
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
||||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
|
||||||
/* #swagger.responses[200] = { description: 'Trusted devices', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/TrustedDevice" } } } } } */
|
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
param('id').isInt(),
|
|
||||||
validate,
|
|
||||||
ctrl.listUserTrustedDevices,
|
|
||||||
)
|
|
||||||
adminRouter.delete(
|
|
||||||
'/users/:id/trusted-devices',
|
|
||||||
// #swagger.tags = ['Admin · Users']
|
|
||||||
// #swagger.summary = 'Revoke all of a user’s trusted devices (admin only)'
|
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
||||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
|
||||||
/* #swagger.responses[200] = { description: 'Revoked count', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "integer" } } } } } } */
|
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
param('id').isInt(),
|
|
||||||
validate,
|
|
||||||
ctrl.revokeAllUserTrustedDevices,
|
|
||||||
)
|
|
||||||
adminRouter.delete(
|
|
||||||
'/users/:id/trusted-devices/:deviceId',
|
|
||||||
// #swagger.tags = ['Admin · Users']
|
|
||||||
// #swagger.summary = 'Revoke one of a user’s trusted devices (admin only)'
|
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
||||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
|
||||||
// #swagger.parameters['deviceId'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Trusted-device id.' }
|
|
||||||
/* #swagger.responses[200] = { description: 'Revoked (idempotent)', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "boolean" } } } } } } */
|
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
param('id').isInt(),
|
|
||||||
param('deviceId').isInt({ min: 1 }),
|
|
||||||
validate,
|
|
||||||
ctrl.revokeUserTrustedDevice,
|
|
||||||
)
|
|
||||||
adminRouter.post(
|
|
||||||
'/users/:id/mfa/reset',
|
|
||||||
// #swagger.tags = ['Admin · Users']
|
|
||||||
// #swagger.summary = 'Reset a user’s MFA (admin only)'
|
|
||||||
// #swagger.description = 'Recovers a locked-out user: turns TOTP off, revokes every trusted device, and clears their recovery codes. The user can then sign in with their password alone and re-enroll.'
|
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
||||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
|
||||||
/* #swagger.responses[200] = { description: 'MFA reset', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
|
|
||||||
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
param('id').isInt(),
|
|
||||||
validate,
|
|
||||||
ctrl.resetUserMfa,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── User → shard (uo-link) footprint (admin only) ─────────────────────
|
|
||||||
// Backs the /admin/users/:id detail page: a user's linked game accounts and,
|
|
||||||
// scoped to those accounts, their vendor sales / houses / online characters.
|
|
||||||
// Live character rosters are fetched by the client through /admin/shard/* (which
|
|
||||||
// already grants admins a bypass to any account), so no routes for them here.
|
|
||||||
adminRouter.get(
|
|
||||||
'/users/:id',
|
|
||||||
// #swagger.tags = ['Admin · Users']
|
|
||||||
// #swagger.summary = 'Get a single user (admin only)'
|
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
||||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
|
||||||
/* #swagger.responses[200] = { description: 'The user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */
|
|
||||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
param('id').isInt(),
|
|
||||||
validate,
|
|
||||||
usersShard.getUser,
|
|
||||||
)
|
|
||||||
adminRouter.get(
|
|
||||||
'/users/:id/shard/accounts',
|
|
||||||
// #swagger.tags = ['Admin · Users']
|
|
||||||
// #swagger.summary = 'A user’s linked game accounts (admin only)'
|
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
||||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
|
||||||
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
|
|
||||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
param('id').isInt(),
|
|
||||||
validate,
|
|
||||||
usersShard.listAccounts,
|
|
||||||
)
|
|
||||||
adminRouter.get(
|
|
||||||
'/users/:id/shard/sales',
|
|
||||||
// #swagger.tags = ['Admin · Users']
|
|
||||||
// #swagger.summary = 'Recent vendor sales on a user’s accounts (admin only)'
|
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
||||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
|
||||||
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
|
|
||||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
param('id').isInt(),
|
|
||||||
validate,
|
|
||||||
usersShard.getSales,
|
|
||||||
)
|
|
||||||
adminRouter.get(
|
|
||||||
'/users/:id/shard/houses',
|
|
||||||
// #swagger.tags = ['Admin · Users']
|
|
||||||
// #swagger.summary = 'Houses owned by a user’s accounts (admin only)'
|
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
||||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
|
||||||
/* #swagger.responses[200] = { description: 'Houses (IDOC first)', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
|
||||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
param('id').isInt(),
|
|
||||||
validate,
|
|
||||||
usersShard.getHouses,
|
|
||||||
)
|
|
||||||
adminRouter.get(
|
|
||||||
'/users/:id/shard/online',
|
|
||||||
// #swagger.tags = ['Admin · Users']
|
|
||||||
// #swagger.summary = 'A user’s characters currently online (admin only)'
|
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
||||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
|
||||||
/* #swagger.responses[200] = { description: 'Online characters', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
|
||||||
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
param('id').isInt(),
|
|
||||||
validate,
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
adminRouter.delete(
|
|
||||||
'/users/:id/shard/link/:account',
|
|
||||||
// #swagger.tags = ['Admin · Users']
|
|
||||||
// #swagger.summary = 'Unlink a game account from this user (admin only)'
|
|
||||||
// #swagger.description = 'Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.'
|
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
||||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
|
||||||
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Game account to unlink.' }
|
|
||||||
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, unlinked: { type: "boolean" } } } } } } */
|
|
||||||
/* #swagger.responses[403] = { description: 'Protected staff account (refused by shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
/* #swagger.responses[404] = { description: 'Not linked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
adminOnly,
|
|
||||||
param('id').isInt(),
|
|
||||||
param('account').matches(SHARD_ACCOUNT_RE),
|
|
||||||
validate,
|
|
||||||
usersShard.unlinkAccount,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── Email invites (admin only) ─────────────────────────────────────────────
|
|
||||||
adminRouter.post(
|
|
||||||
'/invites',
|
|
||||||
// #swagger.tags = ['Admin · Invites']
|
|
||||||
// #swagger.summary = 'Create and email an account invite at a chosen access level'
|
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
||||||
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["email","role"], properties: { email: { type: "string" }, role: { type: "string" } } } } } */
|
|
||||||
/* #swagger.responses[201] = { description: 'Invite created', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
|
||||||
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
|
||||||
adminOnly,
|
|
||||||
body('email').isEmail().isLength({ max: 255 }),
|
|
||||||
body('role').isIn(['admin', 'editor', 'moderator', 'player']),
|
|
||||||
validate,
|
|
||||||
invites.create,
|
|
||||||
)
|
|
||||||
adminRouter.get(
|
|
||||||
'/invites',
|
|
||||||
// #swagger.tags = ['Admin · Invites']
|
|
||||||
// #swagger.summary = 'List recent invites (no tokens)'
|
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
||||||
/* #swagger.responses[200] = { description: 'Invites, newest first', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
|
||||||
adminOnly,
|
|
||||||
invites.list,
|
|
||||||
)
|
|
||||||
adminRouter.delete(
|
|
||||||
'/invites/:id',
|
|
||||||
// #swagger.tags = ['Admin · Invites']
|
|
||||||
// #swagger.summary = 'Revoke a pending invite'
|
|
||||||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
|
||||||
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Invite id.' }
|
|
||||||
/* #swagger.responses[200] = { description: 'Revoked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
|
||||||
/* #swagger.responses[404] = { description: 'No pending invite to revoke', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
|
||||||
adminOnly,
|
|
||||||
param('id').isInt(),
|
|
||||||
validate,
|
|
||||||
invites.revoke,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── 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
|
||||||
// crier. The token is write-only (SECURITY note in uoLink.controller.js).
|
// crier. The token is write-only (SECURITY note in uoLink.controller.js).
|
||||||
|
|||||||
102
server/src/router/v1/admin/authProviders.router.js
Normal file
102
server/src/router/v1/admin/authProviders.router.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
// Admin · Auth Providers — SSO/OAuth2/OIDC provider configuration.
|
||||||
|
//
|
||||||
|
// Mounted at /api/v1/admin/auth by admin/index.js, which already applied
|
||||||
|
// `noindex, isLoggedIn, staffOnly`; the routes below are /providers under that,
|
||||||
|
// so the emitted URLs stay /api/v1/admin/auth/providers[/:id].
|
||||||
|
//
|
||||||
|
// Admin-only: these rows carry client secrets (write-only, AES-GCM at rest via
|
||||||
|
// utils/secretBox.js) and decide which external identities may sign in at all.
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
const { body, param } = require('express-validator')
|
||||||
|
|
||||||
|
const authProviders = require('./authProviders.controller')
|
||||||
|
const { requireRole } = require('../../../utils/auth')
|
||||||
|
const validate = require('../../../middleware/validate')
|
||||||
|
|
||||||
|
const providersRouter = express.Router()
|
||||||
|
const adminOnly = requireRole('admin')
|
||||||
|
|
||||||
|
providersRouter.get(
|
||||||
|
'/providers',
|
||||||
|
// #swagger.tags = ['Admin · Auth Providers']
|
||||||
|
// #swagger.summary = 'List configured SSO providers (admin only)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Providers (secrets stripped)', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ProviderConfig" } } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
authProviders.list,
|
||||||
|
)
|
||||||
|
providersRouter.post(
|
||||||
|
'/providers',
|
||||||
|
// #swagger.tags = ['Admin · Auth Providers']
|
||||||
|
// #swagger.summary = 'Create a custom SSO provider (admin only)'
|
||||||
|
// #swagger.description = 'Built-in providers (google, discord) are configured via PUT, not created here.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderCreateRequest" } } } } */
|
||||||
|
/* #swagger.responses[201] = { description: 'Created provider', content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderConfig" } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Validation error, or a built-in/invalid kind', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[409] = { description: 'Provider id already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
body('id').matches(/^[a-z0-9-]+$/),
|
||||||
|
body('kind').isIn(['oidc', 'oauth2']),
|
||||||
|
body('name').isString().trim().notEmpty().isLength({ max: 80 }),
|
||||||
|
body('enabled').optional().isBoolean(),
|
||||||
|
body('clientId').optional({ values: 'falsy' }).isString(),
|
||||||
|
body('secret').optional({ values: 'falsy' }).isString(),
|
||||||
|
body('authorizeUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
||||||
|
body('tokenUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
||||||
|
body('userinfoUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
||||||
|
body('scopes').optional({ values: 'falsy' }).isString().isLength({ max: 500 }),
|
||||||
|
body('priority').optional().isInt(),
|
||||||
|
validate,
|
||||||
|
authProviders.create,
|
||||||
|
)
|
||||||
|
providersRouter.put(
|
||||||
|
'/providers/:id',
|
||||||
|
// #swagger.tags = ['Admin · Auth Providers']
|
||||||
|
// #swagger.summary = 'Update an SSO provider (admin only)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
|
||||||
|
/* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderCreateRequest" } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Updated provider', content: { "application/json": { schema: { $ref: "#/components/schemas/ProviderConfig" } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Provider not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
param('id').matches(/^[a-z0-9-]+$/),
|
||||||
|
body('name').optional().isString().trim().notEmpty().isLength({ max: 80 }),
|
||||||
|
body('enabled').optional().isBoolean(),
|
||||||
|
body('clientId').optional({ values: 'falsy' }).isString(),
|
||||||
|
body('secret').optional({ values: 'falsy' }).isString(),
|
||||||
|
body('authorizeUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
||||||
|
body('tokenUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
||||||
|
body('userinfoUrl').optional({ values: 'falsy' }).isURL({ require_tld: false }),
|
||||||
|
body('scopes').optional({ values: 'falsy' }).isString().isLength({ max: 500 }),
|
||||||
|
body('priority').optional().isInt(),
|
||||||
|
validate,
|
||||||
|
authProviders.update,
|
||||||
|
)
|
||||||
|
providersRouter.delete(
|
||||||
|
'/providers/:id',
|
||||||
|
// #swagger.tags = ['Admin · Auth Providers']
|
||||||
|
// #swagger.summary = 'Delete a custom SSO provider (admin only)'
|
||||||
|
// #swagger.description = 'Built-in providers cannot be deleted — disable them instead.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Provider id.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedFlag" } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Built-in provider cannot be deleted', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Provider not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
param('id').matches(/^[a-z0-9-]+$/),
|
||||||
|
validate,
|
||||||
|
authProviders.remove,
|
||||||
|
)
|
||||||
|
|
||||||
|
module.exports = providersRouter
|
||||||
46
server/src/router/v1/admin/index.js
Normal file
46
server/src/router/v1/admin/index.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// /api/v1/admin — the admin surface, assembled from per-capability routers.
|
||||||
|
//
|
||||||
|
// This file owns exactly two things: the gate every admin route shares, and the
|
||||||
|
// mount table. No route is declared here. Each capability router mounts at the
|
||||||
|
// prefix it already owned inside the old monolithic admin.routes.js, so the
|
||||||
|
// emitted URL set is byte-identical — proved per PR by a zero-line diff in
|
||||||
|
// server/routes.manifest.json (`npm run routes:manifest`).
|
||||||
|
//
|
||||||
|
// See docs/website/API_V2_PLAN.md § Phase 2 for the split and its remaining PRs.
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
|
||||||
|
const { isLoggedIn, requireRole } = require('../../../utils/auth')
|
||||||
|
const noindex = require('../../../middleware/noindex')
|
||||||
|
|
||||||
|
const accountRouter = require('./account.router')
|
||||||
|
const usersRouter = require('./users.router')
|
||||||
|
const invitesRouter = require('./invites.router')
|
||||||
|
const authProvidersRouter = require('./authProviders.router')
|
||||||
|
const residualRouter = require('./admin.routes')
|
||||||
|
|
||||||
|
const adminRouter = express.Router()
|
||||||
|
|
||||||
|
// Every admin route requires auth, a STAFF role, and is kept out of search
|
||||||
|
// indexes. The staff gate matters now that `player` is a logged-in-but-untrusted
|
||||||
|
// role: without it, the editor-tier routes below (dashboard, posts, wiki,
|
||||||
|
// uploads) that are only guarded by isLoggedIn would be reachable by players.
|
||||||
|
// Players get 403 here and use the self-scoped /player group instead.
|
||||||
|
//
|
||||||
|
// It lives here, ahead of every mount, so a capability router extracted in a
|
||||||
|
// later PR cannot silently ship without it.
|
||||||
|
const staffOnly = requireRole('admin', 'editor', 'moderator')
|
||||||
|
adminRouter.use(noindex, isLoggedIn, staffOnly)
|
||||||
|
|
||||||
|
adminRouter.use('/account', accountRouter)
|
||||||
|
adminRouter.use('/users', usersRouter)
|
||||||
|
adminRouter.use('/invites', invitesRouter)
|
||||||
|
// Mounted at /auth, not /auth/providers: /admin/auth is the capability, and the
|
||||||
|
// routes inside read as /providers[/:id].
|
||||||
|
adminRouter.use('/auth', authProvidersRouter)
|
||||||
|
|
||||||
|
// Everything not yet extracted, at the group root. Mounted last, but none of the
|
||||||
|
// prefixes above appear in it, so nothing here depends on the ordering.
|
||||||
|
adminRouter.use('/', residualRouter)
|
||||||
|
|
||||||
|
module.exports = adminRouter
|
||||||
54
server/src/router/v1/admin/invites.router.js
Normal file
54
server/src/router/v1/admin/invites.router.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// Admin · Invites — create, list and revoke emailed account invites.
|
||||||
|
//
|
||||||
|
// Mounted at /api/v1/admin/invites by admin/index.js, which already applied
|
||||||
|
// `noindex, isLoggedIn, staffOnly`. Issuing an invite picks the new account's
|
||||||
|
// role, so it is admin-only — otherwise an editor could mint an admin.
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
const { body, param } = require('express-validator')
|
||||||
|
|
||||||
|
const invites = require('./invites.controller')
|
||||||
|
const { requireRole } = require('../../../utils/auth')
|
||||||
|
const validate = require('../../../middleware/validate')
|
||||||
|
|
||||||
|
const invitesRouter = express.Router()
|
||||||
|
const adminOnly = requireRole('admin')
|
||||||
|
|
||||||
|
invitesRouter.post(
|
||||||
|
'/',
|
||||||
|
// #swagger.tags = ['Admin · Invites']
|
||||||
|
// #swagger.summary = 'Create and email an account invite at a chosen access level'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["email","role"], properties: { email: { type: "string" }, role: { type: "string" } } } } } */
|
||||||
|
/* #swagger.responses[201] = { description: 'Invite created', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
body('email').isEmail().isLength({ max: 255 }),
|
||||||
|
body('role').isIn(['admin', 'editor', 'moderator', 'player']),
|
||||||
|
validate,
|
||||||
|
invites.create,
|
||||||
|
)
|
||||||
|
invitesRouter.get(
|
||||||
|
'/',
|
||||||
|
// #swagger.tags = ['Admin · Invites']
|
||||||
|
// #swagger.summary = 'List recent invites (no tokens)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Invites, newest first', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||||
|
adminOnly,
|
||||||
|
invites.list,
|
||||||
|
)
|
||||||
|
invitesRouter.delete(
|
||||||
|
'/:id',
|
||||||
|
// #swagger.tags = ['Admin · Invites']
|
||||||
|
// #swagger.summary = 'Revoke a pending invite'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Invite id.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Revoked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'No pending invite to revoke', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
param('id').isInt(),
|
||||||
|
validate,
|
||||||
|
invites.revoke,
|
||||||
|
)
|
||||||
|
|
||||||
|
module.exports = invitesRouter
|
||||||
249
server/src/router/v1/admin/users.router.js
Normal file
249
server/src/router/v1/admin/users.router.js
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
// Admin · Users — user management, MFA recovery, and a user's shard footprint.
|
||||||
|
//
|
||||||
|
// Mounted at /api/v1/admin/users by admin/index.js, which already applied
|
||||||
|
// `noindex, isLoggedIn, staffOnly`. The whole capability is admin-only: editors
|
||||||
|
// and moderators manage content and reports, never accounts.
|
||||||
|
//
|
||||||
|
// Handlers still live in admin.controller.js (users) and usersShard.controller.js
|
||||||
|
// (uo-link footprint); this PR re-wires routes, not logic.
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
const { body, param } = require('express-validator')
|
||||||
|
|
||||||
|
const ctrl = require('./admin.controller')
|
||||||
|
const usersShard = require('./usersShard.controller')
|
||||||
|
const { requireRole } = require('../../../utils/auth')
|
||||||
|
const validate = require('../../../middleware/validate')
|
||||||
|
|
||||||
|
// Same shape the shard routes validate account names with.
|
||||||
|
const SHARD_ACCOUNT_RE = /^[A-Za-z0-9_.-]{1,120}$/
|
||||||
|
|
||||||
|
const usersRouter = express.Router()
|
||||||
|
const adminOnly = requireRole('admin')
|
||||||
|
|
||||||
|
usersRouter.use(adminOnly)
|
||||||
|
usersRouter.get(
|
||||||
|
'/',
|
||||||
|
// #swagger.tags = ['Admin · Users']
|
||||||
|
// #swagger.summary = 'List users (admin only)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.responses[200] = { description: 'Users', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/User" } } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
ctrl.listUsers,
|
||||||
|
)
|
||||||
|
usersRouter.post(
|
||||||
|
'/',
|
||||||
|
// #swagger.tags = ['Admin · Users']
|
||||||
|
// #swagger.summary = 'Create a user (admin only)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/UserCreateRequest" } } } } */
|
||||||
|
/* #swagger.responses[201] = { description: 'Created user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Validation error', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
body('username').isString().trim().isLength({ min: 3, max: 32 }),
|
||||||
|
body('password').isString().isLength({ min: 8, max: 64 }),
|
||||||
|
body('role').optional().isIn(['admin', 'editor', 'moderator', 'player']),
|
||||||
|
body('status').optional().isIn(['active', 'disabled', 'banned', 'pending']),
|
||||||
|
body('email').optional({ values: 'falsy' }).isEmail().isLength({ max: 255 }),
|
||||||
|
validate,
|
||||||
|
ctrl.createUser,
|
||||||
|
)
|
||||||
|
usersRouter.put(
|
||||||
|
'/:id',
|
||||||
|
// #swagger.tags = ['Admin · Users']
|
||||||
|
// #swagger.summary = 'Update a user (admin only)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||||
|
/* #swagger.requestBody = { content: { "application/json": { schema: { $ref: "#/components/schemas/UserCreateRequest" } } } } */
|
||||||
|
/* #swagger.responses[200] = { description: 'Updated user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Validation error, or cannot demote the last admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('id').isInt(),
|
||||||
|
body('username').optional().isString().trim().isLength({ min: 3, max: 32 }),
|
||||||
|
body('password').optional().isString().isLength({ min: 8, max: 64 }),
|
||||||
|
body('role').optional().isIn(['admin', 'editor', 'moderator', 'player']),
|
||||||
|
body('status').optional().isIn(['active', 'disabled', 'banned', 'pending']),
|
||||||
|
body('email').optional({ values: 'null' }).isEmail().isLength({ max: 255 }),
|
||||||
|
validate,
|
||||||
|
ctrl.updateUser,
|
||||||
|
)
|
||||||
|
usersRouter.delete(
|
||||||
|
'/:id',
|
||||||
|
// #swagger.tags = ['Admin · Users']
|
||||||
|
// #swagger.summary = 'Delete a user (admin only)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */
|
||||||
|
/* #swagger.responses[400] = { description: 'Cannot delete your own account or the last admin', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('id').isInt(),
|
||||||
|
validate,
|
||||||
|
ctrl.deleteUser,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── A user's trusted devices & MFA (admin only) ───────────────────────
|
||||||
|
usersRouter.get(
|
||||||
|
'/:id/trusted-devices',
|
||||||
|
// #swagger.tags = ['Admin · Users']
|
||||||
|
// #swagger.summary = 'List a user’s trusted devices (admin only)'
|
||||||
|
// #swagger.description = 'Active (unrevoked, unexpired) trusted devices for the target user — the browsers/apps allowed to skip that user’s TOTP step. Never returns tokens.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Trusted devices', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/TrustedDevice" } } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('id').isInt(),
|
||||||
|
validate,
|
||||||
|
ctrl.listUserTrustedDevices,
|
||||||
|
)
|
||||||
|
usersRouter.delete(
|
||||||
|
'/:id/trusted-devices',
|
||||||
|
// #swagger.tags = ['Admin · Users']
|
||||||
|
// #swagger.summary = 'Revoke all of a user’s trusted devices (admin only)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Revoked count', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "integer" } } } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('id').isInt(),
|
||||||
|
validate,
|
||||||
|
ctrl.revokeAllUserTrustedDevices,
|
||||||
|
)
|
||||||
|
usersRouter.delete(
|
||||||
|
'/:id/trusted-devices/:deviceId',
|
||||||
|
// #swagger.tags = ['Admin · Users']
|
||||||
|
// #swagger.summary = 'Revoke one of a user’s trusted devices (admin only)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||||
|
// #swagger.parameters['deviceId'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Trusted-device id.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Revoked (idempotent)', content: { "application/json": { schema: { type: "object", properties: { revoked: { type: "boolean" } } } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('id').isInt(),
|
||||||
|
param('deviceId').isInt({ min: 1 }),
|
||||||
|
validate,
|
||||||
|
ctrl.revokeUserTrustedDevice,
|
||||||
|
)
|
||||||
|
usersRouter.post(
|
||||||
|
'/:id/mfa/reset',
|
||||||
|
// #swagger.tags = ['Admin · Users']
|
||||||
|
// #swagger.summary = 'Reset a user’s MFA (admin only)'
|
||||||
|
// #swagger.description = 'Recovers a locked-out user: turns TOTP off, revokes every trusted device, and clears their recovery codes. The user can then sign in with their password alone and re-enroll.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'MFA reset', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */
|
||||||
|
/* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('id').isInt(),
|
||||||
|
validate,
|
||||||
|
ctrl.resetUserMfa,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── User → shard (uo-link) footprint (admin only) ─────────────────────
|
||||||
|
// Backs the /admin/users/:id detail page: a user's linked game accounts and,
|
||||||
|
// scoped to those accounts, their vendor sales / houses / online characters.
|
||||||
|
// Live character rosters are fetched by the client through /admin/shard/* (which
|
||||||
|
// already grants admins a bypass to any account), so no routes for them here.
|
||||||
|
usersRouter.get(
|
||||||
|
'/:id',
|
||||||
|
// #swagger.tags = ['Admin · Users']
|
||||||
|
// #swagger.summary = 'Get a single user (admin only)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'The user', content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('id').isInt(),
|
||||||
|
validate,
|
||||||
|
usersShard.getUser,
|
||||||
|
)
|
||||||
|
usersRouter.get(
|
||||||
|
'/:id/shard/accounts',
|
||||||
|
// #swagger.tags = ['Admin · Users']
|
||||||
|
// #swagger.summary = 'A user’s linked game accounts (admin only)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Linked accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardLink" } } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('id').isInt(),
|
||||||
|
validate,
|
||||||
|
usersShard.listAccounts,
|
||||||
|
)
|
||||||
|
usersRouter.get(
|
||||||
|
'/:id/shard/sales',
|
||||||
|
// #swagger.tags = ['Admin · Users']
|
||||||
|
// #swagger.summary = 'Recent vendor sales on a user’s accounts (admin only)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('id').isInt(),
|
||||||
|
validate,
|
||||||
|
usersShard.getSales,
|
||||||
|
)
|
||||||
|
usersRouter.get(
|
||||||
|
'/:id/shard/houses',
|
||||||
|
// #swagger.tags = ['Admin · Users']
|
||||||
|
// #swagger.summary = 'Houses owned by a user’s accounts (admin only)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Houses (IDOC first)', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('id').isInt(),
|
||||||
|
validate,
|
||||||
|
usersShard.getHouses,
|
||||||
|
)
|
||||||
|
usersRouter.get(
|
||||||
|
'/:id/shard/online',
|
||||||
|
// #swagger.tags = ['Admin · Users']
|
||||||
|
// #swagger.summary = 'A user’s characters currently online (admin only)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Online characters', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
param('id').isInt(),
|
||||||
|
validate,
|
||||||
|
usersShard.getOnline,
|
||||||
|
)
|
||||||
|
usersRouter.get(
|
||||||
|
'/: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,
|
||||||
|
)
|
||||||
|
usersRouter.delete(
|
||||||
|
'/:id/shard/link/:account',
|
||||||
|
// #swagger.tags = ['Admin · Users']
|
||||||
|
// #swagger.summary = 'Unlink a game account from this user (admin only)'
|
||||||
|
// #swagger.description = 'Severs a game account’s tie to the website user from the site side (sidecar DELETE /link/{account}) and drops the local mirror. actor is stamped from the session.'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'User id.' }
|
||||||
|
// #swagger.parameters['account'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Game account to unlink.' }
|
||||||
|
/* #swagger.responses[200] = { description: 'Unlinked', content: { "application/json": { schema: { type: "object", properties: { account: { type: "string" }, unlinked: { type: "boolean" } } } } } } */
|
||||||
|
/* #swagger.responses[403] = { description: 'Protected staff account (refused by shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
/* #swagger.responses[404] = { description: 'Not linked', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
|
adminOnly,
|
||||||
|
param('id').isInt(),
|
||||||
|
param('account').matches(SHARD_ACCOUNT_RE),
|
||||||
|
validate,
|
||||||
|
usersShard.unlinkAccount,
|
||||||
|
)
|
||||||
|
|
||||||
|
module.exports = usersRouter
|
||||||
@@ -4,7 +4,7 @@ const v1Router = express.Router()
|
|||||||
|
|
||||||
const authRouter = require('./auth/auth.routes')
|
const authRouter = require('./auth/auth.routes')
|
||||||
const publicRouter = require('./public/public.routes')
|
const publicRouter = require('./public/public.routes')
|
||||||
const adminRouter = require('./admin/admin.routes')
|
const adminRouter = require('./admin')
|
||||||
const playerRouter = require('./player/player.routes')
|
const playerRouter = require('./player/player.routes')
|
||||||
|
|
||||||
v1Router.use('/auth', authRouter)
|
v1Router.use('/auth', authRouter)
|
||||||
|
|||||||
Reference in New Issue
Block a user