diff --git a/src/App.tsx b/src/App.tsx
index 3d7806c..4fa33e0 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,6 +1,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
+import { ConfigOnlyLayout } from "./components/ConfigOnlyLayout";
import { EmbedLayout } from "./components/EmbedLayout";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { Layout } from "./components/Layout";
@@ -29,9 +30,12 @@ const queryClient = new QueryClient({
},
});
-/** In single-tenant mode, redirect home to the tenant detail page. */
+/** In single-tenant or config-only mode, redirect home to the tenant detail page. */
function HomeOrRedirect() {
- if (config.layoutMode === "single-tenant" && config.tenantId) {
+ if (
+ (config.layoutMode === "single-tenant" || config.layoutMode === "config-only") &&
+ config.tenantId
+ ) {
return ;
}
return ;
@@ -51,28 +55,39 @@ export function App() {
- {/* Standard layout with sidebar + header */}
- }>
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
+ {config.layoutMode === "config-only" ? (
+ /* Config-only layout — header only, no sidebar, for non-technical admin use */
+ }>
+ } />
+ } />
+ } />
+
+ ) : (
+ <>
+ {/* Standard layout with sidebar + header */}
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
- {/* Embed layout — no sidebar, no header, for iframe use */}
- }>
- } />
- } />
- } />
- } />
-
+ {/* Embed layout — no sidebar, no header, for iframe use */}
+ }>
+ } />
+ } />
+ } />
+ } />
+
+ >
+ )}
diff --git a/src/__tests__/App.config-only.test.tsx b/src/__tests__/App.config-only.test.tsx
new file mode 100644
index 0000000..e1900a8
--- /dev/null
+++ b/src/__tests__/App.config-only.test.tsx
@@ -0,0 +1,72 @@
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { beforeAll, describe, expect, it, vi } from "vitest";
+import { App } from "../App";
+
+const TEST_TENANT_ID = "00000000-0000-0000-0000-000000000001";
+
+vi.mock("../lib/config", () => ({
+ config: {
+ layoutMode: "config-only",
+ tenantId: "00000000-0000-0000-0000-000000000001",
+ appName: "Test",
+ logoUrl: "",
+ defaultRole: "",
+ defaultSubject: "",
+ },
+}));
+
+// Stub heavy page/component modules to keep the test lightweight.
+vi.mock("../pages/tenants/TenantDetail", () => ({
+ TenantDetail: () =>
,
+}));
+vi.mock("../pages/Home", () => ({ Home: () => }));
+vi.mock("../pages/NotFound", () => ({ NotFound: () => }));
+vi.mock("../pages/schemas/SchemaDetail", () => ({ SchemaDetail: () => null }));
+vi.mock("../pages/schemas/SchemaImport", () => ({ SchemaImport: () => null }));
+vi.mock("../pages/schemas/SchemaList", () => ({ SchemaList: () => null }));
+vi.mock("../pages/tenants/TenantAudit", () => ({ TenantAudit: () => null }));
+vi.mock("../pages/tenants/TenantCreate", () => ({ TenantCreate: () => null }));
+vi.mock("../pages/tenants/TenantHistory", () => ({ TenantHistory: () => null }));
+vi.mock("../pages/tenants/TenantList", () => ({ TenantList: () => null }));
+vi.mock("../pages/tenants/TenantUsage", () => ({ TenantUsage: () => null }));
+
+beforeAll(() => {
+ Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ value: vi
+ .fn()
+ .mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }),
+ });
+});
+
+function renderApp(initialPath = "/") {
+ return render(
+
+
+ ,
+ );
+}
+
+describe("App in config-only mode", () => {
+ it("renders ConfigOnlyLayout header (no sidebar nav)", () => {
+ renderApp();
+ expect(screen.queryByRole("navigation")).not.toBeInTheDocument();
+ expect(screen.getByRole("banner")).toBeInTheDocument();
+ });
+
+ it("redirects / to tenant detail when tenantId is set", () => {
+ renderApp("/");
+ expect(screen.getByTestId("tenant-detail")).toBeInTheDocument();
+ });
+
+ it("renders TenantDetail for /tenants/:id", () => {
+ renderApp(`/tenants/${TEST_TENANT_ID}`);
+ expect(screen.getByTestId("tenant-detail")).toBeInTheDocument();
+ });
+
+ it("renders NotFound for unknown routes", () => {
+ renderApp("/schemas");
+ expect(screen.getByTestId("not-found")).toBeInTheDocument();
+ });
+});
diff --git a/src/__tests__/App.full.test.tsx b/src/__tests__/App.full.test.tsx
new file mode 100644
index 0000000..f7a9c0c
--- /dev/null
+++ b/src/__tests__/App.full.test.tsx
@@ -0,0 +1,63 @@
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { beforeAll, describe, expect, it, vi } from "vitest";
+import { App } from "../App";
+
+vi.mock("../lib/config", () => ({
+ config: {
+ layoutMode: "full",
+ tenantId: undefined,
+ appName: "Test",
+ logoUrl: "",
+ defaultRole: "",
+ defaultSubject: "",
+ },
+}));
+
+vi.mock("../pages/Home", () => ({ Home: () => }));
+vi.mock("../pages/NotFound", () => ({ NotFound: () => }));
+vi.mock("../pages/tenants/TenantDetail", () => ({
+ TenantDetail: () => ,
+}));
+vi.mock("../pages/schemas/SchemaDetail", () => ({ SchemaDetail: () => null }));
+vi.mock("../pages/schemas/SchemaImport", () => ({ SchemaImport: () => null }));
+vi.mock("../pages/schemas/SchemaList", () => ({ SchemaList: () => null }));
+vi.mock("../pages/tenants/TenantAudit", () => ({ TenantAudit: () => null }));
+vi.mock("../pages/tenants/TenantCreate", () => ({ TenantCreate: () => null }));
+vi.mock("../pages/tenants/TenantHistory", () => ({ TenantHistory: () => null }));
+vi.mock("../pages/tenants/TenantList", () => ({ TenantList: () => null }));
+vi.mock("../pages/tenants/TenantUsage", () => ({ TenantUsage: () => null }));
+
+beforeAll(() => {
+ Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ value: vi
+ .fn()
+ .mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }),
+ });
+});
+
+function renderApp(initialPath = "/") {
+ return render(
+
+
+ ,
+ );
+}
+
+describe("App in full mode", () => {
+ it("renders sidebar navigation", () => {
+ renderApp();
+ expect(screen.getByRole("navigation")).toBeInTheDocument();
+ });
+
+ it("renders Home at /", () => {
+ renderApp("/");
+ expect(screen.getByTestId("home")).toBeInTheDocument();
+ });
+
+ it("renders NotFound for unknown routes", () => {
+ renderApp("/unknown-route");
+ expect(screen.getByTestId("not-found")).toBeInTheDocument();
+ });
+});
diff --git a/src/components/AuthBar.tsx b/src/components/AuthBar.tsx
index c52bdc2..eeb2b29 100644
--- a/src/components/AuthBar.tsx
+++ b/src/components/AuthBar.tsx
@@ -1,5 +1,6 @@
import type { AuthState } from "../lib/auth";
import { useAuth } from "../lib/auth";
+import { config } from "../lib/config";
import { DEFAULT_SUBJECT, ROLES, type Role } from "../lib/constants";
import { label } from "../lib/labels";
@@ -11,6 +12,8 @@ export function AuthBar() {
setAuth({ ...auth, ...patch });
};
+ const availableRoles =
+ config.layoutMode === "config-only" ? ROLES.filter((r) => r !== "superadmin") : ROLES;
const needsTenant = auth.role !== "superadmin";
return (
@@ -42,7 +45,7 @@ export function AuthBar() {
}}
className="rounded border border-gray-300 bg-white px-2 py-1 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
>
- {ROLES.map((role) => (
+ {availableRoles.map((role) => (
diff --git a/src/components/ConfigOnlyLayout.tsx b/src/components/ConfigOnlyLayout.tsx
new file mode 100644
index 0000000..b7e17fd
--- /dev/null
+++ b/src/components/ConfigOnlyLayout.tsx
@@ -0,0 +1,28 @@
+import { Outlet } from "react-router-dom";
+import { config } from "../lib/config";
+import { label } from "../lib/labels";
+import { AuthBar } from "./AuthBar";
+import { DarkModeToggle } from "./DarkModeToggle";
+import { ErrorBoundary } from "./ErrorBoundary";
+
+/** Minimal layout for config-only mode — header with app identity, no sidebar. */
+export function ConfigOnlyLayout() {
+ const appName = config.appName || label("app.name");
+ const logoSrc = config.logoUrl || "/logo.svg";
+
+ return (
+
+
+
+ {appName}
+ {!import.meta.env.VITE_HIDE_DEBUG && }
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/__tests__/AuthBar.config-only.test.tsx b/src/components/__tests__/AuthBar.config-only.test.tsx
new file mode 100644
index 0000000..1387ddc
--- /dev/null
+++ b/src/components/__tests__/AuthBar.config-only.test.tsx
@@ -0,0 +1,26 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import type { AuthState } from "../../lib/auth";
+import { AuthContext } from "../../lib/auth";
+import { AuthBar } from "../AuthBar";
+
+vi.mock("../../lib/config", () => ({
+ config: { layoutMode: "config-only" },
+}));
+
+function renderWithAuth(auth: AuthState = { subject: "admin", role: "admin" }) {
+ return render(
+ {} }}>
+
+ ,
+ );
+}
+
+describe("AuthBar in config-only mode", () => {
+ it("excludes superadmin from role dropdown", () => {
+ renderWithAuth();
+ expect(screen.queryByText("superadmin")).not.toBeInTheDocument();
+ expect(screen.getByText("admin")).toBeInTheDocument();
+ expect(screen.getByText("user")).toBeInTheDocument();
+ });
+});
diff --git a/src/components/__tests__/ConfigOnlyLayout.test.tsx b/src/components/__tests__/ConfigOnlyLayout.test.tsx
new file mode 100644
index 0000000..9a1501c
--- /dev/null
+++ b/src/components/__tests__/ConfigOnlyLayout.test.tsx
@@ -0,0 +1,45 @@
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { beforeAll, describe, expect, it, vi } from "vitest";
+import { AuthContext } from "../../lib/auth";
+import { ConfigOnlyLayout } from "../ConfigOnlyLayout";
+
+vi.mock("../../lib/config", () => ({
+ config: { layoutMode: "config-only", appName: "Test App", logoUrl: "" },
+}));
+
+beforeAll(() => {
+ Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ value: vi
+ .fn()
+ .mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }),
+ });
+});
+
+function renderLayout() {
+ return render(
+
+ {} }}>
+
+
+ ,
+ );
+}
+
+describe("ConfigOnlyLayout", () => {
+ it("renders app name in header", () => {
+ renderLayout();
+ expect(screen.getByText("Test App")).toBeInTheDocument();
+ });
+
+ it("renders dark mode toggle", () => {
+ renderLayout();
+ expect(screen.getByRole("button")).toBeInTheDocument();
+ });
+
+ it("renders no sidebar nav", () => {
+ renderLayout();
+ expect(screen.queryByRole("navigation")).not.toBeInTheDocument();
+ });
+});
diff --git a/src/lib/__tests__/auth.config-only.test.ts b/src/lib/__tests__/auth.config-only.test.ts
new file mode 100644
index 0000000..3b81d78
--- /dev/null
+++ b/src/lib/__tests__/auth.config-only.test.ts
@@ -0,0 +1,60 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { loadAuth } from "../auth";
+import { STORAGE_KEY_AUTH } from "../constants";
+
+vi.mock("../config", () => ({
+ config: {
+ layoutMode: "config-only",
+ defaultRole: "",
+ defaultSubject: "",
+ tenantId: undefined,
+ },
+}));
+
+describe("loadAuth in config-only mode", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ it("defaults to admin role when no stored session", () => {
+ const auth = loadAuth();
+ expect(auth.role).toBe("admin");
+ });
+
+ it("caps stored superadmin role to admin", () => {
+ localStorage.setItem(
+ STORAGE_KEY_AUTH,
+ JSON.stringify({ subject: "alice", role: "superadmin" }),
+ );
+ const auth = loadAuth();
+ expect(auth.role).toBe("admin");
+ });
+
+ it("preserves stored admin role", () => {
+ localStorage.setItem(
+ STORAGE_KEY_AUTH,
+ JSON.stringify({ subject: "alice", role: "admin", tenantId: "t-1" }),
+ );
+ const auth = loadAuth();
+ expect(auth.role).toBe("admin");
+ expect(auth.tenantId).toBe("t-1");
+ });
+
+ it("preserves stored user role", () => {
+ localStorage.setItem(
+ STORAGE_KEY_AUTH,
+ JSON.stringify({ subject: "bob", role: "user", tenantId: "t-2" }),
+ );
+ const auth = loadAuth();
+ expect(auth.role).toBe("user");
+ });
+
+ it("returns defaults on corrupt localStorage", () => {
+ localStorage.setItem(STORAGE_KEY_AUTH, "not-json{{{");
+ const auth = loadAuth();
+ expect(auth.role).toBe("admin");
+ });
+});
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 860f7ac..7bf5f5d 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -16,9 +16,11 @@ export interface AuthContextValue {
/** Build auth defaults from runtime config (Docker env) or hardcoded fallbacks. */
function resolveDefaults(): AuthState {
+ const configOnlyDefault: Role = "admin";
+ const fallback = config.layoutMode === "config-only" ? configOnlyDefault : DEFAULT_ROLE;
const role = (ROLES as readonly string[]).includes(config.defaultRole)
? (config.defaultRole as Role)
- : DEFAULT_ROLE;
+ : fallback;
return {
subject: config.defaultSubject || DEFAULT_SUBJECT,
role,
@@ -44,9 +46,13 @@ export function loadAuth(): AuthState {
const stored = localStorage.getItem(STORAGE_KEY_AUTH);
if (stored) {
const parsed = JSON.parse(stored) as AuthState;
+ let role: Role = config.defaultRole ? defaults.role : parsed.role || defaults.role;
+ if (config.layoutMode === "config-only" && role === "superadmin") {
+ role = "admin";
+ }
return {
subject: parsed.subject || defaults.subject,
- role: config.defaultRole ? defaults.role : parsed.role || defaults.role,
+ role,
tenantId: config.tenantId || parsed.tenantId || defaults.tenantId,
};
}
diff --git a/src/lib/config.ts b/src/lib/config.ts
index 5e4987b..14a9aeb 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -1,5 +1,5 @@
/** Layout mode controls which navigation levels are visible. */
-export type LayoutMode = "full" | "single-schema" | "single-tenant";
+export type LayoutMode = "full" | "single-schema" | "single-tenant" | "config-only";
/** Runtime config injected by Docker entrypoint (window.__DECREE_UI_CONFIG__). */
interface RuntimeConfig {