feat(ui): show and pick vector stores for access groups (#51581)

This commit is contained in:
shrey 2026-11-13 09:36:08 -08:00 • committed by GitHub
parent 46716fad56
commit 2a4e90c35e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 493 additions and 25 deletions

View file

@ -63,6 +63,12 @@ const createMockAccessGroup = (overrides: Partial<AccessGroupResponse> = {}): Ac
access_mcp_servers: [{ id: "mcp-1", name: "GitHub MCP" }],
access_agents: [{ id: "agent-1", name: "Support Agent" }],
access_skill_names: ["private-plugin"],
access_vector_store_ids: ["store-a", "store-b", "store-c"],
access_vector_stores: [
{ id: "store-a", name: "Parks knowledge base" },
{ id: "store-b", name: null },
{ id: "store-c", name: "HR policies" },
],
assigned_teams: [{ id: "team-1", name: "Platform Team" }],
assigned_keys: [
{ id: "key-1", name: "ci-key" },
@ -480,6 +486,41 @@ describe("AccessGroupDetail", () => {
});
});
describe("Vector Stores tab", () => {
it("should count the group's vector stores and show each by name, or by id when it has none", async () => {
const user = userEvent.setup();
renderWithProviders(<AccessGroupDetail accessGroupId={accessGroupId} onBack={mockOnBack} />);
const tab = screen.getByRole("tab", { name: /Vector Stores/i });
expect(tab).toHaveTextContent("3");
await user.click(tab);
expect(screen.getByText("Parks knowledge base")).toBeInTheDocument();
expect(screen.getByText("HR policies")).toBeInTheDocument();
expect(screen.getByText("store-b")).toBeInTheDocument();
expect(screen.queryByText("store-a")).not.toBeInTheDocument();
});
it("should fall back to the ids when the proxy sends no names", async () => {
const user = userEvent.setup();
renderWith({ access_vector_stores: undefined });
await user.click(screen.getByRole("tab", { name: /Vector Stores/i }));
expect(screen.getByText("store-a")).toBeInTheDocument();
expect(screen.getByText("store-c")).toBeInTheDocument();
});
it("should show empty state when none assigned", async () => {
const user = userEvent.setup();
renderWith({ access_vector_store_ids: [], access_vector_stores: [] });
await user.click(screen.getByRole("tab", { name: /Vector Stores/i }));
expect(screen.getByText("No vector stores assigned to this group")).toBeInTheDocument();
});
});
it("should show empty state in Models tab when no models assigned", () => {
renderWith({ access_model_names: [] });

View file

@ -3,6 +3,7 @@ import type { TFunction } from "i18next";
import {
ArrowLeftIcon,
BotIcon,
DatabaseIcon,
EditIcon,
KeyIcon,
LayersIcon,
@ -146,6 +147,8 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
const mcpServers = accessGroup.access_mcp_servers;
const agents = accessGroup.access_agents;
const skills = (accessGroup.access_skill_names ?? []).map((name) => ({ id: name, name: null }));
const vectorStores =
accessGroup.access_vector_stores ?? (accessGroup.access_vector_store_ids ?? []).map((id) => ({ id, name: null }));
const keys = accessGroup.assigned_keys;
const teams = accessGroup.assigned_teams;
@ -273,7 +276,10 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
<Card>
<CardContent>
<Tabs defaultValue="models">
<TabsList variant="line" className="h-auto w-full justify-start rounded-none border-b p-0">
<TabsList
variant="line"
className="w-full flex-wrap justify-start rounded-none border-b p-0 group-data-horizontal/tabs:h-auto"
>
<TabsTrigger value="models" className="flex-none gap-2 rounded-none px-4 py-2">
<LayersIcon className="size-4" />
{t("accessGroups.detail.tabs.models")}
@ -294,6 +300,11 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
{t("accessGroups.detail.tabs.skills")}
<Badge variant="secondary">{skills.length}</Badge>
</TabsTrigger>
<TabsTrigger value="vector-stores" className="flex-none gap-2 rounded-none px-4 py-2">
<DatabaseIcon className="size-4" />
{t("accessGroups.detail.tabs.vectorStores")}
<Badge variant="secondary">{vectorStores.length}</Badge>
</TabsTrigger>
</TabsList>
<TabsContent value="models" className="pt-4">
<ResourceList items={models} emptyMessage={t("accessGroups.detail.empty.models")} />
@ -308,6 +319,9 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr
<TabsContent value="skills" className="pt-4">
<ResourceList items={skills} emptyMessage={t("accessGroups.detail.empty.skills")} />
</TabsContent>
<TabsContent value="vector-stores" className="pt-4">
<ResourceList items={vectorStores} emptyMessage={t("accessGroups.detail.empty.vectorStores")} />
</TabsContent>
</Tabs>
</CardContent>
</Card>

View file

@ -1,6 +1,6 @@
"use client";
import { BotIcon, InfoIcon, LayersIcon, ServerIcon, SparklesIcon } from "lucide-react";
import { BotIcon, DatabaseIcon, InfoIcon, LayersIcon, ServerIcon, SparklesIcon } from "lucide-react";
import type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
@ -17,6 +17,7 @@ import SkillSelector from "@/components/skills/SkillSelector";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import VectorStoreSelector from "@/components/vector_store_management/VectorStoreSelector";
import i18n from "@/i18n/i18n";
export const accessGroupFormSchema = z.object({
@ -27,6 +28,7 @@ export const accessGroupFormSchema = z.object({
mcpToolPermissions: z.record(z.string(), z.array(z.string())),
agentIds: z.array(z.string()),
skillNames: z.array(z.string()),
vectorStoreIds: z.array(z.string()),
});
export type AccessGroupFormValues = z.output<typeof accessGroupFormSchema>;
@ -36,6 +38,7 @@ export const MODELS_TAB = "models";
export const MCP_SERVERS_TAB = "mcp-servers";
export const AGENTS_TAB = "agents";
export const SKILLS_TAB = "skills";
export const VECTOR_STORES_TAB = "vector-stores";
interface AccessGroupBaseFormProps {
form: UseFormReturn<AccessGroupFormValues>;
@ -98,7 +101,7 @@ export function AccessGroupBaseForm({
return (
<Tabs value={activeTab} onValueChange={onTabChange}>
<TabsList className="w-full">
<TabsList className="w-full flex-wrap group-data-horizontal/tabs:h-auto">
<TabsTrigger value={GENERAL_TAB}>
<InfoIcon size={16} />
{t("accessGroups.form.tabs.general")}
@ -119,6 +122,10 @@ export function AccessGroupBaseForm({
<SparklesIcon size={16} />
{t("accessGroups.form.tabs.skills")}
</TabsTrigger>
<TabsTrigger value={VECTOR_STORES_TAB}>
<DatabaseIcon size={16} />
{t("accessGroups.form.tabs.vectorStores")}
</TabsTrigger>
</TabsList>
<TabsContent value={GENERAL_TAB} className="pt-4">
@ -198,6 +205,26 @@ export function AccessGroupBaseForm({
)}
</FormField>
</TabsContent>
<TabsContent value={VECTOR_STORES_TAB} className="pt-4">
<FormField
control={form.control}
name="vectorStoreIds"
label={t("accessGroups.form.vectorStores.label")}
description={t("accessGroups.form.vectorStores.description")}
>
{({ id, value, onChange, "aria-describedby": describedBy }) => (
<VectorStoreSelector
id={id}
aria-describedby={describedBy}
onChange={onChange}
value={value}
accessToken={accessToken || ""}
placeholder={t("accessGroups.form.vectorStores.placeholder")}
/>
)}
</FormField>
</TabsContent>
</Tabs>
);
}

View file

@ -45,6 +45,10 @@ vi.mock("@/components/skills/SkillSelector", () => ({
),
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => ({ accessToken: "sk-test" }),
}));
vi.mock("@/lib/toast", () => ({
toast: { success: vi.fn(), fromError: vi.fn(), error: vi.fn() },
}));
@ -61,6 +65,8 @@ const accessGroup: AccessGroupResponse = {
mcp_tool_permissions: { "srv-1": ["read_file"] },
access_agent_ids: ["agent-1"],
access_skill_names: ["old-plugin"],
access_vector_store_ids: ["store-old"],
access_vector_stores: [{ id: "store-old", name: "Old store" }],
assigned_team_ids: [],
assigned_key_ids: [],
access_mcp_servers: [{ id: "srv-1", name: "Server One" }],
@ -80,10 +86,23 @@ const save = async (user: User) => user.click(screen.getByRole("button", { name:
const variables = () => mutate.mock.calls.at(-1)?.[0] as { accessGroupId: string; params: Record<string, unknown> };
const pickVectorStore = async (user: User, label: string) => {
await screen.findByLabelText("Old store (store-old)");
await user.click(screen.getByLabelText("Allowed Vector Stores"));
await user.click(await screen.findByRole("option", { name: (optionName) => optionName.includes(label) }));
await user.keyboard("{Escape}");
};
describe("AccessGroupEditModal submit payload", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]);
vi.mocked(networking.vectorStoreListCall).mockResolvedValue({
data: [
{ vector_store_id: "store-old", vector_store_name: "Old store" },
{ vector_store_id: "store-new", vector_store_name: "New store" },
],
});
vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]);
vi.mocked(networking.fetchMCPServers).mockResolvedValue([
{ server_id: "srv-1", server_name: "Files", alias: "Files" },
@ -115,6 +134,7 @@ describe("AccessGroupEditModal submit payload", () => {
access_mcp_server_ids: undefined,
access_agent_ids: undefined,
access_skill_names: undefined,
access_vector_store_ids: undefined,
});
});
@ -145,6 +165,72 @@ describe("AccessGroupEditModal submit payload", () => {
expect(variables().params.access_skill_names).toStrictEqual([]);
});
it("sends the vector stores picked on the Vector Stores tab", async () => {
const user = setup();
renderModal();
await screen.findByDisplayValue("Engineering");
await user.click(screen.getByRole("tab", { name: /Vector Stores/ }));
await pickVectorStore(user, "New store (store-new)");
await save(user);
await waitFor(() => expect(mutate).toHaveBeenCalled());
expect(variables().params.access_vector_store_ids).toStrictEqual(["store-old", "store-new"]);
expect(variables().params.access_skill_names).toBeUndefined();
});
it("ties the vector store picker to its label and to the text that explains the limit", async () => {
const user = setup();
renderModal();
await screen.findByDisplayValue("Engineering");
await user.click(screen.getByRole("tab", { name: /Vector Stores/ }));
expect(screen.getByLabelText("Allowed Vector Stores")).toHaveAccessibleDescription(
/adding one here limits a team or key that has no limit today/,
);
});
it("sends the saved vector stores unchanged when the tab is opened and nothing is picked", async () => {
const user = setup();
renderModal();
await screen.findByDisplayValue("Engineering");
await user.click(screen.getByRole("tab", { name: /Vector Stores/ }));
await screen.findByLabelText("Old store (store-old)");
await save(user);
await waitFor(() => expect(mutate).toHaveBeenCalled());
expect(variables().params.access_vector_store_ids).toStrictEqual(["store-old"]);
});
it("keeps a saved vector store that the store list no longer returns", async () => {
const user = setup();
renderModal({ ...accessGroup, access_vector_store_ids: ["retired-store", "store-old"] });
await screen.findByDisplayValue("Engineering");
await user.click(screen.getByRole("tab", { name: /Vector Stores/ }));
expect(await screen.findByLabelText("retired-store")).toHaveAttribute("data-slot", "combobox-chip");
await pickVectorStore(user, "New store (store-new)");
await save(user);
await waitFor(() => expect(mutate).toHaveBeenCalled());
expect(variables().params.access_vector_store_ids).toStrictEqual(["retired-store", "store-old", "store-new"]);
});
it("sends an empty list when every vector store is removed on the Vector Stores tab", async () => {
const user = setup();
renderModal();
await screen.findByDisplayValue("Engineering");
await user.click(screen.getByRole("tab", { name: /Vector Stores/ }));
await user.click(within(await screen.findByLabelText("Old store (store-old)")).getByRole("button"));
await save(user);
await waitFor(() => expect(mutate).toHaveBeenCalled());
expect(variables().params.access_vector_store_ids).toStrictEqual([]);
});
it("sends a tab's field only once that tab has been visited", async () => {
const user = setup();
renderModal();
@ -163,6 +249,7 @@ describe("AccessGroupEditModal submit payload", () => {
access_mcp_server_ids: ["srv-1"],
access_agent_ids: ["agent-1"],
access_skill_names: undefined,
access_vector_store_ids: undefined,
});
});

View file

@ -18,6 +18,7 @@ import {
GENERAL_TAB,
MCP_SERVERS_TAB,
MODELS_TAB,
VECTOR_STORES_TAB,
type AccessGroupFormValues,
} from "./AccessGroupBaseForm";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@ -37,6 +38,7 @@ const toFormValues = (accessGroup: AccessGroupResponse): AccessGroupFormValues =
mcpToolPermissions: accessGroup.mcp_tool_permissions ?? {},
agentIds: accessGroup.access_agent_ids ?? [],
skillNames: accessGroup.access_skill_names ?? [],
vectorStoreIds: accessGroup.access_vector_store_ids ?? [],
});
function AccessGroupEditForm({ accessGroup, onCancel, onSuccess }: Omit<AccessGroupEditModalProps, "visible">) {
@ -62,6 +64,7 @@ function AccessGroupEditForm({ accessGroup, onCancel, onSuccess }: Omit<AccessGr
...(dirtyFields.mcpToolPermissions && { mcp_tool_permissions: values.mcpToolPermissions }),
access_agent_ids: visitedTabs.has(AGENTS_TAB) ? values.agentIds : undefined,
access_skill_names: visitedTabs.has(SKILLS_TAB) ? values.skillNames : undefined,
access_vector_store_ids: visitedTabs.has(VECTOR_STORES_TAB) ? values.vectorStoreIds : undefined,
};
editMutation.mutate(

View file

@ -14,6 +14,11 @@ const mockAccessGroups: AccessGroupResponse[] = [
access_mcp_server_ids: ["s1"],
access_agent_ids: ["a1"],
access_skill_names: [],
access_vector_store_ids: ["store-a", "store-b"],
access_vector_stores: [
{ id: "store-a", name: null },
{ id: "store-b", name: null },
],
assigned_team_ids: [],
assigned_key_ids: [],
access_mcp_servers: [{ id: "s1", name: "Server One" }],
@ -33,6 +38,8 @@ const mockAccessGroups: AccessGroupResponse[] = [
access_mcp_server_ids: [],
access_agent_ids: [],
access_skill_names: [],
access_vector_store_ids: [],
access_vector_stores: [],
assigned_team_ids: [],
assigned_key_ids: [],
access_mcp_servers: [],
@ -105,6 +112,22 @@ describe("AccessGroupsPage", () => {
mockUseAuthorized.mockReturnValue({ userRole: "Admin", accessToken: "sk-test" });
});
it("shows how many vector stores each group grants", () => {
renderWithProviders(<AccessGroupsPage />);
expect(screen.getByTitle("2 Vector Stores")).toHaveTextContent("2");
expect(screen.getByTitle("0 Vector Stores")).toHaveTextContent("0");
});
it("counts no vector stores for a group from an older proxy that sends no list", () => {
const { access_vector_store_ids: _unsent, ...olderGroup } = mockAccessGroups[0];
mockUseAccessGroups.mockReturnValue({ data: [olderGroup as AccessGroupResponse], isLoading: false });
renderWithProviders(<AccessGroupsPage />);
expect(screen.getByTitle("0 Vector Stores")).toHaveTextContent("0");
});
it("renders the page title and subtitle", () => {
renderWithProviders(<AccessGroupsPage />);
expect(screen.getByRole("heading", { name: "Access Groups" })).toBeInTheDocument();

View file

@ -24,6 +24,7 @@ function mapResponseToAccessGroup(r: AccessGroupResponse): AccessGroup {
mcpServerIds: r.access_mcp_server_ids,
agentIds: r.access_agent_ids,
skillNames: r.access_skill_names ?? [],
vectorStoreIds: r.access_vector_store_ids ?? [],
keyIds: r.assigned_key_ids,
teamIds: r.assigned_team_ids,
createdAt: r.created_at,

View file

@ -2,7 +2,7 @@
import { ColumnDef } from "@tanstack/react-table";
import type { TFunction } from "i18next";
import { Bot, Layers, MoreHorizontal, Server, Sparkles, Trash2 } from "lucide-react";
import { Bot, Database, Layers, MoreHorizontal, Server, Sparkles, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { DataTableSortHeader } from "@/components/shared/DataTable";
@ -23,7 +23,7 @@ interface ResourceTone {
className: string;
}
const RESOURCE_TONES: Record<"models" | "mcpServers" | "agents" | "skills", ResourceTone> = {
const RESOURCE_TONES: Record<"models" | "mcpServers" | "agents" | "skills" | "vectorStores", ResourceTone> = {
models: { icon: Layers, className: "bg-info/10 text-info ring-blue-600/20" },
mcpServers: { icon: Server, className: "bg-info/10 text-info ring-cyan-600/20" },
agents: {
@ -36,6 +36,11 @@ const RESOURCE_TONES: Record<"models" | "mcpServers" | "agents" | "skills", Reso
className:
"bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-300 dark:ring-amber-400/30",
},
vectorStores: {
icon: Database,
className:
"bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-950 dark:text-emerald-300 dark:ring-emerald-400/30",
},
};
function ResourcesCell({ group }: { group: AccessGroup }) {
@ -45,6 +50,11 @@ function ResourcesCell({ group }: { group: AccessGroup }) {
{ key: "mcpServers" as const, label: "accessGroups.table.resources.mcpServers", count: group.mcpServerIds.length },
{ key: "agents" as const, label: "accessGroups.table.resources.agents", count: group.agentIds.length },
{ key: "skills" as const, label: "accessGroups.table.resources.skills", count: group.skillNames.length },
{
key: "vectorStores" as const,
label: "accessGroups.table.resources.vectorStores",
count: group.vectorStoreIds.length,
},
] as const;
return (

View file

@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { describe, expect, it, vi, type Mock } from "vitest";
@ -11,6 +11,21 @@ vi.mock("@/components/ModelSelect/ModelSelect", () => ({
</button>
),
}));
vi.mock("@/components/vector_store_management/VectorStoreSelector", () => ({
default: ({
id,
onChange,
"aria-describedby": describedBy,
}: {
id?: string;
onChange: (values: string[]) => void;
"aria-describedby"?: string;
}) => (
<button type="button" id={id} aria-describedby={describedBy} onClick={() => onChange(["store-a"])}>
set-vector-stores
</button>
),
}));
vi.mock("@/app/(dashboard)/hooks/agents/useAgents", () => ({
useAgents: () => ({ data: { agents: [{ agent_id: "agent-1", agent_name: "Support Agent" }] } }),
}));
@ -123,6 +138,24 @@ describe("AccessGroupCreateDialog", () => {
});
});
it("sends the vector stores picked on the Vector Stores tab", async () => {
const user = userEvent.setup();
const { createAccessGroup } = renderDialog();
fireEvent.change(screen.getByLabelText("Group Name"), { target: { value: "kb-readers" } });
await user.click(screen.getByRole("tab", { name: "Vector Stores" }));
const picker = screen.getByLabelText("Allowed Vector Stores");
expect(picker).toHaveAccessibleDescription(/adding one here limits a team or key that has no limit today/);
await user.click(picker);
await user.click(screen.getByRole("button", { name: "Create Group" }));
await waitFor(() => expect(createAccessGroup).toHaveBeenCalledTimes(1));
expect(createAccessGroup.mock.calls[0][0]).toStrictEqual({
access_group_name: "kb-readers",
access_vector_store_ids: ["store-a"],
});
});
it("keeps the dialog open with the entered values when the create fails", async () => {
const user = userEvent.setup();
const { createAccessGroup } = renderDialog({

View file

@ -1,7 +1,7 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { BotIcon, InfoIcon, LayersIcon, ServerIcon, SparklesIcon } from "lucide-react";
import { BotIcon, DatabaseIcon, InfoIcon, LayersIcon, ServerIcon, SparklesIcon } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
@ -20,6 +20,7 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import VectorStoreSelector from "@/components/vector_store_management/VectorStoreSelector";
import { useZodForm } from "@/lib/forms/useZodForm";
import { fetchClient } from "@/lib/http/api";
@ -100,14 +101,14 @@ export const AccessGroupCreateDialog = ({
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-[700px]">
<DialogHeader>
<DialogTitle>{t("accessGroups.create.title")}</DialogTitle>
</DialogHeader>
<form onSubmit={onSubmit} noValidate>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="w-full">
<TabsList className="w-full flex-wrap group-data-horizontal/tabs:h-auto">
<TabsTrigger value={GENERAL_TAB}>
<InfoIcon />
{t("accessGroups.form.tabs.general")}
@ -128,6 +129,10 @@ export const AccessGroupCreateDialog = ({
<SparklesIcon />
{t("accessGroups.form.tabs.skills")}
</TabsTrigger>
<TabsTrigger value="vector-stores">
<DatabaseIcon />
{t("accessGroups.form.tabs.vectorStores")}
</TabsTrigger>
</TabsList>
<TabsContent value={GENERAL_TAB} className="pt-4">
@ -206,6 +211,26 @@ export const AccessGroupCreateDialog = ({
)}
</FormField>
</TabsContent>
<TabsContent value="vector-stores" className="pt-4">
<FormField
control={form.control}
name="vectorStoreIds"
label={t("accessGroups.form.vectorStores.label")}
description={t("accessGroups.form.vectorStores.description")}
>
{({ id, value, onChange, "aria-describedby": describedBy }) => (
<VectorStoreSelector
id={id}
aria-describedby={describedBy}
onChange={onChange}
value={value}
accessToken={accessToken || ""}
placeholder={t("accessGroups.form.vectorStores.placeholder")}
/>
)}
</FormField>
</TabsContent>
</Tabs>
<DialogFooter className="mt-6">

View file

@ -25,6 +25,7 @@ describe("buildAccessGroupCreateBody", () => {
mcpToolPermissions: { "srv-1": ["read_file"], "srv-2": [] },
agentIds: ["agent-1"],
skillNames: ["private-plugin"],
vectorStoreIds: ["store-a"],
}),
).toStrictEqual({
access_group_name: "g",
@ -34,6 +35,7 @@ describe("buildAccessGroupCreateBody", () => {
mcp_tool_permissions: { "srv-1": ["read_file"], "srv-2": [] },
access_agent_ids: ["agent-1"],
access_skill_names: ["private-plugin"],
access_vector_store_ids: ["store-a"],
});
});

View file

@ -12,6 +12,7 @@ export const emptyAccessGroupFormValues: AccessGroupCreateFormValues = {
mcpToolPermissions: {},
agentIds: [],
skillNames: [],
vectorStoreIds: [],
};
export const buildAccessGroupCreateBody = (values: AccessGroupCreateFormValues): AccessGroupCreateBody => ({
@ -23,4 +24,5 @@ export const buildAccessGroupCreateBody = (values: AccessGroupCreateFormValues):
Object.keys(values.mcpToolPermissions).length > 0 && { mcp_tool_permissions: values.mcpToolPermissions }),
...(values.agentIds.length > 0 && { access_agent_ids: values.agentIds }),
...(values.skillNames.length > 0 && { access_skill_names: values.skillNames }),
...(values.vectorStoreIds.length > 0 && { access_vector_store_ids: values.vectorStoreIds }),
});

View file

@ -10,6 +10,7 @@ export const accessGroupCreateSchema = z.object({
mcpToolPermissions: z.record(z.string(), z.array(z.string())),
agentIds: z.array(z.string()),
skillNames: z.array(z.string()),
vectorStoreIds: z.array(z.string()),
});
export type AccessGroupCreateFormValues = z.output<typeof accessGroupCreateSchema>;

View file

@ -6,6 +6,7 @@ export interface AccessGroup {
mcpServerIds: string[];
agentIds: string[];
skillNames: string[];
vectorStoreIds: string[];
keyIds: string[];
teamIds: string[];
createdAt: string;

View file

@ -45,6 +45,8 @@ const mockAccessGroups: AccessGroupResponse[] = [
access_mcp_server_ids: [],
access_agent_ids: [],
access_skill_names: [],
access_vector_store_ids: [],
access_vector_stores: [],
assigned_team_ids: [],
assigned_key_ids: [],
access_mcp_servers: [],

View file

@ -12,6 +12,7 @@ export interface AccessGroupUpdateParams {
access_mcp_server_ids?: string[];
access_agent_ids?: string[];
access_skill_names?: string[];
access_vector_store_ids?: string[];
assigned_team_ids?: string[];
assigned_key_ids?: string[];
}

View file

@ -2,12 +2,33 @@ import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import ObjectPermissionsView from "./object_permissions_view";
vi.mock("./permissions/VectorStorePermissions", () => ({ default: () => <div data-testid="vector-stores" /> }));
vi.mock("./permissions/VectorStorePermissions", () => ({
default: ({ inheritedVectorStores }: { inheritedVectorStores?: { id: string }[] }) => (
<div data-testid="vector-stores">{(inheritedVectorStores ?? []).map((grant) => grant.id).join(",")}</div>
),
}));
vi.mock("./permissions/MCPServerPermissions", () => ({ default: () => <div data-testid="mcp-servers" /> }));
vi.mock("./permissions/AgentPermissions", () => ({ default: () => <div data-testid="agents" /> }));
const objectPermission = { object_permission_id: "perm-1", skills: [] as string[] };
describe("ObjectPermissionsView vector stores card", () => {
it("hands the card the stores that come from access groups", () => {
render(
<ObjectPermissionsView
objectPermission={objectPermission}
inheritedVectorStores={[
{ id: "store-a", accessGroupNames: ["kb-readers"] },
{ id: "store-b", accessGroupNames: ["kb-readers"] },
]}
variant="inline"
/>,
);
expect(screen.getByTestId("vector-stores")).toHaveTextContent("store-a,store-b");
});
});
describe("ObjectPermissionsView skills card", () => {
it("shows plugin names granted only through access groups with their source", () => {
render(

View file

@ -11,6 +11,7 @@ interface ObjectPermissionsViewProps {
inheritedMcpServers?: InheritedGrant[];
inheritedAgents?: InheritedGrant[];
inheritedSkills?: InheritedGrant[];
inheritedVectorStores?: InheritedGrant[];
unresolvedAccessGroupIds?: string[] | null;
variant?: "card" | "inline";
className?: string;
@ -22,6 +23,7 @@ export function ObjectPermissionsView({
inheritedMcpServers = [],
inheritedAgents = [],
inheritedSkills,
inheritedVectorStores,
unresolvedAccessGroupIds,
variant = "card",
className = "",
@ -78,7 +80,11 @@ export function ObjectPermissionsView({
const content = (
<div className={variant === "card" ? "grid grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 gap-6" : "space-y-4"}>
<VectorStorePermissions vectorStores={vectorStores} accessToken={accessToken} />
<VectorStorePermissions
vectorStores={vectorStores}
inheritedVectorStores={inheritedVectorStores}
accessToken={accessToken}
/>
<MCPServerPermissions
mcpServers={mcpServers}
mcpAccessGroups={mcpAccessGroups}

View file

@ -0,0 +1,64 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as networking from "../networking";
import VectorStorePermissions from "./VectorStorePermissions";
vi.mock("../networking");
const accessToken = "test-token";
describe("VectorStorePermissions", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(networking.vectorStoreListCall).mockResolvedValue({
data: [
{ vector_store_id: "store-a", vector_store_name: "Parks knowledge base" },
{ vector_store_id: "store-b", vector_store_name: "HR policies" },
],
});
});
it("lists stores that come only from access groups, counts them, and names the groups", async () => {
render(
<VectorStorePermissions
vectorStores={[]}
inheritedVectorStores={[{ id: "store-a", accessGroupNames: ["kb-readers", "support"] }]}
accessToken={accessToken}
/>,
);
const chip = await screen.findByTitle("Granted via access groups kb-readers, support. Full ID: store-a");
expect(chip).toHaveTextContent("Parks knowledge base (store-a) (access group)");
expect(screen.getByText("1")).toBeInTheDocument();
expect(screen.queryByText("No vector stores configured")).not.toBeInTheDocument();
expect(networking.vectorStoreListCall).toHaveBeenCalledWith(accessToken);
});
it("lists a store once, as the team's own, when a group grants it too", async () => {
render(
<VectorStorePermissions
vectorStores={["store-a"]}
inheritedVectorStores={[
{ id: "store-a", accessGroupNames: ["kb-readers"] },
{ id: "store-b", accessGroupNames: ["kb-readers"] },
]}
accessToken={accessToken}
/>,
);
expect(await screen.findByText("Parks knowledge base (store-a)")).toBeInTheDocument();
expect(screen.getByTitle("Granted via access group kb-readers. Full ID: store-b")).toHaveTextContent(
"HR policies (store-b) (access group)",
);
expect(screen.queryByTitle(/Full ID: store-a/)).not.toBeInTheDocument();
expect(screen.getByText("2")).toBeInTheDocument();
});
it("keeps the empty state and skips the store list when nothing is granted", () => {
render(<VectorStorePermissions vectorStores={[]} accessToken={accessToken} />);
expect(screen.getByText("No vector stores configured")).toBeInTheDocument();
expect(screen.getByText("0")).toBeInTheDocument();
expect(networking.vectorStoreListCall).not.toHaveBeenCalled();
});
});

View file

@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { DatabaseIcon } from "@heroicons/react/outline";
import { Badge } from "@/components/ui/badge";
import { vectorStoreListCall } from "../networking";
import { InheritedGrant, inheritedGrantTooltip } from "./inheritedGrants";
interface VectorStoreDetails {
vector_store_id: string;
@ -11,17 +12,27 @@ interface VectorStoreDetails {
interface VectorStorePermissionsProps {
vectorStores: string[];
inheritedVectorStores?: InheritedGrant[];
accessToken?: string | null;
}
export function VectorStorePermissions({ vectorStores, accessToken }: VectorStorePermissionsProps) {
const STORE_CHIP =
"inline-flex min-w-0 items-center px-3 py-1.5 rounded-lg bg-info/10 border border-info/20 text-info text-sm font-medium break-words";
export function VectorStorePermissions({
vectorStores,
inheritedVectorStores = [],
accessToken,
}: VectorStorePermissionsProps) {
const { t } = useTranslation();
const [vectorStoreDetails, setVectorStoreDetails] = useState<VectorStoreDetails[]>([]);
const inheritedOnlyStores = inheritedVectorStores.filter((grant) => !vectorStores.includes(grant.id));
const storeCount = vectorStores.length + inheritedOnlyStores.length;
// Fetch vector store details when component mounts
useEffect(() => {
const fetchVectorStores = async () => {
if (!accessToken || vectorStores.length === 0) return;
if (!accessToken || storeCount === 0) return;
try {
const response = await vectorStoreListCall(accessToken);
@ -39,7 +50,7 @@ export function VectorStorePermissions({ vectorStores, accessToken }: VectorStor
};
fetchVectorStores();
}, [accessToken, vectorStores.length]);
}, [accessToken, storeCount]);
// Function to get display name for vector store
const getVectorStoreDisplayName = (storeId: string) => {
@ -55,19 +66,22 @@ export function VectorStorePermissions({ vectorStores, accessToken }: VectorStor
<div className="flex items-center gap-2">
<DatabaseIcon className="h-4 w-4 text-info" />
<p className="text-sm font-semibold text-foreground">{t("permissions.vectorStorePermissions.title")}</p>
<Badge variant="secondary">{vectorStores.length}</Badge>
<Badge variant="secondary">{storeCount}</Badge>
</div>
{vectorStores.length > 0 ? (
{storeCount > 0 ? (
<div className="flex flex-wrap gap-2">
{vectorStores.map((store, index) => (
<div
key={index}
className="inline-flex min-w-0 items-center px-3 py-1.5 rounded-lg bg-info/10 border border-info/20 text-info text-sm font-medium break-words"
>
<div key={index} className={STORE_CHIP}>
{getVectorStoreDisplayName(store)}
</div>
))}
{inheritedOnlyStores.map((grant) => (
<div key={grant.id} title={inheritedGrantTooltip(grant, t)} className={STORE_CHIP}>
{getVectorStoreDisplayName(grant.id)}
{t("permissions.vectorStorePermissions.accessGroupSuffix")}
</div>
))}
</div>
) : (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted border border-border">

View file

@ -69,6 +69,17 @@ describe("MultiSelect", () => {
expect(screen.getByLabelText("beta-kb (vs-beta)")).toBeInTheDocument();
});
it("points the input at the text that describes it", () => {
render(
<>
<MultiSelect options={OPTIONS} onValueChange={vi.fn()} aria-describedby="stores-help" />
<p id="stores-help">Limits the team to these stores.</p>
</>,
);
expect(screen.getByRole("combobox")).toHaveAccessibleDescription("Limits the team to these stores.");
});
it("labels an unknown selected value with its raw id", () => {
renderMultiSelect({ value: ["vs-deleted"] });

View file

@ -26,6 +26,7 @@ export interface MultiSelectOption {
interface MultiSelectProps {
id?: string;
"aria-describedby"?: string;
options: MultiSelectOption[];
value?: string[];
onValueChange: (value: string[]) => void;
@ -56,6 +57,7 @@ const matchesQuery = (option: MultiSelectOption, query: string): boolean => {
export function MultiSelect({
id,
"aria-describedby": describedBy,
options,
value = [],
onValueChange,
@ -124,6 +126,7 @@ export function MultiSelect({
))}
<ComboboxChipsInput
id={id}
aria-describedby={describedBy}
placeholder={loading ? t("shared.multiSelect.loading") : placeholder}
className="min-w-24"
aria-label={placeholder || undefined}

View file

@ -992,3 +992,54 @@ describe("TeamInfoView - model settings", () => {
expect(screen.queryByRole("option", { name: "gpt-4o" })).not.toBeInTheDocument();
});
});
describe("TeamInfoView - vector stores from access groups", () => {
beforeEach(() => {
seedDefaultMocks();
});
afterEach(() => {
vi.clearAllMocks();
vi.mocked(networking.vectorStoreListCall).mockResolvedValue({ data: [] });
});
it("lists the stores a team gets through its access groups and names the group", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(networking.vectorStoreListCall).mockResolvedValue({
data: [{ vector_store_id: "store-a", vector_store_name: "Parks knowledge base" }],
});
vi.mocked(networking.teamInfoCall).mockResolvedValue(
createMockTeamData({
access_group_ids: ["ag-1"],
access_group_vector_store_ids: ["store-a"],
access_group_details: [
{ access_group_id: "ag-1", access_group_name: "kb-readers", models: [], vector_store_ids: ["store-a"] },
],
}) as TeamData,
);
renderWithProviders(
<TeamInfoView
teamId="123"
onUpdate={vi.fn()}
onClose={vi.fn()}
accessToken="test-token"
is_team_admin
is_proxy_admin
editTeam={false}
userModels={[]}
/>,
);
expect(await screen.findByTitle("Granted via access group kb-readers. Full ID: store-a")).toHaveTextContent(
"Parks knowledge base (store-a) (access group)",
);
expect(screen.queryByText("No vector stores configured")).not.toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "Settings" }));
await waitFor(() =>
expect(screen.getAllByTitle("Granted via access group kb-readers. Full ID: store-a")).toHaveLength(2),
);
});
});

View file

@ -340,6 +340,7 @@ export interface TeamData {
access_group_mcp_server_ids?: string[];
access_group_agent_ids?: string[];
access_group_skill_names?: string[];
access_group_vector_store_ids?: string[];
unresolved_access_group_ids?: string[] | null;
access_group_details?: TeamAccessGroupModelGrant[];
router_settings?: Record<string, any>;
@ -1370,6 +1371,11 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
info.access_group_details,
(grant) => grant.skill_names,
);
const inheritedVectorStores = computeInheritedGrants(
info.access_group_vector_store_ids,
info.access_group_details,
(grant) => grant.vector_store_ids,
);
const initialKillSwitchOn = info.metadata?.disable_global_guardrails === true;
@ -1538,6 +1544,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
inheritedMcpServers={inheritedMcpServers}
inheritedAgents={inheritedAgents}
inheritedSkills={inheritedSkills}
inheritedVectorStores={inheritedVectorStores}
unresolvedAccessGroupIds={info.unresolved_access_group_ids}
variant="card"
accessToken={accessToken}
@ -2643,6 +2650,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
inheritedMcpServers={inheritedMcpServers}
inheritedAgents={inheritedAgents}
inheritedSkills={inheritedSkills}
inheritedVectorStores={inheritedVectorStores}
unresolvedAccessGroupIds={info.unresolved_access_group_ids}
variant="inline"
className="pt-4 border-t border-border"

View file

@ -14,6 +14,7 @@ export interface TeamAccessGroupModelGrant {
mcp_server_ids?: string[];
agent_ids?: string[];
skill_names?: string[];
vector_store_ids?: string[];
}
export type TeamModelBadgeKind = "all-proxy" | "no-default" | "direct" | "access-group";

View file

@ -4,6 +4,8 @@ import { vectorStoreListCall } from "../networking";
import { MultiSelect } from "@/components/shared/MultiSelect";
import i18n from "@/i18n/i18n";
interface VectorStoreSelectorProps {
id?: string;
"aria-describedby"?: string;
onChange: (selectedVectorStores: string[]) => void;
value?: string[];
className?: string;
@ -13,6 +15,8 @@ interface VectorStoreSelectorProps {
}
const VectorStoreSelector: React.FC<VectorStoreSelectorProps> = ({
id,
"aria-describedby": describedBy,
onChange,
value,
className,
@ -46,6 +50,8 @@ const VectorStoreSelector: React.FC<VectorStoreSelectorProps> = ({
return (
<div className="min-w-0">
<MultiSelect
id={id}
aria-describedby={describedBy}
placeholder={placeholder}
onValueChange={onChange}
value={value}

View file

@ -2914,7 +2914,8 @@
},
"vectorStorePermissions": {
"title": "Vector Stores",
"empty": "No vector stores configured"
"empty": "No vector stores configured",
"accessGroupSuffix": " (access group)"
},
"inheritedGrants": {
"anAccessGroup": "an access group",
@ -6428,7 +6429,8 @@
"models": "{{count}} Models",
"mcpServers": "{{count}} MCP Servers",
"agents": "{{count}} Agents",
"skills": "{{count}} Skills"
"skills": "{{count}} Skills",
"vectorStores": "{{count}} Vector Stores"
},
"rowActions": {
"open": "Open access group actions",
@ -6456,13 +6458,15 @@
"models": "Models",
"mcpServers": "MCP Servers",
"agents": "Agents",
"skills": "Skills"
"skills": "Skills",
"vectorStores": "Vector Stores"
},
"empty": {
"models": "No models assigned to this group",
"mcpServers": "No MCP servers assigned to this group",
"agents": "No agents assigned to this group",
"skills": "No skills assigned to this group"
"skills": "No skills assigned to this group",
"vectorStores": "No vector stores assigned to this group"
},
"toolPermissions": {
"label": "MCP tool permissions",
@ -6476,7 +6480,8 @@
"models": "Models",
"mcpServers": "MCP Servers",
"agents": "Agents",
"skills": "Skills"
"skills": "Skills",
"vectorStores": "Vector Stores"
},
"name": {
"label": "Group Name",
@ -6502,6 +6507,11 @@
"label": "Allowed Skills",
"description": "Enabled skills are visible to everyone. Grant disabled (private) Claude Code plugins to the teams and keys in this group here.",
"placeholder": "Select skills (optional)"
},
"vectorStores": {
"label": "Allowed Vector Stores",
"description": "Teams and keys in this group can use these vector stores, as if they were on their own list. A team or key with any allowed vector store can use only its allowed ones, so adding one here limits a team or key that has no limit today.",
"placeholder": "Select vector stores (optional)"
}
},
"create": {