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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
This commit is contained in:
@@ -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 (
|
||||
<AuthProvider>
|
||||
@@ -96,6 +102,18 @@ export default function App() {
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Route>
|
||||
|
||||
{/* Player portal */}
|
||||
<Route path="/account/login" element={<PlayerLogin />} />
|
||||
<Route path="/account/register" element={<PlayerRegister />} />
|
||||
<Route
|
||||
path="/account"
|
||||
element={
|
||||
<RequirePlayer>
|
||||
<PlayerAccount />
|
||||
</RequirePlayer>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</SiteProvider>
|
||||
|
||||
@@ -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 }
|
||||
|
||||
23
client/src/components/RequirePlayer.jsx
Normal file
23
client/src/components/RequirePlayer.jsx
Normal file
@@ -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 (
|
||||
<div style={{ minHeight: '100vh', display: 'grid', placeItems: 'center', background: 'var(--bg-deep)' }}>
|
||||
<span className="spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!user || user.role !== 'player') {
|
||||
return <Navigate to="/account/login" state={{ from: location }} replace />
|
||||
}
|
||||
return children
|
||||
}
|
||||
@@ -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 (
|
||||
<AuthContext.Provider value={{ user, loading, login, loginTotp, ssoLoginTotp, logout, refresh }}>
|
||||
<AuthContext.Provider value={{ user, loading, login, register, loginTotp, ssoLoginTotp, logout, refresh }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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) => (
|
||||
<label key={f.key} style={{ display: 'block' }}>
|
||||
<span className="field-label">{f.label}</span>
|
||||
{f.long ? (
|
||||
{f.options ? (
|
||||
<select value={values[f.key]} onChange={set(f.key)} className="select">
|
||||
{f.options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : f.long ? (
|
||||
<textarea value={values[f.key]} onChange={set(f.key)} className="textarea" style={{ minHeight: 90 }} />
|
||||
) : (
|
||||
<input type="text" value={values[f.key]} onChange={set(f.key)} className="input" />
|
||||
)}
|
||||
{f.help && (
|
||||
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
|
||||
{f.help}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 6, alignItems: 'center' }}>
|
||||
|
||||
@@ -8,6 +8,8 @@ export default function UserEditor({ user, onClose, onSaved }) {
|
||||
username: user?.username || '',
|
||||
password: '',
|
||||
role: user?.role || 'admin',
|
||||
status: user?.status || 'active',
|
||||
email: user?.email || '',
|
||||
})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
@@ -21,12 +23,19 @@ export default function UserEditor({ user, onClose, onSaved }) {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
const email = form.email.trim() || null
|
||||
if (isEdit) {
|
||||
const payload = { username: form.username.trim(), role: form.role }
|
||||
const payload = { username: form.username.trim(), role: form.role, status: form.status, email }
|
||||
if (form.password) payload.password = form.password
|
||||
await api.admin.updateUser(user.id, payload)
|
||||
} else {
|
||||
await api.admin.createUser({ username: form.username.trim(), password: form.password, role: form.role })
|
||||
await api.admin.createUser({
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
role: form.role,
|
||||
status: form.status,
|
||||
email,
|
||||
})
|
||||
}
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
@@ -75,17 +84,41 @@ export default function UserEditor({ user, onClose, onSaved }) {
|
||||
<input type="text" value={form.username} onChange={set('username')} className="input" autoComplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">{isEdit ? 'New password (leave blank to keep)' : 'Password'}</span>
|
||||
<span className="field-label">
|
||||
{isEdit ? 'Reset password (leave blank to keep)' : 'Password'}
|
||||
</span>
|
||||
<input type="password" value={form.password} onChange={set('password')} className="input" autoComplete="new-password" />
|
||||
{isEdit && (
|
||||
<span className="sans dim" style={{ display: 'block', marginTop: 6, fontSize: '0.76rem' }}>
|
||||
Setting a new password here is the supported reset for a player who is locked out. It logs
|
||||
their other sessions out.
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<label>
|
||||
<span className="field-label">Role</span>
|
||||
<select value={form.role} onChange={set('role')} className="select">
|
||||
<option value="admin">admin</option>
|
||||
<option value="editor">editor</option>
|
||||
<option value="moderator">moderator</option>
|
||||
</select>
|
||||
<span className="field-label">Email (optional)</span>
|
||||
<input type="email" value={form.email} onChange={set('email')} className="input" autoComplete="off" placeholder="player@example.com" />
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<label style={{ flex: 1 }}>
|
||||
<span className="field-label">Role</span>
|
||||
<select value={form.role} onChange={set('role')} className="select">
|
||||
<option value="admin">admin</option>
|
||||
<option value="editor">editor</option>
|
||||
<option value="moderator">moderator</option>
|
||||
<option value="player">player</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style={{ flex: 1 }}>
|
||||
<span className="field-label">Status</span>
|
||||
<select value={form.status} onChange={set('status')} className="select">
|
||||
<option value="active">active</option>
|
||||
<option value="disabled">disabled</option>
|
||||
<option value="banned">banned</option>
|
||||
<option value="pending">pending</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -5,7 +5,12 @@ import { dateTime } from '../../../lib/format.js'
|
||||
import { api } from '../../../api/client.js'
|
||||
import UserEditor from './UserEditor.jsx'
|
||||
|
||||
const ROLE_BADGE = { admin: 'badge-admin', editor: 'badge-editor', moderator: 'badge-moderator' }
|
||||
const ROLE_BADGE = {
|
||||
admin: 'badge-admin',
|
||||
editor: 'badge-editor',
|
||||
moderator: 'badge-moderator',
|
||||
player: 'badge-player',
|
||||
}
|
||||
|
||||
export default function UsersAdmin() {
|
||||
const [tick, setTick] = useState(0)
|
||||
@@ -18,7 +23,7 @@ export default function UsersAdmin() {
|
||||
<section>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18, flexWrap: 'wrap', gap: 12 }}>
|
||||
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
||||
Manage admin, editor, and moderator accounts
|
||||
Manage admin, editor, moderator, and player accounts
|
||||
</p>
|
||||
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
|
||||
+ Add user
|
||||
@@ -35,6 +40,7 @@ export default function UsersAdmin() {
|
||||
<tr>
|
||||
<th className="adm-th">Username</th>
|
||||
<th className="adm-th">Role</th>
|
||||
<th className="adm-th">Status</th>
|
||||
<th className="adm-th">Last login</th>
|
||||
<th className="adm-th" />
|
||||
</tr>
|
||||
@@ -48,6 +54,14 @@ export default function UsersAdmin() {
|
||||
<td className="adm-td">
|
||||
<span className={`badge ${ROLE_BADGE[u.role] || 'badge-editor'}`}>{u.role}</span>
|
||||
</td>
|
||||
<td className="adm-td">
|
||||
<span
|
||||
className="sans"
|
||||
style={{ fontSize: '0.82rem', color: u.status && u.status !== 'active' ? '#d98b84' : 'var(--muted)' }}
|
||||
>
|
||||
{u.status || 'active'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="adm-td dim">{u.last_login_at ? dateTime(u.last_login_at) : 'never'}</td>
|
||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||
<span className="link-accent" onClick={() => setEditing(u)}>
|
||||
|
||||
375
client/src/routes/player/PlayerAccount.jsx
Normal file
375
client/src/routes/player/PlayerAccount.jsx
Normal file
@@ -0,0 +1,375 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||
import { Loading, ErrorState } from '../../components/PageState.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
|
||||
// ── Change username ────────────────────────────────────────────────────────
|
||||
function ChangeUsername({ account, onChanged }) {
|
||||
const [username, setUsername] = useState(account.username)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function save(e) {
|
||||
e.preventDefault()
|
||||
setMsg('')
|
||||
setError('')
|
||||
if (username.trim().length < 3) return setError('Username must be at least 3 characters.')
|
||||
setBusy(true)
|
||||
try {
|
||||
const { username: next } = await api.player.changeUsername(username.trim())
|
||||
setMsg('Username updated.')
|
||||
await onChanged(next)
|
||||
} catch (err) {
|
||||
if (err.status === 409) setError('That username is already taken.')
|
||||
else setError(err.message || 'Could not change your username.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title="Username">
|
||||
<form onSubmit={save} style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 320 }}>
|
||||
<label>
|
||||
<span className="field-label">Username</span>
|
||||
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} className="input" autoComplete="username" />
|
||||
</label>
|
||||
<div>
|
||||
<button type="submit" disabled={busy || username.trim() === account.username} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Saving…' : 'Change username'}
|
||||
</button>
|
||||
</div>
|
||||
<Note msg={msg} error={error} />
|
||||
</form>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Change / set password ──────────────────────────────────────────────────
|
||||
function ChangePassword({ account }) {
|
||||
const hasPassword = account.has_password
|
||||
const [current, setCurrent] = useState('')
|
||||
const [next, setNext] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function save(e) {
|
||||
e.preventDefault()
|
||||
setMsg('')
|
||||
setError('')
|
||||
if (next.length < 8) return setError('New password must be at least 8 characters.')
|
||||
if (hasPassword && !current) return setError('Enter your current password.')
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.player.changePassword(next, hasPassword ? current : undefined)
|
||||
setMsg(hasPassword ? 'Password changed.' : 'Password set. You can now sign in with it.')
|
||||
setCurrent('')
|
||||
setNext('')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not change your password.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={hasPassword ? 'Password' : 'Set a password'}>
|
||||
{!hasPassword && (
|
||||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
Your account was created through a linked provider and has no password yet. Set one to also be
|
||||
able to sign in with a username and password.
|
||||
</p>
|
||||
)}
|
||||
<form onSubmit={save} style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 320 }}>
|
||||
{hasPassword && (
|
||||
<label>
|
||||
<span className="field-label">Current password</span>
|
||||
<input type="password" value={current} onChange={(e) => setCurrent(e.target.value)} className="input" autoComplete="current-password" />
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
<span className="field-label">New password</span>
|
||||
<input type="password" value={next} onChange={(e) => setNext(e.target.value)} className="input" autoComplete="new-password" />
|
||||
</label>
|
||||
<div>
|
||||
<button type="submit" disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Saving…' : hasPassword ? 'Change password' : 'Set password'}
|
||||
</button>
|
||||
</div>
|
||||
<Note msg={msg} error={error} />
|
||||
</form>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Two-factor (TOTP) ──────────────────────────────────────────────────────
|
||||
function TwoFactor({ account, reload }) {
|
||||
const enabled = account.totp_enabled
|
||||
const [setup, setSetup] = useState(null)
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function begin() {
|
||||
setBusy(true); setMsg(''); setError('')
|
||||
try {
|
||||
setSetup(await api.player.totpSetup())
|
||||
setCode('')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not start setup.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
async function confirm() {
|
||||
setBusy(true); setMsg(''); setError('')
|
||||
try {
|
||||
await api.player.totpEnable(code.trim())
|
||||
setSetup(null); setCode(''); setMsg('Two-factor is now enabled.')
|
||||
await reload()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not enable two-factor.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
async function disable() {
|
||||
setBusy(true); setMsg(''); setError('')
|
||||
try {
|
||||
await api.player.totpDisable(code.trim())
|
||||
setCode(''); setMsg('Two-factor has been disabled.')
|
||||
await reload()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not disable two-factor.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title="Two-factor authentication">
|
||||
<div className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '6px 12px', borderRadius: 999, border: '1px solid var(--line)', fontSize: '0.82rem', color: enabled ? '#7fd0a4' : 'var(--muted)', marginBottom: 18 }}>
|
||||
<span style={{ width: 9, height: 9, borderRadius: '50%', background: enabled ? '#7fd0a4' : 'var(--dim)' }} />
|
||||
{enabled ? 'Enabled' : 'Not enabled'}
|
||||
</div>
|
||||
|
||||
{!enabled && !setup && (
|
||||
<div>
|
||||
<button onClick={begin} disabled={busy} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Preparing…' : 'Set up two-factor'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!enabled && setup && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.88rem' }}>
|
||||
Scan this QR code with your authenticator app, then enter the current 6-digit code.
|
||||
</p>
|
||||
<img src={setup.qr} alt="TOTP QR code" width={180} height={180} style={{ borderRadius: 8, background: '#fff', padding: 8, alignSelf: 'flex-start' }} />
|
||||
<label style={{ display: 'block', maxWidth: 220 }}>
|
||||
<span className="field-label">Verification code</span>
|
||||
<input type="text" inputMode="numeric" autoComplete="one-time-code" placeholder="6-digit code" value={code} onChange={(e) => setCode(e.target.value)} className="input" />
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<button onClick={confirm} disabled={busy || !code.trim()} className="btn btn-primary btn-sq">
|
||||
{busy ? 'Enabling…' : 'Confirm & enable'}
|
||||
</button>
|
||||
<button onClick={() => setSetup(null)} disabled={busy} className="pill">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{enabled && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.88rem' }}>
|
||||
Enter a current code from your authenticator to turn two-factor off.
|
||||
</p>
|
||||
<label style={{ display: 'block', maxWidth: 220 }}>
|
||||
<span className="field-label">Verification code</span>
|
||||
<input type="text" inputMode="numeric" autoComplete="one-time-code" placeholder="6-digit code" value={code} onChange={(e) => setCode(e.target.value)} className="input" />
|
||||
</label>
|
||||
<div>
|
||||
<button onClick={disable} disabled={busy || !code.trim()} className="btn btn-sq" style={{ borderColor: '#d98b84', color: '#d98b84' }}>
|
||||
{busy ? 'Disabling…' : 'Disable two-factor'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Note msg={msg} error={error} />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Linked SSO identities ──────────────────────────────────────────────────
|
||||
function LinkedAccounts() {
|
||||
const [linked, setLinked] = useState(null)
|
||||
const [available, setAvailable] = useState([])
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const banner = (() => {
|
||||
const q = new URLSearchParams(window.location.search)
|
||||
if (q.get('linked')) return { ok: true, text: 'Account linked.' }
|
||||
if (q.get('link_error') === 'in_use') return { ok: false, text: 'That external account is already linked to another user.' }
|
||||
if (q.get('link_error')) return { ok: false, text: 'Could not link that account. Please try again.' }
|
||||
return null
|
||||
})()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [ids, avail] = await Promise.all([
|
||||
api.player.linkedIdentities(),
|
||||
api.authProviders().catch(() => []),
|
||||
])
|
||||
setLinked(ids)
|
||||
setAvailable(Array.isArray(avail) ? avail : [])
|
||||
} catch {
|
||||
setError('Could not load linked accounts.')
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const nameFor = (id) => available.find((p) => p.id === id)?.name || id.charAt(0).toUpperCase() + id.slice(1)
|
||||
const iconFor = (id) => (id === 'google' || id === 'discord' ? id : 'oidc')
|
||||
|
||||
async function unlink(provider) {
|
||||
if (!window.confirm(`Unlink ${nameFor(provider)} from your account?`)) return
|
||||
try {
|
||||
await api.player.unlinkIdentity(provider)
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not unlink.')
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <ErrorState message={error} />
|
||||
if (!linked) return null
|
||||
|
||||
const linkedIds = new Set(linked.map((i) => i.provider))
|
||||
const linkable = available.filter((p) => !linkedIds.has(p.id))
|
||||
|
||||
return (
|
||||
<Section title="Linked accounts">
|
||||
<p className="sans" style={{ marginTop: 0, color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6 }}>
|
||||
Link a Google, Discord, or other provider so you can sign in with it.
|
||||
</p>
|
||||
{banner && (
|
||||
<p className="sans" style={{ color: banner.ok ? '#7fd0a4' : '#d98b84', fontSize: '0.86rem' }}>{banner.text}</p>
|
||||
)}
|
||||
{linked.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, margin: '14px 0' }}>
|
||||
{linked.map((i) => (
|
||||
<div key={i.provider} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', border: '1px solid var(--line)', borderRadius: 8 }}>
|
||||
<span style={{ display: 'inline-flex', width: 20, height: 20 }}>
|
||||
<ProviderIcon icon={iconFor(i.provider)} size={20} />
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="sans" style={{ color: 'var(--head)', fontSize: '0.9rem' }}>{nameFor(i.provider)}</div>
|
||||
{i.email && <div className="sans dim" style={{ fontSize: '0.78rem' }}>{i.email}</div>}
|
||||
</div>
|
||||
<button onClick={() => unlink(i.provider)} className="pill" style={{ color: '#d98b84', borderColor: '#d98b84' }}>Unlink</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{linkable.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 6 }}>
|
||||
{linkable.map((p) => (
|
||||
<button key={p.id} onClick={() => window.location.assign(`/api/v1/auth/sso/${p.id}/link?returnTo=${encodeURIComponent('/account')}`)} className="btn" style={{ display: 'flex', alignItems: 'center', gap: 10, justifyContent: 'center', width: '100%', maxWidth: 320, borderRadius: 8, padding: 10, border: '1px solid var(--line)', background: 'rgba(255,255,255,0.04)', color: 'var(--ink)' }}>
|
||||
<span style={{ display: 'inline-flex', width: 18, height: 18 }}>
|
||||
<ProviderIcon icon={p.icon} size={18} />
|
||||
</span>
|
||||
Link {p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{linked.length === 0 && linkable.length === 0 && (
|
||||
<p className="sans dim" style={{ fontSize: '0.86rem' }}>No SSO providers are enabled.</p>
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Shared bits ────────────────────────────────────────────────────────────
|
||||
function Section({ title, children }) {
|
||||
return (
|
||||
<section style={{ borderTop: '1px solid var(--line-soft)', paddingTop: 26, marginTop: 26 }}>
|
||||
<h2 className="display" style={{ marginTop: 0, fontSize: '1.15rem', color: 'var(--head)' }}>{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
function Note({ msg, error }) {
|
||||
if (!msg && !error) return null
|
||||
return <p className="sans" style={{ margin: '4px 0 0', color: error ? '#d98b84' : '#7fd0a4', fontSize: '0.85rem' }}>{error || msg}</p>
|
||||
}
|
||||
|
||||
// ── Page ───────────────────────────────────────────────────────────────────
|
||||
export default function PlayerAccount() {
|
||||
const { logout, refresh } = useAuth()
|
||||
const [account, setAccount] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setAccount(await api.player.getAccount())
|
||||
} catch {
|
||||
setError('Could not load your account.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
// After a username change: reload local account + refresh the auth context so
|
||||
// the header reflects the new name.
|
||||
const onUsernameChanged = useCallback(async () => {
|
||||
await Promise.all([load(), refresh()])
|
||||
}, [load, refresh])
|
||||
|
||||
return (
|
||||
<main style={{ minHeight: '100vh', background: 'var(--bg-deep)', color: 'var(--ink)' }}>
|
||||
<header style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '18px 20px', borderBottom: '1px solid var(--line)', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<MoonDot size={12} glow={0.5} />
|
||||
<span className="display" style={{ color: 'var(--head)', fontSize: '1.1rem', letterSpacing: '0.04em' }}>
|
||||
My Account
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<Link to="/" className="sans" style={{ color: 'var(--accent)', fontSize: '0.84rem', textDecoration: 'none' }}>
|
||||
← Site
|
||||
</Link>
|
||||
<button onClick={logout} className="pill">Sign out</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={{ maxWidth: 620, margin: '0 auto', padding: '10px 20px 60px' }}>
|
||||
{loading && <Loading />}
|
||||
{error && <ErrorState message={error} />}
|
||||
{!loading && !error && account && (
|
||||
<>
|
||||
<div style={{ paddingTop: 24 }}>
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem' }}>
|
||||
Signed in as <strong style={{ color: 'var(--head)' }}>{account.username}</strong>
|
||||
{account.email ? ` · ${account.email}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<ChangeUsername account={account} onChanged={onUsernameChanged} />
|
||||
<ChangePassword account={account} />
|
||||
<TwoFactor account={account} reload={load} />
|
||||
<LinkedAccounts />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
205
client/src/routes/player/PlayerLogin.jsx
Normal file
205
client/src/routes/player/PlayerLogin.jsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
|
||||
|
||||
// Friendly copy for the ?sso_error codes the SSO callback can bounce back with.
|
||||
const SSO_ERRORS = {
|
||||
not_linked:
|
||||
'That account is not linked to a player. Enable SSO sign-up, or sign in with a password and link it under your 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.',
|
||||
error: 'Could not complete sign-in. Please try again.',
|
||||
}
|
||||
|
||||
export default function PlayerLogin() {
|
||||
const { user, login, loginTotp, ssoLoginTotp } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const dest = location.state?.from?.pathname || '/account'
|
||||
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [company, setCompany] = useState('') // honeypot — must stay empty
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const [stage, setStage] = useState('creds') // 'creds' | 'totp'
|
||||
const [challenge, setChallenge] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [ssoTotp, setSsoTotp] = useState(false)
|
||||
|
||||
const [providers, setProviders] = useState([])
|
||||
const [canRegister, setCanRegister] = useState(false)
|
||||
const ssoError = SSO_ERRORS[new URLSearchParams(location.search).get('sso_error')] || ''
|
||||
|
||||
// A signed-in player goes straight to their account.
|
||||
useEffect(() => {
|
||||
if (user && user.role === 'player') navigate(dest, { replace: true })
|
||||
}, [user, dest, navigate])
|
||||
|
||||
// The SSO callback bounces 2FA accounts back here with ?sso_totp=1.
|
||||
useEffect(() => {
|
||||
if (new URLSearchParams(location.search).get('sso_totp')) {
|
||||
setStage('totp')
|
||||
setSsoTotp(true)
|
||||
}
|
||||
}, [location.search])
|
||||
|
||||
// SSO providers (for buttons) + whether password registration is open.
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api
|
||||
.authProviders()
|
||||
.then((list) => active && setProviders(Array.isArray(list) ? list : []))
|
||||
.catch(() => active && setProviders([]))
|
||||
api
|
||||
.publicSettings()
|
||||
.then((s) => active && setCanRegister(Boolean(s?.registration?.password)))
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
function startSso(provider) {
|
||||
// Always return into the player portal so the callback lands on /account*.
|
||||
const q = `?returnTo=${encodeURIComponent(dest.startsWith('/account') ? dest : '/account')}`
|
||||
window.location.assign(provider.loginUrl + q)
|
||||
}
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setBusy(true)
|
||||
try {
|
||||
const data = await login(username, password, { company })
|
||||
if (data.totpRequired) {
|
||||
setChallenge(data.challenge)
|
||||
setStage('totp')
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
navigate(dest, { replace: true })
|
||||
} catch (err) {
|
||||
if (err.status === 403) setError('This account is not active. Contact an administrator.')
|
||||
else setError(err.status === 401 ? 'Incorrect username or password.' : 'Could not sign in right now.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmitTotp(e) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setBusy(true)
|
||||
try {
|
||||
if (ssoTotp) {
|
||||
const { returnTo } = await ssoLoginTotp(code)
|
||||
navigate(returnTo || '/account', { replace: true })
|
||||
} else {
|
||||
await loginTotp(challenge, code)
|
||||
navigate(dest, { replace: true })
|
||||
}
|
||||
} catch (err) {
|
||||
const expired = err.status === 401 && /expired/i.test(err.message)
|
||||
setError(expired ? 'Your verification session expired. Please sign in again.' : 'Invalid verification code.')
|
||||
setBusy(false)
|
||||
if (expired) {
|
||||
setStage('creds')
|
||||
setSsoTotp(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PlayerShell
|
||||
subtitle="Player sign-in"
|
||||
footer={
|
||||
canRegister && (
|
||||
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0', color: 'var(--dim)', fontSize: '0.84rem' }}>
|
||||
New here?{' '}
|
||||
<Link to="/account/register" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
Create an account
|
||||
</Link>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
>
|
||||
<form onSubmit={stage === 'totp' ? onSubmitTotp : onSubmit}>
|
||||
{stage === 'creds' ? (
|
||||
<>
|
||||
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||
<span className="field-label">Username</span>
|
||||
<input type="text" autoComplete="username" autoFocus value={username} onChange={(e) => setUsername(e.target.value)} className="input" />
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: 22 }}>
|
||||
<span className="field-label">Password</span>
|
||||
<input type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} className="input" />
|
||||
</label>
|
||||
<div style={honeypotStyle} aria-hidden="true">
|
||||
<label>
|
||||
Company
|
||||
<input type="text" name="company" tabIndex={-1} autoComplete="off" value={company} onChange={(e) => setCompany(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<label style={{ display: 'block', marginBottom: 22 }}>
|
||||
<span className="field-label">Authentication code</span>
|
||||
<input type="text" inputMode="numeric" autoComplete="one-time-code" autoFocus placeholder="6-digit code" value={code} onChange={(e) => setCode(e.target.value)} className="input" />
|
||||
<span className="sans" style={{ display: 'block', marginTop: 8, color: 'var(--dim)', fontSize: '0.76rem' }}>
|
||||
Enter the code from your authenticator app.
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{(error || (stage === 'creds' && ssoError)) && (
|
||||
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center', lineHeight: 1.5 }}>
|
||||
{error || ssoError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={busy} className="btn btn-primary" style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}>
|
||||
{busy ? 'Signing in…' : stage === 'totp' ? 'Verify' : 'Sign in'}
|
||||
</button>
|
||||
|
||||
{stage === 'creds' && providers.length > 0 && (
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, margin: '0 0 16px', color: 'var(--dim)' }}>
|
||||
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||
<span className="sans" style={{ fontSize: '0.72rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>or</span>
|
||||
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{providers.map((p) => (
|
||||
<button key={p.id} type="button" onClick={() => startSso(p)} className="btn" style={ssoBtnStyle}>
|
||||
<span style={{ display: 'inline-flex', width: 18, height: 18 }}>
|
||||
<ProviderIcon icon={p.icon} size={18} />
|
||||
</span>
|
||||
Continue with {p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</PlayerShell>
|
||||
)
|
||||
}
|
||||
|
||||
const ssoBtnStyle = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
width: '100%',
|
||||
borderRadius: 8,
|
||||
padding: 11,
|
||||
border: '1px solid var(--line)',
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
color: 'var(--ink)',
|
||||
}
|
||||
163
client/src/routes/player/PlayerRegister.jsx
Normal file
163
client/src/routes/player/PlayerRegister.jsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||
import { api } from '../../api/client.js'
|
||||
import PlayerShell, { honeypotStyle } from './PlayerShell.jsx'
|
||||
|
||||
export default function PlayerRegister() {
|
||||
const { user, register } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [company, setCompany] = useState('') // honeypot — must stay empty
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
// Which methods are enabled (derived, from /public/settings). null = loading.
|
||||
const [avail, setAvail] = useState(null)
|
||||
const [providers, setProviders] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
if (user && user.role === 'player') navigate('/account', { replace: true })
|
||||
}, [user, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api
|
||||
.publicSettings()
|
||||
.then((s) => active && setAvail(s?.registration || { password: false, sso: false }))
|
||||
.catch(() => active && setAvail({ password: false, sso: false }))
|
||||
api
|
||||
.authProviders()
|
||||
.then((list) => active && setProviders(Array.isArray(list) ? list : []))
|
||||
.catch(() => active && setProviders([]))
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
function startSso(provider) {
|
||||
window.location.assign(provider.loginUrl + `?returnTo=${encodeURIComponent('/account')}`)
|
||||
}
|
||||
|
||||
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 register(username.trim(), password, { email: email.trim() || undefined, company })
|
||||
navigate('/account', { replace: true })
|
||||
} catch (err) {
|
||||
if (err.status === 409) setError('That username is already taken.')
|
||||
else if (err.status === 403) setError('Registration is not open right now.')
|
||||
else if (err.status === 400) setError(err.message || 'Please check your details and try again.')
|
||||
else setError('Could not create your account right now.')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const closed = avail && !avail.password && !avail.sso
|
||||
|
||||
return (
|
||||
<PlayerShell
|
||||
subtitle="Create a player account"
|
||||
footer={
|
||||
<p className="sans" style={{ textAlign: 'center', margin: '16px 0 0', color: 'var(--dim)', fontSize: '0.84rem' }}>
|
||||
Already have an account?{' '}
|
||||
<Link to="/account/login" style={{ color: 'var(--accent)', textDecoration: 'none' }}>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{avail === null ? (
|
||||
<div style={{ display: 'grid', placeItems: 'center', padding: 20 }}>
|
||||
<span className="spin" />
|
||||
</div>
|
||||
) : closed ? (
|
||||
<p className="sans" style={{ margin: 0, color: 'var(--muted)', fontSize: '0.9rem', textAlign: 'center', lineHeight: 1.6 }}>
|
||||
Self-registration is currently closed. Please check back later.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{avail.password && (
|
||||
<form onSubmit={onSubmit}>
|
||||
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||
<span className="field-label">Username</span>
|
||||
<input type="text" autoComplete="username" autoFocus value={username} onChange={(e) => setUsername(e.target.value)} className="input" />
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: 16 }}>
|
||||
<span className="field-label">Password</span>
|
||||
<input type="password" autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} className="input" />
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: 22 }}>
|
||||
<span className="field-label">Email (optional)</span>
|
||||
<input type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} className="input" placeholder="player@example.com" />
|
||||
<span className="sans" style={{ display: 'block', marginTop: 6, color: 'var(--dim)', fontSize: '0.74rem' }}>
|
||||
Used only for account recovery help. No password-reset emails yet — a forgotten password
|
||||
is reset by an administrator.
|
||||
</span>
|
||||
</label>
|
||||
<div style={honeypotStyle} aria-hidden="true">
|
||||
<label>
|
||||
Company
|
||||
<input type="text" name="company" tabIndex={-1} autoComplete="off" value={company} onChange={(e) => setCompany(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="sans" style={{ margin: '0 0 14px', color: '#d98b84', fontSize: '0.85rem', textAlign: 'center' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button type="submit" disabled={busy} className="btn btn-primary" style={{ display: 'block', width: '100%', borderRadius: 8, padding: 12, textAlign: 'center' }}>
|
||||
{busy ? 'Creating…' : 'Create account'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{avail.sso && providers.length > 0 && (
|
||||
<div style={{ marginTop: avail.password ? 20 : 0 }}>
|
||||
{avail.password && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, margin: '0 0 16px', color: 'var(--dim)' }}>
|
||||
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||
<span className="sans" style={{ fontSize: '0.72rem', letterSpacing: '0.14em', textTransform: 'uppercase' }}>or</span>
|
||||
<span style={{ flex: 1, height: 1, background: 'var(--line)' }} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{providers.map((p) => (
|
||||
<button key={p.id} type="button" onClick={() => startSso(p)} className="btn" style={ssoBtnStyle}>
|
||||
<span style={{ display: 'inline-flex', width: 18, height: 18 }}>
|
||||
<ProviderIcon icon={p.icon} size={18} />
|
||||
</span>
|
||||
Sign up with {p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PlayerShell>
|
||||
)
|
||||
}
|
||||
|
||||
const ssoBtnStyle = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 10,
|
||||
width: '100%',
|
||||
borderRadius: 8,
|
||||
padding: 11,
|
||||
border: '1px solid var(--line)',
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
color: 'var(--ink)',
|
||||
}
|
||||
72
client/src/routes/player/PlayerShell.jsx
Normal file
72
client/src/routes/player/PlayerShell.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import MoonDot from '../../components/MoonDot.jsx'
|
||||
|
||||
const BG =
|
||||
"linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('/assets/img/uomysticmoon-main-hero.png')"
|
||||
|
||||
// Centered card layout shared by the player login / register pages. `subtitle`
|
||||
// labels the card; `footer` is optional content under the card (e.g. cross-links).
|
||||
export default function PlayerShell({ subtitle, children, footer }) {
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
padding: '40px 18px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'var(--bg-deep)',
|
||||
backgroundImage: BG,
|
||||
backgroundPosition: 'center',
|
||||
backgroundSize: 'cover',
|
||||
}}
|
||||
>
|
||||
<div style={{ width: '100%', maxWidth: 400 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 26 }}>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<MoonDot size={15} glow={0.55} />
|
||||
</div>
|
||||
<h1 className="display" style={{ margin: 0, fontSize: '1.7rem', letterSpacing: '0.04em', color: 'var(--head)' }}>
|
||||
UOMysticmoon
|
||||
</h1>
|
||||
<p className="sans" style={{ margin: '6px 0 0', color: '#9aa6b4', fontSize: '0.8rem', letterSpacing: '0.16em', textTransform: 'uppercase' }}>
|
||||
{subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 12,
|
||||
padding: 28,
|
||||
background: 'linear-gradient(180deg,rgba(25,34,49,0.92),rgba(20,26,33,0.92))',
|
||||
backdropFilter: 'blur(6px)',
|
||||
boxShadow: '0 24px 60px rgba(0,0,0,0.5)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer}
|
||||
|
||||
<p style={{ textAlign: 'center', margin: '20px 0 0' }}>
|
||||
<Link to="/" className="sans" style={{ color: 'var(--accent)', fontSize: '0.84rem', textDecoration: 'none' }}>
|
||||
← Back to site
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
// Off-screen honeypot styling (matches the admin login): present for bots, never
|
||||
// seen or filled by real users. Name must equal the server HONEYPOT_FIELD.
|
||||
export const honeypotStyle = {
|
||||
position: 'absolute',
|
||||
left: '-9999px',
|
||||
top: 'auto',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
}
|
||||
@@ -618,6 +618,11 @@ button[disabled] {
|
||||
color: #e0b070;
|
||||
border: 1px solid rgba(224, 176, 112, 0.4);
|
||||
}
|
||||
.badge-player {
|
||||
background: rgba(126, 196, 156, 0.12);
|
||||
color: #7ec49c;
|
||||
border: 1px solid rgba(126, 196, 156, 0.4);
|
||||
}
|
||||
/* Action-type badges for the moderation dashboard. */
|
||||
.badge-ban {
|
||||
background: rgba(217, 139, 132, 0.16);
|
||||
|
||||
Reference in New Issue
Block a user