// Player · Account — self-service credentials, 2FA and linked identities for the // signed-in account. // // Mounted at /api/v1/player/account by player/index.js, which already applied // `noindex, requireAuth`. No extra gate: every handler is self-scoped to // req.user.id, and staff are a superset of players (see player/index.js). // // The handlers are admin/account.controller — the same code serving // /admin/account/* and /auth/me/account/*. Three URL surfaces, one implementation; // this file must not grow a fourth copy of the logic. // // The swagger tag stays 'Player', matching the committed spec. const express = require('express') const { body, param } = require('express-validator') const account = require('../admin/account.controller') const validate = require('../../../middleware/validate') const { accountChangeLimiter } = require('../../../middleware/rateLimit') const accountRouter = express.Router() accountRouter.get( '/', // #swagger.tags = ['Player'] // #swagger.summary = 'Get the current player account (self)' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.responses[200] = { description: 'The player account', content: { "application/json": { schema: { $ref: "#/components/schemas/PlayerAccount" } } } } */ /* #swagger.responses[401] = { description: 'Not authenticated', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[403] = { description: 'Account not active (disabled/banned)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ account.getAccount, ) accountRouter.patch( '/username', // #swagger.tags = ['Player'] // #swagger.summary = 'Change the current player’s username' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ChangeUsernameRequest" } } } } */ /* #swagger.responses[200] = { description: 'Updated username (session cookie re-issued)', content: { "application/json": { schema: { type: "object", properties: { username: { type: "string" } } } } } } */ /* #swagger.responses[400] = { description: 'Validation error or unavailable username', content: { "application/json": { schema: { $ref: "#/components/schemas/ValidationError" } } } } */ /* #swagger.responses[403] = { description: 'Account not active (disabled/banned)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[429] = { description: 'Too many changes (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ accountChangeLimiter, body('username').isString().trim().isLength({ min: 3, max: 32 }), validate, account.changeUsername, ) accountRouter.patch( '/password', // #swagger.tags = ['Player'] // #swagger.summary = 'Change or set the current player’s password' // #swagger.description = 'If the account already has a password, currentPassword is required and verified. SSO-provisioned accounts with no password may set an initial one without a current password. On success the caller’s session is re-issued (they stay logged in) while all other sessions are revoked.' // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/ChangePasswordRequest" } } } } */ /* #swagger.responses[200] = { description: 'Password changed', content: { "application/json": { schema: { $ref: "#/components/schemas/OkFlag" } } } } */ /* #swagger.responses[400] = { description: 'Validation error or wrong current password', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[403] = { description: 'Account not active (disabled/banned)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ /* #swagger.responses[429] = { description: 'Too many changes (rate limited)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ accountChangeLimiter, body('newPassword').isString().isLength({ min: 8, max: 64 }), body('currentPassword').optional({ values: 'falsy' }).isString(), validate, account.changePassword, ) // TOTP self-enrollment — identical to the admin account flow (disable requires a // valid current code; it does not take a password). accountRouter.post( '/totp/setup', // #swagger.tags = ['Player'] // #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[409] = { description: 'Two-factor already enabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ account.totpSetup, ) accountRouter.post( '/totp/enable', // #swagger.tags = ['Player'] // #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[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 = ['Player'] // #swagger.summary = 'Disable 2FA by confirming a code' // #swagger.description = 'Requires a valid current authenticator code (proves control of the authenticator); it does not take a password.' // #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" } } } } */ body('code').isString().trim().isLength({ min: 6, max: 8 }), validate, account.totpDisable, ) // Linked SSO identities (self-service). Linking itself starts at // GET /auth/sso/:provider/link (already behind requireAuth; works for players). accountRouter.get( '/identities', // #swagger.tags = ['Player'] // #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" } } } } } */ account.listIdentities, ) accountRouter.delete( '/identities/:provider', // #swagger.tags = ['Player'] // #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[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