Skip to content
Merged
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
61 changes: 38 additions & 23 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 <Navigate to={`/tenants/${config.tenantId}`} replace />;
}
return <Home />;
Expand All @@ -51,28 +55,39 @@ export function App() {
<ToastProvider>
<ErrorBoundary>
<Routes>
{/* Standard layout with sidebar + header */}
<Route element={<Layout />}>
<Route index element={<HomeOrRedirect />} />
<Route path="schemas" element={<SchemaList />} />
<Route path="schemas/import" element={<SchemaImport />} />
<Route path="schemas/:id" element={<SchemaDetail />} />
<Route path="tenants" element={<TenantList />} />
<Route path="tenants/create" element={<TenantCreate />} />
<Route path="tenants/:id" element={<TenantDetail />} />
<Route path="tenants/:id/history" element={<TenantHistory />} />
<Route path="tenants/:id/audit" element={<TenantAudit />} />
<Route path="tenants/:id/usage" element={<TenantUsage />} />
<Route path="*" element={<NotFound />} />
</Route>
{config.layoutMode === "config-only" ? (
/* Config-only layout — header only, no sidebar, for non-technical admin use */
<Route element={<ConfigOnlyLayout />}>
<Route index element={<HomeOrRedirect />} />
<Route path="tenants/:id" element={<TenantDetail />} />
<Route path="*" element={<NotFound />} />
</Route>
) : (
<>
{/* Standard layout with sidebar + header */}
<Route element={<Layout />}>
<Route index element={<HomeOrRedirect />} />
<Route path="schemas" element={<SchemaList />} />
<Route path="schemas/import" element={<SchemaImport />} />
<Route path="schemas/:id" element={<SchemaDetail />} />
<Route path="tenants" element={<TenantList />} />
<Route path="tenants/create" element={<TenantCreate />} />
<Route path="tenants/:id" element={<TenantDetail />} />
<Route path="tenants/:id/history" element={<TenantHistory />} />
<Route path="tenants/:id/audit" element={<TenantAudit />} />
<Route path="tenants/:id/usage" element={<TenantUsage />} />
<Route path="*" element={<NotFound />} />
</Route>

{/* Embed layout — no sidebar, no header, for iframe use */}
<Route path="embed" element={<EmbedLayout />}>
<Route path="tenants/:id" element={<TenantDetail />} />
<Route path="tenants/:id/audit" element={<TenantAudit />} />
<Route path="tenants/:id/usage" element={<TenantUsage />} />
<Route path="schemas/:id" element={<SchemaDetail />} />
</Route>
{/* Embed layout — no sidebar, no header, for iframe use */}
<Route path="embed" element={<EmbedLayout />}>
<Route path="tenants/:id" element={<TenantDetail />} />
<Route path="tenants/:id/audit" element={<TenantAudit />} />
<Route path="tenants/:id/usage" element={<TenantUsage />} />
<Route path="schemas/:id" element={<SchemaDetail />} />
</Route>
</>
)}
</Routes>
</ErrorBoundary>
</ToastProvider>
Expand Down
72 changes: 72 additions & 0 deletions src/__tests__/App.config-only.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div data-testid="tenant-detail" />,
}));
vi.mock("../pages/Home", () => ({ Home: () => <div data-testid="home" /> }));
vi.mock("../pages/NotFound", () => ({ NotFound: () => <div data-testid="not-found" /> }));
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(
<MemoryRouter initialEntries={[initialPath]}>
<App />
</MemoryRouter>,
);
}

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();
});
});
63 changes: 63 additions & 0 deletions src/__tests__/App.full.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <div data-testid="home" /> }));
vi.mock("../pages/NotFound", () => ({ NotFound: () => <div data-testid="not-found" /> }));
vi.mock("../pages/tenants/TenantDetail", () => ({
TenantDetail: () => <div data-testid="tenant-detail" />,
}));
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(
<MemoryRouter initialEntries={[initialPath]}>
<App />
</MemoryRouter>,
);
}

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();
});
});
5 changes: 4 additions & 1 deletion src/components/AuthBar.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 (
Expand Down Expand Up @@ -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) => (
<option key={role} value={role}>
{role}
</option>
Expand Down
28 changes: 28 additions & 0 deletions src/components/ConfigOnlyLayout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex h-screen flex-col">
<header className="flex items-center gap-3 border-b border-gray-200 px-6 py-3 dark:border-gray-800">
<img src={logoSrc} alt="" className="h-8 w-8" />
<span className="text-base font-semibold">{appName}</span>
{!import.meta.env.VITE_HIDE_DEBUG && <AuthBar />}
<DarkModeToggle className="ml-auto" />
</header>
<main className="flex-1 overflow-auto p-6">
<ErrorBoundary>
<Outlet />
</ErrorBoundary>
</main>
</div>
);
}
26 changes: 26 additions & 0 deletions src/components/__tests__/AuthBar.config-only.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<AuthContext value={{ auth, setAuth: () => {} }}>
<AuthBar />
</AuthContext>,
);
}

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();
});
});
45 changes: 45 additions & 0 deletions src/components/__tests__/ConfigOnlyLayout.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter>
<AuthContext value={{ auth: { subject: "admin", role: "admin" }, setAuth: () => {} }}>
<ConfigOnlyLayout />
</AuthContext>
</MemoryRouter>,
);
}

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();
});
});
Loading
Loading