diff --git a/apps/auth-ui/src/components/layout/Sidebar.tsx b/apps/auth-ui/src/components/layout/Sidebar.tsx index b348ab4..dfe1483 100644 --- a/apps/auth-ui/src/components/layout/Sidebar.tsx +++ b/apps/auth-ui/src/components/layout/Sidebar.tsx @@ -1,6 +1,6 @@ import { Link, useLocation } from 'react-router-dom'; import { cn } from '../../lib/utils'; -import { Users, Link as LinkIcon, Globe, Menu, X, Key, Shield, FileCode } from 'lucide-react'; +import { Users, Link as LinkIcon, Globe, Menu, X, Key, Shield, FileCode, Package } from 'lucide-react'; import { Button } from '../ui/button'; import { SiteSwitcher } from './SiteSwitcher'; import { ThemeToggle } from '../theme-toggle'; @@ -35,6 +35,11 @@ export const Sidebar = ({ className, isCollapsed, onCollapse }: SidebarProps) => href: '/assets', icon: FileCode, }, + { + title: 'Bundles', + href: '/bundles', + icon: Package, + }, { title: 'JWT Inspector', href: '/jwt-inspector', diff --git a/apps/auth-ui/src/pages/bundles/BundleDetailsModal.tsx b/apps/auth-ui/src/pages/bundles/BundleDetailsModal.tsx new file mode 100644 index 0000000..dd24702 --- /dev/null +++ b/apps/auth-ui/src/pages/bundles/BundleDetailsModal.tsx @@ -0,0 +1,63 @@ +import type { components } from 'auth-openapi'; +import { Badge } from '../../components/ui/badge'; +import { DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../components/ui/dialog'; + +type Bundle = components['schemas']['bundle']; + +interface BundleDetailsModalProps { + bundle: Bundle; +} + +export const BundleDetailsModal = ({ bundle }: BundleDetailsModalProps) => ( + // Only the body scrolls, so a long list can't carry the close button off-screen with it. + + + Bundle {bundle.id} + + {bundle.environment} ยท {bundle.revision} + + + +
+
+ + +
+ + + + +
+

Metadata

+
{JSON.stringify(bundle.metadata ?? {}, null, 2)}
+
+
+
+); + +const Field = ({ label, value }: { label: string; value?: string }) => ( +
+

{label}

+

{value ?? 'โ€”'}

+
+); + +const BundleEntryList = ({ title, emptyLabel, entries }: { title: string; emptyLabel: string; entries?: { name: string; version: number }[] }) => ( +
+

{title}

+ {entries && entries.length > 0 ? ( + // Bounded so a long list doesn't bury what follows it. + + ) : ( +

{emptyLabel}

+ )} +
+); diff --git a/apps/auth-ui/src/pages/bundles/BundlesPage.tsx b/apps/auth-ui/src/pages/bundles/BundlesPage.tsx new file mode 100644 index 0000000..d542b45 --- /dev/null +++ b/apps/auth-ui/src/pages/bundles/BundlesPage.tsx @@ -0,0 +1,147 @@ +import type { components } from 'auth-openapi'; +import { Loader2 } from 'lucide-react'; +import { useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { Button } from '../../components/ui/button'; +import { Dialog } from '../../components/ui/dialog'; +import { Input } from '../../components/ui/input'; +import { Label } from '../../components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select'; +import { $api } from '../../fetch'; +import { BundleDetailsModal } from './BundleDetailsModal'; +import { BundlesTable } from './BundlesTable'; + +type Bundle = components['schemas']['bundle']; +type Environment = components['schemas']['environment']; + +const ENVIRONMENTS: Environment[] = ['np', 'stage', 'prod']; + +/** Radix rejects an empty option value, so "no filter" needs a name of its own. */ +const ANY = 'all'; + +const oneOf = (value: string | null, allowed: readonly T[], fallback: T): T => + allowed.includes(value as T) ? (value as T) : fallback; + +// Reads the picked day as local time, not UTC, before converting to the wire format โ€” +// same pattern as ClientsPage's Calendar picker. +const dayBoundary = (date: string, end: boolean): string => { + const [year, month, day] = date.split('-').map(Number) as [number, number, number]; + return end ? new Date(year, month - 1, day, 23, 59, 59, 999).toISOString() : new Date(year, month - 1, day, 0, 0, 0, 0).toISOString(); +}; + +const startOfDay = (date: string): string => dayBoundary(date, false); +const endOfDay = (date: string): string => dayBoundary(date, true); + +// No sort parameter on the endpoint, so newest-first is applied here. +const byCreatedAtDesc = (a: Bundle, b: Bundle): number => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime(); + +export const BundlesPage = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const [selectedBundle, setSelectedBundle] = useState(null); + + // Guards against a bad url value reaching the server as a 400. + const environment = oneOf(searchParams.get('environment'), ENVIRONMENTS, ANY); + const createdAfter = searchParams.get('createdAfter') ?? ''; + const createdBefore = searchParams.get('createdBefore') ?? ''; + + const updateParams = (changes: Record) => { + setSearchParams( + (current) => { + const next = new URLSearchParams(current); + for (const [key, value] of Object.entries(changes)) { + if (value === null || value === '' || value === ANY) next.delete(key); + else next.set(key, value); + } + return next; + }, + { replace: true } + ); + }; + + const query = { + ...(environment === ANY ? {} : { environment: [environment] }), + ...(createdAfter && { createdAfter: startOfDay(createdAfter) }), + ...(createdBefore && { createdBefore: endOfDay(createdBefore) }), + }; + + const { data, isLoading, isError, error, refetch } = $api.useQuery('get', '/bundle', { params: { query } }); + + if (isError) { + return ( +
+
+

Failed to load bundles

+

{error?.message ?? 'Please try again later'}

+ +
+
+ ); + } + + return ( +
+
+

Bundles

+
+ +
+
+ + +
+ +
+ + updateParams({ createdAfter: event.target.value || null })} + /> +
+ +
+ + updateParams({ createdBefore: event.target.value || null })} + /> +
+
+ +
+ {isLoading ? ( +
+ +
+ ) : ( + + )} +
+ + !open && setSelectedBundle(null)}> + {selectedBundle && } + +
+ ); +}; diff --git a/apps/auth-ui/src/pages/bundles/BundlesTable.tsx b/apps/auth-ui/src/pages/bundles/BundlesTable.tsx new file mode 100644 index 0000000..79b71ff --- /dev/null +++ b/apps/auth-ui/src/pages/bundles/BundlesTable.tsx @@ -0,0 +1,50 @@ +import type { components } from 'auth-openapi'; +import { Badge } from '../../components/ui/badge'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../components/ui/table'; +import { formatTimestamp } from '../../lib/utils'; + +type Bundle = components['schemas']['bundle']; + +interface BundlesTableProps { + bundles: Bundle[]; + onSelectBundle: (bundle: Bundle) => void; +} + +const COLUMNS = ['Created At', 'Environment', 'Revision', 'OPA Version', 'Id']; + +export const BundlesTable = ({ bundles, onSelectBundle }: BundlesTableProps) => ( +
+ + + + {COLUMNS.map((label) => ( + {label} + ))} + + + + {bundles.length === 0 ? ( + + + No bundles found. + + + ) : ( + bundles.map((bundle) => ( + onSelectBundle(bundle)} className="cursor-pointer hover:bg-muted/50"> + {bundle.createdAt ? formatTimestamp(bundle.createdAt) : 'โ€”'} + + + {bundle.environment} + + + {bundle.revision} + {bundle.opaVersion} + {bundle.id} + + )) + )} + +
+
+); diff --git a/apps/auth-ui/src/pages/bundles/index.ts b/apps/auth-ui/src/pages/bundles/index.ts new file mode 100644 index 0000000..c69f00e --- /dev/null +++ b/apps/auth-ui/src/pages/bundles/index.ts @@ -0,0 +1 @@ +export { BundlesPage } from './BundlesPage'; diff --git a/apps/auth-ui/src/routes.tsx b/apps/auth-ui/src/routes.tsx index 25d5c9b..6c85191 100644 --- a/apps/auth-ui/src/routes.tsx +++ b/apps/auth-ui/src/routes.tsx @@ -1,6 +1,7 @@ import { Navigate, type RouteObject } from 'react-router-dom'; import { Layout } from './components/layout'; import { AssetPage, AssetsPage, CreateAssetPage } from './pages/assets'; +import { BundlesPage } from './pages/bundles'; import { ClientsPage } from './pages/clients'; import { ConnectionsPage } from './pages/connections'; import { DomainsPage } from './pages/domains'; @@ -32,6 +33,7 @@ export const appRoutes: RouteObject[] = [ { path: ':assetName', element: }, ], }, + { path: 'bundles', element: }, { path: 'jwt-inspector', element: }, { path: 'opa-validator', element: }, { path: '*', element: }, diff --git a/apps/auth-ui/tests/assets/assets-list.spec.tsx b/apps/auth-ui/tests/assets/assets-list.spec.tsx index a5496ec..fe9753e 100644 --- a/apps/auth-ui/tests/assets/assets-list.spec.tsx +++ b/apps/auth-ui/tests/assets/assets-list.spec.tsx @@ -28,7 +28,7 @@ describe('assets list', () => { const entries = within(nav) .getAllByRole('link') .map((link) => link.textContent); - expect(entries).toEqual(['Clients', 'Connections', 'Domains', 'Assets', 'JWT Inspector', 'OPA Validator']); + expect(entries).toEqual(['Clients', 'Connections', 'Domains', 'Assets', 'Bundles', 'JWT Inspector', 'OPA Validator']); }); it('marks the Assets entry in the sidebar from anywhere underneath it', async () => { diff --git a/apps/auth-ui/tests/bundle-fixtures.ts b/apps/auth-ui/tests/bundle-fixtures.ts new file mode 100644 index 0000000..1e4ecf4 --- /dev/null +++ b/apps/auth-ui/tests/bundle-fixtures.ts @@ -0,0 +1,17 @@ +import type { components } from 'auth-openapi'; + +export type Bundle = components['schemas']['bundle']; + +export const aBundle = (overrides: Partial = {}): Bundle => ({ + id: 1, + hash: 'sha256:abc123', + revision: 'rev-1', + metadata: { builtBy: 'ci' }, + assets: [{ name: 'authz.rego', version: 1 }], + connections: [{ name: 'my-connection', version: 1 }], + environment: 'np', + createdAt: '2026-01-01T00:00:00.000Z', + keyVersion: 1, + opaVersion: '0.60.0', + ...overrides, +}); diff --git a/apps/auth-ui/tests/bundles/bundle-details-modal.spec.tsx b/apps/auth-ui/tests/bundles/bundle-details-modal.spec.tsx new file mode 100644 index 0000000..621505b --- /dev/null +++ b/apps/auth-ui/tests/bundles/bundle-details-modal.spec.tsx @@ -0,0 +1,105 @@ +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { appRoutes } from '../../src/routes'; +import { aBundle } from '../bundle-fixtures'; +import { http } from '../http-stub'; +import { renderRoutes } from '../render'; + +const openBundles = (search = '') => renderRoutes(appRoutes, `/bundles${search}`); + +describe('bundle details modal', () => { + it('opens from a row and shows the hash, keyVersion, assets, connections and metadata', async () => { + http.on('GET', '/bundle', { + body: [ + aBundle({ + id: 7, + hash: 'sha256:deadbeef', + keyVersion: 3, + assets: [{ name: 'authz.rego', version: 2 }], + connections: [{ name: 'billing-connection', version: 5 }], + metadata: { builtBy: 'ci', commit: 'abc123' }, + }), + ], + }); + + openBundles(); + await userEvent.click(await screen.findByText('rev-1')); + + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('sha256:deadbeef')).toBeInTheDocument(); + expect(within(dialog).getByText('3')).toBeInTheDocument(); + expect(within(dialog).getByText('authz.rego')).toBeInTheDocument(); + expect(within(dialog).getByText('v2')).toBeInTheDocument(); + expect(within(dialog).getByText('billing-connection')).toBeInTheDocument(); + expect(within(dialog).getByText('v5')).toBeInTheDocument(); + expect(within(dialog).getByText(/"builtBy": "ci"/)).toBeInTheDocument(); + expect(within(dialog).getByText(/"commit": "abc123"/)).toBeInTheDocument(); + }); + + it('shows a placeholder when a bundle has no assets or connections', async () => { + http.on('GET', '/bundle', { body: [aBundle({ assets: [], connections: [] })] }); + + openBundles(); + await userEvent.click(await screen.findByText('rev-1')); + + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('No assets')).toBeInTheDocument(); + expect(within(dialog).getByText('No connections')).toBeInTheDocument(); + }); + + it('makes no additional request when opening the modal', async () => { + http.on('GET', '/bundle', { body: [aBundle()] }); + + openBundles(); + await userEvent.click(await screen.findByText('rev-1')); + + await screen.findByRole('dialog'); + expect(http.requestsFor('GET', '/bundle')).toHaveLength(1); + }); + + it('is read-only, with no edit or delete controls', async () => { + http.on('GET', '/bundle', { body: [aBundle()] }); + + openBundles(); + await userEvent.click(await screen.findByText('rev-1')); + + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).queryByRole('button', { name: /edit/i })).not.toBeInTheDocument(); + expect(within(dialog).queryByRole('button', { name: /delete/i })).not.toBeInTheDocument(); + }); + + it('renders a large connections list, keeping the close button reachable and each row uniquely keyed', async () => { + // Duplicate name+version pair, despite uniqueItems in the schema. + const manyConnections = Array.from({ length: 320 }, (_, index) => ({ name: `connection-${index % 300}`, version: 1 })); + http.on('GET', '/bundle', { body: [aBundle({ connections: manyConnections })] }); + + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + + openBundles(); + await userEvent.click(await screen.findByText('rev-1')); + + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getAllByText('connection-0')).toHaveLength(2); + expect(within(dialog).getByText('connection-299')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument(); + + const keyWarning = consoleError.mock.calls.find((call) => typeof call[0] === 'string' && call[0].includes('two children with the same key')); + expect(keyWarning).toBeUndefined(); + + consoleError.mockRestore(); + }); + + it('closes the modal without affecting the underlying list', async () => { + http.on('GET', '/bundle', { body: [aBundle()] }); + + openBundles(); + await userEvent.click(await screen.findByText('rev-1')); + await screen.findByRole('dialog'); + + await userEvent.click(screen.getByRole('button', { name: /close/i })); + + await screen.findByText('rev-1'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/auth-ui/tests/bundles/bundles-list.spec.tsx b/apps/auth-ui/tests/bundles/bundles-list.spec.tsx new file mode 100644 index 0000000..d672fde --- /dev/null +++ b/apps/auth-ui/tests/bundles/bundles-list.spec.tsx @@ -0,0 +1,168 @@ +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import { appRoutes } from '../../src/routes'; +import { aBundle } from '../bundle-fixtures'; +import { http } from '../http-stub'; +import { renderRoutes } from '../render'; + +const openBundles = (search = '') => renderRoutes(appRoutes, `/bundles${search}`); + +/** Round-trips through local getters instead of a hardcoded UTC literal, since the offset depends on the host's timezone. */ +const expectLocalDayBoundary = (iso: string | null | undefined, year: number, month: number, day: number, end: boolean): void => { + expect(iso).not.toBeNull(); + const parsed = new Date(iso!); + expect([ + parsed.getFullYear(), + parsed.getMonth() + 1, + parsed.getDate(), + parsed.getHours(), + parsed.getMinutes(), + parsed.getSeconds(), + parsed.getMilliseconds(), + ]).toEqual([year, month, day, ...(end ? [23, 59, 59, 999] : [0, 0, 0, 0])]); +}; + +describe('bundles list', () => { + it('appends a Bundles entry to the sidebar after Assets, leaving the others in place', async () => { + http.on('GET', '/bundle', { body: [] }); + + openBundles(); + + const nav = await screen.findByRole('navigation'); + const entries = within(nav) + .getAllByRole('link') + .map((link) => link.textContent); + expect(entries).toEqual(['Clients', 'Connections', 'Domains', 'Assets', 'Bundles', 'JWT Inspector', 'OPA Validator']); + }); + + it('renders a table fetched from GET /bundle with no query parameters', async () => { + http.on('GET', '/bundle', { body: [aBundle()] }); + + openBundles(); + + expect(await screen.findByText('rev-1')).toBeInTheDocument(); + expect(http.lastRequestFor('GET', '/bundle')?.query.toString()).toBe(''); + }); + + it('shows Created At, Environment, Revision, OPA Version, and Id columns in that order', async () => { + http.on('GET', '/bundle', { body: [aBundle({ id: 42, revision: 'rev-7', opaVersion: '0.61.0', environment: 'prod' })] }); + + openBundles(); + + const table = await screen.findByRole('table'); + const headers = within(table) + .getAllByRole('columnheader') + .map((header) => header.textContent); + expect(headers).toEqual(['Created At', 'Environment', 'Revision', 'OPA Version', 'Id']); + + const row = within(table).getAllByRole('row')[1]!; + expect(within(row).getByText('prod')).toBeInTheDocument(); + expect(within(row).getByText('rev-7')).toBeInTheDocument(); + expect(within(row).getByText('0.61.0')).toBeInTheDocument(); + expect(within(row).getByText('42')).toBeInTheDocument(); + }); + + it('orders bundles with the newest createdAt first, regardless of the order returned by the server', async () => { + http.on('GET', '/bundle', { + body: [ + aBundle({ id: 1, createdAt: '2026-01-01T00:00:00.000Z' }), + aBundle({ id: 3, createdAt: '2026-03-01T00:00:00.000Z' }), + aBundle({ id: 2, createdAt: '2026-02-01T00:00:00.000Z' }), + ], + }); + + openBundles(); + + const table = await screen.findByRole('table'); + const idsInOrder = within(table) + .getAllByRole('row') + .slice(1) + .map((row) => within(row).getAllByRole('cell').at(-1)?.textContent); + expect(idsInOrder).toEqual(['3', '2', '1']); + }); + + it('shows a loading state while the request is in flight', async () => { + http.on('GET', '/bundle', { body: [aBundle()], delayMs: 30 }); + + openBundles(); + + expect(await screen.findByRole('status', { name: 'Loading bundles' })).toBeInTheDocument(); + }); + + it('shows an empty state when no bundles are returned', async () => { + http.on('GET', '/bundle', { body: [] }); + + openBundles(); + + expect(await screen.findByText('No bundles found.')).toBeInTheDocument(); + }); + + it('renders no create, edit, or delete controls', async () => { + http.on('GET', '/bundle', { body: [aBundle()] }); + + openBundles(); + await screen.findByText('rev-1'); + + expect(screen.queryByRole('button', { name: /add bundle/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument(); + }); + + it('sends no environment filter until one is chosen, then sends it as a one-element array', async () => { + http.on('GET', '/bundle', { body: [aBundle()] }); + + openBundles(); + await screen.findByText('rev-1'); + expect(http.lastRequestFor('GET', '/bundle')?.query.getAll('environment')).toEqual([]); + + await userEvent.click(screen.getByRole('combobox', { name: 'Environment' })); + await userEvent.click(await screen.findByRole('option', { name: 'prod' })); + + await waitFor(() => expect(http.lastRequestFor('GET', '/bundle')?.query.getAll('environment')).toEqual(['prod'])); + }); + + it('sends the createdAfter and createdBefore date filters to the server as the local midnight/end-of-day of the picked day', async () => { + http.on('GET', '/bundle', { body: [aBundle()] }); + + openBundles(); + await screen.findByText('rev-1'); + + await userEvent.type(screen.getByLabelText('Created after'), '2026-01-01'); + await waitFor(() => expectLocalDayBoundary(http.lastRequestFor('GET', '/bundle')?.query.get('createdAfter'), 2026, 1, 1, false)); + + await userEvent.type(screen.getByLabelText('Created before'), '2026-06-01'); + await waitFor(() => expectLocalDayBoundary(http.lastRequestFor('GET', '/bundle')?.query.get('createdBefore'), 2026, 6, 1, true)); + }); + + it('combines the environment and date filters into a single request', async () => { + http.on('GET', '/bundle', { body: [aBundle()] }); + + openBundles('?environment=stage&createdAfter=2026-01-01&createdBefore=2026-06-01'); + await screen.findByText('rev-1'); + + const request = http.lastRequestFor('GET', '/bundle'); + expect(request?.query.getAll('environment')).toEqual(['stage']); + expectLocalDayBoundary(request?.query.get('createdAfter'), 2026, 1, 1, false); + expectLocalDayBoundary(request?.query.get('createdBefore'), 2026, 6, 1, true); + }); + + it('reads the filters back out of the url on load, and restores them across a refresh', async () => { + http.on('GET', '/bundle', { body: [aBundle()] }); + + openBundles('?environment=prod&createdAfter=2026-01-01&createdBefore=2026-06-01'); + + await screen.findByText('rev-1'); + expect(screen.getByRole('combobox', { name: 'Environment' })).toHaveTextContent('prod'); + expect(screen.getByLabelText('Created after')).toHaveValue('2026-01-01'); + expect(screen.getByLabelText('Created before')).toHaveValue('2026-06-01'); + }); + + it('shows an empty state when the active filters match no bundles', async () => { + http.on('GET', '/bundle', { body: [] }); + + openBundles('?environment=prod'); + + expect(await screen.findByText('No bundles found.')).toBeInTheDocument(); + }); +}); diff --git a/apps/auth-ui/vitest.config.ts b/apps/auth-ui/vitest.config.ts index 5bc3ffd..28dfd20 100644 --- a/apps/auth-ui/vitest.config.ts +++ b/apps/auth-ui/vitest.config.ts @@ -21,7 +21,7 @@ export default defineConfig({ environment: 'jsdom', environmentOptions: { jsdom: { url: 'http://localhost:3000/' } }, setupFiles: ['./tests/setup.ts'], - // Scoped to the assets feature. The rest of the application has no tests yet. - include: ['tests/assets/**/*.spec.{ts,tsx}'], + // Scoped to the assets and bundles features. The rest of the application has no tests yet. + include: ['tests/assets/**/*.spec.{ts,tsx}', 'tests/bundles/**/*.spec.{ts,tsx}'], }, });