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:
@@ -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)}>
|
||||
|
||||
Reference in New Issue
Block a user