Gate /admin to staff roles; role-aware login redirects for players
Introducing the 'player' role turned 'logged-in' into 'logged-in but possibly
untrusted', but the admin router only gated content routes (dashboard, posts,
wiki, uploads) by isLoggedIn — so a player session could reach editor-tier
endpoints. Fixes:
- Backend: requireRole('admin','editor','moderator') at the admin router base;
players now 403 on all /admin/* and use /player instead.
- Client: RequireAuth redirects a signed-in player to /account (mirrors
RequirePlayer).
- Both login pages redirect by role after auth (player -> /account, staff ->
/admin) so you land in the right shell whichever door you used.
Verified live: player token 403s on /admin/dashboard + /admin/users, 200s on
/player/account; browser click-through confirms a player at /admin and at
/admin/login both land on /account. 134 server tests green; client builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
import { Navigate, useLocation } from 'react-router-dom'
|
import { Navigate, useLocation } from 'react-router-dom'
|
||||||
import { useAuth } from '../contexts/AuthContext.jsx'
|
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||||
|
|
||||||
// Gate for /admin/* — redirects to the login screen when not authenticated.
|
// Gate for /admin/* — redirects to the login screen when not authenticated, and
|
||||||
|
// bounces a signed-in player to their own portal (the admin API 403s them anyway;
|
||||||
|
// this keeps the UI honest and mirrors RequirePlayer).
|
||||||
export default function RequireAuth({ children }) {
|
export default function RequireAuth({ children }) {
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -16,5 +18,8 @@ export default function RequireAuth({ children }) {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
return <Navigate to="/admin/login" state={{ from: location }} replace />
|
return <Navigate to="/admin/login" state={{ from: location }} replace />
|
||||||
}
|
}
|
||||||
|
if (user.role === 'player') {
|
||||||
|
return <Navigate to="/account" replace />
|
||||||
|
}
|
||||||
return children
|
return children
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ export default function AdminLogin() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const dest = location.state?.from?.pathname || '/admin'
|
const dest = location.state?.from?.pathname || '/admin'
|
||||||
|
// A player who signs in here belongs in the player portal, not the admin shell
|
||||||
|
// (the admin API 403s them anyway). Staff go to their intended admin dest.
|
||||||
|
const destFor = (u) => (u && u.role === 'player' ? '/account' : dest)
|
||||||
|
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
@@ -55,9 +58,10 @@ export default function AdminLogin() {
|
|||||||
const [providers, setProviders] = useState([])
|
const [providers, setProviders] = useState([])
|
||||||
const ssoError = SSO_ERRORS[new URLSearchParams(location.search).get('sso_error')] || ''
|
const ssoError = SSO_ERRORS[new URLSearchParams(location.search).get('sso_error')] || ''
|
||||||
|
|
||||||
// Already signed in → go straight to the panel.
|
// Already signed in → go straight to the right home for the role.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) navigate(dest, { replace: true })
|
if (user) navigate(destFor(user), { replace: true })
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [user, dest, navigate])
|
}, [user, dest, navigate])
|
||||||
|
|
||||||
// The SSO callback bounces 2FA accounts back here with ?sso_totp=1 after the IdP
|
// The SSO callback bounces 2FA accounts back here with ?sso_totp=1 after the IdP
|
||||||
@@ -102,7 +106,7 @@ export default function AdminLogin() {
|
|||||||
setBusy(false)
|
setBusy(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
navigate(dest, { replace: true })
|
navigate(destFor(data.user), { replace: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.status === 401 ? 'Incorrect username or password.' : 'Could not sign in right now.')
|
setError(err.status === 401 ? 'Incorrect username or password.' : 'Could not sign in right now.')
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
@@ -118,8 +122,8 @@ export default function AdminLogin() {
|
|||||||
const { returnTo } = await ssoLoginTotp(code)
|
const { returnTo } = await ssoLoginTotp(code)
|
||||||
navigate(returnTo || '/admin', { replace: true })
|
navigate(returnTo || '/admin', { replace: true })
|
||||||
} else {
|
} else {
|
||||||
await loginTotp(challenge, code)
|
const u = await loginTotp(challenge, code)
|
||||||
navigate(dest, { replace: true })
|
navigate(destFor(u), { replace: true })
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const expired = err.status === 401 && /expired/i.test(err.message)
|
const expired = err.status === 401 && /expired/i.test(err.message)
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export default function PlayerLogin() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const dest = location.state?.from?.pathname || '/account'
|
const dest = location.state?.from?.pathname || '/account'
|
||||||
|
// 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 [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
@@ -37,9 +39,10 @@ export default function PlayerLogin() {
|
|||||||
const [canRegister, setCanRegister] = useState(false)
|
const [canRegister, setCanRegister] = useState(false)
|
||||||
const ssoError = SSO_ERRORS[new URLSearchParams(location.search).get('sso_error')] || ''
|
const ssoError = SSO_ERRORS[new URLSearchParams(location.search).get('sso_error')] || ''
|
||||||
|
|
||||||
// A signed-in player goes straight to their account.
|
// Already signed in → go straight to the right home for the role.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user && user.role === 'player') navigate(dest, { replace: true })
|
if (user) navigate(destFor(user), { replace: true })
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [user, dest, navigate])
|
}, [user, dest, navigate])
|
||||||
|
|
||||||
// The SSO callback bounces 2FA accounts back here with ?sso_totp=1.
|
// The SSO callback bounces 2FA accounts back here with ?sso_totp=1.
|
||||||
@@ -84,7 +87,7 @@ export default function PlayerLogin() {
|
|||||||
setBusy(false)
|
setBusy(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
navigate(dest, { replace: true })
|
navigate(destFor(data.user), { replace: true })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.status === 403) setError('This account is not active. Contact an administrator.')
|
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.')
|
else setError(err.status === 401 ? 'Incorrect username or password.' : 'Could not sign in right now.')
|
||||||
@@ -101,8 +104,8 @@ export default function PlayerLogin() {
|
|||||||
const { returnTo } = await ssoLoginTotp(code)
|
const { returnTo } = await ssoLoginTotp(code)
|
||||||
navigate(returnTo || '/account', { replace: true })
|
navigate(returnTo || '/account', { replace: true })
|
||||||
} else {
|
} else {
|
||||||
await loginTotp(challenge, code)
|
const u = await loginTotp(challenge, code)
|
||||||
navigate(dest, { replace: true })
|
navigate(destFor(u), { replace: true })
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const expired = err.status === 401 && /expired/i.test(err.message)
|
const expired = err.status === 401 && /expired/i.test(err.message)
|
||||||
|
|||||||
@@ -17,8 +17,13 @@ const validate = require('../../../middleware/validate')
|
|||||||
|
|
||||||
const adminRouter = express.Router()
|
const adminRouter = express.Router()
|
||||||
|
|
||||||
// Every admin route requires auth and is kept out of search indexes.
|
// Every admin route requires auth, a STAFF role, and is kept out of search
|
||||||
adminRouter.use(noindex, isLoggedIn)
|
// indexes. The staff gate matters now that `player` is a logged-in-but-untrusted
|
||||||
|
// role: without it, the editor-tier routes below (dashboard, posts, wiki,
|
||||||
|
// uploads) that are only guarded by isLoggedIn would be reachable by players.
|
||||||
|
// Players get 403 here and use the self-scoped /player group instead.
|
||||||
|
const staffOnly = requireRole('admin', 'editor', 'moderator')
|
||||||
|
adminRouter.use(noindex, isLoggedIn, staffOnly)
|
||||||
|
|
||||||
// Admin-only gate. Editors may manage content (posts/wiki), but user
|
// Admin-only gate. Editors may manage content (posts/wiki), but user
|
||||||
// management, site mode, and settings are restricted to the admin role.
|
// management, site mode, and settings are restricted to the admin role.
|
||||||
|
|||||||
Reference in New Issue
Block a user