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) => (