Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/settings-admin-gate.md
Original file line number Diff line number Diff line change
@@ -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.
23 changes: 15 additions & 8 deletions packages/admin/src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -92,14 +97,16 @@ export function Header() {
<Shield className="h-4 w-4" />
{t`Security Settings`}
</Link>
<Link
to="/settings"
onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-kumo-tint"
>
<Gear className="h-4 w-4" />
{t`Settings`}
</Link>
{canManageSettings && (
<Link
to="/settings"
onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-kumo-tint"
>
<Gear className="h-4 w-4" />
{t`Settings`}
</Link>
)}
<hr className="my-1" />
<button
onClick={handleLogout}
Expand Down
33 changes: 33 additions & 0 deletions packages/admin/src/components/RequireAdmin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Loader } from "@cloudflare/kumo";
import { useLingui } from "@lingui/react/macro";
import * as React from "react";

import { useCurrentUser } from "../lib/api/current-user.js";

const ROLE_ADMIN = 50;

export function RequireAdmin({ children }: { children: React.ReactNode }) {
const { t } = useLingui();
const { data: currentUser, isLoading: userLoading } = useCurrentUser();

if (userLoading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<Loader />
</div>
);
}

if (!currentUser || currentUser.role < ROLE_ADMIN) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<div className="text-center">
<h1 className="text-2xl font-semibold leading-tight">{t`Access denied`}</h1>
<p className="mt-2 text-sm text-kumo-subtle">{t`You need admin permissions to view this page.`}</p>
</div>
</div>
);
}

return <>{children}</>;
}
67 changes: 56 additions & 11 deletions packages/admin/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -2093,7 +2094,11 @@ function CommentsPage() {
const settingsRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: "/settings",
component: Settings,
component: () => (
<RequireAdmin>
<Settings />
</RequireAdmin>
),
});

const mediaUsageSettingsRoute = createRoute({
Expand All @@ -2113,49 +2118,77 @@ const securitySettingsRoute = createRoute({
const allowedDomainsSettingsRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: "/settings/allowed-domains",
component: AllowedDomainsSettings,
component: () => (
<RequireAdmin>
<AllowedDomainsSettings />
</RequireAdmin>
),
});

// API tokens settings route
const apiTokenSettingsRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: "/settings/api-tokens",
component: ApiTokenSettings,
component: () => (
<RequireAdmin>
<ApiTokenSettings />
</RequireAdmin>
),
});

// Email settings route
const emailSettingsRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: "/settings/email",
component: EmailSettings,
component: () => (
<RequireAdmin>
<EmailSettings />
</RequireAdmin>
),
});

// Backup settings route
const backupSettingsRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: "/settings/backups",
component: BackupSettings,
component: () => (
<RequireAdmin>
<BackupSettings />
</RequireAdmin>
),
});

// General settings route
const generalSettingsRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: "/settings/general",
component: GeneralSettings,
component: () => (
<RequireAdmin>
<GeneralSettings />
</RequireAdmin>
),
});

// Social settings route
const socialSettingsRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: "/settings/social",
component: SocialSettings,
component: () => (
<RequireAdmin>
<SocialSettings />
</RequireAdmin>
),
});

// SEO settings route
const seoSettingsRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: "/settings/seo",
component: SeoSettings,
component: () => (
<RequireAdmin>
<SeoSettings />
</RequireAdmin>
),
});

// Plugin manager route
Expand Down Expand Up @@ -2287,7 +2320,11 @@ function ThemeDetailPage() {
const wordpressImportRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: "/import/wordpress",
component: WordPressImport,
component: () => (
<RequireAdmin>
<WordPressImport />
</RequireAdmin>
),
});

// Menu routes
Expand Down Expand Up @@ -2350,7 +2387,11 @@ const sectionEditRoute = createRoute({
const usersRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: "/users",
component: UsersPage,
component: () => (
<RequireAdmin>
<UsersPage />
</RequireAdmin>
),
});

// Bylines route
Expand Down Expand Up @@ -2617,7 +2658,11 @@ function ContentTypesEditPage() {
const pluginSettingsRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
path: "/plugins-manager/$pluginId/settings",
component: PluginSettingsPage,
component: () => (
<RequireAdmin>
<PluginSettingsPage />
</RequireAdmin>
),
});

function PluginSettingsPage() {
Expand Down
46 changes: 45 additions & 1 deletion packages/admin/tests/components/Header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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 },
Expand All @@ -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({
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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(
<TestWrapper>
<Header />
</TestWrapper>,
);
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(
<TestWrapper>
<Header />
</TestWrapper>,
);
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();
});
});
});
84 changes: 84 additions & 0 deletions packages/admin/tests/components/RequireAdmin.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<RequireAdmin>
<p>{CHILD_TEXT}</p>
</RequireAdmin>,
);
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(
<RequireAdmin>
<p>{CHILD_TEXT}</p>
</RequireAdmin>,
);
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(
<RequireAdmin>
<p>{CHILD_TEXT}</p>
</RequireAdmin>,
);
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(
<RequireAdmin>
<p>{CHILD_TEXT}</p>
</RequireAdmin>,
);
await expect.element(screen.getByText(CHILD_TEXT)).not.toBeInTheDocument();
await expect.element(screen.getByText("Access denied")).not.toBeInTheDocument();
});
});
Loading
Loading