Makes `users.email` unique, de-duplicates the addresses an upgrade will find, and builds the self-service change-and-verify flow that did not exist. The uniqueness index is on a generated `email_norm AS (LOWER(email)) STORED` column under `utf8mb4_bin`, NOT on `email` under a `_ci` collation as the plan specified. Every case-insensitive collation this server offers is also accent-insensitive: `josé@x.com` and `jose@x.com` compare equal, and those are two different mailboxes. The plan's index would have refused the second address forever and the de-duplication would have nulled a legitimate account's. A requested address is STAGED in `email_pending` and only a tokened link installs it, so a typo cannot silently redirect account-recovery mail. `isDuplicateUsername()` now distinguishes the two indexes. All five call sites branch on it; each answers differently on purpose, because a public form, an IdP callback, a half-completed invite and an admin screen do not owe the same person the same amount of truth. SSO reads the IdP's actual `email_verified`/`verified` claim instead of inferring verification from an address merely being present. Co-Authored-By: Claude <noreply@anthropic.com>
205 lines
14 KiB
JavaScript
205 lines
14 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 live in admin.controller.js. The shard footprint that used to be
|
||
// wired here is now an EXTENSION SLOT (MODULE_SYSTEM.md §1.9) — see the bottom of
|
||
// this file.
|
||
|
||
const express = require('express')
|
||
const { body, param } = require('express-validator')
|
||
|
||
const ctrl = require('./admin.controller')
|
||
const registries = require('../../../modules/registries')
|
||
const { requireRole } = require('../../../utils/auth')
|
||
const validate = require('../../../middleware/validate')
|
||
|
||
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,
|
||
)
|
||
// The Phase 1b de-duplication report. Declared BEFORE '/:id' — Express matches in
|
||
// order, so a literal segment registered after a parameterised one is never
|
||
// reached ('email-dedupe-report' would bind as :id).
|
||
usersRouter.get(
|
||
'/email-dedupe-report',
|
||
// #swagger.tags = ['Admin · Users']
|
||
// #swagger.summary = 'Accounts whose email was cleared by de-duplication (admin only)'
|
||
// #swagger.description = 'When email addresses became unique, accounts sharing an address kept only the earliest-created one; the rest had their address cleared. These users can still sign in but cannot receive password-reset or notification email until they set a new address, so they are the ones to contact.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'The affected accounts', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/EmailDedupeEntry" } } } } } */
|
||
/* #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.emailDedupeReport,
|
||
)
|
||
usersRouter.post(
|
||
'/email-dedupe-report/acknowledge',
|
||
// #swagger.tags = ['Admin · Users']
|
||
// #swagger.summary = 'Dismiss the de-duplication warning (admin only)'
|
||
// #swagger.description = 'Marks the report acknowledged so it stops appearing as a dashboard warning. The rows are kept as a record of what the upgrade did.'
|
||
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||
/* #swagger.responses[200] = { description: 'Acknowledged', content: { "application/json": { schema: { type: "object", properties: { ok: { type: "boolean" }, acknowledged: { 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" } } } } */
|
||
ctrl.acknowledgeEmailDedupeReport,
|
||
)
|
||
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,
|
||
)
|
||
|
||
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,
|
||
ctrl.getUser,
|
||
)
|
||
|
||
// ── The `admin.users.detail` extension slot (MODULE_SYSTEM.md §1.9) ────────
|
||
//
|
||
// A module may hang routes off this core resource. Core DECLARES the slot; only
|
||
// core may, and a module may only fill one (MODULE_API.md §2.4). What fills it
|
||
// today is core's own usersShard.router.js, registered in registries.js's
|
||
// registerCore() — the shard footprint that used to be wired inline right here.
|
||
// Phase 3 changes the registrant, not this line.
|
||
//
|
||
// LAST, deliberately: every core route on the resource is already declared, so
|
||
// first-match-wins means core owns any path conflict. The router is created at
|
||
// declare time and filled later, because this file is required while app.js is
|
||
// still being built — long before a module has been scanned.
|
||
usersRouter.use('/:id', registries.declareSlot('admin.users.detail'))
|
||
|
||
module.exports = usersRouter
|