From 872004768cc8f564e7f02d8b1637de2c8df3b52c Mon Sep 17 00:00:00 2001 From: zeevdr Date: Sun, 31 May 2026 09:16:03 +0300 Subject: [PATCH 1/3] feat(layout): add config-only mode for non-technical admin UIs Adds a new LAYOUT_MODE=config-only that lands directly on the config editor for a pre-selected tenant, with no sidebar navigation and no superadmin role option. Intended for demo admin panels that should feel like a product settings page rather than a developer tool. - ConfigOnlyLayout: header-only shell (logo, app name, debug bar, dark mode toggle) with no sidebar - Auth: defaults to admin role in config-only mode; caps any stored superadmin session to admin on load - AuthBar: filters superadmin from the role dropdown in config-only mode - App routing: config-only mode gets its own route tree to avoid conflicting with the full Layout's /tenants/:id routes Closes #11 Co-Authored-By: Claude --- src/App.tsx | 61 ++++++++++++------- src/components/AuthBar.tsx | 5 +- src/components/ConfigOnlyLayout.tsx | 28 +++++++++ .../__tests__/AuthBar.config-only.test.tsx | 26 ++++++++ src/lib/auth.ts | 10 ++- src/lib/config.ts | 2 +- 6 files changed, 105 insertions(+), 27 deletions(-) create mode 100644 src/components/ConfigOnlyLayout.tsx create mode 100644 src/components/__tests__/AuthBar.config-only.test.tsx 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/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/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 { From e8ae25e99519b59fbb1fa2a66f8d19bd62f241cc Mon Sep 17 00:00:00 2001 From: zeevdr Date: Sun, 31 May 2026 09:23:54 +0300 Subject: [PATCH 2/3] test(layout): cover config-only auth defaults and layout render Adds unit tests for the new config-only branches in loadAuth (superadmin cap, default role fallback) and a render test for ConfigOnlyLayout (header identity, no sidebar nav) to satisfy the Codecov patch threshold. Co-Authored-By: Claude --- .../__tests__/ConfigOnlyLayout.test.tsx | 45 ++++++++++++++ src/lib/__tests__/auth.config-only.test.ts | 60 +++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 src/components/__tests__/ConfigOnlyLayout.test.tsx create mode 100644 src/lib/__tests__/auth.config-only.test.ts 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"); + }); +}); From 9bc081dde30e57c4262ee925009744b16d09694f Mon Sep 17 00:00:00 2001 From: zeevdr Date: Sun, 31 May 2026 09:30:41 +0300 Subject: [PATCH 3/3] test(app): add App routing tests for config-only and full modes Covers the new config-only routing branch and the re-indented standard Layout routes in App.tsx, bringing App.tsx branch coverage to 100% and pushing patch coverage above the Codecov 80% threshold. Co-Authored-By: Claude --- src/__tests__/App.config-only.test.tsx | 72 ++++++++++++++++++++++++++ src/__tests__/App.full.test.tsx | 63 ++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 src/__tests__/App.config-only.test.tsx create mode 100644 src/__tests__/App.full.test.tsx 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(); + }); +});