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..16344e4c18 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,8 @@ export function Header() {
const { data: user } = useCurrentUser();
+ 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 +97,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`}
+
+ )}
+
+
+ );
+ }
+
+ if (!currentUser || currentUser.role < ROLE_ADMIN) {
+ return (
+
+
+
{t`Access denied`}
+
{t`You need admin permissions to view this page.`}
+
+
+ );
+ }
+
+ return <>{children}>;
+}
diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx
index 07bee287c1..c7ec74ce37 100644
--- a/packages/admin/src/router.tsx
+++ b/packages/admin/src/router.tsx
@@ -54,6 +54,7 @@ import { PluginSettings } from "./components/PluginSettings";
import { Redirects } from "./components/Redirects";
import { RegistryBrowse } from "./components/RegistryBrowse";
import { RegistryPluginDetail } from "./components/RegistryPluginDetail";
+import { RequireAdmin } from "./components/RequireAdmin";
import { SandboxedPluginPage } from "./components/SandboxedPluginPage";
import { SectionEditor } from "./components/SectionEditor";
import { Sections } from "./components/Sections";
@@ -2093,7 +2094,11 @@ function CommentsPage() {
const settingsRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: "/settings",
- component: Settings,
+ component: () => (
+
+
+
+ ),
});
const mediaUsageSettingsRoute = createRoute({
@@ -2113,49 +2118,77 @@ const securitySettingsRoute = createRoute({
const allowedDomainsSettingsRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: "/settings/allowed-domains",
- component: AllowedDomainsSettings,
+ component: () => (
+
+
+
+ ),
});
// API tokens settings route
const apiTokenSettingsRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: "/settings/api-tokens",
- component: ApiTokenSettings,
+ component: () => (
+
+
+
+ ),
});
// Email settings route
const emailSettingsRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: "/settings/email",
- component: EmailSettings,
+ component: () => (
+
+
+
+ ),
});
// Backup settings route
const backupSettingsRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: "/settings/backups",
- component: BackupSettings,
+ component: () => (
+
+
+
+ ),
});
// General settings route
const generalSettingsRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: "/settings/general",
- component: GeneralSettings,
+ component: () => (
+
+
+
+ ),
});
// Social settings route
const socialSettingsRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: "/settings/social",
- component: SocialSettings,
+ component: () => (
+
+
+
+ ),
});
// SEO settings route
const seoSettingsRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: "/settings/seo",
- component: SeoSettings,
+ component: () => (
+
+
+
+ ),
});
// Plugin manager route
@@ -2287,7 +2320,11 @@ function ThemeDetailPage() {
const wordpressImportRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: "/import/wordpress",
- component: WordPressImport,
+ component: () => (
+
+
+
+ ),
});
// Menu routes
@@ -2350,7 +2387,11 @@ const sectionEditRoute = createRoute({
const usersRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: "/users",
- component: UsersPage,
+ component: () => (
+
+
+
+ ),
});
// Bylines route
@@ -2617,7 +2658,11 @@ function ContentTypesEditPage() {
const pluginSettingsRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: "/plugins-manager/$pluginId/settings",
- component: PluginSettingsPage,
+ component: () => (
+
+
+
+ ),
});
function PluginSettingsPage() {
diff --git a/packages/admin/tests/components/Header.test.tsx b/packages/admin/tests/components/Header.test.tsx
index 36f18fd80f..9c12884930 100644
--- a/packages/admin/tests/components/Header.test.tsx
+++ b/packages/admin/tests/components/Header.test.tsx
@@ -20,6 +20,9 @@ vi.mock("@tanstack/react-router", async () => {
};
});
+// Mutable so individual tests can render the header as a non-admin.
+const currentUser = vi.hoisted(() => ({ role: 50 }));
+
// Mock API
vi.mock("../../src/lib/api/client", async () => {
const actual = await vi.importActual("../../src/lib/api/client");
@@ -34,7 +37,7 @@ vi.mock("../../src/lib/api/client", async () => {
id: "1",
name: "Matt Kane",
email: "matt@test.com",
- role: 50,
+ role: currentUser.role,
},
}),
{ status: 200 },
@@ -54,6 +57,11 @@ const { Header } = await import("../../src/components/Header");
// ---------------------------------------------------------------------------
const THEME_BUTTON_REGEX = /Switch to (light|dark)/;
+const USER_MENU_REGEX = /Matt Kane/;
+
+// Mirror @emdash-cms/auth Role levels (kept inline, matching Header.tsx).
+const ROLE_AUTHOR = 30;
+const ROLE_ADMIN = 50;
function TestWrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({
@@ -72,6 +80,7 @@ describe("Header", () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.removeAttribute("data-mode");
+ currentUser.role = ROLE_ADMIN;
});
it("theme toggle button is present", async () => {
@@ -104,4 +113,39 @@ describe("Header", () => {
);
await expect.element(screen.getByText("View Site")).toBeInTheDocument();
});
+
+ describe("user menu role gate", () => {
+ it("shows Settings and Security Settings for an admin", async () => {
+ const screen = await render(
+
+
+ ,
+ );
+ await screen.getByRole("button", { name: USER_MENU_REGEX }).click();
+
+ await expect
+ .element(screen.getByRole("link", { name: "Settings", exact: true }))
+ .toBeInTheDocument();
+ await expect
+ .element(screen.getByRole("link", { name: "Security Settings", exact: true }))
+ .toBeInTheDocument();
+ });
+
+ it("hides Settings for a non-admin but keeps Security Settings (own passkeys)", async () => {
+ currentUser.role = ROLE_AUTHOR;
+ const screen = await render(
+
+
+ ,
+ );
+ await screen.getByRole("button", { name: USER_MENU_REGEX }).click();
+
+ await expect
+ .element(screen.getByRole("link", { name: "Security Settings", exact: true }))
+ .toBeInTheDocument();
+ await expect
+ .element(screen.getByRole("link", { name: "Settings", exact: true }))
+ .not.toBeInTheDocument();
+ });
+ });
});
diff --git a/packages/admin/tests/components/RequireAdmin.test.tsx b/packages/admin/tests/components/RequireAdmin.test.tsx
new file mode 100644
index 0000000000..cb3b1987ba
--- /dev/null
+++ b/packages/admin/tests/components/RequireAdmin.test.tsx
@@ -0,0 +1,84 @@
+import * as React from "react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+import { render } from "../utils/render.tsx";
+
+// Mirror @emdash-cms/auth Role levels (kept inline, matching RequireAdmin.tsx).
+const ROLE_AUTHOR = 30;
+const ROLE_EDITOR = 40;
+const ROLE_ADMIN = 50;
+
+const currentUser = vi.hoisted(() => ({
+ role: 50 as number,
+ isLoading: false,
+ signedIn: true,
+}));
+
+vi.mock("../../src/lib/api/current-user.js", () => ({
+ useCurrentUser: () => ({
+ data: currentUser.signedIn
+ ? { id: "user-1", email: "user@example.com", role: currentUser.role }
+ : undefined,
+ isLoading: currentUser.isLoading,
+ }),
+}));
+
+// Import after mocks
+const { RequireAdmin } = await import("../../src/components/RequireAdmin");
+
+const CHILD_TEXT = "Protected settings content";
+
+describe("RequireAdmin", () => {
+ beforeEach(() => {
+ currentUser.role = ROLE_ADMIN;
+ currentUser.isLoading = false;
+ currentUser.signedIn = true;
+ });
+
+ it("renders its children for an admin", async () => {
+ const screen = await render(
+
+ {CHILD_TEXT}
+ ,
+ );
+ await expect.element(screen.getByText(CHILD_TEXT)).toBeInTheDocument();
+ await expect.element(screen.getByText("Access denied")).not.toBeInTheDocument();
+ });
+
+ it.each([
+ ["editor", ROLE_EDITOR],
+ ["author", ROLE_AUTHOR],
+ ])("shows Access denied instead of its children for an %s", async (_label, role) => {
+ currentUser.role = role;
+ const screen = await render(
+
+ {CHILD_TEXT}
+ ,
+ );
+ await expect.element(screen.getByText("Access denied")).toBeInTheDocument();
+ await expect.element(screen.getByText(CHILD_TEXT)).not.toBeInTheDocument();
+ });
+
+ it("treats a missing user as denied", async () => {
+ currentUser.signedIn = false;
+ const screen = await render(
+
+ {CHILD_TEXT}
+ ,
+ );
+ await expect.element(screen.getByText("Access denied")).toBeInTheDocument();
+ await expect.element(screen.getByText(CHILD_TEXT)).not.toBeInTheDocument();
+ });
+
+ it("renders neither children nor the denial while the user is loading", async () => {
+ currentUser.isLoading = true;
+ currentUser.signedIn = false;
+ const screen = await render(
+
+ {CHILD_TEXT}
+ ,
+ );
+ await expect.element(screen.getByText(CHILD_TEXT)).not.toBeInTheDocument();
+ await expect.element(screen.getByText("Access denied")).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/admin/tests/router-admin-gate.test.tsx b/packages/admin/tests/router-admin-gate.test.tsx
new file mode 100644
index 0000000000..86c41544dc
--- /dev/null
+++ b/packages/admin/tests/router-admin-gate.test.tsx
@@ -0,0 +1,204 @@
+import { Toasty } from "@cloudflare/kumo";
+import { i18n } from "@lingui/core";
+import { I18nProvider } from "@lingui/react";
+import { QueryClientProvider } from "@tanstack/react-query";
+import { RouterProvider } from "@tanstack/react-router";
+import * as React from "react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+
+import type { AdminManifest } from "../src/lib/api";
+import { createAdminRouter } from "../src/router";
+import { render } from "./utils/render.tsx";
+import { createTestQueryClient, createMockFetch } from "./utils/test-helpers";
+
+vi.mock("../src/components/Shell", () => ({
+ Shell: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock("../src/components/AdminCommandPalette", () => ({
+ AdminCommandPalette: () => null,
+}));
+
+vi.mock("../src/routes/users", () => ({
+ UsersPage: () => Users page content
,
+}));
+
+vi.mock("../src/components/WordPressImport", () => ({
+ WordPressImport: () => WordPress import content
,
+}));
+
+vi.mock("../src/components/PluginSettings", () => ({
+ PluginSettings: () => Plugin settings content
,
+}));
+
+// Spread `importOriginal` rather than replacing the module outright: three
+// of these four also export siblings that other modules import, and a bare
+// factory would drop them and break the import graph.
+vi.mock("../src/components/ContentTypeList", async (importOriginal) => ({
+ ...(await importOriginal()),
+ ContentTypeList: () => Content types content
,
+}));
+
+vi.mock("../src/components/PluginManager", async (importOriginal) => ({
+ ...(await importOriginal()),
+ PluginManager: () => Plugin manager content
,
+}));
+
+vi.mock("../src/components/MarketplaceBrowse", async (importOriginal) => ({
+ ...(await importOriginal()),
+ MarketplaceBrowse: () => Marketplace browse content
,
+}));
+
+vi.mock("../src/components/ThemeMarketplaceBrowse", async (importOriginal) => ({
+ ...(await importOriginal()),
+ ThemeMarketplaceBrowse: () => Theme marketplace content
,
+}));
+
+vi.mock("../src/components/Settings", () => ({
+ Settings: () => Settings content
,
+}));
+
+vi.mock("../src/components/settings/AllowedDomainsSettings", () => ({
+ AllowedDomainsSettings: () => Allowed domains content
,
+}));
+
+vi.mock("../src/components/settings/ApiTokenSettings", () => ({
+ ApiTokenSettings: () => API tokens content
,
+}));
+
+vi.mock("../src/components/settings/BackupSettings", () => ({
+ BackupSettings: () => Backups content
,
+}));
+
+vi.mock("../src/components/settings/EmailSettings", () => ({
+ EmailSettings: () => Email settings content
,
+}));
+
+vi.mock("../src/components/settings/GeneralSettings", () => ({
+ GeneralSettings: () => General settings content
,
+}));
+
+vi.mock("../src/components/settings/SeoSettings", () => ({
+ SeoSettings: () => SEO settings content
,
+}));
+
+vi.mock("../src/components/settings/SocialSettings", () => ({
+ SocialSettings: () => Social settings content
,
+}));
+
+const MANIFEST: AdminManifest = {
+ version: "1.0.0",
+ hash: "abc123",
+ authMode: "passkey",
+ collections: {},
+ plugins: {},
+ taxonomies: [],
+};
+
+function buildRouter() {
+ const queryClient = createTestQueryClient();
+ const router = createAdminRouter(queryClient);
+ if (!i18n.locale) {
+ i18n.loadAndActivate({ locale: "en", messages: {} });
+ }
+ function TestApp() {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+ return { router, TestApp };
+}
+
+const WRAPPED_ROUTES: Array<[string, string]> = [
+ ["/settings", "Settings content"],
+ ["/settings/allowed-domains", "Allowed domains content"],
+ ["/settings/api-tokens", "API tokens content"],
+ ["/settings/email", "Email settings content"],
+ ["/settings/backups", "Backups content"],
+ ["/settings/general", "General settings content"],
+ ["/settings/social", "Social settings content"],
+ ["/settings/seo", "SEO settings content"],
+ ["/users", "Users page content"],
+ ["/import/wordpress", "WordPress import content"],
+ ["/plugins-manager/test-plugin/settings", "Plugin settings content"],
+];
+
+// Routes that must stay reachable for a non-admin (Editor-tier reads).
+const UNWRAPPED_ROUTES: Array<[string, string]> = [
+ ["/content-types", "Content types content"],
+ ["/plugins-manager", "Plugin manager content"],
+ ["/plugins/marketplace", "Marketplace browse content"],
+ ["/themes/marketplace", "Theme marketplace content"],
+];
+
+describe("admin-only routes show Access denied to non-admins", () => {
+ let mockFetch: ReturnType;
+
+ afterEach(() => {
+ mockFetch.restore();
+ });
+
+ it.each(WRAPPED_ROUTES)(
+ "shows Access denied instead of %s for a non-admin (Editor) user",
+ async (to, marker) => {
+ mockFetch = createMockFetch();
+ mockFetch
+ .on("GET", "/_emdash/api/manifest", { data: MANIFEST })
+ .on("GET", "/_emdash/api/auth/me", { data: { id: "user_01", role: 40 } });
+
+ const { router, TestApp } = buildRouter();
+ await router.navigate({ to });
+ const screen = await render( );
+
+ await expect.element(screen.getByText("Access denied")).toBeInTheDocument();
+ await expect.element(screen.getByText(marker)).not.toBeInTheDocument();
+ },
+ );
+
+ it.each(WRAPPED_ROUTES)("renders %s for an admin", async (to, marker) => {
+ mockFetch = createMockFetch();
+ mockFetch
+ .on("GET", "/_emdash/api/manifest", { data: MANIFEST })
+ .on("GET", "/_emdash/api/auth/me", { data: { id: "user_01", role: 50 } });
+
+ const { router, TestApp } = buildRouter();
+ await router.navigate({ to });
+ const screen = await render( );
+
+ await expect.element(screen.getByText(marker)).toBeInTheDocument();
+ await expect.element(screen.getByText("Access denied")).not.toBeInTheDocument();
+ });
+});
+
+describe("routes that stay reachable for non-admins", () => {
+ let mockFetch: ReturnType;
+
+ afterEach(() => {
+ mockFetch.restore();
+ });
+
+ it.each(UNWRAPPED_ROUTES)("renders %s for a non-admin (Editor) user", async (to, marker) => {
+ mockFetch = createMockFetch();
+ mockFetch
+ .on("GET", "/_emdash/api/manifest", { data: MANIFEST })
+ .on("GET", "/_emdash/api/auth/me", { data: { id: "user_01", role: 40 } })
+ // `/content-types` treats a failed collections OR orphans query as a
+ // fatal ErrorScreen, which would mask the marker; both are Editor-
+ // readable in production (`schema:read`), so both resolve empty here.
+ .on("GET", "/_emdash/api/schema/collections", { data: { items: [] } })
+ .on("GET", "/_emdash/api/schema/orphans", { data: { items: [] } });
+
+ const { router, TestApp } = buildRouter();
+ await router.navigate({ to });
+ const screen = await render( );
+
+ await expect.element(screen.getByText(marker)).toBeInTheDocument();
+ await expect.element(screen.getByText("Access denied")).not.toBeInTheDocument();
+ });
+});