From 2976d5982fe4fd51690133d205291bd51f72f721 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 16:06:01 -0500 Subject: [PATCH] =?UTF-8?q?feat(provisioning):=20provisioning=20UI=20?= =?UTF-8?q?=E2=80=94=20signup,=20invites,=20accept=20page,=20unlink?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6: the UI for the Phase 5 provisioning backend. - CreateGameAccountForm: reusable game-account form (own username + password), mapping the sidecar errors (409/429/403/503) to friendly messages. Wired into GameAccounts (self-serve) — shown alongside the [link flow when the game_account_signup flag is on (exposed via public settings), so a registered player can create + link a game account from their portal. - Admin Invites view (/admin/invites, admin-only): send an invite at a chosen access level, list invites with status, revoke pending ones. When email isn't configured the create response's accept link is surfaced to copy manually. - Public accept page (/invite/:token): validates the invite, sets username + password (email + role pre-assigned), creates the account at that role and logs in; for a player invite it then offers the built-in "create game account" step before the portal. Honeypot-guarded like registration. - Admin unlink wired into UserDetail via GameAccounts (per-account Unlink button, confirm + reconcile). - Backend: expose gameAccountSignup availability in public settings. Client build clean; server 193/193. Refs .plans/protocol2-integration.md (Phase 6). Completes the Protocol 2.0/2.1 integration. Co-Authored-By: Claude Opus 4.8 --- client/src/App.jsx | 4 + client/src/api/client.js | 11 ++ .../src/components/CreateGameAccountForm.jsx | 69 ++++++++ client/src/components/GameAccounts.jsx | 78 +++++++-- client/src/routes/admin/AdminLayout.jsx | 2 + .../src/routes/admin/views/InvitesAdmin.jsx | 152 ++++++++++++++++++ client/src/routes/admin/views/UserDetail.jsx | 2 +- client/src/routes/player/AcceptInvite.jsx | 132 +++++++++++++++ server/src/model/settings/settings.model.js | 3 + 9 files changed, 442 insertions(+), 11 deletions(-) create mode 100644 client/src/components/CreateGameAccountForm.jsx create mode 100644 client/src/routes/admin/views/InvitesAdmin.jsx create mode 100644 client/src/routes/player/AcceptInvite.jsx diff --git a/client/src/App.jsx b/client/src/App.jsx index 66f2881..f803e27 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -46,6 +46,7 @@ import AdminCharacter from './routes/admin/views/AdminCharacter.jsx' import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx' import UsersAdmin from './routes/admin/views/UsersAdmin.jsx' import UserDetail from './routes/admin/views/UserDetail.jsx' +import InvitesAdmin from './routes/admin/views/InvitesAdmin.jsx' import AccountAdmin from './routes/admin/views/AccountAdmin.jsx' import Moderation from './routes/admin/views/Moderation.jsx' import ModerationUser from './routes/admin/views/ModerationUser.jsx' @@ -53,6 +54,7 @@ import ModerationUser from './routes/admin/views/ModerationUser.jsx' // Player portal import PlayerLogin from './routes/player/PlayerLogin.jsx' import PlayerRegister from './routes/player/PlayerRegister.jsx' +import AcceptInvite from './routes/player/AcceptInvite.jsx' import PlayerPortalLayout from './routes/player/PlayerPortalLayout.jsx' import PlayerCharacters from './routes/player/PlayerCharacters.jsx' import PlayerCharacter from './routes/player/PlayerCharacter.jsx' @@ -147,6 +149,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> @@ -154,6 +157,7 @@ export default function App() { {/* Player portal */} } /> } /> + } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index c515752..9b46a73 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -48,6 +48,10 @@ export const api = { // optional email. Returns { user } and sets the session cookie on success. register: (username, password, extra = {}) => req('/auth/register', { method: 'POST', body: { username, password, ...extra } }), + // Email invites (public, token-gated accept). + getInvite: (token) => req(`/auth/invite/${encodeURIComponent(token)}`), + acceptInvite: (token, username, password, extra = {}) => + req(`/auth/invite/${encodeURIComponent(token)}/accept`, { method: 'POST', body: { username, password, ...extra } }), loginTotp: (challenge, code) => req('/auth/login/totp', { method: 'POST', body: { challenge, code } }), // Second factor for an SSO login (challenge is held in an httpOnly cookie set by @@ -171,6 +175,10 @@ export const api = { createUser: (data) => req('/admin/users', { method: 'POST', body: data }), updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }), 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 } }), + 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 // reuse the admin-bypass /admin/shard/* endpoints (which already read any @@ -184,6 +192,7 @@ export const api = { houses: () => req(`/admin/users/${id}/shard/houses`), online: () => req(`/admin/users/${id}/shard/online`), standing: () => req(`/admin/users/${id}/shard/standing`), + unlink: (account) => req(`/admin/users/${id}/shard/link/${encodeURIComponent(account)}`, { method: 'DELETE' }), }), // ----- moderation dashboard (admin + moderator) ----- @@ -313,6 +322,8 @@ export const api = { vendors: (account) => req(`/player/shard/vendors/${encodeURIComponent(account)}`), char: (serial) => req(`/player/shard/char/${encodeURIComponent(serial)}`), sales: () => req('/player/shard/sales'), + createAccount: (account, password) => + req('/player/shard/account', { method: 'POST', body: { account, password } }), }, }, } diff --git a/client/src/components/CreateGameAccountForm.jsx b/client/src/components/CreateGameAccountForm.jsx new file mode 100644 index 0000000..7b045f6 --- /dev/null +++ b/client/src/components/CreateGameAccountForm.jsx @@ -0,0 +1,69 @@ +import { useState } from 'react' + +// Reusable "create a game account" form (its own username + password — the game +// client credentials, distinct from the website login). Calls `submit(account, +// password)` which should POST /player/shard/account; on success calls onCreated. +// Used by the player portal (self-serve) and the invite-accept page alike. +export default function CreateGameAccountForm({ submit, onCreated, compact = false }) { + const [account, setAccount] = useState('') + const [password, setPassword] = useState('') + const [busy, setBusy] = useState(false) + const [msg, setMsg] = useState('') + const [error, setError] = useState('') + + async function onSubmit(e) { + e.preventDefault() + setMsg(''); setError('') + if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{2,29}$/.test(account)) { + return setError('Account name must be 3–30 letters, numbers, . _ or -.') + } + if (password.length < 8) return setError('Password must be at least 8 characters.') + setBusy(true) + try { + await submit(account, password) + setMsg(`Game account “${account}” created and linked.`) + setAccount(''); setPassword('') + if (onCreated) await onCreated() + } catch (err) { + if (err.status === 409) setError('That account name is already taken.') + else if (err.status === 429) setError('The account limit for your network has been reached.') + else if (err.status === 403) setError('Game-account signup is not available right now.') + else if (err.status === 503) setError('The game server is unavailable — try again shortly.') + else setError(err.message || 'Could not create the account right now.') + } finally { + setBusy(false) + } + } + + return ( +
+ {!compact && ( +

+ Choose the username and password you’ll type into the game client. These are your + game credentials — separate from your website login. +

+ )} + + + + {error &&

{error}

} + {msg &&

{msg}

} + + +
+ ) +} diff --git a/client/src/components/GameAccounts.jsx b/client/src/components/GameAccounts.jsx index fccc8b8..ff1539d 100644 --- a/client/src/components/GameAccounts.jsx +++ b/client/src/components/GameAccounts.jsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useState } from 'react' import { Link } from 'react-router-dom' import { Loading, ErrorState } from './PageState.jsx' import ShardAccountActions from './ShardAccountActions.jsx' +import CreateGameAccountForm from './CreateGameAccountForm.jsx' +import { api } from '../api/client.js' // Shared game-account linking + character roster, used by both the player portal // (/player) and the staff account page (/admin/account). `scope` is the api @@ -108,9 +110,37 @@ function AccountRoster({ scope, account, charTo }) { ) } -export default function GameAccounts({ scope, charTo, readOnly = false, moderation = false }) { +// Compact per-account "Unlink" button for the admin (readOnly) view. Confirms, +// then calls onUnlink(account) and reloads. Errors surface inline. +function UnlinkButton({ account, onUnlink }) { + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + async function go() { + if (!window.confirm(`Unlink game account “${account}” from this user? Attribution stops immediately.`)) return + setBusy(true); setError('') + try { + await onUnlink(account) + } catch (err) { + setError(err.status === 403 ? 'Protected account — refused.' : err.status === 404 ? 'Not linked.' : (err.message || 'Could not unlink.')) + setBusy(false) + } + } + return ( + + + {error && {error}} + + ) +} + +export default function GameAccounts({ scope, charTo, readOnly = false, moderation = false, onUnlink = null }) { const [accounts, setAccounts] = useState(null) const [error, setError] = useState('') + // Whether the site currently offers game-account creation (public flag). Only + // relevant for the self-service (non-readOnly) view with a createAccount scope. + const [signupOk, setSignupOk] = useState(false) const load = useCallback(async () => { setError('') @@ -122,6 +152,17 @@ export default function GameAccounts({ scope, charTo, readOnly = false, moderati }, [scope, readOnly]) useEffect(() => { load() }, [load]) + useEffect(() => { + if (readOnly || !scope.createAccount) return + let active = true + api.publicSettings() + .then((s) => active && setSignupOk(Boolean(s?.gameAccountSignup))) + .catch(() => {}) + return () => { active = false } + }, [readOnly, scope]) + + const canCreate = !readOnly && Boolean(scope.createAccount) && signupOk + if (error) return if (!accounts) return @@ -138,13 +179,21 @@ export default function GameAccounts({ scope, charTo, readOnly = false, moderati ) } return ( -
-
Link your game account
-

- You haven’t linked a game account yet. In game, type [link to get a - one-time code, then enter it below to see your characters, stats, skills and vendors here. -

- +
+
+
Link your game account
+

+ Already play? In game, type [link to get a + one-time code, then enter it below to see your characters, stats, skills and vendors here. +

+ +
+ {canCreate && ( +
+
Create a new game account
+ +
+ )}
) } @@ -154,8 +203,11 @@ export default function GameAccounts({ scope, charTo, readOnly = false, moderati
{accounts.map((a) => (
-
- {a.account} +
+
+ {a.account} +
+ {onUnlink && { await onUnlink(acct); await load() }} />}
{moderation && } @@ -165,6 +217,12 @@ export default function GameAccounts({ scope, charTo, readOnly = false, moderati
Link another account
+ {canCreate && ( +
+
Create another game account
+ +
+ )}
)}
diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index 3073b89..5294579 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -70,6 +70,7 @@ const NAV = [ title: 'System', items: [ { to: '/admin/users', label: 'Users', icon: IconUsers, roles: ['admin'] }, + { to: '/admin/invites', label: 'Invites', icon: IconUsers, roles: ['admin'] }, { to: '/admin/settings', label: 'Settings', icon: IconGear, roles: ['admin'] }, { to: '/admin/hero', label: 'Hero Editor', icon: IconHero, roles: ['admin'] }, { to: '/admin/auth-providers', label: 'Authentication', icon: IconKey, roles: ['admin'] }, @@ -104,6 +105,7 @@ const TITLES = { '/admin/characters': 'My Characters', '/admin/auth-providers': 'Authentication', '/admin/users': 'Users', + '/admin/invites': 'Invites', '/admin/account': 'Account Security', } diff --git a/client/src/routes/admin/views/InvitesAdmin.jsx b/client/src/routes/admin/views/InvitesAdmin.jsx new file mode 100644 index 0000000..f064194 --- /dev/null +++ b/client/src/routes/admin/views/InvitesAdmin.jsx @@ -0,0 +1,152 @@ +import { useCallback, useEffect, useState } from 'react' +import { Loading, ErrorState } from '../../../components/PageState.jsx' +import { dateTime } from '../../../lib/format.js' +import { api } from '../../../api/client.js' + +// Admin email invites: send an invite at a chosen access level, see recent +// invites and their status, revoke pending ones. When email delivery isn't +// configured the create response hands back the accept link to copy manually. + +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 CreateInvite({ onCreated }) { + const [email, setEmail] = useState('') + const [role, setRole] = useState('player') + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const [result, setResult] = useState(null) // { emailed, acceptUrl } + + async function submit(e) { + e.preventDefault() + setError(''); setResult(null) + if (!email.trim()) return setError('Enter an email address.') + setBusy(true) + try { + const res = await api.admin.createInvite(email.trim(), role) + setResult(res) + setEmail('') + await onCreated() + } catch (err) { + setError(err.message || 'Could not create the invite.') + } finally { + setBusy(false) + } + } + + return ( +
+
Invite someone
+
+ + + +
+ + {error &&

{error}

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

Invitation emailed.

+ ) : ( +
+

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

+ + {result.acceptUrl} + +
+ )} +
+ )} +
+ ) +} + +export default function InvitesAdmin() { + const [invites, setInvites] = useState(null) + const [error, setError] = useState('') + + const load = useCallback(async () => { + setError('') + try { + setInvites(await api.admin.listInvites()) + } catch { + setError('Could not load invites.') + } + }, []) + useEffect(() => { load() }, [load]) + + async function revoke(id) { + if (!window.confirm('Revoke this pending invitation?')) return + try { + await api.admin.revokeInvite(id) + await load() + } catch { + /* surfaced by the row staying; keep it simple */ + } + } + + if (error) return + + return ( +
+ + + {!invites ? ( + + ) : ( +
+ + + + + + + + + + + + {invites.length === 0 && ( + + )} + {invites.map((iv) => { + const status = iv.status === 'pending' && iv.expired ? 'expired' : iv.status + return ( + + + + + + + + + ) + })} + +
EmailRoleStatusExpiresCreated +
No invites yet.
{iv.email}{iv.role}{status}{dateTime(iv.expiresAt)}{dateTime(iv.createdAt)} + {iv.status === 'pending' && ( + + )} +
+
+ )} +
+ ) +} diff --git a/client/src/routes/admin/views/UserDetail.jsx b/client/src/routes/admin/views/UserDetail.jsx index 7bc5a31..bdf276a 100644 --- a/client/src/routes/admin/views/UserDetail.jsx +++ b/client/src/routes/admin/views/UserDetail.jsx @@ -130,7 +130,7 @@ function ShardSections({ scope }) { <> Linked accounts & characters - `/admin/characters/${serial}`} /> + `/admin/characters/${serial}`} /> diff --git a/client/src/routes/player/AcceptInvite.jsx b/client/src/routes/player/AcceptInvite.jsx new file mode 100644 index 0000000..4498ad6 --- /dev/null +++ b/client/src/routes/player/AcceptInvite.jsx @@ -0,0 +1,132 @@ +import { useEffect, useState } from 'react' +import { Link, useNavigate, useParams } from 'react-router-dom' +import { useAuth } from '../../contexts/AuthContext.jsx' +import { api } from '../../api/client.js' +import PlayerShell, { honeypotStyle } from './PlayerShell.jsx' +import CreateGameAccountForm from '../../components/CreateGameAccountForm.jsx' + +// Public, token-gated invite acceptance (/invite/:token). Validates the invite, +// lets the invitee set a username + password (their email + role are pre-assigned), +// creates the account at that role and logs them in. For a player invite it then +// offers the built-in "create game account" step before sending them to the portal. +export default function AcceptInvite() { + const { token } = useParams() + const navigate = useNavigate() + const { refresh } = useAuth() + + const [invite, setInvite] = useState(null) // { email, role } + const [loadErr, setLoadErr] = useState('') + const [signupOk, setSignupOk] = useState(false) + + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [company, setCompany] = useState('') // honeypot + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + const [accepted, setAccepted] = useState(false) + + useEffect(() => { + let active = true + api.getInvite(token) + .then((iv) => active && setInvite(iv)) + .catch((err) => active && setLoadErr(err.status === 404 ? 'This invitation is invalid or has expired.' : 'Could not load this invitation.')) + api.publicSettings() + .then((s) => active && setSignupOk(Boolean(s?.gameAccountSignup))) + .catch(() => {}) + return () => { active = false } + }, [token]) + + const dest = invite && invite.role === 'player' ? '/player' : '/admin' + + async function onSubmit(e) { + e.preventDefault() + setError('') + if (username.trim().length < 3) return setError('Username must be at least 3 characters.') + if (password.length < 8) return setError('Password must be at least 8 characters.') + setBusy(true) + try { + await api.acceptInvite(token, username.trim(), password, { company }) + await refresh() // pull the freshly-issued session into context + setAccepted(true) + // Staff invites are web-only — no game step; go straight in. + if (!(invite.role === 'player' && signupOk)) navigate(dest, { replace: true }) + } catch (err) { + if (err.status === 409) setError('That username is already taken, or the invite was already used.') + else if (err.status === 404) setError('This invitation is invalid or has expired.') + else if (err.status === 400) setError(err.message || 'Please check your details and try again.') + else setError('Could not accept the invitation right now.') + setBusy(false) + } + } + + // ── Loading / invalid ───────────────────────────────────────────────────── + if (loadErr) { + return ( + +

{loadErr}

+

+ Go to sign in +

+
+ ) + } + if (!invite) { + return ( + +
+
+ ) + } + + // ── Accepted: optional game-account step (player invites) ────────────────── + if (accepted) { + return ( + +

+ Your account is ready. Create a game account now to play, or skip and do it later from your portal. +

+ navigate('/player', { replace: true })} + /> +

+ +

+
+ ) + } + + // ── Accept form ──────────────────────────────────────────────────────────── + return ( + +

+ You’ve been invited as {invite.role} + {invite.email ? <> for {invite.email} : null}. Choose a username and password to finish. +

+
+ + + + + {error &&

{error}

} + + +
+
+ ) +} diff --git a/server/src/model/settings/settings.model.js b/server/src/model/settings/settings.model.js index 19e0d9f..6488558 100644 --- a/server/src/model/settings/settings.model.js +++ b/server/src/model/settings/settings.model.js @@ -71,6 +71,9 @@ async function getPublic() { // page show/hide the password form and SSO buttons. const mode = REGISTRATION_MODES.includes(all[REGISTRATION_KEY]) ? all[REGISTRATION_KEY] : 'disabled' 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' return out }