From b873df218dc7159175e27ba7feec6b4ec807c94b Mon Sep 17 00:00:00 2001 From: Jaideep Verma Mirchandani <18165084+jmirchandani@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:18:44 -0400 Subject: [PATCH 1/2] fix(admin): restrict admin-only routes to administrators The user menu showed a Settings link to every role, and admin-only routes mounted for anyone who typed their URL. The sidebar and command palette already hide these entries below Admin, but that is navigation, not a guard: a non-admin could still open the /settings hub and its site-level pages, /users, /import/wordpress and a plugin's settings page, where requests the server rejects left screens that failed to load or could not save. Add a RequireAdmin route guard, using the same in-component pattern the byline schema page already uses, around those routes, and show the user menu's Settings link only to admins. /settings/security stays open to every role because it manages the signed-in user's own passkeys. Editors lose read access to General, Social Links and SEO, which the server allows at settings:read, and non-admins lose the language switcher on the Settings hub. Both are deliberate and described in the changeset. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AznEK6RvKGboBkFg4XqadT --- .changeset/settings-admin-gate.md | 11 + packages/admin/src/components/Header.tsx | 27 +- .../admin/src/components/RequireAdmin.tsx | 47 ++++ packages/admin/src/router.tsx | 135 ++++++++-- .../admin/tests/components/Header.test.tsx | 46 +++- .../tests/components/RequireAdmin.test.tsx | 91 +++++++ .../admin/tests/router-admin-gate.test.tsx | 242 ++++++++++++++++++ 7 files changed, 571 insertions(+), 28 deletions(-) create mode 100644 .changeset/settings-admin-gate.md create mode 100644 packages/admin/src/components/RequireAdmin.tsx create mode 100644 packages/admin/tests/components/RequireAdmin.test.tsx create mode 100644 packages/admin/tests/router-admin-gate.test.tsx diff --git a/.changeset/settings-admin-gate.md b/.changeset/settings-admin-gate.md new file mode 100644 index 0000000000..9f4b64c28d --- /dev/null +++ b/.changeset/settings-admin-gate.md @@ -0,0 +1,11 @@ +--- +"@emdash-cms/admin": patch +--- + +Fixes non-admin users being able to open admin-only screens in the admin UI. The **Settings** link in the user menu is now shown only to administrators, and a non-admin who opens a site-level settings page, **Users**, **WordPress import**, or a plugin's settings page by its URL now sees an "Access denied" message instead of a screen that fails to load or cannot save. + +#### Changes for Editors + +Editors could previously open **Settings → General**, **Social Links**, and **SEO** and read their values, although saving them already required an administrator. They can no longer open these pages. The **Settings** screen is also where the admin language is chosen, so Editors and other non-admins now choose the admin language on the sign-in screen. + +**Security** settings, where each user manages their own passkeys, remain available to every role from the user menu. diff --git a/packages/admin/src/components/Header.tsx b/packages/admin/src/components/Header.tsx index e60cb1fe5c..13efd70611 100644 --- a/packages/admin/src/components/Header.tsx +++ b/packages/admin/src/components/Header.tsx @@ -11,6 +11,9 @@ import { ThemeToggle } from "./ThemeToggle"; export type { CurrentUser } from "../lib/api/current-user"; +// Role levels (matching @emdash-cms/auth) +const ROLE_ADMIN = 50; + async function handleLogout() { // Clear the public-site toolbar-bootstrap flag (see Shell.tsx). try { @@ -39,6 +42,12 @@ export function Header() { const { data: user } = useCurrentUser(); + // Site settings are admin-only (the sidebar gates `/settings` on + // `ROLE_ADMIN` and the route itself is wrapped in `RequireAdmin`). + // Security settings stay visible to everyone: that page manages the + // signed-in user's own passkeys. + const canManageSettings = (user?.role ?? 0) >= ROLE_ADMIN; + // Get display name and initials const displayName = user?.name || user?.email || t`User`; const initialsSource = user?.name || user?.email || "U"; @@ -92,14 +101,16 @@ export function Header() { {t`Security Settings`} - setUserMenuOpen(false)} - className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-kumo-tint" - > - - {t`Settings`} - + {canManageSettings && ( + setUserMenuOpen(false)} + className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-kumo-tint" + > + + {t`Settings`} + + )}