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>
250 lines
17 KiB
JavaScript
250 lines
17 KiB
JavaScript
// 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
|