Skip to content
Open
7 changes: 6 additions & 1 deletion apps/auth-ui/src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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',
Expand Down
63 changes: 63 additions & 0 deletions apps/auth-ui/src/pages/bundles/BundleDetailsModal.tsx
Original file line number Diff line number Diff line change
@@ -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.
<DialogContent className="flex max-h-[85vh] flex-col sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Bundle {bundle.id}</DialogTitle>
<DialogDescription>
{bundle.environment} · {bundle.revision}
</DialogDescription>
</DialogHeader>

<div className="min-h-0 flex-1 space-y-4 overflow-y-auto">
<div className="grid grid-cols-2 gap-4">
<Field label="Hash" value={bundle.hash} />
<Field label="Key version" value={bundle.keyVersion?.toString()} />
</div>

<BundleEntryList title="Assets" emptyLabel="No assets" entries={bundle.assets} />
<BundleEntryList title="Connections" emptyLabel="No connections" entries={bundle.connections} />

<div>
<h3 className="mb-2 text-sm font-medium">Metadata</h3>
<pre className="max-h-48 overflow-auto rounded-md border bg-muted/50 p-3 text-xs">{JSON.stringify(bundle.metadata ?? {}, null, 2)}</pre>
</div>
</div>
</DialogContent>
);

const Field = ({ label, value }: { label: string; value?: string }) => (
<div>
<p className="text-sm font-medium text-muted-foreground">{label}</p>
<p className="break-all text-sm">{value ?? '—'}</p>
</div>
);

const BundleEntryList = ({ title, emptyLabel, entries }: { title: string; emptyLabel: string; entries?: { name: string; version: number }[] }) => (
<div>
<h3 className="mb-2 text-sm font-medium">{title}</h3>
{entries && entries.length > 0 ? (
// Bounded so a long list doesn't bury what follows it.
<ul className="max-h-48 space-y-1 overflow-y-auto rounded-md border p-2">
{entries.map((entry, index) => (
// uniqueItems isn't enforced at this layer, so the index guards the key.
<li key={`${index}-${entry.name}-${entry.version}`} className="flex items-center gap-2 text-sm">
<span>{entry.name}</span>
<Badge variant="outline">v{entry.version}</Badge>
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">{emptyLabel}</p>
)}
</div>
);
147 changes: 147 additions & 0 deletions apps/auth-ui/src/pages/bundles/BundlesPage.tsx
Original file line number Diff line number Diff line change
@@ -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 = <T extends string>(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<Bundle | null>(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<string, string | null>) => {
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 (
<div className="flex h-[450px] items-center justify-center">
<div className="text-destructive">
<p className="font-medium">Failed to load bundles</p>
<p className="text-sm text-muted-foreground mt-1">{error?.message ?? 'Please try again later'}</p>
<Button variant="outline" className="mt-4" onClick={() => refetch()}>
Retry
</Button>
</div>
</div>
);
}

return (
<div className="flex flex-col h-full p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Bundles</h1>
</div>

<div className="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label className="text-sm font-medium">Environment</Label>
<Select value={environment} onValueChange={(value) => updateParams({ environment: value })}>
<SelectTrigger aria-label="Environment">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ANY}>All</SelectItem>
{ENVIRONMENTS.map((value) => (
<SelectItem key={value} value={value}>
{value}
</SelectItem>
))}
</SelectContent>
</Select>
</div>

<div className="space-y-2">
<Label htmlFor="created-after" className="text-sm font-medium">
Created after
</Label>
<Input
id="created-after"
type="date"
value={createdAfter}
onChange={(event) => updateParams({ createdAfter: event.target.value || null })}
/>
</div>

<div className="space-y-2">
<Label htmlFor="created-before" className="text-sm font-medium">
Created before
</Label>
<Input
id="created-before"
type="date"
value={createdBefore}
onChange={(event) => updateParams({ createdBefore: event.target.value || null })}
/>
</div>
</div>

<div className="flex-1 min-h-[400px] overflow-hidden border rounded-md">
{isLoading ? (
<div className="flex h-[400px] items-center justify-center" role="status" aria-label="Loading bundles">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<BundlesTable bundles={[...(data ?? [])].sort(byCreatedAtDesc)} onSelectBundle={setSelectedBundle} />
)}
</div>

<Dialog open={selectedBundle !== null} onOpenChange={(open) => !open && setSelectedBundle(null)}>
{selectedBundle && <BundleDetailsModal bundle={selectedBundle} />}
</Dialog>
</div>
);
};
50 changes: 50 additions & 0 deletions apps/auth-ui/src/pages/bundles/BundlesTable.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<div className="h-full flex flex-col">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
{COLUMNS.map((label) => (
<TableHead key={label}>{label}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{bundles.length === 0 ? (
<TableRow>
<TableCell colSpan={COLUMNS.length} className="h-24 text-center">
No bundles found.
</TableCell>
</TableRow>
) : (
bundles.map((bundle) => (
<TableRow key={bundle.id} onClick={() => onSelectBundle(bundle)} className="cursor-pointer hover:bg-muted/50">
<TableCell className="whitespace-nowrap text-muted-foreground">{bundle.createdAt ? formatTimestamp(bundle.createdAt) : '—'}</TableCell>
<TableCell>
<Badge variant={bundle.environment === 'prod' ? 'destructive' : bundle.environment === 'stage' ? 'secondary' : 'default'}>
{bundle.environment}
</Badge>
</TableCell>
<TableCell>{bundle.revision}</TableCell>
<TableCell>{bundle.opaVersion}</TableCell>
<TableCell>{bundle.id}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
1 change: 1 addition & 0 deletions apps/auth-ui/src/pages/bundles/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { BundlesPage } from './BundlesPage';
2 changes: 2 additions & 0 deletions apps/auth-ui/src/routes.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -32,6 +33,7 @@ export const appRoutes: RouteObject[] = [
{ path: ':assetName', element: <AssetPage /> },
],
},
{ path: 'bundles', element: <BundlesPage /> },
{ path: 'jwt-inspector', element: <JWTInspectorPage /> },
{ path: 'opa-validator', element: <OPAValidatorPage /> },
{ path: '*', element: <NotFoundPage /> },
Expand Down
2 changes: 1 addition & 1 deletion apps/auth-ui/tests/assets/assets-list.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
17 changes: 17 additions & 0 deletions apps/auth-ui/tests/bundle-fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { components } from 'auth-openapi';

export type Bundle = components['schemas']['bundle'];

export const aBundle = (overrides: Partial<Bundle> = {}): 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,
});
Loading
Loading