- 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
301 lines
11 KiB
JavaScript
301 lines
11 KiB
JavaScript
import { useEffect, useState } from 'react'
|
|
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
|
import MoonDot from '../../components/MoonDot.jsx'
|
|
import ProviderIcon from '../../components/ProviderIcon.jsx'
|
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
|
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.',
|
|
error: 'Could not complete sign-in. Please try again.',
|
|
}
|
|
|
|
const BG =
|
|
"linear-gradient(180deg,rgba(11,15,20,0.72),rgba(11,15,20,0.9)),url('/assets/img/uomysticmoon-main-hero.png')"
|
|
|
|
// Hidden anti-bot field. Off-screen via CSS (NOT display:none/hidden, which bots
|
|
// skip) so real users never fill it but naive scripted bots do. Name must match
|
|
// the server's HONEYPOT_FIELD ('company').
|
|
const honeypotStyle = {
|
|
position: 'absolute',
|
|
left: '-9999px',
|
|
top: 'auto',
|
|
width: '1px',
|
|
height: '1px',
|
|
opacity: 0,
|
|
pointerEvents: 'none',
|
|
}
|
|
|
|
export default function AdminLogin() {
|
|
const { user, login, loginTotp, ssoLoginTotp } = useAuth()
|
|
const navigate = useNavigate()
|
|
const location = useLocation()
|
|
const dest = location.state?.from?.pathname || '/admin'
|
|
|
|
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)
|
|
|
|
// Two-factor step state. `ssoTotp` marks the SSO variant: the challenge lives in
|
|
// an httpOnly cookie (not React state), so the code posts to a different endpoint.
|
|
const [stage, setStage] = useState('creds') // 'creds' | 'totp'
|
|
const [challenge, setChallenge] = useState('')
|
|
const [code, setCode] = useState('')
|
|
const [ssoTotp, setSsoTotp] = useState(false)
|
|
|
|
// SSO providers to offer (empty if none configured) + any error the callback
|
|
// bounced us back with (?sso_error=...).
|
|
const [providers, setProviders] = useState([])
|
|
const ssoError = SSO_ERRORS[new URLSearchParams(location.search).get('sso_error')] || ''
|
|
|
|
// Already signed in → go straight to the panel.
|
|
useEffect(() => {
|
|
if (user) navigate(dest, { replace: true })
|
|
}, [user, dest, navigate])
|
|
|
|
// The SSO callback bounces 2FA accounts back here with ?sso_totp=1 after the IdP
|
|
// step: it has staged an httpOnly TOTP challenge and needs the authenticator code
|
|
// before it will issue a session. Jump straight to the code step.
|
|
useEffect(() => {
|
|
if (new URLSearchParams(location.search).get('sso_totp')) {
|
|
setStage('totp')
|
|
setSsoTotp(true)
|
|
}
|
|
}, [location.search])
|
|
|
|
// Load enabled SSO providers for the buttons. Failure is non-fatal — the page
|
|
// still works with password login and simply shows no provider buttons.
|
|
useEffect(() => {
|
|
let active = true
|
|
api
|
|
.authProviders()
|
|
.then((list) => active && setProviders(Array.isArray(list) ? list : []))
|
|
.catch(() => active && setProviders([]))
|
|
return () => {
|
|
active = false
|
|
}
|
|
}, [])
|
|
|
|
// Full-page redirect into the provider's OAuth flow, preserving the intended
|
|
// destination so the callback can return the user there.
|
|
function startSso(provider) {
|
|
const q = dest && dest !== '/admin' ? `?returnTo=${encodeURIComponent(dest)}` : ''
|
|
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) {
|
|
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 || '/admin', { 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 (
|
|
<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' }}>
|
|
Admin Panel
|
|
</p>
|
|
</div>
|
|
|
|
<form
|
|
onSubmit={stage === 'totp' ? onSubmitTotp : onSubmit}
|
|
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)',
|
|
}}
|
|
>
|
|
{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>
|
|
|
|
{/* Honeypot: hidden from humans, left empty; bots that fill it are rejected. */}
|
|
<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>
|
|
|
|
{/* SSO providers — only on the credentials step, only if any are enabled. */}
|
|
{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={{
|
|
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)',
|
|
}}
|
|
>
|
|
<span style={{ display: 'inline-flex', width: 18, height: 18 }}>
|
|
<ProviderIcon icon={p.icon} size={18} />
|
|
</span>
|
|
Continue with {p.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<p className="sans" style={{ margin: '16px 0 0', textAlign: 'center', color: 'var(--dim)', fontSize: '0.76rem' }}>
|
|
Protected area — not indexed. Sessions expire after 1 day.
|
|
</p>
|
|
</form>
|
|
<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>
|
|
)
|
|
}
|