diff --git a/client/src/api/client.js b/client/src/api/client.js index 8f36c2a..d2d3f7e 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -177,7 +177,8 @@ export const api = { deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }), // Email invites. listInvites: () => req('/admin/invites'), - createInvite: (email, role) => req('/admin/invites', { method: 'POST', body: { email, role } }), + createInvite: (email, role, sendEmail = true) => + req('/admin/invites', { method: 'POST', body: { email, role, sendEmail } }), revokeInvite: (id) => req(`/admin/invites/${id}`, { method: 'DELETE' }), // A single user's shard (uo-link) footprint, scoped to their linked accounts. // accounts/sales/houses/online are user-scoped endpoints; roster/vendors/char @@ -260,6 +261,8 @@ export const api = { char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`), sales: () => req('/admin/shard/sales'), houses: () => req('/admin/shard/houses'), // full registry (admin/moderator) + createAccount: (account, password) => + req('/admin/shard/account', { method: 'POST', body: { account, password } }), }, // ----- auth providers / SSO config (admin only) ----- diff --git a/client/src/routes/admin/views/InvitesAdmin.jsx b/client/src/routes/admin/views/InvitesAdmin.jsx index f064194..7367894 100644 --- a/client/src/routes/admin/views/InvitesAdmin.jsx +++ b/client/src/routes/admin/views/InvitesAdmin.jsx @@ -11,12 +11,39 @@ const ROLES = ['player', 'moderator', 'editor', 'admin'] const ROLE_BADGE = { admin: 'badge-admin', editor: 'badge-editor', moderator: 'badge-moderator', player: 'badge-player' } const STATUS_COLOR = { pending: 'var(--accent)', accepted: '#7fd0a4', revoked: 'var(--muted)' } +function CopyLink({ url }) { + const [copied, setCopied] = useState(false) + async function copy() { + try { + await navigator.clipboard.writeText(url) + setCopied(true) + setTimeout(() => setCopied(false), 1800) + } catch { + /* clipboard blocked — the link is selectable in the box regardless */ + } + } + return ( +
+ { const r = document.createRange(); r.selectNodeContents(e.currentTarget); const s = window.getSelection(); s.removeAllRanges(); s.addRange(r) }} + style={{ flex: 1, wordBreak: 'break-all', color: 'var(--head)', background: 'var(--panel-flat)', padding: '8px 10px', borderRadius: 6, border: '1px solid var(--line)', cursor: 'text', fontSize: '0.8rem' }} + > + {url} + + +
+ ) +} + function CreateInvite({ onCreated }) { const [email, setEmail] = useState('') const [role, setRole] = useState('player') + const [sendEmail, setSendEmail] = useState(true) const [busy, setBusy] = useState(false) const [error, setError] = useState('') - const [result, setResult] = useState(null) // { emailed, acceptUrl } + const [result, setResult] = useState(null) // { emailed, acceptUrl, emailError } async function submit(e) { e.preventDefault() @@ -24,7 +51,7 @@ function CreateInvite({ onCreated }) { if (!email.trim()) return setError('Enter an email address.') setBusy(true) try { - const res = await api.admin.createInvite(email.trim(), role) + const res = await api.admin.createInvite(email.trim(), role, sendEmail) setResult(res) setEmail('') await onCreated() @@ -50,25 +77,24 @@ function CreateInvite({ onCreated }) { + + {error &&

{error}

} {result && (
- {result.emailed ? ( -

Invitation emailed.

- ) : ( -
-

- Email isn’t configured{result.emailError ? ` (${result.emailError})` : ''} — share this single-use link: -

- - {result.acceptUrl} - -
- )} +

+ {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": [