+ {result.emailed
+ ? 'Invitation emailed. You can also share this single-use link:'
+ : `Invite created${result.emailError ? ` (email not sent: ${result.emailError})` : ''}. Share this single-use link:`}
+
+
)}
diff --git a/client/src/routes/admin/views/SettingsAdmin.jsx b/client/src/routes/admin/views/SettingsAdmin.jsx
index 95d1eaa..ca59eb8 100644
--- a/client/src/routes/admin/views/SettingsAdmin.jsx
+++ b/client/src/routes/admin/views/SettingsAdmin.jsx
@@ -35,6 +35,18 @@ const FIELDS = [
],
fallback: 'disabled',
},
+ {
+ key: 'game_account_signup',
+ label: 'Game-account creation',
+ help: 'Whether players can create a GAME account (for the game client) from the site. The game server’s own SignupMode (Bridge.cfg) must agree: website/hybrid accept site-created accounts, game refuses them. When enabled, a “Create a game account” form appears in the player portal.',
+ options: [
+ { value: 'disabled', label: 'Disabled — link an existing account only' },
+ { value: 'website', label: 'Website — the site creates game accounts' },
+ { value: 'hybrid', label: 'Hybrid — site or in-game (recommended)' },
+ { value: 'game', label: 'Game only — created in the game client, not the site' },
+ ],
+ fallback: 'disabled',
+ },
]
export default function SettingsAdmin() {
diff --git a/server/src/model/settings/settings.model.js b/server/src/model/settings/settings.model.js
index 6488558..4b6e29c 100644
--- a/server/src/model/settings/settings.model.js
+++ b/server/src/model/settings/settings.model.js
@@ -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,
}
diff --git a/server/src/router/v1/admin/admin.controller.js b/server/src/router/v1/admin/admin.controller.js
index dbf98f1..9cd18ed 100644
--- a/server/src/router/v1/admin/admin.controller.js
+++ b/server/src/router/v1/admin/admin.controller.js
@@ -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).
diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js
index b4ac68b..e2677ab 100644
--- a/server/src/router/v1/admin/admin.routes.js
+++ b/server/src/router/v1/admin/admin.routes.js
@@ -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 shard’s 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
diff --git a/server/src/router/v1/admin/invites.controller.js b/server/src/router/v1/admin/invites.controller.js
index e2f67e4..08feb26 100644
--- a/server/src/router/v1/admin/invites.controller.js
+++ b/server/src/router/v1/admin/invites.controller.js
@@ -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' })
diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json
index 39b8b42..7f90a2b 100644
--- a/server/swagger/swagger-output.json
+++ b/server/swagger/swagger-output.json
@@ -2426,6 +2426,63 @@
]
}
},
+ "/api/v1/admin/shard/account": {
+ "post": {
+ "tags": [
+ "Admin · Account"
+ ],
+ "summary": "Create a game account and link it to the caller (staff self-service)",
+ "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 shard’s mode; the password is never stored or logged.",
+ "responses": {
+ "201": {
+ "description": "Account created and linked",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request"
+ },
+ "403": {
+ "description": "Game-account signup unavailable (site or shard)",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "409": {
+ "description": "Account name already taken",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error"
+ }
+ },
+ "security": [
+ {
+ "cookieAuth": []
+ },
+ {
+ "bearerAuth": []
+ }
+ ],
+ "requestBody": {}
+ }
+ },
"/api/v1/admin/shard/kick": {
"post": {
"tags": [