From 82807d18d9fdb0e7fd53b60ead0777379e055424 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 19:57:31 -0500 Subject: [PATCH] Gate /admin to staff roles; role-aware login redirects for players MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV --- client/src/components/RequireAuth.jsx | 7 ++++++- client/src/routes/admin/AdminLogin.jsx | 14 +++++++++----- client/src/routes/player/PlayerLogin.jsx | 13 ++++++++----- server/src/router/v1/admin/admin.routes.js | 9 +++++++-- 4 files changed, 30 insertions(+), 13 deletions(-) diff --git a/client/src/components/RequireAuth.jsx b/client/src/components/RequireAuth.jsx index 052481e..ceae49e 100644 --- a/client/src/components/RequireAuth.jsx +++ b/client/src/components/RequireAuth.jsx @@ -1,7 +1,9 @@ import { Navigate, useLocation } from 'react-router-dom' 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 }) { const { user, loading } = useAuth() const location = useLocation() @@ -16,5 +18,8 @@ export default function RequireAuth({ children }) { if (!user) { return } + if (user.role === 'player') { + return + } return children } diff --git a/client/src/routes/admin/AdminLogin.jsx b/client/src/routes/admin/AdminLogin.jsx index c492276..874b90a 100644 --- a/client/src/routes/admin/AdminLogin.jsx +++ b/client/src/routes/admin/AdminLogin.jsx @@ -36,6 +36,9 @@ export default function AdminLogin() { const navigate = useNavigate() const location = useLocation() 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 [password, setPassword] = useState('') @@ -55,9 +58,10 @@ export default function AdminLogin() { const [providers, setProviders] = useState([]) 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(() => { - if (user) navigate(dest, { replace: true }) + 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 after the IdP @@ -102,7 +106,7 @@ export default function AdminLogin() { setBusy(false) return } - navigate(dest, { replace: true }) + navigate(destFor(data.user), { replace: true }) } catch (err) { setError(err.status === 401 ? 'Incorrect username or password.' : 'Could not sign in right now.') setBusy(false) @@ -118,8 +122,8 @@ export default function AdminLogin() { const { returnTo } = await ssoLoginTotp(code) navigate(returnTo || '/admin', { replace: true }) } else { - await loginTotp(challenge, code) - navigate(dest, { replace: true }) + const u = await loginTotp(challenge, code) + navigate(destFor(u), { replace: true }) } } catch (err) { const expired = err.status === 401 && /expired/i.test(err.message) diff --git a/client/src/routes/player/PlayerLogin.jsx b/client/src/routes/player/PlayerLogin.jsx index d95a0da..4958c46 100644 --- a/client/src/routes/player/PlayerLogin.jsx +++ b/client/src/routes/player/PlayerLogin.jsx @@ -21,6 +21,8 @@ export default function PlayerLogin() { const navigate = useNavigate() const location = useLocation() 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 [password, setPassword] = useState('') @@ -37,9 +39,10 @@ export default function PlayerLogin() { const [canRegister, setCanRegister] = useState(false) 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(() => { - 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]) // The SSO callback bounces 2FA accounts back here with ?sso_totp=1. @@ -84,7 +87,7 @@ export default function PlayerLogin() { setBusy(false) return } - navigate(dest, { replace: true }) + 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.') @@ -101,8 +104,8 @@ export default function PlayerLogin() { const { returnTo } = await ssoLoginTotp(code) navigate(returnTo || '/account', { replace: true }) } else { - await loginTotp(challenge, code) - navigate(dest, { replace: true }) + const u = await loginTotp(challenge, code) + navigate(destFor(u), { replace: true }) } } catch (err) { const expired = err.status === 401 && /expired/i.test(err.message) diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js index 117e0a9..68c4458 100644 --- a/server/src/router/v1/admin/admin.routes.js +++ b/server/src/router/v1/admin/admin.routes.js @@ -17,8 +17,13 @@ const validate = require('../../../middleware/validate') const adminRouter = express.Router() -// Every admin route requires auth and is kept out of search indexes. -adminRouter.use(noindex, isLoggedIn) +// Every admin route requires auth, a STAFF role, and is kept out of search +// 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 // management, site mode, and settings are restricted to the admin role.