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.