Turns the raw shard endpoints into proper, navigable pages in the site's visual language. - components/CharacterSheet.jsx: reusable sheet — attribute tiles, vitals bars, resistances, skills (with bars), and equipment — styled with the shared panel/grid vocabulary. - Player portal with a nav bar: PlayerPortalLayout (Characters / Account tabs + sign-out) wraps /player and /account. /player (PlayerCharacters) tells the logged-in player if they haven't linked a game account (with the [link code prompt) or, once linked, shows their characters grouped by account; each character opens its sheet at /player/char/:serial. Account security moved into the same shell (the buried "Game accounts" block was removed from it). Login/register now land on /player. - Public: GET /public/shard/online (redacted name+serial+map) drives an "Online now" list on /site/shard that links to public character sheets at /site/shard/char/:serial (ShardChar). Swagger: ShardOnlinePlayer + regenerated. - api.shard.online added. Verified live against the running shard: Darrow's full sheet (STR 120, 58 skills, 3 equipment) renders through the browser-facing proxy; the online list returns the live roster; player routes 401 without a session. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011qPmpmVH1xGCiZoz9m9vW3
164 lines
6.7 KiB
JavaScript
164 lines
6.7 KiB
JavaScript
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('/player', { 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('/player', { 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)',
|
|
}
|