Files
website/server/src/router/v1/admin/invites.controller.js
Claude 3ef1c8e438
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
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>
2026-07-17 21:08:59 -05:00

91 lines
3.8 KiB
JavaScript

// ── Admin: email invites ───────────────────────────────────────────────────
//
// Admin-only. A staff member invites someone by email at a pre-chosen access
// level; the invitee accepts via a tokened link (auth/invite.controller) which
// creates their website user at that role. The plaintext token exists only in the
// emailed link and in the create response (so the admin can copy the link if email
// isn't configured); the DB stores only its hash.
const invites = require('../../../model/invites/invites.model')
const activity = require('../../../model/activity/activity.model')
const mailer = require('../../../utils/mailer')
const log = require('../../../utils/logger')('admin-invites')
const ROLES = ['admin', 'editor', 'moderator', 'player']
function baseUrl() {
return (process.env.APP_BASE_URL || 'http://localhost:5173').replace(/\/+$/, '')
}
function acceptUrl(token) {
return `${baseUrl()}/invite/${token}`
}
// 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.' })
}
try {
const { invite, token } = await invites.create({ email, role, invitedBy: req.user.id })
const url = acceptUrl(token)
// 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
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 })
// 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' })
}
}
// GET /admin/invites — recent invites (no tokens).
async function list(req, res) {
try {
return res.json(await invites.list(req.query.limit))
} catch (err) {
log.error('list invites', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
// DELETE /admin/invites/:id — revoke a pending invite.
async function revoke(req, res) {
const id = Number(req.params.id)
try {
const changed = await invites.revoke(id)
if (!changed) return res.status(404).json({ message: 'No pending invite to revoke.' })
await activity.log({ req, userId: req.user.id, action: 'invite.revoke', detail: { id } })
return res.json({ id, revoked: true })
} catch (err) {
log.error('revoke invite', err)
return res.status(500).json({ message: 'Internal Server Error' })
}
}
module.exports = { create, list, revoke }