feat(provisioning): admin game-signup mode setting, invite link option, staff self-create
All checks were successful
PR Checks / server-tests (pull_request) Successful in 9m37s
PR Checks / client-build (pull_request) Successful in 10m18s
PR Checks / bot-install (pull_request) Successful in 9m22s

Follow-ups from live testing:

- Game-account creation is now an admin Settings control (disabled / website /
  hybrid / game) instead of a hidden on/off flag. The site offers creation for
  website+hybrid; help text notes the shard's SignupMode (Bridge.cfg) has the final
  say. game_account_signup setting widened to a 4-value enum + validated on save.
- Invites: the accept link is ALWAYS returned and shown with a Copy button, and a
  "Email the invitation" toggle lets an admin create a link-only invite (no email)
  or email it. Backend takes sendEmail (default true) and always returns acceptUrl.
- Staff can create a game account from their own /admin/characters page too
  (POST /admin/shard/account → the shared createGameAccount controller), so the
  form is reachable in both the player and admin portals.

Note: the admin Houses view (/admin/houses) already worked; the earlier failure
was a stale Vite HMR state for the new route (needs a hard refresh).

Client build clean; server routes load; swagger regenerated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 21:08:59 -05:00
parent 1629796235
commit 3ef1c8e438
8 changed files with 174 additions and 32 deletions

View File

@@ -32,11 +32,25 @@ function registrationFlags(mode) {
}
}
// Game-account signup (Protocol 2.0 hybrid mode). Off unless an admin opts in;
// the shard's own signup mode still has the final say when we call the sidecar.
// Game-account signup (Protocol 2.0). The admin picks who mints game accounts:
// disabled — the site never offers game-account creation (link-only).
// website — the site is the authority (offer creation; pair with the shard in
// website mode + AutoCreateAccounts=false).
// hybrid — either side may create (the site offers creation).
// game — the game server is the authority; the site does NOT offer creation.
// The site OFFERS creation only for 'website'/'hybrid'; the shard's own SignupMode
// (Bridge.cfg) still has the final say and may 403 a call regardless.
const GAME_SIGNUP_KEY = 'game_account_signup'
const GAME_SIGNUP_MODES = ['disabled', 'website', 'hybrid', 'game']
const GAME_SIGNUP_OFFER = ['website', 'hybrid']
async function getGameSignupMode() {
const v = await settingsDb.get(GAME_SIGNUP_KEY)
return GAME_SIGNUP_MODES.includes(v) ? v : 'disabled'
}
async function isGameAccountSignupEnabled() {
return (await settingsDb.get(GAME_SIGNUP_KEY)) === 'enabled'
return GAME_SIGNUP_OFFER.includes(await getGameSignupMode())
}
async function get(key) {
@@ -73,7 +87,8 @@ async function getPublic() {
out.registration = registrationFlags(mode)
// Whether the site offers game-account creation (the shard's own mode still has
// the final say when the call is made). Lets the portal show/hide the form.
out.gameAccountSignup = all[GAME_SIGNUP_KEY] === 'enabled'
const gsMode = GAME_SIGNUP_MODES.includes(all[GAME_SIGNUP_KEY]) ? all[GAME_SIGNUP_KEY] : 'disabled'
out.gameAccountSignup = GAME_SIGNUP_OFFER.includes(gsMode)
return out
}
@@ -89,5 +104,7 @@ module.exports = {
getRegistrationMode,
registrationFlags,
GAME_SIGNUP_KEY,
GAME_SIGNUP_MODES,
getGameSignupMode,
isGameAccountSignupEnabled,
}

View File

@@ -494,6 +494,12 @@ async function updateSettings(req, res) {
) {
return res.status(400).json({ message: 'Invalid player_registration value' })
}
if (
settings.GAME_SIGNUP_KEY in updates &&
!settings.GAME_SIGNUP_MODES.includes(updates[settings.GAME_SIGNUP_KEY])
) {
return res.status(400).json({ message: 'Invalid game_account_signup value' })
}
// The homepage teaser is rich text (HTML) from the shared editor — sanitize it
// against the same allowlist as post/wiki bodies so a stored value is safe (the
// client re-sanitizes on render as defense in depth).

View File

@@ -182,6 +182,21 @@ adminRouter.get(
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
selfShard.getSales,
)
adminRouter.post(
'/shard/account',
// #swagger.tags = ['Admin · Account']
// #swagger.summary = 'Create a game account and link it to the caller (staff self-service)'
// #swagger.description = 'Same as POST /player/shard/account but for a signed-in staff user — provisions a game account (own username + password) and links it. Gated by game_account_signup + the shards mode; the password is never stored or logged.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["account","password"], properties: { account: { type: "string" }, password: { type: "string" } } } } } */
/* #swagger.responses[201] = { description: 'Account created and linked', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[403] = { description: 'Game-account signup unavailable (site or shard)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Account name already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('account').matches(/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/),
body('password').isString().isLength({ min: 8, max: 64 }),
validate,
selfShard.createGameAccount,
)
// ── In-game staff operations (uo-link write plane + support queue) ─────
// Privileged live-shard actions and the help-page queue, open to moderators as

View File

@@ -22,10 +22,14 @@ function acceptUrl(token) {
return `${baseUrl()}/invite/${token}`
}
// POST /admin/invites — create an invite and email it.
// POST /admin/invites — create an invite. Optionally email it (sendEmail, default
// true); the copyable accept link is ALWAYS returned so the admin can hand it over
// directly. The token is single-use + expiring and the caller is the authenticated
// admin who made it, so echoing the link back to them is safe.
async function create(req, res) {
const email = String(req.body.email || '').trim()
const role = req.body.role
const sendEmail = req.body.sendEmail !== false // default true
if (!email || !ROLES.includes(role)) {
return res.status(400).json({ message: 'A valid email and role are required.' })
}
@@ -33,24 +37,26 @@ async function create(req, res) {
const { invite, token } = await invites.create({ email, role, invitedBy: req.user.id })
const url = acceptUrl(token)
// Send the email; if mail isn't configured, hand the link back so the admin
// can share it manually. A send failure doesn't delete the invite — surface it.
// Send the email only if asked. A send failure doesn't delete the invite — the
// link is still returned so the admin can share it manually.
let emailed = false
let emailError = null
try {
const result = await mailer.sendInvite({ to: email, acceptUrl: url, role, invitedByName: req.user.username })
emailed = Boolean(result.sent)
} catch (err) {
emailError = err.message
log.warn('invite email failed (invite still created)', { id: invite.id, message: err.message })
if (sendEmail) {
try {
const result = await mailer.sendInvite({ to: email, acceptUrl: url, role, invitedByName: req.user.username })
emailed = Boolean(result.sent)
if (!result.sent && result.reason === 'NOT_CONFIGURED') emailError = 'email is not configured'
} catch (err) {
emailError = err.message
log.warn('invite email failed (invite still created)', { id: invite.id, message: err.message })
}
}
await activity.log({ req, userId: req.user.id, action: 'invite.create', detail: { email, role, emailed } })
log.info('invite created', { id: invite.id, email, role, emailed, by: req.user.username })
// The accept link is returned only when email did not deliver, so the admin
// can copy it. When emailed, we don't echo the token.
return res.status(201).json({ invite, emailed, acceptUrl: emailed ? undefined : url, emailError })
// acceptUrl is always returned (copyable link); emailed says whether it also went out.
return res.status(201).json({ invite, emailed, acceptUrl: url, emailError })
} catch (err) {
log.error('create invite', err)
return res.status(500).json({ message: 'Internal Server Error' })