import { useEffect, useState } from 'react' import { Link, useNavigate, useLocation } from 'react-router-dom' import ProviderIcon from '../../components/ProviderIcon.jsx' import TrustLimitModal from '../../components/security/TrustLimitModal.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 || '/player' // A staff member who signs in here belongs in the admin shell, not the portal. const destFor = (u) => (u && u.role !== 'player' ? '/admin' : dest) 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 [trustDevice, setTrustDevice] = useState(false) const [useRecovery, setUseRecovery] = useState(false) // When trust was requested at login but the device cap is reached: show the // revoke-to-continue modal, then navigate on resolve. `pendingDest` holds where // to go once the prompt is dealt with. const [trustLimit, setTrustLimit] = useState(null) // { devices, dest } const [providers, setProviders] = useState([]) const [canRegister, setCanRegister] = useState(false) const ssoError = SSO_ERRORS[new URLSearchParams(location.search).get('sso_error')] || '' // Already signed in → go straight to the right home for the role. useEffect(() => { if (user) navigate(destFor(user), { replace: true }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [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(destFor(data.user), { 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, redirect } = await ssoLoginTotp(code) // Native SSO bridge (M9): a mobile 2FA completion returns an absolute // deep link (e.g. runicgateway://…) to hand the app its one-time code. // React Router can't navigate a custom scheme, so leave the SPA for it. if (redirect) { window.location.href = redirect return } navigate(returnTo || '/account', { replace: true }) } else { const entered = code.trim() const data = await loginTotp(challenge, useRecovery ? '' : entered, { recoveryCode: useRecovery ? entered : undefined, trustDevice, }) const to = destFor(data.user) // Trust was requested but the device cap is reached: the session is already // issued, so prompt to revoke one before trusting, then navigate. if (data.trustLimitReached) { setTrustLimit({ devices: data.devices || [], dest: to }) setBusy(false) return } navigate(to, { replace: true }) } } catch (err) { const expired = err.status === 401 && /expired/i.test(err.message) const badRecovery = useRecovery ? 'That recovery code is not valid.' : 'Invalid verification code.' setError(expired ? 'Your verification session expired. Please sign in again.' : badRecovery) setBusy(false) if (expired) { setStage('creds') setSsoTotp(false) } } } let submitLabel = 'Sign in' if (busy) submitLabel = 'Signing in…' else if (stage === 'totp') submitLabel = 'Verify' return (

Forgot your password?

{canRegister && (

New here?{' '} Create an account

)} } >
{stage === 'creds' ? ( <> ) : ( <> {/* Trust-this-device only applies to real authenticator/recovery login, not the SSO 2FA bounce (which has no trust cookie flow here). */} {!ssoTotp && ( )} {!ssoTotp && ( )} )} {(error || (stage === 'creds' && ssoError)) && (

{error || ssoError}

)} {stage === 'creds' && providers.length > 0 && (
or
{providers.map((p) => ( ))}
)}
{trustLimit && ( navigate(trustLimit.dest, { replace: true })} onCancel={() => navigate(trustLimit.dest, { replace: true })} /> )}
) } 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)', }