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.