mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
feat(ui): show and pick vector stores for access groups (#51581)
This commit is contained in:
parent
46716fad56
commit
2a4e90c35e
27 changed files with 493 additions and 25 deletions
|
|
@ -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: [] });
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export interface AccessGroup {
|
|||
mcpServerIds: string[];
|
||||
agentIds: string[];
|
||||
skillNames: string[];
|
||||
vectorStoreIds: string[];
|
||||
keyIds: string[];
|
||||
teamIds: string[];
|
||||
createdAt: string;
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"] });
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue