feat(provisioning): admin game-signup mode setting, invite link option, staff self-create
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:
@@ -177,7 +177,8 @@ export const api = {
|
|||||||
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
|
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
|
||||||
// Email invites.
|
// Email invites.
|
||||||
listInvites: () => req('/admin/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' }),
|
revokeInvite: (id) => req(`/admin/invites/${id}`, { method: 'DELETE' }),
|
||||||
// A single user's shard (uo-link) footprint, scoped to their linked accounts.
|
// A single user's shard (uo-link) footprint, scoped to their linked accounts.
|
||||||
// accounts/sales/houses/online are user-scoped endpoints; roster/vendors/char
|
// 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)}`),
|
char: (serial) => req(`/admin/shard/char/${encodeURIComponent(serial)}`),
|
||||||
sales: () => req('/admin/shard/sales'),
|
sales: () => req('/admin/shard/sales'),
|
||||||
houses: () => req('/admin/shard/houses'), // full registry (admin/moderator)
|
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) -----
|
// ----- auth providers / SSO config (admin only) -----
|
||||||
|
|||||||
@@ -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 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)' }
|
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 (
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'stretch' }}>
|
||||||
|
<code
|
||||||
|
onClick={(e) => { 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}
|
||||||
|
</code>
|
||||||
|
<button type="button" onClick={copy} className="btn btn-sq" style={{ flex: 'none' }}>
|
||||||
|
{copied ? 'Copied ✓' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function CreateInvite({ onCreated }) {
|
function CreateInvite({ onCreated }) {
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [role, setRole] = useState('player')
|
const [role, setRole] = useState('player')
|
||||||
|
const [sendEmail, setSendEmail] = useState(true)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [result, setResult] = useState(null) // { emailed, acceptUrl }
|
const [result, setResult] = useState(null) // { emailed, acceptUrl, emailError }
|
||||||
|
|
||||||
async function submit(e) {
|
async function submit(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -24,7 +51,7 @@ function CreateInvite({ onCreated }) {
|
|||||||
if (!email.trim()) return setError('Enter an email address.')
|
if (!email.trim()) return setError('Enter an email address.')
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
const res = await api.admin.createInvite(email.trim(), role)
|
const res = await api.admin.createInvite(email.trim(), role, sendEmail)
|
||||||
setResult(res)
|
setResult(res)
|
||||||
setEmail('')
|
setEmail('')
|
||||||
await onCreated()
|
await onCreated()
|
||||||
@@ -50,25 +77,24 @@ function CreateInvite({ onCreated }) {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
|
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
|
||||||
{busy ? 'Sending…' : 'Send invite'}
|
{busy ? 'Creating…' : (sendEmail ? 'Create & email' : 'Create link')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, marginTop: 12, fontSize: '0.85rem', color: 'var(--ink)', cursor: 'pointer' }}>
|
||||||
|
<input type="checkbox" checked={sendEmail} onChange={(e) => setSendEmail(e.target.checked)} />
|
||||||
|
Email the invitation (otherwise just generate a link to share)
|
||||||
|
</label>
|
||||||
|
|
||||||
{error && <p className="sans" style={{ margin: '12px 0 0', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
|
{error && <p className="sans" style={{ margin: '12px 0 0', color: '#d98b84', fontSize: '0.85rem' }}>{error}</p>}
|
||||||
{result && (
|
{result && (
|
||||||
<div style={{ marginTop: 14 }}>
|
<div style={{ marginTop: 14 }}>
|
||||||
{result.emailed ? (
|
<p className="sans" style={{ margin: '0 0 8px', fontSize: '0.84rem', color: result.emailed ? '#7fd0a4' : 'var(--muted)' }}>
|
||||||
<p className="sans" style={{ margin: 0, color: '#7fd0a4', fontSize: '0.86rem' }}>Invitation emailed.</p>
|
{result.emailed
|
||||||
) : (
|
? 'Invitation emailed. You can also share this single-use link:'
|
||||||
<div className="sans" style={{ fontSize: '0.84rem', color: 'var(--muted)' }}>
|
: `Invite created${result.emailError ? ` (email not sent: ${result.emailError})` : ''}. Share this single-use link:`}
|
||||||
<p style={{ margin: '0 0 6px', color: '#e0b070' }}>
|
|
||||||
Email isn’t configured{result.emailError ? ` (${result.emailError})` : ''} — share this single-use link:
|
|
||||||
</p>
|
</p>
|
||||||
<code style={{ display: 'block', wordBreak: 'break-all', color: 'var(--head)', background: 'var(--panel-flat)', padding: '8px 10px', borderRadius: 6, border: '1px solid var(--line)' }}>
|
<CopyLink url={result.acceptUrl} />
|
||||||
{result.acceptUrl}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,6 +35,18 @@ const FIELDS = [
|
|||||||
],
|
],
|
||||||
fallback: 'disabled',
|
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() {
|
export default function SettingsAdmin() {
|
||||||
|
|||||||
@@ -32,11 +32,25 @@ function registrationFlags(mode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Game-account signup (Protocol 2.0 hybrid mode). Off unless an admin opts in;
|
// Game-account signup (Protocol 2.0). The admin picks who mints game accounts:
|
||||||
// the shard's own signup mode still has the final say when we call the sidecar.
|
// 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_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() {
|
async function isGameAccountSignupEnabled() {
|
||||||
return (await settingsDb.get(GAME_SIGNUP_KEY)) === 'enabled'
|
return GAME_SIGNUP_OFFER.includes(await getGameSignupMode())
|
||||||
}
|
}
|
||||||
|
|
||||||
async function get(key) {
|
async function get(key) {
|
||||||
@@ -73,7 +87,8 @@ async function getPublic() {
|
|||||||
out.registration = registrationFlags(mode)
|
out.registration = registrationFlags(mode)
|
||||||
// Whether the site offers game-account creation (the shard's own mode still has
|
// 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.
|
// 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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,5 +104,7 @@ module.exports = {
|
|||||||
getRegistrationMode,
|
getRegistrationMode,
|
||||||
registrationFlags,
|
registrationFlags,
|
||||||
GAME_SIGNUP_KEY,
|
GAME_SIGNUP_KEY,
|
||||||
|
GAME_SIGNUP_MODES,
|
||||||
|
getGameSignupMode,
|
||||||
isGameAccountSignupEnabled,
|
isGameAccountSignupEnabled,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -494,6 +494,12 @@ async function updateSettings(req, res) {
|
|||||||
) {
|
) {
|
||||||
return res.status(400).json({ message: 'Invalid player_registration value' })
|
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
|
// 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
|
// against the same allowlist as post/wiki bodies so a stored value is safe (the
|
||||||
// client re-sanitizes on render as defense in depth).
|
// client re-sanitizes on render as defense in depth).
|
||||||
|
|||||||
@@ -182,6 +182,21 @@ adminRouter.get(
|
|||||||
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
|
/* #swagger.responses[200] = { description: 'Vendor sales', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/ShardVendorSale" } } } } } */
|
||||||
selfShard.getSales,
|
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) ─────
|
// ── In-game staff operations (uo-link write plane + support queue) ─────
|
||||||
// Privileged live-shard actions and the help-page queue, open to moderators as
|
// Privileged live-shard actions and the help-page queue, open to moderators as
|
||||||
|
|||||||
@@ -22,10 +22,14 @@ function acceptUrl(token) {
|
|||||||
return `${baseUrl()}/invite/${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) {
|
async function create(req, res) {
|
||||||
const email = String(req.body.email || '').trim()
|
const email = String(req.body.email || '').trim()
|
||||||
const role = req.body.role
|
const role = req.body.role
|
||||||
|
const sendEmail = req.body.sendEmail !== false // default true
|
||||||
if (!email || !ROLES.includes(role)) {
|
if (!email || !ROLES.includes(role)) {
|
||||||
return res.status(400).json({ message: 'A valid email and role are required.' })
|
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 { invite, token } = await invites.create({ email, role, invitedBy: req.user.id })
|
||||||
const url = acceptUrl(token)
|
const url = acceptUrl(token)
|
||||||
|
|
||||||
// Send the email; if mail isn't configured, hand the link back so the admin
|
// Send the email only if asked. A send failure doesn't delete the invite — the
|
||||||
// can share it manually. A send failure doesn't delete the invite — surface it.
|
// link is still returned so the admin can share it manually.
|
||||||
let emailed = false
|
let emailed = false
|
||||||
let emailError = null
|
let emailError = null
|
||||||
|
if (sendEmail) {
|
||||||
try {
|
try {
|
||||||
const result = await mailer.sendInvite({ to: email, acceptUrl: url, role, invitedByName: req.user.username })
|
const result = await mailer.sendInvite({ to: email, acceptUrl: url, role, invitedByName: req.user.username })
|
||||||
emailed = Boolean(result.sent)
|
emailed = Boolean(result.sent)
|
||||||
|
if (!result.sent && result.reason === 'NOT_CONFIGURED') emailError = 'email is not configured'
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
emailError = err.message
|
emailError = err.message
|
||||||
log.warn('invite email failed (invite still created)', { id: invite.id, message: 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 } })
|
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 })
|
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
|
// acceptUrl is always returned (copyable link); emailed says whether it also went out.
|
||||||
// can copy it. When emailed, we don't echo the token.
|
return res.status(201).json({ invite, emailed, acceptUrl: url, emailError })
|
||||||
return res.status(201).json({ invite, emailed, acceptUrl: emailed ? undefined : url, emailError })
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('create invite', err)
|
log.error('create invite', err)
|
||||||
return res.status(500).json({ message: 'Internal Server Error' })
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
|||||||
@@ -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": {
|
"/api/v1/admin/shard/kick": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
Reference in New Issue
Block a user