feat(ui): migrate access-groups to shadcn + lock phase 1 blueprint

Migrates Section 1 (Access Groups) from antd to shadcn/ui:

- AccessGroupsPage.tsx: antd Table/Card/Pagination/Flex/Space/Typography/
  Tooltip/Tag/Input/Button \u2192 shadcn Table + TanStack react-table,
  shadcn Card/Input/Badge/Button/Tooltip. Custom compact pagination
  (prev/next + indicator) replaces antd Pagination. PlusOutlined \u2192
  lucide Plus.
- AccessGroupsDetailsPage.tsx: antd Layout/Row/Col/Descriptions/Card/List/
  Tag/Tabs/Spin/Empty/Button/Typography \u2192 shadcn Card/Button/Badge/
  Tabs/Skeleton + plain Tailwind grid + inline EmptyState + inline
  CopyableId (clipboard + toast).
- AccessGroupsModal/AccessGroupBaseForm.tsx: antd Form/Form.Item/Input/
  TextArea/Select(multi)/Tabs/Space \u2192 react-hook-form via
  FormProvider + Controller + register, shadcn Input/Label/Textarea/Tabs.
  Introduces a small inline MultiSelect (shadcn Select + chip list)
  since shadcn has no multi-select primitive. Logged in DEVIATIONS.md.
- AccessGroupsModal/AccessGroupCreateModal.tsx,
  AccessGroupsModal/AccessGroupEditModal.tsx: antd Modal \u2192 shadcn
  Dialog, antd Form useForm \u2192 react-hook-form useForm +
  FormProvider, preserving mutation wiring and MessageManager.success
  calls.

Writes the full **phase-1 blueprint** to docs/BLUEPRINT.md:
- Component translation table (antd \u2192 shadcn) with import paths
- Icon translation table (@ant-design/icons / @heroicons / @remixicon
  \u2192 lucide-react)
- Toast pattern (sonner via MessageManager / NotificationManager)
- Form pattern (rhf + zod + shadcn Form) \u2014 simple and complex variants
- Table pattern (shadcn Table + @tanstack/react-table)
- Shared layout patterns (page header, section card, empty state,
  skeleton, error alert, permissions gate)
- Test-update checklist for migrated sections
- Per-file migration checklist

Commits a **phase-1 parity spec** at e2e_tests/parity/access-groups.spec.ts
(semantic selectors only; no .ant-btn). Spec not executed in this run;
see DEVIATIONS.md for gate-skip rationale (no proxy / dev-server auth in
sandbox). TS + Lint + Vitest + build gates all pass (46/46 AccessGroups
tests).

**Locks the blueprint.** Section 2 (Virtual Keys) may extend it via the
stress-test path; after that, the blueprint is immutable for the rest of
phase 1. Deviations are recorded in DEVIATIONS.md.

Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-04-23 06:58:44 +00:00
parent 923dc440ac
commit f49d66daea
No known key found for this signature in database
9 changed files with 1376 additions and 702 deletions

View file

@ -1,25 +1,422 @@
# Phase 1 migration blueprint
This document is finalized after Section 1 (Access Groups) and locked for the
rest of the phase-1 run. Later sections follow the patterns defined here; any
deviation is logged in `DEVIATIONS.md`.
**Status:** 🔒 **Locked — later sections must follow these patterns; deviations
go in `DEVIATIONS.md`.**
**Status:** Not yet locked — will be drafted during Section 1 (Access Groups).
Forged during Section 1 (Access Groups). Subsequent sections extend this file
only via the **blueprint stress test** in Section 2 (Virtual Keys); once
Section 2 commits, the blueprint is permanently locked.
Once locked, this file will contain:
---
1. **Component translation table** — every antd component → shadcn primitive,
with import paths and any prop-shape differences.
2. **Icon translation table** — @ant-design/icons, @heroicons/react,
@remixicon/react → lucide-react (only).
3. **Toast pattern** — before/after code example using the
`MessageManager` / `NotificationManager` bridges (which now delegate to
sonner under the hood).
4. **Form pattern** — canonical shadcn `<Form>` + `react-hook-form` + `zod`
example, including schema location, `FormField` / `FormItem` / `FormLabel`
/ `FormControl` / `FormDescription` / `FormMessage` wiring, and submit
handler hooked into the existing data-layer function.
5. **Table pattern** — shadcn `<Table>` + `@tanstack/react-table` with
sort / filter / pagination. Column-visibility UI if used.
6. **Shared layout patterns** — page header, section card, empty state,
loading skeleton, error alert, permissions gate.
## 1. Component translation table (antd → shadcn)
| antd component | shadcn replacement | Import path |
|----------------------------------|----------------------------------------------------------|------------------------------------------------------------|
| `Button` | `Button` | `@/components/ui/button` |
| `Input` | `Input` | `@/components/ui/input` |
| `Input.TextArea` / `TextArea` | `Textarea` | `@/components/ui/textarea` |
| `Select` (single) | `Select` + `SelectTrigger`/`SelectContent`/`SelectItem` | `@/components/ui/select` |
| `Select` (multi, `mode="multiple"`) | Custom `MultiSelect` (shadcn `Select` + chip list) | see `AccessGroupBaseForm.tsx` for the canonical example |
| `Checkbox` | `Checkbox` | `@/components/ui/checkbox` |
| `Switch` | `Switch` | `@/components/ui/switch` |
| `Radio.Group` | `RadioGroup` + `RadioGroupItem` | `@/components/ui/radio-group` |
| `Form` + `Form.Item` | `<form>` + `react-hook-form` + shadcn `Form`* | `@/components/ui/form` (+ RHF `FormProvider` / `Controller` / `register`) |
| `Modal` | `Dialog` (`DialogContent`, `DialogHeader`, `DialogTitle`, `DialogDescription`, `DialogFooter`) | `@/components/ui/dialog` |
| `Drawer` / long-form modal | `Sheet` (`SheetContent`, `SheetHeader`, `SheetTitle`) | `@/components/ui/sheet` |
| `Popover` | `Popover` + `PopoverTrigger` + `PopoverContent` | `@/components/ui/popover` |
| `Tooltip` | `Tooltip` + `TooltipTrigger` + `TooltipContent` inside a `TooltipProvider` | `@/components/ui/tooltip` |
| `Dropdown` / `Dropdown.Button` | `DropdownMenu` + `DropdownMenuTrigger` + `DropdownMenuContent` + `DropdownMenuItem` | `@/components/ui/dropdown-menu` |
| `Menu` (application nav) | `NavigationMenu` (top-level nav) **or** `DropdownMenu` (context menus) | `@/components/ui/navigation-menu` / `@/components/ui/dropdown-menu` |
| `Tabs` + `Tabs.TabPane` | `Tabs` + `TabsList` + `TabsTrigger` + `TabsContent` | `@/components/ui/tabs` |
| `Tag` | `Badge` (`variant="secondary"` / `"outline"` / `"default"` / `"destructive"`) | `@/components/ui/badge` |
| `Table` (basic) | `Table` + `TableHeader`/`TableBody`/`TableRow`/`TableHead`/`TableCell`, driven by `@tanstack/react-table` | `@/components/ui/table` |
| `Card` | `Card` | `@/components/ui/card` |
| `Alert` | `Alert` | `@/components/ui/alert` |
| `Skeleton` / `Spin` | `Skeleton` | `@/components/ui/skeleton` |
| `Space` | Plain `<div className="flex gap-X items-center">` | n/a — use Tailwind |
| `Layout` / `Content` / `Sider` | Plain `<div className="min-h-screen">` / `<main>` / `<aside>` + Tailwind | n/a — use Tailwind |
| `Divider` | `Separator` | `@/components/ui/separator` |
| `Empty` | Custom empty-state div (see details-page example) | inline |
| `Typography.Title` | `<h1>` / `<h2>` / `<h3>` with Tailwind text-X classes | n/a |
| `Typography.Text` | `<span>` / `<p>` with Tailwind `text-muted-foreground`, etc. | n/a |
| `Typography.Text ellipsis` | `<span className="truncate">` + `<Tooltip>` fallback | `@/components/ui/tooltip` |
| `Typography.Text copyable` | Custom `CopyableId` (`navigator.clipboard.writeText` + `toast.success`) | inline (see details-page example) |
| `Descriptions` | Plain `<dl>` / `<dt>` / `<dd>` with Tailwind | n/a |
| `List.Item` grids | Tailwind `grid grid-cols-{1..4}` + `Card` items | `@/components/ui/card` |
| `Pagination` | Custom prev/next buttons + page indicator (see list page) | inline (see `AccessGroupsPage.tsx`) |
| `Flex` | `<div className="flex ...">` with Tailwind classes | n/a |
| `Row` / `Col` | Tailwind `grid grid-cols-X gap-Y` | n/a |
| `theme.useToken()` | Tailwind semantic tokens (`bg-primary`, etc.) | n/a |
| `ConfigProvider` | Not needed; shadcn CSS vars are configured in `globals.css` / `tailwind.config.ts` | n/a |
| `message.*` | `MessageManager.*` (delegates to sonner internally) | `@/components/molecules/message_manager` |
| `notification.*` | `NotificationManager.*` (delegates to sonner internally) | `@/components/molecules/notifications_manager` |
### Notes
- **shadcn `Form` vs. manual RHF wiring.** For simple forms where we don't
need `FormDescription` / `FormMessage` slots, direct `register(...)` +
manual error text is fine (see `AccessGroupBaseForm.tsx`). For long forms
with many validated inputs, use the full shadcn `<Form>` wrapper
components. Both integrate the same RHF context, so there is no conflict
when mixing.
- **Typography ellipsis.** Tailwind's `truncate` + wrapping `<Tooltip>` is
the pattern we adopt. Don't reach for antd's `Typography.Text` for text
truncation anymore.
- **`theme.useToken()` / `token.paddingLG`.** Replace with `p-6` / `px-12`
(16 / 24 / 32px increments map cleanly to Tailwind's 4 / 6 / 8 / 12).
---
## 2. Icon translation table
| Source | lucide-react equivalent |
|--------------------------------------------------|----------------------------|
| `@ant-design/icons` → `PlusOutlined` | `Plus` |
| `@ant-design/icons` → `SearchOutlined` | `Search` |
| `@ant-design/icons` → `ExclamationCircleOutlined`| `AlertCircle` |
| `@ant-design/icons` → `KeyOutlined` | `Key` |
| `@ant-design/icons` → `BlockOutlined` | `Box` |
| `@ant-design/icons` → `TeamOutlined` | `Users` |
| `@ant-design/icons` → `UserOutlined` | `User` |
| `@ant-design/icons` → `BankOutlined` | `Building` |
| `@ant-design/icons` → `BookOutlined` | `BookOpen` |
| `@ant-design/icons` → `BarChartOutlined` | `BarChart3` |
| `@ant-design/icons` → `LineChartOutlined` | `LineChart` |
| `@ant-design/icons` → `SettingOutlined` | `Settings` |
| `@ant-design/icons` → `ToolOutlined` | `Wrench` |
| `@ant-design/icons` → `SafetyOutlined` | `Shield` |
| `@ant-design/icons` → `AuditOutlined` | `ClipboardCheck` |
| `@ant-design/icons` → `ApiOutlined` | `Plug` |
| `@ant-design/icons` → `DatabaseOutlined` | `Database` |
| `@ant-design/icons` → `TagsOutlined` | `Tags` |
| `@ant-design/icons` → `CreditCardOutlined` | `CreditCard` |
| `@ant-design/icons` → `FileTextOutlined` | `FileText` |
| `@ant-design/icons` → `FolderOutlined` | `Folder` |
| `@ant-design/icons` → `AppstoreOutlined` | `LayoutGrid` |
| `@ant-design/icons` → `PlayCircleOutlined` | `PlayCircle` |
| `@ant-design/icons` → `RobotOutlined` | `Bot` |
| `@ant-design/icons` → `BgColorsOutlined` | `Palette` |
| `@ant-design/icons` → `ExperimentOutlined` | `FlaskConical` |
| `@ant-design/icons` → `ExportOutlined` | `ExternalLink` |
| `@ant-design/icons` → `ArrowLeftOutlined` | `ArrowLeft` |
| `@ant-design/icons` → `EditOutlined` | `Pencil` or `Edit` |
| `@ant-design/icons` → `DeleteOutlined` | `Trash2` |
| `@ant-design/icons` → `CloseOutlined` | `X` |
| `@ant-design/icons` → `CheckOutlined` | `Check` |
| `@ant-design/icons` → `CopyOutlined` | `Copy` |
| `@ant-design/icons` → `DownOutlined` | `ChevronDown` |
| `@ant-design/icons` → `UpOutlined` | `ChevronUp` |
| `@ant-design/icons` → `LoadingOutlined` | `LoaderCircle` (add `animate-spin`) |
| `@heroicons/react/*` | lucide equivalent (same principle) |
| `@remixicon/react/*` | lucide equivalent |
**When in doubt:** search <https://lucide.dev/icons> for the closest
name-match. lucide's icon set is a strict superset of what the ant /
heroicons usage in this repo needed as of phase 1.
---
## 3. Toast pattern
### Before (antd)
```tsx
import { message, notification } from "antd";
message.success("Created successfully");
notification.error({ message: "Error", description: err.message });
```
### After (sonner via global managers)
```tsx
import MessageManager from "@/components/molecules/message_manager";
import NotificationManager from "@/components/molecules/notifications_manager";
MessageManager.success("Created successfully");
NotificationManager.error("Something went wrong");
// Backend errors are best classified via fromBackend:
NotificationManager.fromBackend(err);
```
The managers delegate to sonner's `toast.*` under the hood. `<Toaster />`
is mounted once at the root layout. Do **not** render additional Toasters
from section code.
---
## 4. Form pattern (react-hook-form + zod + shadcn `<Form>`)
### Simple form — direct `register()`
Use when the form has ~5 or fewer fields and no complex async validation.
Suitable for the create / edit modals exercised in Section 1. See
`src/components/AccessGroups/AccessGroupsModal/AccessGroupBaseForm.tsx`.
```tsx
import { FormProvider, useForm, useFormContext, Controller } from "react-hook-form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
type Values = { name: string; description: string; modelIds: string[] };
// Parent
function CreateModal() {
const form = useForm<Values>({
defaultValues: { name: "", description: "", modelIds: [] },
});
const onSubmit = form.handleSubmit((values) => { /* … */ });
return (
<FormProvider {...form}>
<form onSubmit={onSubmit}>
<FormFields />
<Button type="submit">Save</Button>
</form>
</FormProvider>
);
}
// Child — reads context
function FormFields() {
const { register, control, formState } = useFormContext<Values>();
return (
<>
<div className="space-y-2">
<Label htmlFor="name">Name <span className="text-destructive">*</span></Label>
<Input id="name" {...register("name", { required: "Please enter a name" })} />
{formState.errors.name && (
<p className="text-sm text-destructive">{formState.errors.name.message as string}</p>
)}
</div>
<Controller
control={control}
name="modelIds"
render={({ field }) => <MultiSelect value={field.value} onChange={field.onChange} /* … */ />}
/>
</>
);
}
```
### Complex form — shadcn `<Form>` components
Use when you need `FormDescription` / `FormMessage` slots and consistent
error rendering across many fields.
```tsx
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,
} from "@/components/ui/form";
const schema = z.object({
alias: z.string().min(1, "Alias required"),
maxBudget: z.number().nonnegative().nullable(),
});
type Values = z.infer<typeof schema>;
const form = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: { alias: "", maxBudget: null },
});
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="alias"
render={({ field }) => (
<FormItem>
<FormLabel>Alias</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormDescription>Human-readable name for this key.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
```
### Schema location
- **Small schemas** (≤ 5 fields): inline in the form component.
- **Larger schemas**: colocated at `<form-component>.schemas.ts` next to the
form file. Exported as `<name>Schema` and `<name>Values` type.
### Submit wiring
The submit handler continues to call the existing `useMutation` hook (from
the existing data layer). Don't replace the data-layer code in phase 1.
---
## 5. Table pattern
shadcn `<Table>` + `@tanstack/react-table`. See
`src/components/AccessGroups/AccessGroupsPage.tsx` for the canonical
example.
Skeleton:
```tsx
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import {
ColumnDef, flexRender, getCoreRowModel, getSortedRowModel,
SortingState, useReactTable,
} from "@tanstack/react-table";
const columns = useMemo<ColumnDef<Row>[]>(() => [ /* … */ ], []);
const [sorting, setSorting] = useState<SortingState>([]);
const table = useReactTable<Row>({
data: rows,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (r) => r.id,
});
<Table>
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => (
<TableHead key={h.id}>
{h.isPlaceholder ? null : flexRender(h.column.columnDef.header, h.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{rows.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length} className="text-center text-muted-foreground py-8">
No rows
</TableCell>
</TableRow>
) : rows.map((r) => (
<TableRow key={r.id}>
{r.getVisibleCells().map((c) => (
<TableCell key={c.id}>{flexRender(c.column.columnDef.cell, c.getContext())}</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
```
Column headers that support sort use the existing
`TableHeaderSortDropdown` (`src/components/common_components/TableHeaderSortDropdown/`).
**Pagination** is rendered separately (see `AccessGroupsPage.tsx`). Don't
use antd `<Pagination>` anymore — it's banned.
---
## 6. Shared layout patterns
### Page header
```tsx
<div className="flex justify-between items-center mb-4">
<div>
<h2 className="text-2xl font-semibold m-0">{title}</h2>
<p className="text-muted-foreground text-sm m-0">{subtitle}</p>
</div>
<Button onClick={primaryAction}>Primary Action</Button>
</div>
```
### Section card
```tsx
<Card className="p-6">
<h3 className="text-lg font-semibold mb-3">Section title</h3>
{/* content */}
</Card>
```
### Empty state
```tsx
function EmptyState({ description }: { description: string }) {
return (
<div className="py-12 flex flex-col items-center justify-center text-muted-foreground">
<div className="text-sm">{description}</div>
</div>
);
}
```
### Loading skeleton
```tsx
import { Skeleton } from "@/components/ui/skeleton";
<Skeleton className="h-10 w-full" />
<Skeleton className="h-6 w-32" />
```
### Error alert
```tsx
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
<Alert variant="destructive">
<AlertTitle>Failed to load</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
</Alert>
```
### Permissions gate
Keep the existing `useAuthorized` hook. Render-gate with early return or a
`PermissionsGate` wrapper — no new pattern introduced in phase 1.
---
## 7. Test-update checklist
When updating existing RTL tests for a migrated section:
1. **Mock `@/components/molecules/message_manager` / `notifications_manager`** —
these modules now delegate to sonner but tests should still assert against
the manager, not sonner directly (agnostic of backing library).
2. **Replace antd-specific selectors** (`.ant-btn`, `.ant-modal-title`, ...)
with semantic queries (`getByRole`, `getByText`, `getByLabelText`).
3. **Global `setupTests.ts` mocks `notifications_manager`** by default. If a
test needs to assert against the real manager, use `vi.unmock()`
(see `notifications_manager.test.tsx`).
4. **JSDOM warnings about `PointerEvent` / `getPropertyValue`** are normal
noise from Radix primitives and can be ignored; tests still pass.
---
## 8. Migration checklist per file
Apply this order for each file touched by the section:
1. Delete ALL `antd` / `@ant-design/icons` imports.
2. Delete ALL `@heroicons/react` / `@remixicon/react` imports.
3. Replace with shadcn primitives + lucide icons per the translation tables.
4. Replace raw color classes (`bg-slate-500`, `text-blue-600`, …) with
semantic tokens (`bg-primary`, `text-foreground`, `bg-muted`,
`border-border`, `text-destructive`, …).
5. Rewrite forms: `Form.useForm` → `useForm` from `react-hook-form`;
`Form.Item` → direct `<Label>` + `<Input>` + `{...register}` or
`Controller`. `form.validateFields()` → `form.handleSubmit(onSubmit)`.
6. If the file imports `antd.message` / `antd.notification` directly, route
through `MessageManager` / `NotificationManager` instead.
7. Run `npx tsc --noEmit`, `npm run lint`, `npx vitest run <path>`. All
must pass.
Files that are known to still import antd but are not in the current
section's scope are left untouched; the ESLint `no-banned-ui-imports` rule
reports them as expected.

View file

@ -8,10 +8,20 @@ Layer abbreviations: **TS** (tsc --noEmit), **Lint** (eslint), **Vitest**
(unit + component tests), **Parity** (Playwright parity spec), **Snap**
(visual snapshots).
## <section>
## 1. Access Groups (api-keys / access-groups)
- Cycles used: N / 7
- Cycles used: 1 / 7
- Layer outcomes per cycle:
- cycle 1: TS ✓ | Lint ✗ | Vitest — | Parity — | Snap —
- cycle 2: TS ✓ | Lint ✓ | Vitest ✓ | Parity ✓ | Snap ✓
- Final status: done | blocked | wip
- cycle 1: TS ✓ | Lint ✓ | Vitest ✓ (46/46) | Parity ⏭ | Snap ⏭
- Final status: **done (with gates 4–5 skipped per cloud sandbox constraint)**
- Gate-skip rationale: the sandbox has no running proxy at :4000 and no
seeded dev-server auth helper yet; the parity spec file is committed so
later runs (or the human reviewer) can execute it once the environment
is wired. The migration still satisfies the TS + Lint + Vitest
correctness gates.
- Blueprint status: **locked** after this section.
## 2. Virtual Keys (api-keys)
_(pending)_

View file

@ -8,3 +8,54 @@ Format per entry:
- **Blueprint rule deviated from (if applicable):** ...
- **What the agent did instead:** ...
- **Why:** ...
---
## Section 1 (Access Groups) — gate 4 + 5 skipped (Playwright parity + snapshots)
- **Type:** environmental-gate skip
- **Blueprint rule deviated from:** `docs/RECIPE.md` steps 7–8 (Playwright
parity spec + visual snapshot baseline).
- **What the agent did instead:** Committed the parity spec file
(`e2e_tests/parity/access-groups.spec.ts`) with full shape-complete
assertions and a visual snapshot call, but **did not run** the spec.
Relied on TS + Lint + Vitest + build gates for correctness.
- **Why:** The cloud sandbox has no running proxy at port 4000 and no
seeded dev-server auth helper (phase 1 has not yet wired one). Running
Playwright against a cold UI without auth produces 0 passing assertions,
which is worse than skipping. Later runs or the human reviewer will
execute the spec once a dev-server auth helper (or the proxy stack) is
available. The same gate-skip applies to all subsequent sections for the
duration of this autonomous run.
---
## Section 1 (Access Groups) — DeleteResourceModal deferred to chrome sweep
- **Type:** blueprint deviation
- **What the agent did instead:** `DeleteResourceModal`
(`src/components/common_components/DeleteResourceModal.tsx`) still
imports antd. It is a shared component used by multiple sections.
Leaving it for the global chrome sweep (Task 43) to migrate in a single
coordinated pass, so we don't thrash the component once per owning
section.
- **Why:** Shared-chrome component with no section that exclusively owns
it. The RECIPE.md explicitly allows deferring shared parent components
that aren't covered by the current section's parity spec.
---
## Section 1 (Access Groups) — custom MultiSelect shim in lieu of antd Select mode=multiple
- **Type:** `blueprint deviation`
- **Blueprint rule deviated from:** "use shadcn `Select`" for multi-select.
- **What the agent did instead:** introduced a small in-file `MultiSelect`
wrapper (shadcn `Select` + chip list rendered below) in
`AccessGroupBaseForm.tsx`. The shadcn primitive does not ship a multi-
select mode; this is the canonical phase-1 substitute. Recorded in
`BLUEPRINT.md §1` so subsequent sections use the same shape.
- **Why:** No multi-select shadcn primitive exists; building a dedicated
`MultiSelect` component in `@/components/ui/` is phase-2 work. The
inline wrapper is small enough to copy/paste into any section that
needs it until phase 2.

View file

@ -0,0 +1,136 @@
import { expect, test } from "@playwright/test";
/**
* Phase-1 parity spec for the Access Groups section.
*
* This spec asserts on user-observable behavior via semantic queries only.
* It deliberately avoids framework-specific selectors (e.g. `.ant-btn` or
* `[data-radix-*]`) so it can pass against both the pre-migration (antd)
* and post-migration (shadcn) UIs.
*
* The parity workflow is:
* 1. Run this spec against the unmigrated UI (baseline) — it must pass.
* 2. Migrate the section.
* 3. Run this spec against the migrated UI — it must still pass.
* 4. Run with --update-snapshots once to baseline visual snapshots, and
* again without to verify pixel diffs are below the 1% threshold
* defined in `playwright.config.ts`.
*
* **Auth:** The dashboard requires an admin session. When `PARITY_BASE_URL`
* is unset (default), the Next dev server at :3000 is used; this spec
* expects a helper to seed `sessionStorage` with a dev JWT — see
* `e2e_tests/parity/helpers.ts` (TBD; add when we run parity specs against
* the dev server). When `PARITY_BASE_URL=http://localhost:4000` the proxy
* is used and the existing auth flow applies.
*
* This file is committed as the phase-1 baseline spec shape. In this
* autonomous run the spec is not executed because neither the proxy nor a
* seeded dev-server auth helper is available in the sandbox environment
* — the gating check for Section 1 is vitest + tsc + lint + build. See
* `docs/CYCLES.md` for the explicit gate-skip rationale.
*/
test.describe("Access Groups — parity", () => {
test.beforeEach(async ({ page }) => {
// Page under test is typically reached via the leftnav.
await page.goto("/ui?page=access-groups");
});
test("lists access groups with page header and search input", async ({
page,
}) => {
await expect(
page.getByRole("heading", { name: /access groups/i }),
).toBeVisible();
await expect(
page.getByText(/manage resource permissions for your organization/i),
).toBeVisible();
await expect(
page.getByPlaceholder(
/search groups by name, id, or description\.\.\./i,
),
).toBeVisible();
await expect(
page.getByRole("button", { name: /create access group/i }),
).toBeVisible();
});
test("create access group flow opens the dialog and validates required fields", async ({
page,
}) => {
await page
.getByRole("button", { name: /create access group/i })
.click();
await expect(
page.getByRole("dialog", { name: /create access group/i }),
).toBeVisible();
await expect(
page.getByRole("textbox", { name: /group name/i }),
).toBeVisible();
await expect(
page.getByRole("button", { name: /create group/i }),
).toBeVisible();
await page.getByRole("button", { name: /cancel/i }).click();
await expect(
page.getByRole("dialog", { name: /create access group/i }),
).toBeHidden();
});
test("filters access groups by search", async ({ page }) => {
const search = page.getByPlaceholder(
/search groups by name, id, or description\.\.\./i,
);
await search.fill("__no_match_sentinel__");
// The table should show an empty-state row, not crash.
await expect(page.getByRole("table")).toBeVisible();
});
test("table exposes ID, Name, Resources, Actions columns", async ({
page,
}) => {
await expect(
page.getByRole("columnheader", { name: /^id$/i }),
).toBeVisible();
await expect(
page.getByRole("columnheader", { name: /^name$/i }),
).toBeVisible();
await expect(
page.getByRole("columnheader", { name: /^resources$/i }),
).toBeVisible();
await expect(
page.getByRole("columnheader", { name: /^actions$/i }),
).toBeVisible();
});
test("delete action opens confirmation dialog with resource information", async ({
page,
}) => {
const deleteButtons = page.getByRole("button", {
name: /delete access group/i,
});
const count = await deleteButtons.count();
test.skip(count === 0, "No existing access groups to exercise delete on.");
await deleteButtons.first().click();
await expect(
page.getByRole("dialog", { name: /delete access group/i }),
).toBeVisible();
await expect(
page.getByText(
/are you sure you want to delete this access group\?/i,
),
).toBeVisible();
await expect(
page.getByText(/access group information/i),
).toBeVisible();
});
test("visual — list view", async ({ page }) => {
await expect(
page.getByRole("heading", { name: /access groups/i }),
).toBeVisible();
await expect(page).toHaveScreenshot("list.png", {
fullPage: true,
mask: [page.getByRole("cell", { name: /ag-/i })], // mask dynamic IDs
});
});
});

View file

@ -1,23 +1,20 @@
import { useAccessGroupDetails } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Button,
Card,
Col,
Descriptions,
Empty,
Flex,
Layout,
List,
Row,
Spin,
Tabs,
Tag,
theme,
Typography
} from "antd";
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import {
ArrowLeftIcon,
BotIcon,
CopyIcon,
EditIcon,
KeyIcon,
LayersIcon,
@ -28,21 +25,48 @@ import { useState } from "react";
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
import { AccessGroupEditModal } from "./AccessGroupsModal/AccessGroupEditModal";
const { Title, Text } = Typography;
const { Content } = Layout;
interface AccessGroupDetailProps {
accessGroupId: string;
onBack: () => void;
}
function EmptyState({ description }: { description: string }) {
return (
<div className="py-12 flex flex-col items-center justify-center text-muted-foreground">
<div className="text-sm">{description}</div>
</div>
);
}
function CopyableId({ value }: { value: string }) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
navigator.clipboard?.writeText(value);
toast.success("Copied to clipboard");
}}
className="inline-flex items-center gap-1 text-xs font-mono rounded bg-muted px-1.5 py-0.5 hover:bg-muted/80"
>
{value}
<CopyIcon size={12} />
</button>
</TooltipTrigger>
<TooltipContent>Copy</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export function AccessGroupDetail({
accessGroupId,
onBack,
}: AccessGroupDetailProps) {
const { data: accessGroup, isLoading } =
useAccessGroupDetails(accessGroupId);
const { token } = theme.useToken();
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
const [showAllKeys, setShowAllKeys] = useState(false);
const [showAllTeams, setShowAllTeams] = useState(false);
@ -51,35 +75,22 @@ export function AccessGroupDetail({
if (isLoading) {
return (
<Content
style={{
padding: token.paddingLG,
paddingInline: token.paddingLG * 2,
}}
>
<Flex justify="center" align="center" style={{ minHeight: 300 }}>
<Spin size="large" />
</Flex>
</Content>
<div className="p-6 md:px-12">
<div className="flex justify-center items-center min-h-[300px]">
<Skeleton className="h-10 w-10 rounded-full" />
</div>
</div>
);
}
if (!accessGroup) {
return (
<Content
style={{
padding: token.paddingLG,
paddingInline: token.paddingLG * 2,
}}
>
<Button
icon={<ArrowLeftIcon size={16} />}
onClick={onBack}
type="text"
style={{ marginBottom: 16 }}
/>
<Empty description="Access group not found" />
</Content>
<div className="p-6 md:px-12">
<Button variant="ghost" size="sm" onClick={onBack} className="mb-4">
<ArrowLeftIcon size={16} />
</Button>
<EmptyState description="Access group not found" />
</div>
);
}
@ -90,248 +101,195 @@ export function AccessGroupDetail({
const teamIds = accessGroup.assigned_team_ids ?? [];
const displayedKeys = showAllKeys ? keyIds : keyIds.slice(0, MAX_PREVIEW);
const displayedTeams = showAllTeams
? teamIds
: teamIds.slice(0, MAX_PREVIEW);
const displayedTeams = showAllTeams ? teamIds : teamIds.slice(0, MAX_PREVIEW);
const handleEdit = () => {
setIsEditModalVisible(true);
};
const tabItems = [
{
key: "models",
label: (
<Flex align="center" gap={8}>
<LayersIcon size={16} />
Models
<Tag style={{ marginInlineEnd: 0 }}>{modelIds?.length}</Tag>
</Flex>
),
children:
modelIds?.length > 0 ? (
<List
grid={{ gutter: 16, xs: 1, sm: 2, md: 3, lg: 4 }}
dataSource={modelIds}
renderItem={(id) => (
<List.Item>
<Card size="small">
<Text code>{id}</Text>
</Card>
</List.Item>
)}
/>
) : (
<Empty description="No models assigned to this group" />
),
},
{
key: "mcp",
label: (
<Flex align="center" gap={8}>
<ServerIcon size={16} />
MCP Servers
<Tag>{mcpServerIds?.length}</Tag>
</Flex>
),
children:
mcpServerIds?.length > 0 ? (
<List
grid={{ gutter: 16, xs: 1, sm: 2, md: 3, lg: 4 }}
dataSource={mcpServerIds}
renderItem={(id) => (
<List.Item>
<Card size="small">
<Text code>{id}</Text>
</Card>
</List.Item>
)}
/>
) : (
<Empty description="No MCP servers assigned to this group" />
),
},
{
key: "agents",
label: (
<Flex align="center" gap={8}>
<BotIcon size={16} />
Agents
<Tag>{agentIds?.length}</Tag>
</Flex>
),
children:
agentIds?.length > 0 ? (
<List
grid={{ gutter: 16, xs: 1, sm: 2, md: 3, lg: 4 }}
dataSource={agentIds}
renderItem={(id) => (
<List.Item>
<Card size="small">
<Text code>{id}</Text>
</Card>
</List.Item>
)}
/>
) : (
<Empty description="No agents assigned to this group" />
),
},
];
const handleEdit = () => setIsEditModalVisible(true);
return (
<Content
style={{ padding: token.paddingLG, paddingInline: token.paddingLG * 2 }}
>
<div className="p-6 md:px-12">
{/* Header */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 24,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<Button
icon={<ArrowLeftIcon size={16} />}
onClick={onBack}
type="text"
/>
<div className="flex justify-between items-center mb-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={onBack}>
<ArrowLeftIcon size={16} />
</Button>
<div>
<Title level={2} style={{ margin: 0 }}>
<h2 className="text-2xl font-semibold m-0">
{accessGroup.access_group_name}
</Title>
<Text type="secondary">
ID: <Text copyable>{accessGroup.access_group_id}</Text>
</Text>
</h2>
<div className="text-sm text-muted-foreground flex items-center gap-2">
ID: <CopyableId value={accessGroup.access_group_id} />
</div>
</div>
</div>
<Button
type="primary"
icon={<EditIcon size={16} />}
onClick={handleEdit}
>
<Button onClick={handleEdit}>
<EditIcon size={16} />
Edit Access Group
</Button>
</div>
{/* Group Details */}
<Row style={{ marginBottom: 24 }}>
<Card>
<Descriptions title="Group Details" column={1}>
<Descriptions.Item label="Description">
{accessGroup.description || "—"}
</Descriptions.Item>
<Descriptions.Item label="Created">
<Card className="p-6 mb-6">
<h3 className="text-lg font-semibold mb-3">Group Details</h3>
<dl className="space-y-2 text-sm">
<div className="flex gap-2">
<dt className="w-36 text-muted-foreground">Description</dt>
<dd>{accessGroup.description || "—"}</dd>
</div>
<div className="flex gap-2">
<dt className="w-36 text-muted-foreground">Created</dt>
<dd>
{new Date(accessGroup.created_at).toLocaleString()}
{accessGroup.created_by && (
<Text>
&nbsp;{"by"}&nbsp;
<>
&nbsp;by&nbsp;
<DefaultProxyAdminTag userId={accessGroup.created_by} />
</Text>
</>
)}
</Descriptions.Item>
<Descriptions.Item label="Last Updated">
</dd>
</div>
<div className="flex gap-2">
<dt className="w-36 text-muted-foreground">Last Updated</dt>
<dd>
{new Date(accessGroup.updated_at).toLocaleString()}
{accessGroup.updated_by && (
<Text>
&nbsp;{"by"}&nbsp;
<>
&nbsp;by&nbsp;
<DefaultProxyAdminTag userId={accessGroup.updated_by} />
</Text>
</>
)}
</Descriptions.Item>
</Descriptions>
</Card>
</Row>
</dd>
</div>
</dl>
</Card>
{/* Attached Keys & Teams */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} lg={12}>
<Card
title={
<Flex align="center" gap={8}>
<KeyIcon size={16} />
Attached Keys
<Tag>{keyIds?.length}</Tag>
</Flex>
}
extra={
keyIds?.length > MAX_PREVIEW ? (
<Button
type="link"
onClick={() => setShowAllKeys(!showAllKeys)}
>
{showAllKeys ? "Show Less" : `View All (${keyIds?.length})`}
</Button>
) : null
}
>
{keyIds?.length > 0 ? (
<Flex wrap="wrap" gap={8}>
{displayedKeys.map((id) => (
<Tag key={id}>
<Text code style={{ fontSize: 12 }}>
{id.length > 20
? `${id.slice(0, 10)}...${id.slice(-6)}`
: id}
</Text>
</Tag>
))}
</Flex>
) : (
<Empty
description="No keys attached"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
<Card className="p-6">
<div className="flex justify-between items-center mb-4">
<div className="flex items-center gap-2 font-semibold">
<KeyIcon size={16} />
Attached Keys
<Badge variant="secondary">{keyIds?.length}</Badge>
</div>
{keyIds?.length > MAX_PREVIEW && (
<Button
variant="link"
size="sm"
onClick={() => setShowAllKeys(!showAllKeys)}
>
{showAllKeys ? "Show Less" : `View All (${keyIds?.length})`}
</Button>
)}
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
title={
<Flex align="center" gap={8}>
<UsersIcon size={16} />
Attached Teams
<Tag>{teamIds?.length}</Tag>
</Flex>
}
extra={
teamIds?.length > MAX_PREVIEW ? (
<Button
type="link"
onClick={() => setShowAllTeams(!showAllTeams)}
>
{showAllTeams
? "Show Less"
: `View All (${teamIds?.length})`}
</Button>
) : null
}
>
{teamIds?.length > 0 ? (
<Flex wrap="wrap" gap={8}>
{displayedTeams.map((id) => (
<Tag key={id}>
<Text code style={{ fontSize: 12 }}>
{id}
</Text>
</Tag>
))}
</Flex>
) : (
<Empty
description="No teams attached"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</div>
{keyIds?.length > 0 ? (
<div className="flex flex-wrap gap-2">
{displayedKeys.map((id) => (
<Badge key={id} variant="secondary" className="font-mono text-xs">
{id.length > 20
? `${id.slice(0, 10)}...${id.slice(-6)}`
: id}
</Badge>
))}
</div>
) : (
<EmptyState description="No keys attached" />
)}
</Card>
<Card className="p-6">
<div className="flex justify-between items-center mb-4">
<div className="flex items-center gap-2 font-semibold">
<UsersIcon size={16} />
Attached Teams
<Badge variant="secondary">{teamIds?.length}</Badge>
</div>
{teamIds?.length > MAX_PREVIEW && (
<Button
variant="link"
size="sm"
onClick={() => setShowAllTeams(!showAllTeams)}
>
{showAllTeams ? "Show Less" : `View All (${teamIds?.length})`}
</Button>
)}
</Card>
</Col>
</Row>
</div>
{teamIds?.length > 0 ? (
<div className="flex flex-wrap gap-2">
{displayedTeams.map((id) => (
<Badge key={id} variant="secondary" className="font-mono text-xs">
{id}
</Badge>
))}
</div>
) : (
<EmptyState description="No teams attached" />
)}
</Card>
</div>
{/* Resources Tabs */}
<Card>
<Tabs defaultActiveKey="models" items={tabItems} />
<Card className="p-6">
<Tabs defaultValue="models" className="w-full">
<TabsList>
<TabsTrigger value="models" className="gap-1">
<LayersIcon size={16} />
Models
<Badge variant="secondary">{modelIds?.length}</Badge>
</TabsTrigger>
<TabsTrigger value="mcp" className="gap-1">
<ServerIcon size={16} />
MCP Servers
<Badge variant="secondary">{mcpServerIds?.length}</Badge>
</TabsTrigger>
<TabsTrigger value="agents" className="gap-1">
<BotIcon size={16} />
Agents
<Badge variant="secondary">{agentIds?.length}</Badge>
</TabsTrigger>
</TabsList>
<TabsContent value="models" className="pt-4">
{modelIds?.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{modelIds.map((id) => (
<Card key={id} className="p-3">
<code className="text-xs font-mono">{id}</code>
</Card>
))}
</div>
) : (
<EmptyState description="No models assigned to this group" />
)}
</TabsContent>
<TabsContent value="mcp" className="pt-4">
{mcpServerIds?.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{mcpServerIds.map((id) => (
<Card key={id} className="p-3">
<code className="text-xs font-mono">{id}</code>
</Card>
))}
</div>
) : (
<EmptyState description="No MCP servers assigned to this group" />
)}
</TabsContent>
<TabsContent value="agents" className="pt-4">
{agentIds?.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{agentIds.map((id) => (
<Card key={id} className="p-3">
<code className="text-xs font-mono">{id}</code>
</Card>
))}
</div>
) : (
<EmptyState description="No agents assigned to this group" />
)}
</TabsContent>
</Tabs>
</Card>
{/* Edit Modal */}
@ -340,6 +298,6 @@ export function AccessGroupDetail({
accessGroup={accessGroup}
onCancel={() => setIsEditModalVisible(false)}
/>
</Content>
</div>
);
}

View file

@ -1,11 +1,22 @@
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers";
import { ModelSelect } from "@/components/ModelSelect/ModelSelect";
import type { FormInstance } from "antd";
import { Form, Input, Select, Space, Tabs } from "antd";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { X } from "lucide-react";
import { BotIcon, InfoIcon, LayersIcon, ServerIcon } from "lucide-react";
const { TextArea } = Input;
import { useMemo } from "react";
import { Controller, useFormContext } from "react-hook-form";
export interface AccessGroupFormValues {
name: string;
@ -16,144 +27,212 @@ export interface AccessGroupFormValues {
}
interface AccessGroupBaseFormProps {
form: FormInstance<AccessGroupFormValues>;
isNameDisabled?: boolean;
}
/**
* Multi-select rendered with shadcn Select + chip list below. Accepts any
* `Array<{ label: string; value: string }>` list of options.
*/
function MultiSelect({
value,
onChange,
options,
placeholder,
emptyText,
}: {
value: string[];
onChange: (next: string[]) => void;
options: { label: string; value: string }[];
placeholder: string;
emptyText: string;
}) {
const selected = useMemo(() => value ?? [], [value]);
const remaining = useMemo(
() => options.filter((o) => !selected.includes(o.value)),
[options, selected],
);
return (
<div className="space-y-2">
<Select
value=""
onValueChange={(v) => {
if (v) onChange([...selected, v]);
}}
>
<SelectTrigger>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{remaining.length === 0 ? (
<div className="py-2 px-3 text-sm text-muted-foreground">
{emptyText}
</div>
) : (
remaining.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
{selected.length > 0 && (
<div className="flex flex-wrap gap-1">
{selected.map((v) => {
const opt = options.find((o) => o.value === v);
return (
<Badge key={v} variant="secondary" className="flex items-center gap-1">
{opt?.label ?? v}
<button
type="button"
onClick={() => onChange(selected.filter((s) => s !== v))}
className="inline-flex items-center justify-center rounded-full hover:bg-muted-foreground/20"
aria-label={`Remove ${opt?.label ?? v}`}
>
<X size={12} />
</button>
</Badge>
);
})}
</div>
)}
</div>
);
}
/**
* Fields-only block — expected to be rendered inside a parent form managed
* by `react-hook-form` via a `FormProvider`. See `AccessGroupCreateModal`
* / `AccessGroupEditModal` for the integration.
*/
export function AccessGroupBaseForm({
form,
isNameDisabled = false,
}: AccessGroupBaseFormProps) {
const { control, register, formState } = useFormContext<AccessGroupFormValues>();
const { data: agentsData } = useAgents();
const { data: mcpServersData } = useMCPServers();
const agents = agentsData?.agents ?? [];
const mcpServers = mcpServersData ?? [];
const items = [
{
key: "1",
label: (
<Space align="center" size={4}>
<InfoIcon size={16} />
General Info
</Space>
),
children: (
<div style={{ paddingTop: 16 }}>
<Form.Item
name="name"
label="Group Name"
rules={[
{
required: true,
message: "Please enter the access group name",
},
]}
>
<Input
placeholder="e.g. Engineering Team"
disabled={isNameDisabled}
/>
</Form.Item>
<Form.Item
name="description"
label="Description"
>
<TextArea
rows={4}
placeholder="Describe the purpose of this access group..."
/>
</Form.Item>
</div>
),
},
{
key: "2",
label: (
<Space align="center" size={4}>
<LayersIcon size={16} />
Models
</Space>
),
children: (
<div style={{ paddingTop: 16 }}>
<Form.Item name="modelIds" label="Allowed Models">
<ModelSelect
context="global"
value={form.getFieldValue("modelIds") ?? []}
onChange={(values) => form.setFieldsValue({ modelIds: values })}
style={{ width: "100%" }}
/>
</Form.Item>
</div>
),
},
{
key: "3",
label: (
<Space align="center" size={4}>
<ServerIcon size={16} />
MCP Servers
</Space>
),
children: (
<div style={{ paddingTop: 16 }}>
<Form.Item name="mcpServerIds" label="Allowed MCP Servers">
<Select
mode="multiple"
placeholder="Select MCP servers"
style={{ width: "100%" }}
optionFilterProp="label"
allowClear
options={mcpServers.map((server) => ({
label: server.server_name ?? server.server_id,
value: server.server_id,
}))}
/>
</Form.Item>
</div>
),
},
{
key: "4",
label: (
<Space align="center" size={4}>
<BotIcon size={16} />
Agents
</Space>
),
children: (
<div style={{ paddingTop: 16 }}>
<Form.Item name="agentIds" label="Allowed Agents">
<Select
mode="multiple"
placeholder="Select agents"
style={{ width: "100%" }}
optionFilterProp="label"
allowClear
options={agents.map((agent) => ({
label: agent.agent_name,
value: agent.agent_id,
}))}
/>
</Form.Item>
</div>
),
},
];
const mcpOptions = mcpServers.map((s) => ({
label: s.server_name ?? s.server_id,
value: s.server_id,
}));
const agentOptions = agents.map((a) => ({
label: a.agent_name,
value: a.agent_id,
}));
return (
<Form
form={form}
layout="vertical"
name="access_group_form"
initialValues={{
modelIds: [],
mcpServerIds: [],
agentIds: [],
}}
>
<Tabs defaultActiveKey="1" items={items} />
</Form>
<Tabs defaultValue="general" className="w-full">
<TabsList>
<TabsTrigger value="general" className="gap-1">
<InfoIcon size={16} />
General Info
</TabsTrigger>
<TabsTrigger value="models" className="gap-1">
<LayersIcon size={16} />
Models
</TabsTrigger>
<TabsTrigger value="mcp" className="gap-1">
<ServerIcon size={16} />
MCP Servers
</TabsTrigger>
<TabsTrigger value="agents" className="gap-1">
<BotIcon size={16} />
Agents
</TabsTrigger>
</TabsList>
<TabsContent value="general" className="pt-4 space-y-4">
<div className="space-y-2">
<Label htmlFor="access-group-name">
Group Name <span className="text-destructive">*</span>
</Label>
<Input
id="access-group-name"
placeholder="e.g. Engineering Team"
disabled={isNameDisabled}
aria-invalid={!!formState.errors.name}
{...register("name", {
required: "Please enter the access group name",
})}
/>
{formState.errors.name && (
<p className="text-sm text-destructive">
{formState.errors.name.message as string}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="access-group-description">Description</Label>
<Textarea
id="access-group-description"
rows={4}
placeholder="Describe the purpose of this access group..."
{...register("description")}
/>
</div>
</TabsContent>
<TabsContent value="models" className="pt-4">
<div className="space-y-2">
<Label>Allowed Models</Label>
<Controller
control={control}
name="modelIds"
render={({ field }) => (
<ModelSelect
context="global"
value={field.value ?? []}
onChange={(values) => field.onChange(values)}
style={{ width: "100%" }}
/>
)}
/>
</div>
</TabsContent>
<TabsContent value="mcp" className="pt-4">
<div className="space-y-2">
<Label>Allowed MCP Servers</Label>
<Controller
control={control}
name="mcpServerIds"
render={({ field }) => (
<MultiSelect
value={field.value ?? []}
onChange={field.onChange}
options={mcpOptions}
placeholder="Select MCP servers"
emptyText="No MCP servers available"
/>
)}
/>
</div>
</TabsContent>
<TabsContent value="agents" className="pt-4">
<div className="space-y-2">
<Label>Allowed Agents</Label>
<Controller
control={control}
name="agentIds"
render={({ field }) => (
<MultiSelect
value={field.value ?? []}
onChange={field.onChange}
options={agentOptions}
placeholder="Select agents"
emptyText="No agents available"
/>
)}
/>
</div>
</TabsContent>
</Tabs>
);
}

View file

@ -1,5 +1,12 @@
import React from "react";
import { Modal, Form } from "antd";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import MessageManager from "@/components/molecules/message_manager";
import {
AccessGroupBaseForm,
@ -9,6 +16,8 @@ import {
useCreateAccessGroup,
AccessGroupCreateParams,
} from "@/app/(dashboard)/hooks/accessGroups/useCreateAccessGroup";
import { FormProvider, useForm } from "react-hook-form";
import { useEffect } from "react";
interface AccessGroupCreateModalProps {
visible: boolean;
@ -16,53 +25,73 @@ interface AccessGroupCreateModalProps {
onSuccess?: () => void;
}
const emptyValues: AccessGroupFormValues = {
name: "",
description: "",
modelIds: [],
mcpServerIds: [],
agentIds: [],
};
export function AccessGroupCreateModal({
visible,
onCancel,
onSuccess,
}: AccessGroupCreateModalProps) {
const [form] = Form.useForm<AccessGroupFormValues>();
const form = useForm<AccessGroupFormValues>({
defaultValues: emptyValues,
mode: "onSubmit",
});
const createMutation = useCreateAccessGroup();
const handleOk = () => {
form
.validateFields()
.then((values) => {
const params: AccessGroupCreateParams = {
access_group_name: values.name,
description: values.description,
access_model_names: values.modelIds,
access_mcp_server_ids: values.mcpServerIds,
access_agent_ids: values.agentIds,
};
// Reset whenever the dialog reopens — sonner equivalent of antd
// destroyOnClose is a deliberate form.reset().
useEffect(() => {
if (visible) form.reset(emptyValues);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible]);
createMutation.mutate(params, {
onSuccess: () => {
MessageManager.success("Access group created successfully");
form.resetFields();
onSuccess?.();
onCancel();
},
});
})
.catch((info) => {
console.log("Validate Failed:", info);
});
};
const onSubmit = form.handleSubmit((values) => {
const params: AccessGroupCreateParams = {
access_group_name: values.name,
description: values.description,
access_model_names: values.modelIds,
access_mcp_server_ids: values.mcpServerIds,
access_agent_ids: values.agentIds,
};
createMutation.mutate(params, {
onSuccess: () => {
MessageManager.success("Access group created successfully");
form.reset(emptyValues);
onSuccess?.();
onCancel();
},
});
});
return (
<Modal
title="Create Access Group"
open={visible}
onOk={handleOk}
onCancel={onCancel}
width={700}
okText="Create Group"
cancelText="Cancel"
confirmLoading={createMutation.isPending}
destroyOnClose
>
<AccessGroupBaseForm form={form} />
</Modal>
<Dialog open={visible} onOpenChange={(o) => (!o ? onCancel() : undefined)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create Access Group</DialogTitle>
<DialogDescription className="sr-only">
Create a new access group and assign models, MCP servers, and agents.
</DialogDescription>
</DialogHeader>
<FormProvider {...form}>
<form onSubmit={onSubmit}>
<AccessGroupBaseForm />
<DialogFooter className="mt-6">
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? "Creating…" : "Create Group"}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);
}

View file

@ -1,5 +1,12 @@
import React, { useEffect } from "react";
import { Modal, Form } from "antd";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import MessageManager from "@/components/molecules/message_manager";
import {
AccessGroupBaseForm,
@ -10,6 +17,8 @@ import {
AccessGroupUpdateParams,
} from "@/app/(dashboard)/hooks/accessGroups/useEditAccessGroup";
import { AccessGroupResponse } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
import { FormProvider, useForm } from "react-hook-form";
import { useEffect } from "react";
interface AccessGroupEditModalProps {
visible: boolean;
@ -24,13 +33,21 @@ export function AccessGroupEditModal({
onCancel,
onSuccess,
}: AccessGroupEditModalProps) {
const [form] = Form.useForm<AccessGroupFormValues>();
const form = useForm<AccessGroupFormValues>({
defaultValues: {
name: "",
description: "",
modelIds: [],
mcpServerIds: [],
agentIds: [],
},
mode: "onSubmit",
});
const editMutation = useEditAccessGroup();
// Populate the form with initial values whenever the modal opens or the data changes
useEffect(() => {
if (visible && accessGroup) {
form.setFieldsValue({
form.reset({
name: accessGroup.access_group_name,
description: accessGroup.description ?? "",
modelIds: accessGroup.access_model_names ?? [],
@ -38,49 +55,52 @@ export function AccessGroupEditModal({
agentIds: accessGroup.access_agent_ids ?? [],
});
}
}, [visible, accessGroup, form]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, accessGroup]);
const handleOk = () => {
form
.validateFields()
.then((values) => {
const params: AccessGroupUpdateParams = {
access_group_name: values.name,
description: values.description,
access_model_names: values.modelIds,
access_mcp_server_ids: values.mcpServerIds,
access_agent_ids: values.agentIds,
};
editMutation.mutate(
{ accessGroupId: accessGroup.access_group_id, params },
{
onSuccess: () => {
MessageManager.success("Access group updated successfully");
onSuccess?.();
onCancel();
},
},
);
})
.catch((info) => {
console.log("Validate Failed:", info);
});
};
const onSubmit = form.handleSubmit((values) => {
const params: AccessGroupUpdateParams = {
access_group_name: values.name,
description: values.description,
access_model_names: values.modelIds,
access_mcp_server_ids: values.mcpServerIds,
access_agent_ids: values.agentIds,
};
editMutation.mutate(
{ accessGroupId: accessGroup.access_group_id, params },
{
onSuccess: () => {
MessageManager.success("Access group updated successfully");
onSuccess?.();
onCancel();
},
},
);
});
return (
<Modal
title="Edit Access Group"
open={visible}
onOk={handleOk}
onCancel={onCancel}
width={700}
okText="Save Changes"
cancelText="Cancel"
confirmLoading={editMutation.isPending}
destroyOnHidden
>
<AccessGroupBaseForm form={form} />
</Modal>
<Dialog open={visible} onOpenChange={(o) => (!o ? onCancel() : undefined)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Edit Access Group</DialogTitle>
<DialogDescription className="sr-only">
Edit the access group and its resource assignments.
</DialogDescription>
</DialogHeader>
<FormProvider {...form}>
<form onSubmit={onSubmit}>
<AccessGroupBaseForm />
<DialogFooter className="mt-6">
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" disabled={editMutation.isPending}>
{editMutation.isPending ? "Saving…" : "Save Changes"}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);
}

View file

@ -3,36 +3,34 @@ import {
useAccessGroups,
} from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups";
import { useDeleteAccessGroup } from "@/app/(dashboard)/hooks/accessGroups/useDeleteAccessGroup";
import { PlusOutlined } from "@ant-design/icons";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
Row,
SortingState,
useReactTable,
} from "@tanstack/react-table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Button,
Card,
Flex,
Input,
Layout,
Pagination,
Space,
Table,
Tag,
theme,
Tooltip,
Typography,
} from "antd";
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
BotIcon,
LayersIcon,
SearchIcon,
ServerIcon
} from "lucide-react";
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { BotIcon, LayersIcon, Plus, SearchIcon, ServerIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import DeleteResourceModal from "../common_components/DeleteResourceModal";
import TableIconActionButton from "../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
@ -44,16 +42,6 @@ import { AccessGroupDetail } from "./AccessGroupsDetailsPage";
import { AccessGroupCreateModal } from "./AccessGroupsModal/AccessGroupCreateModal";
import { AccessGroup } from "./types";
declare module "@tanstack/react-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData, TValue> {
responsive?: string[];
}
}
const { Title, Text } = Typography;
const { Content } = Layout;
function mapResponseToAccessGroup(r: AccessGroupResponse): AccessGroup {
return {
id: r.access_group_id,
@ -70,66 +58,8 @@ function mapResponseToAccessGroup(r: AccessGroupResponse): AccessGroup {
updatedBy: r.updated_by ?? "",
};
}
function buildAntdColumns(
table: ReturnType<typeof useReactTable<AccessGroup>>,
rowLookup: Map<string, Row<AccessGroup>>,
onSortingChange: (s: SortingState) => void,
) {
const headers = table.getHeaderGroups()[0]?.headers ?? [];
return headers.map((header) => {
const canSort = header.column.getCanSort();
const isSorted = header.column.getIsSorted();
const meta = header.column.columnDef.meta as
| { responsive?: string[] }
| undefined;
const col: Record<string, unknown> = {
title: (
<div style={{ display: "flex", alignItems: "center", gap: 4 }}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
{canSort && (
<TableHeaderSortDropdown
sortState={isSorted === false ? false : (isSorted as SortState)}
onSortChange={(newState) => {
if (newState === false) {
onSortingChange([]);
} else {
onSortingChange([
{ id: header.column.id, desc: newState === "desc" },
]);
}
}}
columnId={header.column.id}
/>
)}
</div>
),
key: header.id,
width: header.column.columnDef.size,
render: (_: unknown, record: AccessGroup) => {
const row = rowLookup.get(record.id);
if (!row) return null;
const cell = row
.getVisibleCells()
.find((c) => c.column.id === header.id);
if (!cell) return null;
return flexRender(cell.column.columnDef.cell, cell.getContext());
},
};
if (meta?.responsive) {
col.responsive = meta.responsive;
}
return col;
});
}
export function AccessGroupsPage() {
const { token } = theme.useToken();
const { data: groupsData, isLoading } = useAccessGroups();
const groups = useMemo(
() => (groupsData ?? []).map(mapResponseToAccessGroup),
@ -149,7 +79,6 @@ export function AccessGroupsPage() {
setCurrentPage(1);
}, [searchText]);
// ---------- filtered data ----------
const filteredGroups = useMemo(
() =>
groups.filter(
@ -161,7 +90,6 @@ export function AccessGroupsPage() {
[groups, searchText],
);
// ---------- TanStack column definitions ----------
const columnDefs = useMemo<ColumnDef<AccessGroup>[]>(
() => [
{
@ -173,16 +101,21 @@ export function AccessGroupsPage() {
cell: ({ row }) => {
const record = row.original;
return (
<Tooltip title={record.id}>
<Text
ellipsis
className="text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs cursor-pointer"
style={{ fontSize: 14, padding: "1px 8px" }}
onClick={() => setSelectedGroupId(record.id)}
>
{record.id}
</Text>
</Tooltip>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="truncate text-primary bg-primary/10 hover:bg-primary/20 text-xs cursor-pointer px-2 py-0.5 rounded max-w-full text-left"
style={{ fontSize: 14 }}
onClick={() => setSelectedGroupId(record.id)}
>
{record.id}
</button>
</TooltipTrigger>
<TooltipContent>{record.id}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
},
@ -203,32 +136,37 @@ export function AccessGroupsPage() {
const mcpServerIds = record.mcpServerIds ?? [];
const agentIds = record.agentIds ?? [];
return (
<Flex gap={12} align="center">
<Tooltip title={`${modelIds?.length} Models`}>
<Tag color="blue" style={{ fontSize: 14, padding: "2px 8px", margin: 0 }}>
<Flex align="center" gap={6}>
<LayersIcon size={14} />
{modelIds?.length}
</Flex>
</Tag>
</Tooltip>
<Tooltip title={`${mcpServerIds?.length} MCP Servers`}>
<Tag color="cyan" style={{ fontSize: 14, padding: "2px 8px", margin: 0 }}>
<Flex align="center" gap={6}>
<ServerIcon size={14} />
{mcpServerIds?.length}
</Flex>
</Tag>
</Tooltip>
<Tooltip title={`${agentIds?.length} Agents`}>
<Tag color="purple" style={{ fontSize: 14, padding: "2px 8px", margin: 0 }}>
<Flex align="center" gap={6}>
<BotIcon size={14} />
{agentIds?.length}
</Flex>
</Tag>
</Tooltip>
</Flex>
<TooltipProvider>
<div className="flex items-center gap-3">
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary" className="flex items-center gap-1.5 px-2 py-0.5 text-sm">
<LayersIcon size={14} />
{modelIds?.length}
</Badge>
</TooltipTrigger>
<TooltipContent>{`${modelIds?.length} Models`}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary" className="flex items-center gap-1.5 px-2 py-0.5 text-sm">
<ServerIcon size={14} />
{mcpServerIds?.length}
</Badge>
</TooltipTrigger>
<TooltipContent>{`${mcpServerIds?.length} MCP Servers`}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary" className="flex items-center gap-1.5 px-2 py-0.5 text-sm">
<BotIcon size={14} />
{agentIds?.length}
</Badge>
</TooltipTrigger>
<TooltipContent>{`${agentIds?.length} Agents`}</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);
},
},
@ -240,7 +178,6 @@ export function AccessGroupsPage() {
sortingFn: "datetime",
cell: ({ getValue }) =>
new Date(getValue() as string).toLocaleDateString(),
meta: { responsive: ["lg"] },
},
{
id: "updatedAt",
@ -249,29 +186,25 @@ export function AccessGroupsPage() {
enableSorting: false,
cell: ({ getValue }) =>
new Date(getValue() as string).toLocaleDateString(),
meta: { responsive: ["xl"] },
},
{
id: "actions",
header: () => <span>Actions</span>,
enableSorting: false,
cell: ({ row }) => (
<Space>
<div className="flex gap-2">
<TableIconActionButton
variant="Delete"
tooltipText="Delete access group"
onClick={() => setGroupToDelete(row.original)}
/>
</Space>
</div>
),
},
],
// setSelectedGroup is stable (useState setter)
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
// ---------- TanStack table instance ----------
const table = useReactTable<AccessGroup>({
data: filteredGroups,
columns: columnDefs,
@ -282,26 +215,12 @@ export function AccessGroupsPage() {
getRowId: (row) => row.id,
});
// All sorted rows from TanStack
const sortedRows = table.getRowModel().rows;
// Paginated slice
const paginatedRows = sortedRows.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize,
);
// Map for O(1) lookup by record id in antd render()
const rowLookup = useMemo(
() => new Map(paginatedRows.map((row) => [row.original.id, row])),
[paginatedRows],
);
// Convert TanStack headers → antd columns
const antdColumns = buildAntdColumns(table, rowLookup, setSorting);
// antd dataSource (just the originals for the current page)
const dataSource = paginatedRows.map((row) => row.original);
const totalPages = Math.max(1, Math.ceil(sortedRows.length / pageSize));
if (selectedGroupId) {
return (
@ -313,64 +232,139 @@ export function AccessGroupsPage() {
}
return (
<Content
style={{ padding: token.paddingLG, paddingInline: token.paddingLG * 2 }}
>
<Flex
justify="space-between"
align="center"
style={{ marginBottom: 16 }}
>
<Space direction="vertical" size={0}>
<Title level={2} style={{ margin: 0 }}>
Access Groups
</Title>
<Text type="secondary">
<div className="p-6 md:px-12">
<div className="flex justify-between items-center mb-4">
<div>
<h2 className="text-2xl font-semibold m-0">Access Groups</h2>
<p className="text-muted-foreground text-sm m-0">
Manage resource permissions for your organization
</Text>
</Space>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setIsCreateModalVisible(true)}
>
</p>
</div>
<Button onClick={() => setIsCreateModalVisible(true)}>
<Plus className="h-4 w-4" />
Create Access Group
</Button>
</Flex>
</div>
<Card styles={{ body: { padding: 0 } }}>
<Flex
justify="space-between"
align="center"
style={{
padding: "12px 16px",
}}
>
<Input
prefix={<SearchIcon size={16} />}
placeholder="Search groups by name, ID, or description..."
style={{ maxWidth: 400 }}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
/>
<Pagination
current={currentPage}
total={sortedRows?.length}
pageSize={pageSize}
onChange={(page) => setCurrentPage(page)}
size="small"
showTotal={(total) => `${total} groups`}
showSizeChanger={false}
/>
</Flex>
<Table
columns={antdColumns}
dataSource={dataSource}
rowKey="id"
loading={isLoading}
pagination={false}
/>
<Card>
<div className="flex justify-between items-center px-4 py-3 gap-3">
<div className="relative max-w-md w-full">
<SearchIcon
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none"
/>
<Input
placeholder="Search groups by name, ID, or description..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="pl-9"
/>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>{sortedRows.length} groups</span>
{totalPages > 1 && (
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
>
Prev
</Button>
<span className="tabular-nums">
{currentPage} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage >= totalPages}
>
Next
</Button>
</div>
)}
</div>
</div>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const canSort = header.column.getCanSort();
const isSorted = header.column.getIsSorted();
return (
<TableHead
key={header.id}
style={{ width: header.column.columnDef.size }}
>
<div className={cn("flex items-center gap-1")}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
{canSort && (
<TableHeaderSortDropdown
sortState={
isSorted === false ? false : (isSorted as SortState)
}
onSortChange={(newState) => {
if (newState === false) {
setSorting([]);
} else {
setSorting([
{
id: header.column.id,
desc: newState === "desc",
},
]);
}
}}
columnId={header.column.id}
/>
)}
</div>
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell
colSpan={columnDefs.length}
className="text-center text-muted-foreground py-8"
>
Loading…
</TableCell>
</TableRow>
) : paginatedRows.length === 0 ? (
<TableRow>
<TableCell
colSpan={columnDefs.length}
className="text-center text-muted-foreground py-8"
>
No access groups found
</TableCell>
</TableRow>
) : (
paginatedRows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</Card>
<AccessGroupCreateModal
@ -399,6 +393,6 @@ export function AccessGroupsPage() {
}}
confirmLoading={deleteMutation.isPending}
/>
</Content>
</div>
);
}