From 5daf260db93ed333658d8c7f7179ff789eae882d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 01:49:12 -0500 Subject: [PATCH] Player accounts frontend + Swagger + schema comment fix - Player portal: RequirePlayer guard, /account routes (login, register, settings) with shared PlayerShell; register reads /public/settings derived flags; AuthContext.register; api.register + api.player.* namespace. - Admin UI: player role + status/email + reset-password hint in UserEditor, status column + badge-player in UsersAdmin, player_registration select in SettingsAdmin; 'disabled' SSO error copy. - Swagger: Player tag + RegisterRequest/ChangeUsername/ChangePassword/ PlayerAccount/OkFlag schemas; regenerated swagger-output.json. - Fix: remove a semicolon from a schema.sql inline comment that broke the statement splitter in ensureSchema. Verified against the live dev DB: schema migrations apply (player enum, nullable password_hash, email/status/last_login_ip, seeded setting); 21-check controller smoke (register gating, dup/reserved, null-hash rules, self change username/password with session re-issue surviving the cutoff, SSO-only initial password, banned-login refusal); case-insensitive uniqueness; public settings expose only derived registration flags. Client builds; 133 server tests green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV --- client/src/App.jsx | 18 + client/src/api/client.js | 20 + client/src/components/RequirePlayer.jsx | 23 + client/src/contexts/AuthContext.jsx | 10 +- client/src/routes/admin/AdminLogin.jsx | 1 + .../src/routes/admin/views/SettingsAdmin.jsx | 29 +- client/src/routes/admin/views/UserEditor.jsx | 51 +- client/src/routes/admin/views/UsersAdmin.jsx | 18 +- client/src/routes/player/PlayerAccount.jsx | 375 ++++++ client/src/routes/player/PlayerLogin.jsx | 205 ++++ client/src/routes/player/PlayerRegister.jsx | 163 +++ client/src/routes/player/PlayerShell.jsx | 72 ++ client/src/styles/theme.css | 5 + server/db/schema.sql | 2 +- server/swagger/swagger-output.json | 1067 ++++++++++++++++- server/swagger/swagger.js | 62 +- 16 files changed, 2102 insertions(+), 19 deletions(-) create mode 100644 client/src/components/RequirePlayer.jsx create mode 100644 client/src/routes/player/PlayerAccount.jsx create mode 100644 client/src/routes/player/PlayerLogin.jsx create mode 100644 client/src/routes/player/PlayerRegister.jsx create mode 100644 client/src/routes/player/PlayerShell.jsx diff --git a/client/src/App.jsx b/client/src/App.jsx index 9deccb3..e83ae99 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -3,6 +3,7 @@ import { AuthProvider } from './contexts/AuthContext.jsx' import { SiteProvider } from './contexts/SiteContext.jsx' import MaintenanceGate from './components/MaintenanceGate.jsx' import RequireAuth from './components/RequireAuth.jsx' +import RequirePlayer from './components/RequirePlayer.jsx' import RoleGate from './components/RoleGate.jsx' // Public @@ -35,6 +36,11 @@ import AccountAdmin from './routes/admin/views/AccountAdmin.jsx' import Moderation from './routes/admin/views/Moderation.jsx' 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 PlayerAccount from './routes/player/PlayerAccount.jsx' + export default function App() { return ( @@ -96,6 +102,18 @@ export default function App() { } /> + {/* Player portal */} + } /> + } /> + + + + } + /> + } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index 91c8223..5c0fc18 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -44,6 +44,10 @@ export const api = { // `extra` carries the honeypot field (and any future login fields). login: (username, password, extra = {}) => req('/auth/login', { method: 'POST', body: { username, password, ...extra } }), + // Public self-registration (player accounts). `extra` carries the honeypot + + // optional email. Returns { user } and sets the session cookie on success. + register: (username, password, extra = {}) => + req('/auth/register', { 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 @@ -186,6 +190,22 @@ export const api = { getDiscordBotConfig: () => req('/admin/discord-bot/config'), saveDiscordBotConfig: (data) => req('/admin/discord-bot/config', { method: 'PUT', body: data }), }, + + // ----- player self-service (role: 'player') ----- + // Mirrors the admin account methods but self-scoped under /player. The change + // endpoints re-issue the session cookie server-side, so the caller stays signed in. + player: { + getAccount: () => req('/player/account'), + changeUsername: (username) => + req('/player/account/username', { method: 'PATCH', body: { username } }), + changePassword: (newPassword, currentPassword) => + req('/player/account/password', { method: 'PATCH', body: { newPassword, currentPassword } }), + totpSetup: () => req('/player/account/totp/setup', { method: 'POST' }), + totpEnable: (code) => req('/player/account/totp/enable', { method: 'POST', body: { code } }), + totpDisable: (code) => req('/player/account/totp/disable', { method: 'POST', body: { code } }), + linkedIdentities: () => req('/player/account/identities'), + unlinkIdentity: (provider) => req(`/player/account/identities/${provider}`, { method: 'DELETE' }), + }, } export { ApiError } diff --git a/client/src/components/RequirePlayer.jsx b/client/src/components/RequirePlayer.jsx new file mode 100644 index 0000000..32547cf --- /dev/null +++ b/client/src/components/RequirePlayer.jsx @@ -0,0 +1,23 @@ +import { Navigate, useLocation } from 'react-router-dom' +import { useAuth } from '../contexts/AuthContext.jsx' + +// Gate for the /account player portal. Redirects to the player login when there +// is no session, or when the signed-in user is not a player (staff manage their +// own account under /admin/account). Server-side requireRole('player') is the +// real enforcement; this just keeps the UI honest. +export default function RequirePlayer({ children }) { + const { user, loading } = useAuth() + const location = useLocation() + + if (loading) { + return ( +
+ +
+ ) + } + if (!user || user.role !== 'player') { + return + } + return children +} diff --git a/client/src/contexts/AuthContext.jsx b/client/src/contexts/AuthContext.jsx index 42f25ee..298ecff 100644 --- a/client/src/contexts/AuthContext.jsx +++ b/client/src/contexts/AuthContext.jsx @@ -30,6 +30,14 @@ export function AuthProvider({ children }) { return data }, []) + // Public self-registration (player). Creates the account, sets the session + // cookie, and returns { user }. `extra` carries the honeypot + optional email. + const register = useCallback(async (username, password, extra) => { + const data = await api.register(username, password, extra) + if (data.user) setUser(data.user) + return data + }, []) + // Step 2 for TOTP users: exchange the challenge + code for a real session. const loginTotp = useCallback(async (challenge, code) => { const data = await api.loginTotp(challenge, code) @@ -54,7 +62,7 @@ export function AuthProvider({ children }) { }, []) return ( - + {children} ) diff --git a/client/src/routes/admin/AdminLogin.jsx b/client/src/routes/admin/AdminLogin.jsx index 73ec8d2..c492276 100644 --- a/client/src/routes/admin/AdminLogin.jsx +++ b/client/src/routes/admin/AdminLogin.jsx @@ -8,6 +8,7 @@ import { api } from '../../api/client.js' // Friendly copy for the ?sso_error codes the SSO callback can redirect back with. const SSO_ERRORS = { not_linked: 'That account is not linked to an admin user. Sign in with your password, then link it under Account.', + disabled: 'This account is not active. Contact an administrator.', denied: 'Sign-in was cancelled.', unavailable: 'That sign-in method is not available right now.', bad_state: 'Your sign-in session expired. Please try again.', diff --git a/client/src/routes/admin/views/SettingsAdmin.jsx b/client/src/routes/admin/views/SettingsAdmin.jsx index 65bb5d9..ce95ef0 100644 --- a/client/src/routes/admin/views/SettingsAdmin.jsx +++ b/client/src/routes/admin/views/SettingsAdmin.jsx @@ -10,6 +10,18 @@ const FIELDS = [ { key: 'maintenance_message', label: 'Maintenance message', long: true }, { key: 'status_message', label: 'Status message' }, { key: 'contact_email', label: 'Contact email' }, + { + key: 'player_registration', + label: 'Player registration', + help: 'Who can create a player account, and how. Off by default.', + options: [ + { value: 'disabled', label: 'Disabled — no self-registration' }, + { value: 'password', label: 'Password — username + password sign-up' }, + { value: 'sso', label: 'SSO — sign up with a linked provider' }, + { value: 'both', label: 'Both — password and SSO' }, + ], + fallback: 'disabled', + }, ] export default function SettingsAdmin() { @@ -28,7 +40,7 @@ export default function SettingsAdmin() { .then((all) => { if (!active) return const v = {} - FIELDS.forEach((f) => (v[f.key] = all[f.key] ?? '')) + FIELDS.forEach((f) => (v[f.key] = all[f.key] ?? f.fallback ?? '')) setValues(v) setInitial(v) }) @@ -68,11 +80,24 @@ export default function SettingsAdmin() { {FIELDS.map((f) => (