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

@@ -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) -----

View File

@@ -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 (
<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 }) {
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 }) {
</select>
</label>
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
{busy ? 'Sending…' : 'Send invite'}
{busy ? 'Creating…' : (sendEmail ? 'Create & email' : 'Create link')}
</button>
</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>}
{result && (
<div style={{ marginTop: 14 }}>
{result.emailed ? (
<p className="sans" style={{ margin: 0, color: '#7fd0a4', fontSize: '0.86rem' }}>Invitation emailed.</p>
) : (
<div className="sans" style={{ fontSize: '0.84rem', color: 'var(--muted)' }}>
<p style={{ margin: '0 0 6px', color: '#e0b070' }}>
Email isnt configured{result.emailError ? ` (${result.emailError})` : ''} share this single-use link:
</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)' }}>
{result.acceptUrl}
</code>
</div>
)}
<p className="sans" style={{ margin: '0 0 8px', fontSize: '0.84rem', color: result.emailed ? '#7fd0a4' : 'var(--muted)' }}>
{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:`}
</p>
<CopyLink url={result.acceptUrl} />
</div>
)}
</div>

View File

@@ -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 servers 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() {