mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-16 06:14:12 +00:00
Roles audit and revamp
This commit is contained in:
1 parent
208d34abc7
commit
98afa5d93c
134 files changed
+13725
-1702
No files matched your search
+4
-1
@@ -890,7 +890,10 @@ line, a trailing control) is `ListRow` inside `ListRows` (`divide-y
|
||||
divide-border`, no box of its own; wrap it in `Card padding="none"` or a
|
||||
bordered list for one). Rows are `px-4 py-3`, the title `text-sm
|
||||
font-medium`. `interactive` (with `asChild` around a `<Link>` or `<button>`)
|
||||
hovers to `bg-accent` and draws an inset focus ring. An icon square in
|
||||
hovers to `bg-accent` and draws an inset focus ring. `selected` marks the
|
||||
row whose detail is open in a drawer beside the list (a team's shared
|
||||
resources): the `bg-secondary` tint of a selected TableRow, kept on hover,
|
||||
with `aria-current`. An icon square in
|
||||
`leading` is a plain `bg-muted text-muted-foreground size-8 rounded-md` span.
|
||||
In a narrow side panel (the graph node panel's relationships) rows are
|
||||
`size="sm"`: `px-2 py-1.5`, `gap-2.5`, `rounded-md` and top-aligned so a small
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
agentEditPathFor,
|
||||
sharedAgentPath,
|
||||
} from './agents/paths';
|
||||
import { canOpenAgentEditor } from './agents/agentAccess';
|
||||
import { Agent } from './agents/types';
|
||||
import conversationService from './api/services/conversationService';
|
||||
import userService from './api/services/userService';
|
||||
@@ -391,9 +392,8 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
const currentConversation = conversationId
|
||||
? conversations?.data?.find((c) => c.id === conversationId)
|
||||
: undefined;
|
||||
const ownsSelectedAgent = Boolean(
|
||||
selectedAgent?.id && agents?.some((a) => a.id === selectedAgent.id),
|
||||
);
|
||||
// Edit agent is offered to a role that may open the edit page.
|
||||
const canEditSelectedAgent = canOpenAgentEditor(selectedAgent, agents);
|
||||
const mobileTitle = routeSection
|
||||
? undefined
|
||||
: (currentConversation?.name ?? selectedAgent?.name);
|
||||
@@ -863,7 +863,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
|
||||
onRename={updateConversationName}
|
||||
onDelete={handleDeleteConversation}
|
||||
editAgentPath={
|
||||
!routeSection && ownsSelectedAgent && selectedAgent
|
||||
!routeSection && canEditSelectedAgent && selectedAgent
|
||||
? agentEditPathFor(selectedAgent)
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
dispatch: vi.fn(),
|
||||
goToLevel: vi.fn(),
|
||||
deleteAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: (selector: (state: unknown) => unknown) =>
|
||||
selector({ preference: { token: null, agents: [] } }),
|
||||
useDispatch: () => mocks.dispatch,
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: { deleteAgent: mocks.deleteAgent },
|
||||
}));
|
||||
|
||||
vi.mock('../navigation/SidebarLevelProvider', () => ({
|
||||
useSidebarLevel: () => ({ goToLevel: mocks.goToLevel }),
|
||||
}));
|
||||
|
||||
vi.mock('../modals/MoveToFolderModal', () => ({ default: () => null }));
|
||||
vi.mock('../teams/ShareToTeamModal', () => ({ default: () => null }));
|
||||
vi.mock('../modals/ConfirmationModal', () => ({
|
||||
default: ({
|
||||
modalState,
|
||||
handleSubmit,
|
||||
}: {
|
||||
modalState: string;
|
||||
handleSubmit: () => void;
|
||||
}) =>
|
||||
modalState === 'ACTIVE' ? (
|
||||
<button type="button" data-testid="confirm" onClick={handleSubmit} />
|
||||
) : null,
|
||||
}));
|
||||
|
||||
import AgentCard from './AgentCard';
|
||||
import type { Agent } from './types';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const OWNER_ACTIONS = [
|
||||
'delete',
|
||||
'edit',
|
||||
'edit_policy',
|
||||
'export',
|
||||
'manage_access_details',
|
||||
'manage_schedules',
|
||||
'manage_settings',
|
||||
'move_folder',
|
||||
'pin',
|
||||
'publish',
|
||||
'share',
|
||||
'use',
|
||||
'view',
|
||||
'view_logs',
|
||||
];
|
||||
const EDITOR_ACTIONS = [
|
||||
'edit',
|
||||
'edit_policy',
|
||||
'export',
|
||||
'manage_access_details',
|
||||
'manage_schedules',
|
||||
'pin',
|
||||
'publish',
|
||||
'use',
|
||||
'view',
|
||||
'view_logs',
|
||||
];
|
||||
const VIEWER_ACTIONS = ['pin', 'use'];
|
||||
|
||||
const agentWith = (
|
||||
access: 'owner' | 'editor' | 'viewer',
|
||||
allowed: string[],
|
||||
): Agent =>
|
||||
({
|
||||
id: 'a1',
|
||||
name: 'Deal Desk',
|
||||
description: 'Researches deals',
|
||||
status: 'published',
|
||||
agent_type: 'classic',
|
||||
ownership: access === 'owner' ? 'user' : 'team',
|
||||
team_access: access === 'owner' ? null : access,
|
||||
access,
|
||||
allowed_actions: allowed,
|
||||
}) as Agent;
|
||||
|
||||
describe('AgentCard menu', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
mocks.dispatch.mockClear();
|
||||
mocks.deleteAgent.mockReset();
|
||||
});
|
||||
|
||||
const render = async (agent: Agent, section: string) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<AgentCard agent={agent} agents={[agent]} section={section} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const openMenu = async () => {
|
||||
const trigger = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="agents.card.actions"]',
|
||||
);
|
||||
if (!trigger) return [];
|
||||
await act(async () => {
|
||||
trigger.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
|
||||
);
|
||||
});
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
);
|
||||
};
|
||||
|
||||
const menuLabels = async () =>
|
||||
(await openMenu()).map((item) => item.textContent);
|
||||
|
||||
it('gives the owner the full menu', async () => {
|
||||
await render(agentWith('owner', OWNER_ACTIONS), 'user');
|
||||
expect(await menuLabels()).toEqual([
|
||||
'agents.form.buttons.logs',
|
||||
'agents.edit',
|
||||
'agents.exportAgent',
|
||||
'agents.shareWithTeam',
|
||||
'agents.card.pin',
|
||||
'agents.folders.moveToFolder',
|
||||
'agents.form.buttons.delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives an editor Logs, Edit, Export and Pin', async () => {
|
||||
await render(agentWith('editor', EDITOR_ACTIONS), 'team');
|
||||
expect(await menuLabels()).toEqual([
|
||||
'agents.form.buttons.logs',
|
||||
'agents.edit',
|
||||
'agents.exportAgent',
|
||||
'agents.card.pin',
|
||||
]);
|
||||
});
|
||||
|
||||
it('brings Share and Delete back for an editor the owner allows', async () => {
|
||||
await render(
|
||||
agentWith('editor', [...EDITOR_ACTIONS, 'share', 'delete']),
|
||||
'team',
|
||||
);
|
||||
expect(await menuLabels()).toEqual([
|
||||
'agents.form.buttons.logs',
|
||||
'agents.edit',
|
||||
'agents.exportAgent',
|
||||
'agents.shareWithTeam',
|
||||
'agents.card.pin',
|
||||
'agents.form.buttons.delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives a viewer Pin only', async () => {
|
||||
await render(agentWith('viewer', VIEWER_ACTIONS), 'team');
|
||||
expect(await menuLabels()).toEqual(['agents.card.pin']);
|
||||
});
|
||||
|
||||
it('adds Logs for a viewer when the owner shares logs', async () => {
|
||||
await render(agentWith('viewer', [...VIEWER_ACTIONS, 'view_logs']), 'team');
|
||||
expect(await menuLabels()).toEqual([
|
||||
'agents.form.buttons.logs',
|
||||
'agents.card.pin',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows no menu to a viewer of a draft', async () => {
|
||||
await render(
|
||||
{ ...agentWith('viewer', VIEWER_ACTIONS), status: 'draft' },
|
||||
'team',
|
||||
);
|
||||
expect(
|
||||
container.querySelector('button[aria-label="agents.card.actions"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('treats an own agent without access fields as the owner', async () => {
|
||||
const own = {
|
||||
...agentWith('owner', []),
|
||||
access: undefined,
|
||||
allowed_actions: undefined,
|
||||
} as Agent;
|
||||
await render(own, 'user');
|
||||
expect(await menuLabels()).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('hides Logs on an own draft (no runs to show) but keeps the rest', async () => {
|
||||
await render(
|
||||
{ ...agentWith('owner', OWNER_ACTIONS), status: 'draft' },
|
||||
'user',
|
||||
);
|
||||
const labels = await menuLabels();
|
||||
expect(labels).not.toContain('agents.form.buttons.logs');
|
||||
expect(labels).not.toContain('agents.card.pin');
|
||||
expect(labels).toContain('agents.edit');
|
||||
});
|
||||
|
||||
it('gives Discovered cards Pin and Remove; the card itself opens the agent', async () => {
|
||||
await render(
|
||||
{
|
||||
...agentWith('viewer', VIEWER_ACTIONS),
|
||||
shared_token: 'tok',
|
||||
},
|
||||
'shared',
|
||||
);
|
||||
expect(await menuLabels()).toEqual([
|
||||
'agents.card.pin',
|
||||
'agents.card.remove',
|
||||
]);
|
||||
});
|
||||
|
||||
it('opens the chat when a viewer clicks a published card', async () => {
|
||||
await render(agentWith('viewer', VIEWER_ACTIONS), 'team');
|
||||
await act(async () =>
|
||||
container.querySelector<HTMLElement>('[role="button"]')!.click(),
|
||||
);
|
||||
expect(mocks.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'preference/setSelectedAgent' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a refused delete in a toast', async () => {
|
||||
mocks.deleteAgent.mockResolvedValue({
|
||||
ok: false,
|
||||
json: () => Promise.resolve({ message: 'Only the owner can delete' }),
|
||||
});
|
||||
await render(agentWith('owner', OWNER_ACTIONS), 'user');
|
||||
const items = await openMenu();
|
||||
await act(async () =>
|
||||
items
|
||||
.find((i) => i.textContent === 'agents.form.buttons.delete')!
|
||||
.click(),
|
||||
);
|
||||
await act(async () =>
|
||||
container.querySelector<HTMLElement>('[data-testid="confirm"]')!.click(),
|
||||
);
|
||||
expect(mocks.dispatch).toHaveBeenCalledWith({
|
||||
type: 'actionToast/showActionToast',
|
||||
payload: { variant: 'destructive', message: 'Only the owner can delete' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Activity,
|
||||
Copy,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Folder,
|
||||
Pencil,
|
||||
Pin,
|
||||
@@ -25,6 +24,7 @@ import { Modal } from '../components/ui/modal';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import MoveToFolderModal from '../modals/MoveToFolderModal';
|
||||
import { ActiveState } from '../models/misc';
|
||||
import { showActionToast } from '../notifications/actionToastSlice';
|
||||
import { useSidebarLevel } from '../navigation/SidebarLevelProvider';
|
||||
import ShareToTeamModal from '../teams/ShareToTeamModal';
|
||||
import {
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
agentLogsPath,
|
||||
sharedAgentPath,
|
||||
} from './paths';
|
||||
import { can } from '../utils/accessUtils';
|
||||
import { canAgent } from './agentAccess';
|
||||
import { Agent } from './types';
|
||||
|
||||
type AgentCardProps = {
|
||||
@@ -84,6 +86,59 @@ export default function AgentCard({
|
||||
onClick: () => togglePin(),
|
||||
};
|
||||
|
||||
const ownedMenu: MenuOption[] = [
|
||||
...(canAgent(agent, 'view_logs')
|
||||
? [
|
||||
{
|
||||
icon: Activity,
|
||||
label: t('agents.form.buttons.logs'),
|
||||
onClick: () => goToLevel(agentLogsPath(agent.id)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(agent, 'view')
|
||||
? [{ icon: Pencil, label: t('agents.edit'), onClick: openEditor }]
|
||||
: []),
|
||||
...(can(agent, 'export')
|
||||
? [
|
||||
{
|
||||
icon: Download,
|
||||
label: t('agents.exportAgent'),
|
||||
onClick: () => handleExport(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(agent, 'share')
|
||||
? [
|
||||
{
|
||||
icon: Users,
|
||||
label: t('agents.shareWithTeam'),
|
||||
onClick: () => setShareModalOpen(true),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canAgent(agent, 'pin') ? [pinOption] : []),
|
||||
...(can(agent, 'move_folder')
|
||||
? [
|
||||
{
|
||||
icon: Folder,
|
||||
label: t('agents.folders.moveToFolder'),
|
||||
onClick: () => setMoveModalState('ACTIVE'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(can(agent, 'delete')
|
||||
? [
|
||||
{
|
||||
icon: Trash2,
|
||||
label: t('agents.form.buttons.delete'),
|
||||
onClick: () => setDeleteConfirmation('ACTIVE'),
|
||||
variant: 'destructive' as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const menuOptionsConfig: Record<string, MenuOption[]> = {
|
||||
template: [
|
||||
{
|
||||
@@ -92,64 +147,14 @@ export default function AgentCard({
|
||||
onClick: () => handleDuplicate(),
|
||||
},
|
||||
],
|
||||
user: [
|
||||
{
|
||||
icon: Activity,
|
||||
label: t('agents.form.buttons.logs'),
|
||||
onClick: () => goToLevel(agentLogsPath(agent.id)),
|
||||
},
|
||||
{
|
||||
icon: Pencil,
|
||||
label: t('agents.edit'),
|
||||
onClick: openEditor,
|
||||
},
|
||||
{
|
||||
icon: Download,
|
||||
label: t('agents.exportAgent'),
|
||||
onClick: () => handleExport(),
|
||||
},
|
||||
// Sharing is an owner-only action: only show it for agents the user
|
||||
// owns ('user'), not agents shared into their workspace by a team.
|
||||
...(agent.ownership === 'user'
|
||||
? [
|
||||
{
|
||||
icon: Users,
|
||||
label: t('agents.shareWithTeam'),
|
||||
onClick: () => setShareModalOpen(true),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(agent.status === 'published' ? [pinOption] : []),
|
||||
{
|
||||
icon: Folder,
|
||||
label: t('agents.folders.moveToFolder'),
|
||||
onClick: () => setMoveModalState('ACTIVE'),
|
||||
},
|
||||
{
|
||||
icon: Trash2,
|
||||
label: t('agents.form.buttons.delete'),
|
||||
onClick: () => setDeleteConfirmation('ACTIVE'),
|
||||
variant: 'destructive',
|
||||
},
|
||||
],
|
||||
// Agents shared with the user via a team. They don't own it, so only
|
||||
// non-destructive, non-owner actions are offered: open the config
|
||||
// (editors can save, viewers see it read-only) and pin for quick access.
|
||||
// Logs / Export / Share / Move-to-folder / Delete stay owner-only.
|
||||
team: [
|
||||
{
|
||||
icon: Pencil,
|
||||
label: t('agents.edit'),
|
||||
onClick: openEditor,
|
||||
},
|
||||
...(agent.status === 'published' ? [pinOption] : []),
|
||||
],
|
||||
// My agents and the Team section share one menu, built from what the
|
||||
// caller's role allows on this agent (`allowed_actions` from the API).
|
||||
// Editors get Logs, Edit, Export and Pin; viewers only Pin. Share, Move
|
||||
// and Delete stay with the owner unless the owner's switches widen them.
|
||||
user: ownedMenu,
|
||||
team: ownedMenu,
|
||||
// Discovered (link-opened) agents: the card itself opens the agent.
|
||||
shared: [
|
||||
{
|
||||
icon: ExternalLink,
|
||||
label: t('agents.card.open'),
|
||||
onClick: () => navigate(sharedAgentPath(agent.shared_token)),
|
||||
},
|
||||
pinOption,
|
||||
{
|
||||
icon: Trash2,
|
||||
@@ -244,13 +249,31 @@ export default function AgentCard({
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
const response = await userService.deleteAgent(agent.id ?? '', token);
|
||||
if (!response.ok) throw new Error('Failed to delete agent');
|
||||
if (!response.ok) {
|
||||
const message = await response
|
||||
.json()
|
||||
.then((data: { message?: string }) => data?.message)
|
||||
.catch(() => null);
|
||||
dispatch(
|
||||
showActionToast({
|
||||
variant: 'destructive',
|
||||
message: message || t('agents.deleteFailed'),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const updatedAgents = agents.filter(
|
||||
(prevAgent) => prevAgent.id !== agent.id,
|
||||
);
|
||||
updateAgents?.(updatedAgents);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
dispatch(
|
||||
showActionToast({
|
||||
variant: 'destructive',
|
||||
message: t('agents.deleteFailed'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -300,12 +323,14 @@ export default function AgentCard({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ActionMenu
|
||||
options={menuOptions}
|
||||
triggerLabel={t('agents.card.actions')}
|
||||
align="end"
|
||||
className="absolute top-3 right-3 z-10"
|
||||
/>
|
||||
{menuOptions.length > 0 && (
|
||||
<ActionMenu
|
||||
options={menuOptions}
|
||||
triggerLabel={t('agents.card.actions')}
|
||||
align="end"
|
||||
className="absolute top-3 right-3 z-10"
|
||||
/>
|
||||
)}
|
||||
{/* Team access badge — pinned to the top row, left of the ⋯ menu
|
||||
(right-11 clears the 28px trigger at right-3) so the two align. */}
|
||||
{agent.ownership === 'team' && (
|
||||
|
||||
@@ -54,6 +54,31 @@ describe('AgentPageHeader sub-nav', () => {
|
||||
expect(tabs[0].getAttribute('data-active')).not.toBe('true');
|
||||
});
|
||||
|
||||
it('shows only the tabs the role allows', () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<AgentPageHeader
|
||||
agentId="a1"
|
||||
agentName="Renewals"
|
||||
currentPage="overview"
|
||||
access={{
|
||||
access: 'editor',
|
||||
allowed_actions: ['view', 'view_logs'],
|
||||
}}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
const nav = container.querySelector(
|
||||
'nav[aria-label="agents.pageHeader.subnavAriaLabel"]',
|
||||
);
|
||||
expect(Array.from(nav?.children ?? []).map((t) => t.textContent)).toEqual([
|
||||
'agents.pageHeader.tabs.overview',
|
||||
'agents.pageHeader.tabs.logs',
|
||||
]);
|
||||
});
|
||||
|
||||
it('makes the current crumb a button with the avatar and a chevron that opens the details', () => {
|
||||
const onNameClick = vi.fn();
|
||||
act(() => {
|
||||
|
||||
@@ -15,6 +15,8 @@ import { Avatar } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { type AccessFields } from '../utils/accessUtils';
|
||||
import { canAgent } from './agentAccess';
|
||||
import {
|
||||
AGENTS_MANAGE_ROOT,
|
||||
agentEditPath as agentEditPathProp,
|
||||
@@ -47,6 +49,11 @@ type AgentPageHeaderProps = {
|
||||
onNameClick?: () => void;
|
||||
/** A status Badge placed after the crumbs. */
|
||||
status?: ReactNode;
|
||||
/**
|
||||
* The agent's access fields: each tab shows only when the role allows its
|
||||
* page. Omitted (a new workflow, not yet loaded), every tab shows.
|
||||
*/
|
||||
access?: (AccessFields & { status?: string }) | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -69,6 +76,7 @@ export default function AgentPageHeader({
|
||||
agentImage,
|
||||
onNameClick,
|
||||
status,
|
||||
access,
|
||||
}: AgentPageHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -81,20 +89,26 @@ export default function AgentPageHeader({
|
||||
id: 'overview' as const,
|
||||
label: t('agents.pageHeader.tabs.overview'),
|
||||
href: editPath,
|
||||
action: 'view',
|
||||
},
|
||||
{
|
||||
id: 'logs' as const,
|
||||
label: t('agents.pageHeader.tabs.logs'),
|
||||
href: agentId ? agentLogsPath(agentId) : '#',
|
||||
action: 'view_logs',
|
||||
},
|
||||
{
|
||||
id: 'schedules' as const,
|
||||
label: t('agents.pageHeader.tabs.schedules'),
|
||||
href: agentId ? agentSchedulesPath(agentId) : '#',
|
||||
action: 'manage_schedules',
|
||||
},
|
||||
],
|
||||
[agentId, editPath, t],
|
||||
);
|
||||
const visibleTabs = tabs.filter(
|
||||
(tab) => !access || canAgent(access, tab.action),
|
||||
);
|
||||
|
||||
const currentTabLabel =
|
||||
tabs.find((tab) => tab.id === currentPage)?.label ?? '';
|
||||
@@ -182,7 +196,7 @@ export default function AgentPageHeader({
|
||||
!inline && 'border-border border-b',
|
||||
)}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
{visibleTabs.map((tab) => {
|
||||
const isActive = tab.id === currentPage;
|
||||
// -mb-px lays the tab's 2px underline over the nav's 1px baseline.
|
||||
if (isActive) {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
|
||||
const state = {
|
||||
preference: {
|
||||
token: null,
|
||||
agents: [] as unknown[],
|
||||
sharedAgents: [],
|
||||
selectedAgent: null,
|
||||
},
|
||||
};
|
||||
|
||||
const mocks = vi.hoisted(() => ({ getAgent: vi.fn() }));
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: (selector: (s: unknown) => unknown) => selector(state),
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: { getAgent: mocks.getAgent },
|
||||
}));
|
||||
|
||||
import AgentRouteGuard from './AgentRouteGuard';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const respond = (body: unknown, ok = true) =>
|
||||
Promise.resolve({ ok, json: () => Promise.resolve(body) });
|
||||
|
||||
describe('AgentRouteGuard', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
state.preference.agents = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
mocks.getAgent.mockReset();
|
||||
});
|
||||
|
||||
const render = async (action: string, path = '/agents/manage/logs/a1') => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/agents/manage/:page/:agentId"
|
||||
element={
|
||||
<AgentRouteGuard action={action}>
|
||||
<div data-testid="page" />
|
||||
</AgentRouteGuard>
|
||||
}
|
||||
/>
|
||||
<Route path="/agents/manage" element={<div data-testid="list" />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const shows = (id: string) =>
|
||||
container.querySelector(`[data-testid="${id}"]`) !== null;
|
||||
|
||||
it('sends a viewer back to the list without showing the page', async () => {
|
||||
let resolve: (v: unknown) => void = () => undefined;
|
||||
mocks.getAgent.mockReturnValue(
|
||||
new Promise((r) => {
|
||||
resolve = r;
|
||||
}),
|
||||
);
|
||||
await render('view', '/agents/manage/edit/a1');
|
||||
// Nothing of the page while the agent loads.
|
||||
expect(shows('page')).toBe(false);
|
||||
await act(async () =>
|
||||
resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
id: 'a1',
|
||||
access: 'viewer',
|
||||
allowed_actions: ['pin', 'use'],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(shows('page')).toBe(false);
|
||||
expect(shows('list')).toBe(true);
|
||||
});
|
||||
|
||||
it('lets an editor open the page', async () => {
|
||||
mocks.getAgent.mockReturnValue(
|
||||
respond({
|
||||
id: 'a1',
|
||||
access: 'editor',
|
||||
allowed_actions: ['view', 'view_logs'],
|
||||
}),
|
||||
);
|
||||
await render('view_logs');
|
||||
expect(shows('page')).toBe(true);
|
||||
});
|
||||
|
||||
it('decides from the agent list without a fetch when it has the actions', async () => {
|
||||
state.preference.agents = [
|
||||
{ id: 'a1', access: 'viewer', allowed_actions: ['pin', 'use'] },
|
||||
];
|
||||
await render('manage_schedules', '/agents/manage/schedules/a1');
|
||||
expect(mocks.getAgent).not.toHaveBeenCalled();
|
||||
expect(shows('list')).toBe(true);
|
||||
});
|
||||
|
||||
it('sends the caller back when the agent does not load', async () => {
|
||||
mocks.getAgent.mockReturnValue(respond({}, false));
|
||||
await render('view_logs');
|
||||
expect(shows('list')).toBe(true);
|
||||
});
|
||||
|
||||
it('lets an owner in when the record has no access fields', async () => {
|
||||
mocks.getAgent.mockReturnValue(respond({ id: 'a1' }));
|
||||
await render('view');
|
||||
expect(shows('page')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Navigate, useParams } from 'react-router-dom';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import {
|
||||
selectAgents,
|
||||
selectSelectedAgent,
|
||||
selectSharedAgents,
|
||||
selectToken,
|
||||
} from '../preferences/preferenceSlice';
|
||||
import { canAgent } from './agentAccess';
|
||||
import { agentsListPath } from './paths';
|
||||
import type { Agent } from './types';
|
||||
|
||||
type AgentRouteGuardProps = {
|
||||
/** The action the page needs (`view`, `view_logs`, `manage_schedules`). */
|
||||
action: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens an agent's page only for a role that may use it.
|
||||
*
|
||||
* Decides from the agent already in the store when that record carries the
|
||||
* server's `allowed_actions`, else fetches the agent. Nothing of the page
|
||||
* renders until it knows, so a viewer never sees a flash of the edit form.
|
||||
* A caller who may not open the page, or an agent that does not load, goes
|
||||
* back to the agent list.
|
||||
*
|
||||
* @param action The agent action the wrapped page needs.
|
||||
* @param children The page.
|
||||
*/
|
||||
export default function AgentRouteGuard({
|
||||
action,
|
||||
children,
|
||||
}: AgentRouteGuardProps) {
|
||||
const { agentId } = useParams();
|
||||
const token = useSelector(selectToken);
|
||||
const agents = useSelector(selectAgents);
|
||||
const sharedAgents = useSelector(selectSharedAgents);
|
||||
const selectedAgent = useSelector(selectSelectedAgent);
|
||||
|
||||
const stored = [
|
||||
...(agents ?? []),
|
||||
...(sharedAgents ?? []),
|
||||
...(selectedAgent ? [selectedAgent] : []),
|
||||
].find((agent) => agent.id === agentId && agent.allowed_actions);
|
||||
|
||||
const [fetched, setFetched] = useState<{
|
||||
id: string;
|
||||
agent: Agent | null;
|
||||
} | null>(null);
|
||||
|
||||
const needsFetch = Boolean(agentId) && !stored;
|
||||
useEffect(() => {
|
||||
if (!needsFetch || !agentId) return;
|
||||
let cancelled = false;
|
||||
userService
|
||||
.getAgent(agentId, token)
|
||||
.then(async (response: Response) => {
|
||||
const agent = response.ok ? ((await response.json()) as Agent) : null;
|
||||
if (!cancelled) setFetched({ id: agentId, agent });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFetched({ id: agentId, agent: null });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentId, needsFetch, token]);
|
||||
|
||||
if (!agentId) return <>{children}</>;
|
||||
|
||||
let agent: Agent | null | undefined = stored;
|
||||
if (!agent) {
|
||||
if (fetched?.id !== agentId) return null;
|
||||
agent = fetched.agent;
|
||||
}
|
||||
if (!agent || !canAgent(agent, action))
|
||||
return <Navigate to={agentsListPath()} replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -27,6 +27,8 @@ const mocks = vi.hoisted(() => {
|
||||
dispatch: vi.fn(),
|
||||
getAgent: vi.fn(() => jsonResponse({})),
|
||||
createAgent: vi.fn(() => jsonResponse({ message: 'Name is taken' }, false)),
|
||||
deleteAgent: vi.fn(() => jsonResponse({})),
|
||||
guardrailsProps: vi.fn(),
|
||||
};
|
||||
});
|
||||
const { jsonResponse } = mocks;
|
||||
@@ -53,7 +55,7 @@ vi.mock('../api/services/userService', () => ({
|
||||
getAgent: mocks.getAgent,
|
||||
createAgent: mocks.createAgent,
|
||||
updateAgent: () => jsonResponse({}),
|
||||
deleteAgent: () => jsonResponse({}),
|
||||
deleteAgent: mocks.deleteAgent,
|
||||
createPrompt: () => jsonResponse({}),
|
||||
},
|
||||
}));
|
||||
@@ -97,13 +99,29 @@ vi.mock('./workflow/WorkflowBuilder', () => ({ default: () => null }));
|
||||
vi.mock('./AgentPreview', () => ({ default: () => null }));
|
||||
vi.mock('../settings/Prompts', () => ({ default: () => null }));
|
||||
vi.mock('./components/GuardrailsSection', () => ({
|
||||
default: () => null,
|
||||
default: (props: { disabled?: boolean }) => {
|
||||
mocks.guardrailsProps(props);
|
||||
return null;
|
||||
},
|
||||
guardrailsIncomplete: () => false,
|
||||
}));
|
||||
vi.mock('../upload/Upload', () => ({ default: () => null }));
|
||||
vi.mock('../modals/AgentDetailsModal', () => ({ default: () => null }));
|
||||
vi.mock('../teams/ShareToTeamModal', () => ({ default: () => null }));
|
||||
vi.mock('../modals/ConfirmationModal', () => ({ default: () => null }));
|
||||
vi.mock('../modals/ConfirmationModal', () => ({
|
||||
default: ({
|
||||
modalState,
|
||||
handleSubmit,
|
||||
}: {
|
||||
modalState: string;
|
||||
handleSubmit: () => void;
|
||||
}) =>
|
||||
modalState === 'ACTIVE' ? (
|
||||
<button type="button" data-testid="confirm-delete" onClick={handleSubmit}>
|
||||
confirm
|
||||
</button>
|
||||
) : null,
|
||||
}));
|
||||
vi.mock('../preferences/PromptsModal', () => ({ default: () => null }));
|
||||
vi.mock('../navigation/SectionPills', () => ({
|
||||
default: () => <div data-testid="section-pills" />,
|
||||
@@ -534,3 +552,178 @@ describe('NewAgent form', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('NewAgent gating by role', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const OWNER = [
|
||||
'delete',
|
||||
'edit',
|
||||
'edit_policy',
|
||||
'export',
|
||||
'manage_access_details',
|
||||
'manage_schedules',
|
||||
'manage_settings',
|
||||
'move_folder',
|
||||
'pin',
|
||||
'publish',
|
||||
'share',
|
||||
'use',
|
||||
'view',
|
||||
'view_logs',
|
||||
];
|
||||
const EDITOR = [
|
||||
'edit',
|
||||
'edit_policy',
|
||||
'export',
|
||||
'manage_access_details',
|
||||
'manage_schedules',
|
||||
'pin',
|
||||
'publish',
|
||||
'use',
|
||||
'view',
|
||||
'view_logs',
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
mocks.dispatch.mockClear();
|
||||
mocks.getAgent.mockReset();
|
||||
mocks.getAgent.mockImplementation(() => jsonResponse({}));
|
||||
mocks.deleteAgent.mockReset();
|
||||
mocks.deleteAgent.mockImplementation(() => jsonResponse({}));
|
||||
mocks.guardrailsProps.mockClear();
|
||||
});
|
||||
|
||||
const renderEdit = async (access: 'owner' | 'editor', allowed: string[]) => {
|
||||
mocks.getAgent.mockImplementation(() =>
|
||||
jsonResponse({
|
||||
id: 'agent-1',
|
||||
name: 'Deal Desk',
|
||||
description: 'Researches deals',
|
||||
status: 'published',
|
||||
agent_type: 'classic',
|
||||
prompt_id: 'default',
|
||||
ownership: access === 'owner' ? 'user' : 'team',
|
||||
team_access: access === 'owner' ? null : access,
|
||||
access,
|
||||
allowed_actions: allowed,
|
||||
}),
|
||||
);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={['/agents/edit/agent-1']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/agents/edit/:agentId"
|
||||
element={<NewAgent mode="edit" />}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const buttonByText = (text: string) =>
|
||||
Array.from(container.querySelectorAll('button')).find((b) =>
|
||||
b.textContent?.includes(text),
|
||||
);
|
||||
|
||||
const menuLabels = async () => {
|
||||
const menu = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="agents.form.buttons.moreActions"]',
|
||||
)!;
|
||||
await act(async () => {
|
||||
menu.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
|
||||
);
|
||||
});
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).map((item) => item.textContent);
|
||||
};
|
||||
|
||||
const lastGuardrailsDisabled = () =>
|
||||
mocks.guardrailsProps.mock.calls.at(-1)?.[0].disabled;
|
||||
|
||||
it('gives the owner Share, Access details and the danger zone', async () => {
|
||||
await renderEdit('owner', OWNER);
|
||||
expect(buttonByText('agents.form.dangerZone.deleteButton')).toBeDefined();
|
||||
expect(await menuLabels()).toEqual([
|
||||
'agents.form.buttons.accessDetails',
|
||||
'agents.shareWithTeam',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides Share and Delete from an editor but keeps Access details', async () => {
|
||||
await renderEdit('editor', EDITOR);
|
||||
expect(buttonByText('agents.form.dangerZone.deleteButton')).toBeUndefined();
|
||||
expect(await menuLabels()).toEqual(['agents.form.buttons.accessDetails']);
|
||||
});
|
||||
|
||||
it('lets an editor change guardrails and quotas', async () => {
|
||||
await renderEdit('editor', EDITOR);
|
||||
expect(lastGuardrailsDisabled()).toBe(false);
|
||||
await act(async () =>
|
||||
buttonByText('agents.form.sections.advanced')!.click(),
|
||||
);
|
||||
const switches =
|
||||
container.querySelectorAll<HTMLButtonElement>('[role="switch"]');
|
||||
expect(switches.length).toBeGreaterThan(0);
|
||||
for (const s of Array.from(switches)) expect(s.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('locks guardrails and quotas without edit_policy', async () => {
|
||||
await renderEdit(
|
||||
'editor',
|
||||
EDITOR.filter((a) => a !== 'edit_policy'),
|
||||
);
|
||||
expect(lastGuardrailsDisabled()).toBe(true);
|
||||
await act(async () =>
|
||||
buttonByText('agents.form.sections.advanced')!.click(),
|
||||
);
|
||||
const token = container.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="agents.form.placeholders.enterTokenLimit"]',
|
||||
)!;
|
||||
const tokenSwitch = token
|
||||
.closest('[data-slot="setting-row"]')
|
||||
?.querySelector<HTMLButtonElement>('[role="switch"]');
|
||||
expect(tokenSwitch?.disabled).toBe(true);
|
||||
expect(token.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('hides Access details without manage_access_details', async () => {
|
||||
await renderEdit(
|
||||
'editor',
|
||||
EDITOR.filter((a) => a !== 'manage_access_details'),
|
||||
);
|
||||
expect(await menuLabels()).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports a failed delete in a toast instead of throwing', async () => {
|
||||
mocks.deleteAgent.mockImplementation(() =>
|
||||
jsonResponse({ message: 'Only the owner can delete' }, false),
|
||||
);
|
||||
await renderEdit('owner', OWNER);
|
||||
await act(async () =>
|
||||
buttonByText('agents.form.dangerZone.deleteButton')!.click(),
|
||||
);
|
||||
await act(async () =>
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[data-testid="confirm-delete"]')!
|
||||
.click(),
|
||||
);
|
||||
expect(mocks.dispatch).toHaveBeenCalledWith({
|
||||
type: 'actionToast/showActionToast',
|
||||
payload: { variant: 'destructive', message: 'Only the owner can delete' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,7 @@ import AgentDetailsModal from '../modals/AgentDetailsModal';
|
||||
import ShareToTeamModal from '../teams/ShareToTeamModal';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import { ActiveState, Prompt } from '../models/misc';
|
||||
import { showActionToast } from '../notifications/actionToastSlice';
|
||||
import {
|
||||
selectAgentFolders,
|
||||
selectSelectedAgent,
|
||||
@@ -69,6 +70,7 @@ import {
|
||||
import PromptsModal from '../preferences/PromptsModal';
|
||||
import Prompts from '../settings/Prompts';
|
||||
import { UserToolType } from '../settings/types';
|
||||
import { can } from '../utils/accessUtils';
|
||||
import Upload from '../upload/Upload';
|
||||
import {
|
||||
selectedSourceIdsFromAgent,
|
||||
@@ -78,7 +80,7 @@ import {
|
||||
} from '../utils/sourceUtils';
|
||||
import {
|
||||
getToolDisplayName,
|
||||
isClassicAgentToolVisible,
|
||||
isAgentPickerToolVisible,
|
||||
} from '../utils/toolUtils';
|
||||
import { agentsListPath } from './paths';
|
||||
import GuardrailsSection, {
|
||||
@@ -359,9 +361,27 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
};
|
||||
|
||||
const handleDelete = async (agentId: string) => {
|
||||
const response = await userService.deleteAgent(agentId, token);
|
||||
if (!response.ok) throw new Error('Failed to delete agent');
|
||||
navigateBackToAgents();
|
||||
try {
|
||||
const response = await userService.deleteAgent(agentId, token);
|
||||
if (!response.ok) {
|
||||
dispatch(
|
||||
showActionToast({
|
||||
variant: 'destructive',
|
||||
message: await extractApiError(response, t('agents.deleteFailed')),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
navigateBackToAgents();
|
||||
} catch (error) {
|
||||
console.error('Error deleting agent:', error);
|
||||
dispatch(
|
||||
showActionToast({
|
||||
variant: 'destructive',
|
||||
message: t('agents.deleteFailed'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
@@ -588,7 +608,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
// Hide workflow-only builtins (e.g. read_document) from the classic
|
||||
// agent picker; they belong to the workflow-node picker only.
|
||||
const visibleTools = (data.tools as UserToolType[]).filter(
|
||||
isClassicAgentToolVisible,
|
||||
isAgentPickerToolVisible,
|
||||
);
|
||||
const devicesById = new Map<
|
||||
string,
|
||||
@@ -829,6 +849,12 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
}, [agent, dispatch, effectiveMode, imageFile, jsonSchemaText]);
|
||||
|
||||
const isPublished = agent.status === 'published';
|
||||
// What the caller's role allows on this agent (`allowed_actions` from the
|
||||
// API). A new agent carries no access fields, so it reads as the owner's.
|
||||
const canEditPolicy = can(agent, 'edit_policy');
|
||||
// Save on a published agent is an edit; on a draft or a new agent the main
|
||||
// button publishes it.
|
||||
const canSubmit = can(agent, effectiveMode === 'edit' ? 'edit' : 'publish');
|
||||
const agentDisplayName =
|
||||
agent.name?.trim() || t('agents.pageHeader.fallbackName');
|
||||
|
||||
@@ -845,7 +871,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
onClick: () => setPreviewOpen(true),
|
||||
},
|
||||
]),
|
||||
...(modeConfig[effectiveMode].showAccessDetails
|
||||
...(modeConfig[effectiveMode].showAccessDetails &&
|
||||
can(agent, 'manage_access_details')
|
||||
? [
|
||||
{
|
||||
label: t('agents.form.buttons.accessDetails'),
|
||||
@@ -853,10 +880,9 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// Sharing is owner-only — hidden for agents shared into the workspace by
|
||||
// a team (ownership === 'team').
|
||||
// Sharing is the owner's, unless the owner lets editors share.
|
||||
...(modeConfig[effectiveMode].showAccessDetails &&
|
||||
agent.ownership !== 'team' &&
|
||||
can(agent, 'share') &&
|
||||
agent.id
|
||||
? [
|
||||
{
|
||||
@@ -882,7 +908,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
{t('agents.form.buttons.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
{modeConfig[effectiveMode].showSaveDraft && (
|
||||
{modeConfig[effectiveMode].showSaveDraft && can(agent, 'edit') && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -907,17 +933,19 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
{t('agents.form.sections.preview')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="field"
|
||||
shape="pill"
|
||||
disabled={!isPublishable() || !hasChanges}
|
||||
loading={publishLoading}
|
||||
onClick={handlePublish}
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
{modeConfig[effectiveMode].buttonText}
|
||||
</Button>
|
||||
{canSubmit && (
|
||||
<Button
|
||||
type="button"
|
||||
size="field"
|
||||
shape="pill"
|
||||
disabled={!isPublishable() || !hasChanges}
|
||||
loading={publishLoading}
|
||||
onClick={handlePublish}
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
{modeConfig[effectiveMode].buttonText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1369,7 +1397,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
disabled={!agent.limited_token_mode}
|
||||
disabled={!agent.limited_token_mode || !canEditPolicy}
|
||||
placeholder={t(
|
||||
'agents.form.placeholders.enterTokenLimit',
|
||||
)}
|
||||
@@ -1381,6 +1409,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
<Switch
|
||||
id={tokenLimitSwitchId}
|
||||
checked={agent.limited_token_mode}
|
||||
disabled={!canEditPolicy}
|
||||
onCheckedChange={(checked) => {
|
||||
setAgent({
|
||||
...agent,
|
||||
@@ -1411,7 +1440,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
disabled={!agent.limited_request_mode}
|
||||
disabled={!agent.limited_request_mode || !canEditPolicy}
|
||||
placeholder={t(
|
||||
'agents.form.placeholders.enterRequestLimit',
|
||||
)}
|
||||
@@ -1423,6 +1452,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
<Switch
|
||||
id={requestLimitSwitchId}
|
||||
checked={agent.limited_request_mode}
|
||||
disabled={!canEditPolicy}
|
||||
onCheckedChange={(checked) => {
|
||||
setAgent({
|
||||
...agent,
|
||||
@@ -1459,15 +1489,11 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
<GuardrailsSection
|
||||
value={agent.config?.guardrails}
|
||||
token={token}
|
||||
// Guardrails are the owner's policy: the update route drops
|
||||
// ``config`` for team members, editors included. Leaving the
|
||||
// controls live for editors let them save a change the server
|
||||
// silently discarded, and the success toast said it had worked.
|
||||
disabled={Boolean(agent.team_access)}
|
||||
// Guardrails are policy (`edit_policy`): editors and the owner
|
||||
// change them; anyone else sees them read-only.
|
||||
disabled={!canEditPolicy}
|
||||
disabledNotice={
|
||||
agent.team_access
|
||||
? t('agents.form.guardrails.ownerOnly')
|
||||
: undefined
|
||||
canEditPolicy ? undefined : t('agents.form.guardrails.readOnly')
|
||||
}
|
||||
onChange={(guardrails) =>
|
||||
setAgent({
|
||||
@@ -1476,29 +1502,31 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
})
|
||||
}
|
||||
/>
|
||||
{modeConfig[effectiveMode].showDelete && agent.id && (
|
||||
<Card
|
||||
tone="destructive"
|
||||
padding="lg"
|
||||
className="flex-row flex-wrap items-start justify-between"
|
||||
>
|
||||
<SectionHeader
|
||||
{modeConfig[effectiveMode].showDelete &&
|
||||
agent.id &&
|
||||
can(agent, 'delete') && (
|
||||
<Card
|
||||
tone="destructive"
|
||||
title={t('agents.form.dangerZone.heading')}
|
||||
description={t('agents.form.dangerZone.description')}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive-outline"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirmation('ACTIVE')}
|
||||
className="shrink-0"
|
||||
padding="lg"
|
||||
className="flex-row flex-wrap items-start justify-between"
|
||||
>
|
||||
{t('agents.form.dangerZone.deleteButton')}
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
<SectionHeader
|
||||
tone="destructive"
|
||||
title={t('agents.form.dangerZone.heading')}
|
||||
description={t('agents.form.dangerZone.description')}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive-outline"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirmation('ACTIVE')}
|
||||
className="shrink-0"
|
||||
>
|
||||
{t('agents.form.dangerZone.deleteButton')}
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
<ConfirmationModal
|
||||
message={t('agents.deleteConfirmation')}
|
||||
@@ -1595,16 +1623,18 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
title={t('agents.form.preview.publishTitle')}
|
||||
description={t('agents.form.preview.publishDescription')}
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
disabled={!isPublishable()}
|
||||
loading={publishLoading}
|
||||
onClick={handlePublish}
|
||||
>
|
||||
{t('agents.form.buttons.publish')}
|
||||
</Button>
|
||||
can(agent, 'publish') ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
disabled={!isPublishable()}
|
||||
loading={publishLoading}
|
||||
onClick={handlePublish}
|
||||
>
|
||||
{t('agents.form.buttons.publish')}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { canAgent, canOpenAgentEditor } from './agentAccess';
|
||||
import type { Agent } from './types';
|
||||
|
||||
const agent = (fields: Partial<Agent>) => ({ id: 'a1', ...fields }) as Agent;
|
||||
|
||||
describe('canOpenAgentEditor', () => {
|
||||
it('follows the list copy of the agent when there is one', () => {
|
||||
const listed = agent({ access: 'editor', allowed_actions: ['view'] });
|
||||
expect(canOpenAgentEditor(agent({}), [listed])).toBe(true);
|
||||
const viewer = agent({ access: 'viewer', allowed_actions: ['use'] });
|
||||
expect(canOpenAgentEditor(agent({}), [viewer])).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the selected agent when it carries the actions', () => {
|
||||
expect(
|
||||
canOpenAgentEditor(
|
||||
agent({ access: 'viewer', allowed_actions: ['pin', 'use'] }),
|
||||
[],
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canOpenAgentEditor(agent({ access: 'owner', allowed_actions: ['view'] })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses an unlisted agent with no access fields', () => {
|
||||
expect(canOpenAgentEditor(agent({}), [])).toBe(false);
|
||||
expect(canOpenAgentEditor(null, [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAgent', () => {
|
||||
const all = ['view', 'view_logs', 'manage_schedules', 'pin'];
|
||||
it('follows allowed_actions on a published agent', () => {
|
||||
const a = agent({
|
||||
status: 'published',
|
||||
access: 'editor',
|
||||
allowed_actions: all,
|
||||
});
|
||||
expect(all.every((x) => canAgent(a, x))).toBe(true);
|
||||
});
|
||||
|
||||
it('drops Logs, Schedules and Pin on a draft, keeps the editor', () => {
|
||||
const a = agent({ status: 'draft', access: 'owner', allowed_actions: all });
|
||||
expect(canAgent(a, 'view')).toBe(true);
|
||||
expect(canAgent(a, 'view_logs')).toBe(false);
|
||||
expect(canAgent(a, 'manage_schedules')).toBe(false);
|
||||
expect(canAgent(a, 'pin')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats an agent with no status yet as published', () => {
|
||||
expect(canAgent(agent({ allowed_actions: all }), 'view_logs')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { can, type AccessFields } from '../utils/accessUtils';
|
||||
import type { Agent } from './types';
|
||||
|
||||
/**
|
||||
* Whether the chat header and the phone top bar offer Edit for an agent.
|
||||
*
|
||||
* Prefers the agent list's copy, which carries the server's access fields.
|
||||
* An agent that is not in the list (a template, a link-shared agent) must
|
||||
* carry `allowed_actions` itself; without them it gets no Edit, so a record
|
||||
* with no access fields is never taken for the caller's own.
|
||||
*
|
||||
* @param agent The agent the chat is with.
|
||||
* @param agents The caller's agent list (own and team-shared).
|
||||
* @returns True when the role may open the agent's edit page.
|
||||
*/
|
||||
export function canOpenAgentEditor(
|
||||
agent: Agent | null | undefined,
|
||||
agents?: Agent[] | null,
|
||||
): boolean {
|
||||
if (!agent?.id) return false;
|
||||
const listed = agents?.find((a) => a.id === agent.id);
|
||||
if (listed) return can(listed, 'view');
|
||||
return Boolean(agent.allowed_actions) && can(agent, 'view');
|
||||
}
|
||||
|
||||
/** Actions that only make sense once an agent is published. */
|
||||
const PUBLISHED_ONLY = new Set(['view_logs', 'manage_schedules', 'pin']);
|
||||
|
||||
/**
|
||||
* `can()` for an agent, minus what a draft can't have: a draft has no runs
|
||||
* to log, no schedule that fires and nothing to pin, so Logs, Schedules and
|
||||
* Pin wait until it's published. An agent without a status (a record still
|
||||
* loading) is treated as published.
|
||||
*
|
||||
* @param agent The agent, with its access fields and status.
|
||||
* @param action The agent action to check.
|
||||
* @returns True when the role allows it and the agent's state makes sense.
|
||||
*/
|
||||
export function canAgent(
|
||||
agent: (AccessFields & { status?: string }) | null | undefined,
|
||||
action: string,
|
||||
): boolean {
|
||||
if (!agent) return false;
|
||||
if (
|
||||
PUBLISHED_ONLY.has(action) &&
|
||||
agent.status &&
|
||||
agent.status !== 'published'
|
||||
)
|
||||
return false;
|
||||
return can(agent, action);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
|
||||
|
||||
import AgentLogs from './AgentLogs';
|
||||
import AgentRouteGuard from './AgentRouteGuard';
|
||||
import AgentsList from './AgentsList';
|
||||
import NewAgent from './NewAgent';
|
||||
import { AGENTS_MANAGE_ROOT } from './paths';
|
||||
@@ -31,13 +32,40 @@ export default function Agents() {
|
||||
<Route path="manage/team" element={<AgentsList />} />
|
||||
<Route path="manage/discovered" element={<AgentsList />} />
|
||||
<Route path="manage/new" element={<NewAgent mode="new" />} />
|
||||
<Route path="manage/edit/:agentId" element={<NewAgent mode="edit" />} />
|
||||
<Route path="manage/logs/:agentId" element={<AgentLogs />} />
|
||||
<Route path="manage/schedules/:agentId" element={<SchedulesView />} />
|
||||
{/* An agent's pages open only for a role that may use them; the
|
||||
guard sends anyone else back to the list. */}
|
||||
<Route
|
||||
path="manage/edit/:agentId"
|
||||
element={
|
||||
<AgentRouteGuard action="view">
|
||||
<NewAgent mode="edit" />
|
||||
</AgentRouteGuard>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="manage/logs/:agentId"
|
||||
element={
|
||||
<AgentRouteGuard action="view_logs">
|
||||
<AgentLogs />
|
||||
</AgentRouteGuard>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="manage/schedules/:agentId"
|
||||
element={
|
||||
<AgentRouteGuard action="manage_schedules">
|
||||
<SchedulesView />
|
||||
</AgentRouteGuard>
|
||||
}
|
||||
/>
|
||||
<Route path="manage/workflow/new" element={<WorkflowBuilder />} />
|
||||
<Route
|
||||
path="manage/workflow/edit/:agentId"
|
||||
element={<WorkflowBuilder />}
|
||||
element={
|
||||
<AgentRouteGuard action="view">
|
||||
<WorkflowBuilder />
|
||||
</AgentRouteGuard>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Using an agent someone shared. */}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AccessFields } from '../../utils/accessUtils';
|
||||
|
||||
export type ToolSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -30,6 +32,9 @@ export type Agent = {
|
||||
// sharing with a team) are gated on 'user'.
|
||||
ownership?: 'user' | 'team';
|
||||
team_access?: 'viewer' | 'editor' | null;
|
||||
/** The caller's role and the actions it allows (see `utils/accessUtils`). */
|
||||
access?: AccessFields['access'];
|
||||
allowed_actions?: AccessFields['allowed_actions'];
|
||||
// Owner-agnostic display names resolved server-side (GET /api/get_agent) so a
|
||||
// team member viewing a shared agent sees the owner's prompt/source names
|
||||
// instead of a blank prompt / "External KB" (the client can only resolve
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import 'reactflow/dist/style.css';
|
||||
|
||||
import { CircleAlert, Link, Pencil, Play, Trash2, X } from 'lucide-react';
|
||||
import {
|
||||
CircleAlert,
|
||||
Link,
|
||||
Pencil,
|
||||
Play,
|
||||
Trash2,
|
||||
Users,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -31,6 +39,8 @@ import userService from '../../api/services/userService';
|
||||
import AgentDetailsModal from '../../modals/AgentDetailsModal';
|
||||
import ConfirmationModal from '../../modals/ConfirmationModal';
|
||||
import { ActiveState } from '../../models/misc';
|
||||
import ShareToTeamModal from '../../teams/ShareToTeamModal';
|
||||
import { can } from '../../utils/accessUtils';
|
||||
import {
|
||||
selectSourceDocs,
|
||||
selectToken,
|
||||
@@ -85,6 +95,7 @@ import {
|
||||
validateJsonSchemaConfig,
|
||||
} from './workflowHelpers';
|
||||
import { selectWorkflowPreviewStatus } from './workflowPreviewSlice';
|
||||
import { canAddToolToOwn } from '../../utils/toolUtils';
|
||||
|
||||
import type { Model } from '../../models/types';
|
||||
|
||||
@@ -219,6 +230,7 @@ function WorkflowBuilderInner() {
|
||||
const [deleteConfirmation, setDeleteConfirmation] =
|
||||
useState<ActiveState>('INACTIVE');
|
||||
const [agentDetails, setAgentDetails] = useState<ActiveState>('INACTIVE');
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const [isDeletingAgent, setIsDeletingAgent] = useState(false);
|
||||
const [currentAgent, setCurrentAgent] = useState<Agent>(
|
||||
createEmptyWorkflowAgent(),
|
||||
@@ -789,7 +801,10 @@ function WorkflowBuilderInner() {
|
||||
const toolsResponse = await userService.getUserTools(token);
|
||||
if (toolsResponse.ok) {
|
||||
const toolsData = await toolsResponse.json();
|
||||
setAvailableTools(toolsData.tools);
|
||||
// Shared tools the caller can't add to their own agents stay out.
|
||||
setAvailableTools(
|
||||
(toolsData.tools as UserTool[]).filter(canAddToolToOwn),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load models or tools:', error);
|
||||
@@ -1517,8 +1532,11 @@ function WorkflowBuilderInner() {
|
||||
});
|
||||
}, [detailsSaveRequested, persistWorkflow]);
|
||||
|
||||
// Save on a saved workflow is an edit; the first save publishes it. A new
|
||||
// workflow has no access fields, so it reads as the owner's.
|
||||
const canSubmit = can(currentAgent, canManageAgent ? 'edit' : 'publish');
|
||||
const isPrimaryActionDisabled =
|
||||
isPublishing || (canManageAgent && !hasSavableChanges);
|
||||
!canSubmit || isPublishing || (canManageAgent && !hasSavableChanges);
|
||||
const primaryActionLabel = canManageAgent
|
||||
? t('agents.form.buttons.save')
|
||||
: t('agents.form.buttons.publish');
|
||||
@@ -1642,6 +1660,7 @@ function WorkflowBuilderInner() {
|
||||
agentEditPath={agentEditPath(effectiveAgentId, true)}
|
||||
agentImage={currentAgentImage}
|
||||
currentPage="overview"
|
||||
access={canManageAgent ? currentAgent : undefined}
|
||||
onNameClick={openDetails}
|
||||
status={
|
||||
canManageAgent && currentAgent.status !== 'draft' ? (
|
||||
@@ -1678,16 +1697,18 @@ function WorkflowBuilderInner() {
|
||||
<Play />
|
||||
{t('agents.form.sections.preview')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handlePrimaryAction}
|
||||
disabled={isPrimaryActionDisabled}
|
||||
loading={showPrimaryActionSpinner}
|
||||
size="field"
|
||||
shape="pill"
|
||||
>
|
||||
{primaryActionLabel}
|
||||
</Button>
|
||||
{canSubmit && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handlePrimaryAction}
|
||||
disabled={isPrimaryActionDisabled}
|
||||
loading={showPrimaryActionSpinner}
|
||||
size="field"
|
||||
shape="pill"
|
||||
>
|
||||
{primaryActionLabel}
|
||||
</Button>
|
||||
)}
|
||||
<ActionMenu
|
||||
size="toolbar"
|
||||
triggerLabel={t('agents.form.buttons.moreActions')}
|
||||
@@ -1697,13 +1718,26 @@ function WorkflowBuilderInner() {
|
||||
icon: Pencil,
|
||||
onClick: openDetails,
|
||||
},
|
||||
...(canManageAgent
|
||||
...(canManageAgent && can(currentAgent, 'manage_access_details')
|
||||
? [
|
||||
{
|
||||
label: t('agents.form.buttons.accessDetails'),
|
||||
icon: Link,
|
||||
onClick: () => setAgentDetails('ACTIVE'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canManageAgent && can(currentAgent, 'share')
|
||||
? [
|
||||
{
|
||||
label: t('agents.shareWithTeam'),
|
||||
icon: Users,
|
||||
onClick: () => setShareModalOpen(true),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canManageAgent && can(currentAgent, 'delete')
|
||||
? [
|
||||
{
|
||||
label: t('agents.form.buttons.delete'),
|
||||
icon: Trash2,
|
||||
@@ -1927,6 +1961,14 @@ function WorkflowBuilderInner() {
|
||||
cancelLabel={t('agents.form.buttons.cancel')}
|
||||
variant="destructive"
|
||||
/>
|
||||
{shareModalOpen && effectiveAgentId && (
|
||||
<ShareToTeamModal
|
||||
resourceType="agent"
|
||||
resourceId={effectiveAgentId}
|
||||
resourceName={workflowName}
|
||||
onClose={() => setShareModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{canManageAgent && (
|
||||
<AgentDetailsModal
|
||||
agent={agentForDetails}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type TFunction } from 'i18next';
|
||||
|
||||
import { ConditionCase } from '../types/workflow';
|
||||
import { FilePassing } from './documentConfig';
|
||||
import type { AccessFields } from '../../utils/accessUtils';
|
||||
|
||||
// Names and handles are the user's own text: React escapes on render, so
|
||||
// i18next must not escape them first.
|
||||
@@ -32,6 +33,10 @@ export interface UserTool {
|
||||
// Workflow-only builtins (e.g. read_document) are kept here; the classic
|
||||
// agent picker filters them out.
|
||||
workflow_only?: boolean;
|
||||
/** Team sharing: shared tools the caller can't use in their own agents are hidden. */
|
||||
access?: AccessFields['access'];
|
||||
allowed_actions?: string[];
|
||||
ownership?: AccessFields['ownership'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,6 +44,7 @@ const endpoints = {
|
||||
TEAM_TRANSFER_OWNER: (id: string) => `/api/teams/${id}/transfer_owner`,
|
||||
RESOURCE_SHARES: (resourceType: string, resourceId: string) =>
|
||||
`/api/resource_shares?resource_type=${resourceType}&resource_id=${resourceId}`,
|
||||
RESOURCE_SETTINGS: '/api/resource_settings',
|
||||
ALL_TEAMS: '/api/admin/teams',
|
||||
PROMPTS: '/api/get_prompts',
|
||||
CREATE_PROMPT: '/api/create_prompt',
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
const get = vi.fn();
|
||||
const post = vi.fn();
|
||||
const put = vi.fn();
|
||||
const del = vi.fn();
|
||||
|
||||
vi.mock('../client', () => ({
|
||||
default: {
|
||||
get: (...args: unknown[]) => get(...args),
|
||||
post: (...args: unknown[]) => post(...args),
|
||||
put: (...args: unknown[]) => put(...args),
|
||||
delete: (...args: unknown[]) => del(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
import teamsService, { TeamsApiError } from './teamsService';
|
||||
|
||||
const response = (status: number, body: unknown) =>
|
||||
({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: () => Promise.resolve(body),
|
||||
}) as unknown as Response;
|
||||
|
||||
describe('teamsService', () => {
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
post.mockReset();
|
||||
put.mockReset();
|
||||
del.mockReset();
|
||||
});
|
||||
|
||||
it('returns the parsed body on 2xx', async () => {
|
||||
get.mockResolvedValue(response(200, { success: true, teams: [] }));
|
||||
await expect(teamsService.list('t')).resolves.toEqual({
|
||||
success: true,
|
||||
teams: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws with the server message on 403', async () => {
|
||||
del.mockResolvedValue(
|
||||
response(403, { success: false, message: 'Only the owner can delete' }),
|
||||
);
|
||||
const error = await teamsService.remove('team-1', 't').catch((e) => e);
|
||||
expect(error).toBeInstanceOf(TeamsApiError);
|
||||
expect(error.status).toBe(403);
|
||||
expect(error.message).toBe('Only the owner can delete');
|
||||
});
|
||||
|
||||
it('throws on a non-2xx with no JSON body', async () => {
|
||||
get.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: () => Promise.reject(new Error('not json')),
|
||||
});
|
||||
const error = await teamsService.list('t').catch((e) => e);
|
||||
expect(error).toBeInstanceOf(TeamsApiError);
|
||||
expect(error.status).toBe(500);
|
||||
});
|
||||
|
||||
it('reads and writes resource settings', async () => {
|
||||
get.mockResolvedValue(response(200, { success: true, settings: [] }));
|
||||
await teamsService.getResourceSettings('agent', 'a 1', 't');
|
||||
expect(get.mock.calls[0][0]).toBe(
|
||||
'/api/resource_settings?resource_type=agent&resource_id=a%201',
|
||||
);
|
||||
put.mockResolvedValue(response(200, { success: true, settings: [] }));
|
||||
await teamsService.updateResourceSettings(
|
||||
'agent',
|
||||
'a1',
|
||||
{ editors_can_share: true },
|
||||
't',
|
||||
);
|
||||
expect(put.mock.calls[0][0]).toBe('/api/resource_settings');
|
||||
expect(put.mock.calls[0][1]).toEqual({
|
||||
resource_type: 'agent',
|
||||
resource_id: 'a1',
|
||||
settings: { editors_can_share: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -25,13 +25,73 @@ export type ResourceShare = {
|
||||
team_slug?: string;
|
||||
access_level: AccessLevel;
|
||||
target_user_id?: string | null;
|
||||
created_at?: string | null;
|
||||
};
|
||||
|
||||
// A grant row from GET /api/teams/<id>/grants. The server resolves names and
|
||||
// labels, plus the caller's own access to the resource (`caller`).
|
||||
export type TeamGrant = {
|
||||
resource_type: ResourceType;
|
||||
resource_id: string;
|
||||
access_level: AccessLevel;
|
||||
target_user_id?: string | null;
|
||||
resource_name?: string | null;
|
||||
owner_id?: string | null;
|
||||
owner_label?: string | null;
|
||||
target_user_label?: string | null;
|
||||
created_at?: string | null;
|
||||
granted_by?: string | null;
|
||||
granted_by_label?: string | null;
|
||||
caller?: {
|
||||
access: 'owner' | 'editor' | 'viewer';
|
||||
allowed_actions: string[];
|
||||
} | null;
|
||||
};
|
||||
|
||||
// One owner switch on a resource (`resource_share_settings`).
|
||||
export type ResourceSetting = { key: string; value: boolean; default: boolean };
|
||||
|
||||
export type ResourceSettingsResponse = {
|
||||
success: boolean;
|
||||
resource_type: ResourceType;
|
||||
resource_id: string;
|
||||
settings: ResourceSetting[];
|
||||
access?: 'owner' | 'editor' | 'viewer' | null;
|
||||
allowed_actions?: string[];
|
||||
};
|
||||
|
||||
/** A non-2xx teams API response; `message` is the server's own message. */
|
||||
export class TeamsApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = 'TeamsApiError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
// apiClient resolves to the raw fetch Response (the app convention); services
|
||||
// consumed by slices/components parse the JSON here so callers get plain data.
|
||||
// A non-2xx status rejects with the server's message, so callers never mistake
|
||||
// a 403/404 for success.
|
||||
const json = async (response: Response | unknown) => {
|
||||
const r = response as Response;
|
||||
if (!r || !('json' in r) || typeof r.json !== 'function') return r as unknown;
|
||||
if (typeof r.ok === 'boolean' && !r.ok) {
|
||||
let message = `Request failed (${r.status})`;
|
||||
try {
|
||||
const body = await r.json();
|
||||
if (body && typeof body.message === 'string' && body.message) {
|
||||
message = body.message;
|
||||
} else if (body && typeof body.error === 'string' && body.error) {
|
||||
message = body.error;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON: keep the generic message.
|
||||
}
|
||||
throw new TeamsApiError(r.status, message);
|
||||
}
|
||||
return r.json();
|
||||
};
|
||||
|
||||
@@ -154,6 +214,37 @@ const teamsService = {
|
||||
),
|
||||
),
|
||||
|
||||
getResourceSettings: async (
|
||||
resourceType: ResourceType,
|
||||
resourceId: string,
|
||||
token: string | null,
|
||||
): Promise<ResourceSettingsResponse> =>
|
||||
json(
|
||||
await apiClient.get(
|
||||
`${endpoints.USER.RESOURCE_SETTINGS}?resource_type=${resourceType}&resource_id=${encodeURIComponent(
|
||||
resourceId,
|
||||
)}`,
|
||||
token,
|
||||
),
|
||||
) as Promise<ResourceSettingsResponse>,
|
||||
updateResourceSettings: async (
|
||||
resourceType: ResourceType,
|
||||
resourceId: string,
|
||||
settings: Record<string, boolean>,
|
||||
token: string | null,
|
||||
): Promise<ResourceSettingsResponse> =>
|
||||
json(
|
||||
await apiClient.put(
|
||||
endpoints.USER.RESOURCE_SETTINGS,
|
||||
{
|
||||
resource_type: resourceType,
|
||||
resource_id: resourceId,
|
||||
settings,
|
||||
},
|
||||
token,
|
||||
),
|
||||
) as Promise<ResourceSettingsResponse>,
|
||||
|
||||
listAll: async (token: string | null): Promise<any> =>
|
||||
json(await apiClient.get(endpoints.USER.ALL_TEAMS, token)),
|
||||
};
|
||||
|
||||
@@ -843,4 +843,39 @@ describe('Chunks', () => {
|
||||
await act(async () => buttonByText('retry')!.click());
|
||||
expect(tile()).not.toBeNull();
|
||||
});
|
||||
const openReaderMenu = async () => {
|
||||
const trigger = buttonByLabel('settings.sources.menuAlt')!;
|
||||
await act(async () => {
|
||||
trigger.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
|
||||
);
|
||||
trigger.click();
|
||||
});
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).map((el) => el.textContent);
|
||||
};
|
||||
|
||||
it('read-only (canEdit false): no Add chunk, Edit or Delete; reading stays', async () => {
|
||||
await render({ embedded: true, canEdit: false });
|
||||
expect(buttonByText('settings.sources.addChunk')).toBeUndefined();
|
||||
await act(async () => tile()!.click());
|
||||
expect(container.querySelector('h2')?.textContent).toBe(
|
||||
'Late pickup clause',
|
||||
);
|
||||
expect(buttonByText('modals.chunk.edit')).toBeUndefined();
|
||||
expect(buttonByLabel('settings.sources.nextChunk')).not.toBeNull();
|
||||
expect(await openReaderMenu()).toEqual(['settings.sources.copyText']);
|
||||
});
|
||||
|
||||
it('an editor (canEdit true) keeps Add chunk, Edit and Delete', async () => {
|
||||
await render({ embedded: true, canEdit: true });
|
||||
expect(buttonByText('settings.sources.addChunk')).toBeDefined();
|
||||
await act(async () => tile()!.click());
|
||||
expect(buttonByText('modals.chunk.edit')).toBeDefined();
|
||||
expect(await openReaderMenu()).toEqual([
|
||||
'settings.sources.copyText',
|
||||
'modals.chunk.delete',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -89,6 +89,11 @@ interface ChunksProps {
|
||||
/** Where the open chunk is; see {@link OpenChunkPosition}. */
|
||||
onOpenChunkChange?: (position: OpenChunkPosition) => void;
|
||||
controllerRef?: React.MutableRefObject<ChunksController | null>;
|
||||
/**
|
||||
* Whether the caller may change the source (`can(source, 'edit')`). False
|
||||
* hides Add chunk, Edit and Delete; reading, copying and paging stay.
|
||||
*/
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
type SheetMode = 'edit' | 'add';
|
||||
@@ -103,6 +108,7 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
embedded = false,
|
||||
onOpenChunkChange,
|
||||
controllerRef,
|
||||
canEdit = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
@@ -556,15 +562,17 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="field"
|
||||
shape="pill"
|
||||
className="sm:ml-auto"
|
||||
onClick={() => openSheet('add')}
|
||||
>
|
||||
{t('settings.sources.addChunk')}
|
||||
</Button>
|
||||
{canEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="field"
|
||||
shape="pill"
|
||||
className="sm:ml-auto"
|
||||
onClick={() => openSheet('add')}
|
||||
>
|
||||
{t('settings.sources.addChunk')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -675,16 +683,18 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
disabled={!canGoNext}
|
||||
onClick={() => goToChunk(openPosition + 1)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => openSheet('edit')}
|
||||
>
|
||||
<Pencil />
|
||||
{t('modals.chunk.edit')}
|
||||
</Button>
|
||||
{canEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => openSheet('edit')}
|
||||
>
|
||||
<Pencil />
|
||||
{t('modals.chunk.edit')}
|
||||
</Button>
|
||||
) : null}
|
||||
<ActionMenu
|
||||
size="toolbar"
|
||||
triggerLabel={t('settings.sources.menuAlt')}
|
||||
@@ -702,12 +712,16 @@ const Chunks: React.FC<ChunksProps> = ({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: Trash2,
|
||||
label: t('modals.chunk.delete'),
|
||||
variant: 'destructive',
|
||||
onClick: () => confirmDeleteChunk(chunk),
|
||||
},
|
||||
...(canEdit
|
||||
? [
|
||||
{
|
||||
icon: Trash2,
|
||||
label: t('modals.chunk.delete'),
|
||||
variant: 'destructive' as const,
|
||||
onClick: () => confirmDeleteChunk(chunk),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { act, useState } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
const { tree } = vi.hoisted(() => ({
|
||||
tree: {
|
||||
structure: {
|
||||
'a.docx': { type: 'docx' },
|
||||
'b.docx': { type: 'docx' },
|
||||
} as Record<string, unknown>,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
@@ -24,10 +33,7 @@ vi.mock('../api/services/userService', () => ({
|
||||
getDirectoryStructure: vi.fn(async () => ({
|
||||
json: async () => ({
|
||||
provider: 'google_drive',
|
||||
directory_structure: {
|
||||
'a.docx': { type: 'docx' },
|
||||
'b.docx': { type: 'docx' },
|
||||
},
|
||||
directory_structure: tree.structure,
|
||||
}),
|
||||
})),
|
||||
getDocumentChunks: vi.fn(async () => ({
|
||||
@@ -144,4 +150,85 @@ describe('ConnectorTree and FileTree headers', () => {
|
||||
);
|
||||
expect(crumbs()).toEqual(['settings.sources.label', 'Files', 'a.docx']);
|
||||
});
|
||||
const openFirstRowMenu = async () => {
|
||||
const trigger = container.querySelector<HTMLButtonElement>(
|
||||
'tbody button[aria-label="settings.sources.menuAlt"]',
|
||||
)!;
|
||||
await act(async () => {
|
||||
trigger.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
|
||||
);
|
||||
trigger.click();
|
||||
});
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).map((el) => el.textContent);
|
||||
};
|
||||
|
||||
it('read-only ConnectorTree hides Sync, keeps headerAction', async () => {
|
||||
await render(
|
||||
<ConnectorTree
|
||||
docId="doc"
|
||||
sourceName="Drive"
|
||||
onBackToDocuments={vi.fn()}
|
||||
headerAction={retrieval}
|
||||
canEdit={false}
|
||||
/>,
|
||||
);
|
||||
expect(container.textContent).toContain('retrieval');
|
||||
expect(container.textContent).not.toContain('settings.sources.sync');
|
||||
});
|
||||
|
||||
it('read-only FileTree hides Add file and the row Delete', async () => {
|
||||
await render(
|
||||
<FileTree
|
||||
docId="doc"
|
||||
sourceName="Files"
|
||||
onBackToDocuments={vi.fn()}
|
||||
headerAction={retrieval}
|
||||
canEdit={false}
|
||||
/>,
|
||||
);
|
||||
expect(container.textContent).toContain('retrieval');
|
||||
expect(container.textContent).not.toContain('settings.sources.addFile');
|
||||
expect(await openFirstRowMenu()).not.toContain('convTile.delete');
|
||||
});
|
||||
|
||||
it('an editable FileTree keeps Add file and the row Delete', async () => {
|
||||
await render(
|
||||
<FileTree
|
||||
docId="doc"
|
||||
sourceName="Files"
|
||||
onBackToDocuments={vi.fn()}
|
||||
canEdit
|
||||
/>,
|
||||
);
|
||||
expect(container.textContent).toContain('settings.sources.addFile');
|
||||
expect(await openFirstRowMenu()).toContain('convTile.delete');
|
||||
});
|
||||
|
||||
it('a read-only one-file FileTree has no header menu and no Add chunk', async () => {
|
||||
tree.structure = { 'a.docx': { type: 'docx' } };
|
||||
try {
|
||||
await render(
|
||||
<FileTree
|
||||
docId="doc"
|
||||
sourceName="Files"
|
||||
onBackToDocuments={vi.fn()}
|
||||
canEdit={false}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
container.querySelector(
|
||||
'button[aria-label="settings.sources.menuAlt"]',
|
||||
),
|
||||
).toBeNull();
|
||||
expect(container.textContent).not.toContain('settings.sources.addChunk');
|
||||
} finally {
|
||||
tree.structure = {
|
||||
'a.docx': { type: 'docx' },
|
||||
'b.docx': { type: 'docx' },
|
||||
};
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,11 @@ interface ConnectorTreeProps {
|
||||
initialPath?: string;
|
||||
/** Embedded only: the tree's crumbs, for the host's header (see TreeBrowser). */
|
||||
onCrumbsChange?: (crumbs: Crumb[]) => void;
|
||||
/**
|
||||
* Whether the caller may change the source (`can(source, 'edit')`).
|
||||
* False hides Sync and the chunk writes; browsing stays.
|
||||
*/
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
// Provider names are brand names, so they are not translated.
|
||||
@@ -64,6 +69,7 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
|
||||
actionsTarget,
|
||||
initialPath,
|
||||
onCrumbsChange,
|
||||
canEdit = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
@@ -145,28 +151,30 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
|
||||
const topRightAction = (
|
||||
<>
|
||||
{embedded ? null : headerAction}
|
||||
<Button
|
||||
type="button"
|
||||
size="field"
|
||||
shape="pill"
|
||||
onClick={() => setSyncConfirmationModal('ACTIVE')}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
{syncDone ? (
|
||||
<Check />
|
||||
) : isSyncing ? (
|
||||
// The busy state shows its percentage, so it keeps the label and
|
||||
// draws the app's ring spinner at icon size (DESIGN.md, Button).
|
||||
<Spinner size="xs" label={t('settings.sources.syncing')} />
|
||||
) : (
|
||||
<RefreshCw />
|
||||
)}
|
||||
{isSyncing
|
||||
? `${syncProgress}%`
|
||||
: syncDone
|
||||
? t('settings.sources.syncDone')
|
||||
: t('settings.sources.sync')}
|
||||
</Button>
|
||||
{canEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="field"
|
||||
shape="pill"
|
||||
onClick={() => setSyncConfirmationModal('ACTIVE')}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
{syncDone ? (
|
||||
<Check />
|
||||
) : isSyncing ? (
|
||||
// The busy state shows its percentage, so it keeps the label and
|
||||
// draws the app's ring spinner at icon size (DESIGN.md, Button).
|
||||
<Spinner size="xs" label={t('settings.sources.syncing')} />
|
||||
) : (
|
||||
<RefreshCw />
|
||||
)}
|
||||
{isSyncing
|
||||
? `${syncProgress}%`
|
||||
: syncDone
|
||||
? t('settings.sources.syncDone')
|
||||
: t('settings.sources.sync')}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -188,6 +196,7 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
|
||||
onBackToDocuments={onBackToDocuments}
|
||||
embedded={embedded}
|
||||
onCrumbsChange={onCrumbsChange}
|
||||
canEdit={canEdit}
|
||||
actionsTarget={actionsTarget}
|
||||
initialPath={initialPath}
|
||||
badge={
|
||||
|
||||
@@ -38,6 +38,11 @@ interface FileTreeProps {
|
||||
initialPath?: string;
|
||||
/** Embedded only: the tree's crumbs, for the host's header (see TreeBrowser). */
|
||||
onCrumbsChange?: (crumbs: Crumb[]) => void;
|
||||
/**
|
||||
* Whether the caller may change the source (`can(source, 'edit')`).
|
||||
* False hides Add file, file and folder Delete (row and header menus) and the chunk writes; browsing stays.
|
||||
*/
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
const FileTree: React.FC<FileTreeProps> = ({
|
||||
@@ -49,6 +54,7 @@ const FileTree: React.FC<FileTreeProps> = ({
|
||||
actionsTarget,
|
||||
initialPath,
|
||||
onCrumbsChange,
|
||||
canEdit = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
@@ -226,6 +232,8 @@ const FileTree: React.FC<FileTreeProps> = ({
|
||||
isFile,
|
||||
defaultViewOption,
|
||||
}: RowMenuContext): MenuOption[] => {
|
||||
// Read-only: View only, so a one-file source draws no header menu.
|
||||
if (!canEdit) return [defaultViewOption];
|
||||
return [
|
||||
defaultViewOption,
|
||||
{
|
||||
@@ -248,7 +256,7 @@ const FileTree: React.FC<FileTreeProps> = ({
|
||||
const topRightAction = (
|
||||
<>
|
||||
{embedded ? null : headerAction}
|
||||
{!isProcessing ? (
|
||||
{canEdit && !isProcessing ? (
|
||||
<Button type="button" size="field" shape="pill" onClick={handleAddFile}>
|
||||
{t('settings.sources.addFile')}
|
||||
</Button>
|
||||
@@ -281,6 +289,7 @@ const FileTree: React.FC<FileTreeProps> = ({
|
||||
onBackToDocuments={onBackToDocuments}
|
||||
embedded={embedded}
|
||||
onCrumbsChange={onCrumbsChange}
|
||||
canEdit={canEdit}
|
||||
actionsTarget={actionsTarget}
|
||||
initialPath={initialPath}
|
||||
columnOrder="size-first"
|
||||
|
||||
@@ -79,6 +79,8 @@ interface GraphViewProps {
|
||||
active?: boolean;
|
||||
/** Show a chunk's file on the Files tab (the chunk drawer's "Open in Files"). */
|
||||
onOpenInFiles?: (path: string) => void;
|
||||
/** Whether the chunk drawer offers Edit (`can(source, 'edit')`). */
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
type PositionedNode = NodeObject<GraphNode> & { x?: number; y?: number };
|
||||
@@ -110,6 +112,7 @@ const GraphView: React.FC<GraphViewProps> = ({
|
||||
onSelect,
|
||||
active = true,
|
||||
onOpenInFiles,
|
||||
canEdit = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { isDesktop } = useMediaQuery();
|
||||
@@ -590,6 +593,7 @@ const GraphView: React.FC<GraphViewProps> = ({
|
||||
overview={data}
|
||||
onOpenInFiles={onOpenInFiles}
|
||||
onChunkSaved={nodeDetail.reload}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
|
||||
@@ -63,7 +63,9 @@ import {
|
||||
} from '../constants/fileUpload';
|
||||
import { UserToolType } from '../settings/types';
|
||||
import { sourceItemId, toSourcePickerItems } from '../utils/sourceUtils';
|
||||
import { isChatToolVisible } from '../utils/toolUtils';
|
||||
import { showActionToast } from '../notifications/actionToastSlice';
|
||||
import { isOwner } from '../utils/accessUtils';
|
||||
import { isChatPickerToolVisible, toolInChat } from '../utils/toolUtils';
|
||||
|
||||
const generateId = (): string =>
|
||||
`${Date.now()}-${Math.random().toString(36).substring(2)}`;
|
||||
@@ -1551,7 +1553,7 @@ export default function MessageInput({
|
||||
.getUserTools(token)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
const filtered = (data.tools || []).filter(isChatToolVisible);
|
||||
const filtered = (data.tools || []).filter(isChatPickerToolVisible);
|
||||
setUserTools(filtered);
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -1568,25 +1570,53 @@ export default function MessageInput({
|
||||
id: tool.id,
|
||||
label: tool.customName || tool.displayName,
|
||||
icon: <ToolIcon name={tool.name} className="size-5" />,
|
||||
description:
|
||||
!isOwner(tool) && tool.shared_via
|
||||
? t('settings.tools.sharedBy', {
|
||||
interpolation: { escapeValue: false },
|
||||
team: tool.shared_via,
|
||||
})
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
const selectedToolIds = userTools
|
||||
.filter((tool) => tool.status)
|
||||
.filter((tool) => toolInChat(tool))
|
||||
.map((tool) => tool.id);
|
||||
|
||||
// Ticks at once and unticks again when the server refuses it.
|
||||
const setToolInChat = (id: string, value: boolean) =>
|
||||
setUserTools((prev) =>
|
||||
prev.map((tool) =>
|
||||
tool.id !== id
|
||||
? tool
|
||||
: isOwner(tool)
|
||||
? { ...tool, status: value, in_chat: value }
|
||||
: { ...tool, in_chat: value },
|
||||
),
|
||||
);
|
||||
|
||||
const handleToggleTool = (id: string) => {
|
||||
const tool = userTools.find((t) => t.id === id);
|
||||
if (!tool) return;
|
||||
const newStatus = !tool.status;
|
||||
const newStatus = !toolInChat(tool);
|
||||
setToolInChat(id, newStatus);
|
||||
const fail = () => {
|
||||
setToolInChat(id, !newStatus);
|
||||
dispatch(
|
||||
showActionToast({
|
||||
variant: 'destructive',
|
||||
message: t('settings.tools.statusUpdateFailed'),
|
||||
}),
|
||||
);
|
||||
};
|
||||
userService
|
||||
.updateToolStatus({ id, status: newStatus }, token)
|
||||
.then(() => {
|
||||
setUserTools((prev) =>
|
||||
prev.map((t) => (t.id === id ? { ...t, status: newStatus } : t)),
|
||||
);
|
||||
.then((response: Response) => {
|
||||
if (!response.ok) fail();
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch((error: unknown) => {
|
||||
console.error('Failed to update tool status:', error);
|
||||
fail();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ interface GraphChunkSheetProps {
|
||||
onOpenInFiles?: (path: string) => void;
|
||||
/** Called after a saved edit, to refetch the node detail. */
|
||||
onSaved?: () => void;
|
||||
/** Whether the read drawer offers Edit (`can(source, 'edit')`). */
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,6 +47,7 @@ export default function GraphChunkSheet({
|
||||
onClose,
|
||||
onOpenInFiles,
|
||||
onSaved,
|
||||
canEdit = true,
|
||||
}: GraphChunkSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
@@ -91,6 +94,8 @@ export default function GraphChunkSheet({
|
||||
? t('settings.sources.graphrag.view.chunkTokens', { tokens })
|
||||
: '';
|
||||
|
||||
const showOpenInFiles = !!onOpenInFiles && !!path;
|
||||
|
||||
const close = () => {
|
||||
setEditing(false);
|
||||
setSaveFailed(false);
|
||||
@@ -173,32 +178,44 @@ export default function GraphChunkSheet({
|
||||
highlight={highlight}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex justify-end gap-3 px-6 py-4">
|
||||
{onOpenInFiles && path ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
shape="pill"
|
||||
onClick={() => {
|
||||
close();
|
||||
onOpenInFiles(path);
|
||||
}}
|
||||
>
|
||||
{t('settings.sources.graphrag.view.openInFiles')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" size="lg" shape="pill" onClick={startEdit}>
|
||||
<Pencil />
|
||||
{t('modals.chunk.edit')}
|
||||
</Button>
|
||||
</div>
|
||||
{/* A reader with nothing to open or edit gets no action row. */}
|
||||
{showOpenInFiles || canEdit ? (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex justify-end gap-3 px-6 py-4">
|
||||
{showOpenInFiles ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
shape="pill"
|
||||
onClick={() => {
|
||||
close();
|
||||
onOpenInFiles?.(path);
|
||||
}}
|
||||
>
|
||||
{t('settings.sources.graphrag.view.openInFiles')}
|
||||
</Button>
|
||||
) : null}
|
||||
{canEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
shape="pill"
|
||||
onClick={startEdit}
|
||||
>
|
||||
<Pencil />
|
||||
{t('modals.chunk.edit')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<SourceEditSheet
|
||||
open={editing}
|
||||
open={canEdit && editing}
|
||||
onClose={leaveEdit}
|
||||
title={t('settings.sources.graphrag.view.editChunk')}
|
||||
description={meta || undefined}
|
||||
|
||||
@@ -62,6 +62,7 @@ export default function GraphEntities({
|
||||
onShowInGraph,
|
||||
overview,
|
||||
onOpenInFiles,
|
||||
canEdit = true,
|
||||
}: {
|
||||
docId: string;
|
||||
fold: FoldedGraphTypes;
|
||||
@@ -70,6 +71,8 @@ export default function GraphEntities({
|
||||
overview?: ForceGraphData;
|
||||
/** Show a chunk's file on the Files tab. */
|
||||
onOpenInFiles?: (path: string) => void;
|
||||
/** Whether the chunk drawer offers Edit (`can(source, 'edit')`). */
|
||||
canEdit?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
@@ -157,6 +160,7 @@ export default function GraphEntities({
|
||||
overview={overview}
|
||||
onOpenInFiles={onOpenInFiles}
|
||||
onChunkSaved={nodeDetail.reload}
|
||||
canEdit={canEdit}
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -69,6 +69,8 @@ interface GraphNodePanelProps {
|
||||
onOpenInFiles?: (path: string) => void;
|
||||
/** Refetch the detail after a chunk edit, keeping it on screen. */
|
||||
onChunkSaved?: () => void;
|
||||
/** Whether the chunk drawer offers Edit (`can(source, 'edit')`). */
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,6 +107,7 @@ export default function GraphNodePanel({
|
||||
overview,
|
||||
onOpenInFiles,
|
||||
onChunkSaved,
|
||||
canEdit = true,
|
||||
}: GraphNodePanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const name = detail?.name ?? node.name;
|
||||
@@ -166,6 +169,7 @@ export default function GraphNodePanel({
|
||||
overview={overview}
|
||||
onOpenInFiles={onOpenInFiles}
|
||||
onChunkSaved={onChunkSaved}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
@@ -193,6 +197,7 @@ function NodeDetailBody({
|
||||
overview,
|
||||
onOpenInFiles,
|
||||
onChunkSaved,
|
||||
canEdit,
|
||||
}: {
|
||||
docId: string;
|
||||
detail: GraphNodeDetail;
|
||||
@@ -201,6 +206,7 @@ function NodeDetailBody({
|
||||
overview?: ForceGraphData;
|
||||
onOpenInFiles?: (path: string) => void;
|
||||
onChunkSaved?: () => void;
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [descriptionOpen, setDescriptionOpen] = useState(false);
|
||||
@@ -413,6 +419,7 @@ function NodeDetailBody({
|
||||
onClose={() => setOpenChunk(null)}
|
||||
onOpenInFiles={onOpenInFiles}
|
||||
onSaved={onChunkSaved}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -40,6 +40,7 @@ vi.mock('../FileTree', async () => {
|
||||
return {
|
||||
default: (props: {
|
||||
embedded?: boolean;
|
||||
canEdit?: boolean;
|
||||
initialPath?: string;
|
||||
actionsTarget?: HTMLElement | null;
|
||||
onCrumbsChange?: (crumbs: { label: string }[]) => void;
|
||||
@@ -51,6 +52,7 @@ vi.mock('../FileTree', async () => {
|
||||
return (
|
||||
<div
|
||||
data-testid="file-tree"
|
||||
data-can-edit={String(props.canEdit)}
|
||||
data-initial-path={props.initialPath ?? ''}
|
||||
>
|
||||
{props.embedded ? 'embedded' : 'page'}
|
||||
@@ -72,6 +74,7 @@ vi.mock('../Chunks', async () => {
|
||||
return {
|
||||
default: (props: {
|
||||
embedded?: boolean;
|
||||
canEdit?: boolean;
|
||||
documentId: string;
|
||||
onOpenChunkChange?: (position: number | 'unplaced' | null) => void;
|
||||
controllerRef?: { current: { closeChunk: () => boolean } | null };
|
||||
@@ -90,7 +93,11 @@ vi.mock('../Chunks', async () => {
|
||||
};
|
||||
}
|
||||
return (
|
||||
<div data-testid="chunks" data-doc={props.documentId}>
|
||||
<div
|
||||
data-testid="chunks"
|
||||
data-doc={props.documentId}
|
||||
data-can-edit={String(props.canEdit)}
|
||||
>
|
||||
{props.embedded ? 'embedded' : 'page'}
|
||||
<button type="button" onClick={() => setOpen(2)}>
|
||||
OPEN CHUNK
|
||||
@@ -104,7 +111,9 @@ vi.mock('../Chunks', async () => {
|
||||
};
|
||||
});
|
||||
vi.mock('../ConnectorTree', () => ({
|
||||
default: () => <div data-testid="connector-tree" />,
|
||||
default: (props: { canEdit?: boolean }) => (
|
||||
<div data-testid="connector-tree" data-can-edit={String(props.canEdit)} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/services/userService', () => ({
|
||||
@@ -207,6 +216,7 @@ describe('GraphSourceView', () => {
|
||||
sourceType?: string,
|
||||
onBack = vi.fn(),
|
||||
isNested?: boolean,
|
||||
canEdit?: boolean,
|
||||
) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
@@ -215,6 +225,7 @@ describe('GraphSourceView', () => {
|
||||
sourceName="Key Accounts"
|
||||
sourceType={sourceType}
|
||||
isNested={isNested}
|
||||
canEdit={canEdit}
|
||||
onBackToDocuments={onBack}
|
||||
headerAction={<button type="button">Test retrieval</button>}
|
||||
/>,
|
||||
@@ -554,6 +565,75 @@ describe('GraphSourceView', () => {
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it('passes canEdit to every Files view', async () => {
|
||||
const canEditOf = (testId: string) =>
|
||||
container
|
||||
.querySelector(`[data-testid="${testId}"]`)
|
||||
?.getAttribute('data-can-edit');
|
||||
await render(undefined, vi.fn(), true, false);
|
||||
await openTab('settings.sources.graphrag.view.tabs.files');
|
||||
expect(canEditOf('file-tree')).toBe('false');
|
||||
await render('connector:file', vi.fn(), true, false);
|
||||
expect(canEditOf('connector-tree')).toBe('false');
|
||||
await render(undefined, vi.fn(), false, false);
|
||||
expect(canEditOf('chunks')).toBe('false');
|
||||
await render(undefined, vi.fn(), false, true);
|
||||
expect(canEditOf('chunks')).toBe('true');
|
||||
});
|
||||
|
||||
const openEntityChunk = async () => {
|
||||
service.getSourceGraphNode.mockResolvedValue(
|
||||
ok({
|
||||
node: {
|
||||
id: 'n',
|
||||
name: 'Nordhaven',
|
||||
type: 'Company',
|
||||
degree: 9,
|
||||
relationships: [],
|
||||
chunks: [
|
||||
{
|
||||
chunk_id: 'c1',
|
||||
text: 'Nordhaven runs the lane.',
|
||||
metadata: { source: 'briefs/Nordhaven.md' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await openTab('settings.sources.graphrag.view.tabs.entities');
|
||||
const row = Array.from(container.querySelectorAll('tbody tr')).find((r) =>
|
||||
r.textContent?.includes('Nordhaven'),
|
||||
) as HTMLTableRowElement;
|
||||
await act(async () => row.click());
|
||||
await flush();
|
||||
const tile = Array.from(
|
||||
container.querySelectorAll('button[data-slot="card"]'),
|
||||
).find((b) =>
|
||||
b.textContent?.includes('Nordhaven runs the lane.'),
|
||||
) as HTMLButtonElement;
|
||||
await act(async () => tile.click());
|
||||
};
|
||||
|
||||
const drawerButtons = () =>
|
||||
Array.from(document.body.querySelectorAll('[role="dialog"] button')).map(
|
||||
(b) => b.textContent,
|
||||
);
|
||||
|
||||
it("a read-only graph's chunk drawer has Open in Files but no Edit", async () => {
|
||||
await render(undefined, vi.fn(), true, false);
|
||||
await openEntityChunk();
|
||||
expect(drawerButtons()).toContain(
|
||||
'settings.sources.graphrag.view.openInFiles',
|
||||
);
|
||||
expect(drawerButtons()).not.toContain('modals.chunk.edit');
|
||||
});
|
||||
|
||||
it("an editor's graph chunk drawer keeps Edit", async () => {
|
||||
await render(undefined, vi.fn(), true, true);
|
||||
await openEntityChunk();
|
||||
expect(drawerButtons()).toContain('modals.chunk.edit');
|
||||
});
|
||||
|
||||
it('a new entity filter fetches page 1 once, not the old page first', async () => {
|
||||
service.getSourceGraphNodes.mockImplementation(async () =>
|
||||
ok({
|
||||
|
||||
@@ -50,6 +50,11 @@ interface GraphSourceViewProps {
|
||||
onBackToDocuments: () => void;
|
||||
/** Extra header control (Test retrieval), right-aligned in the title row. */
|
||||
headerAction?: ReactNode;
|
||||
/**
|
||||
* Whether the caller may change the source (`can(source, 'edit')`). False
|
||||
* hides the Files tab's writes and the graph chunk drawer's Edit.
|
||||
*/
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,6 +71,7 @@ export default function GraphSourceView({
|
||||
isNested = true,
|
||||
onBackToDocuments,
|
||||
headerAction,
|
||||
canEdit = true,
|
||||
}: GraphSourceViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
@@ -172,6 +178,7 @@ export default function GraphSourceView({
|
||||
const files = !isNested ? (
|
||||
<Chunks
|
||||
embedded
|
||||
canEdit={canEdit}
|
||||
documentId={docId}
|
||||
documentName={sourceName}
|
||||
handleGoBack={onBackToDocuments}
|
||||
@@ -181,6 +188,7 @@ export default function GraphSourceView({
|
||||
) : sourceType === 'connector:file' ? (
|
||||
<ConnectorTree
|
||||
embedded
|
||||
canEdit={canEdit}
|
||||
docId={docId}
|
||||
sourceName={sourceName}
|
||||
onBackToDocuments={onBackToDocuments}
|
||||
@@ -191,6 +199,7 @@ export default function GraphSourceView({
|
||||
) : (
|
||||
<FileTree
|
||||
embedded
|
||||
canEdit={canEdit}
|
||||
docId={docId}
|
||||
sourceName={sourceName}
|
||||
onBackToDocuments={onBackToDocuments}
|
||||
@@ -277,6 +286,7 @@ export default function GraphSourceView({
|
||||
onSelect={setSelected}
|
||||
active={tab === 'graph'}
|
||||
onOpenInFiles={openInFiles}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="entities" className="mt-4">
|
||||
@@ -286,6 +296,7 @@ export default function GraphSourceView({
|
||||
onShowInGraph={showInGraph}
|
||||
overview={data}
|
||||
onOpenInFiles={openInFiles}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="files" className="mt-4">
|
||||
|
||||
@@ -337,6 +337,17 @@ describe('TreeBrowser', () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('canEdit false reaches the chunk list: no Add chunk', async () => {
|
||||
await render({ 'report.pdf': { type: 'pdf' } }, { canEdit: false });
|
||||
expect(chunkListOpen()).toBe(true);
|
||||
expect(container.textContent).not.toContain('settings.sources.addChunk');
|
||||
});
|
||||
|
||||
it('an editable tree keeps Add chunk on the chunk list', async () => {
|
||||
await render({ 'report.pdf': { type: 'pdf' } });
|
||||
expect(container.textContent).toContain('settings.sources.addChunk');
|
||||
});
|
||||
|
||||
it('a failed load says so and retries', async () => {
|
||||
const getDirectoryStructure = vi.mocked(userService.getDirectoryStructure);
|
||||
getDirectoryStructure.mockImplementationOnce(async () => {
|
||||
|
||||
@@ -114,6 +114,11 @@ export interface TreeBrowserProps {
|
||||
* changes.
|
||||
*/
|
||||
initialPath?: string;
|
||||
/**
|
||||
* Whether the caller may change the source (`can(source, 'edit')`).
|
||||
* False hides the chunk list's Add, Edit and Delete; browsing stays.
|
||||
*/
|
||||
canEdit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,6 +199,7 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
|
||||
actionsTarget,
|
||||
initialPath,
|
||||
onCrumbsChange,
|
||||
canEdit = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useLoaderState(true, 500);
|
||||
@@ -688,6 +694,7 @@ const TreeBrowser: React.FC<TreeBrowserProps> = ({
|
||||
fileName={file.name}
|
||||
controllerRef={chunksControllerRef}
|
||||
onOpenChunkChange={setOpenChunkPosition}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -45,4 +45,15 @@ describe('ListRow', () => {
|
||||
expect(html).not.toContain('px-4 py-3');
|
||||
expect(html).toContain('focus-visible:ring-inset');
|
||||
});
|
||||
|
||||
it('keeps the brand tint on a selected row, hover included', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ListRow interactive selected asChild title="Carrier Rates MCP">
|
||||
<button type="button" />
|
||||
</ListRow>,
|
||||
);
|
||||
expect(html).toContain('bg-secondary hover:bg-secondary');
|
||||
expect(html).not.toContain('hover:bg-accent');
|
||||
expect(html).toContain('aria-current="true"');
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,12 @@ type ListRowProps = Omit<React.ComponentProps<'li'>, 'title'> & {
|
||||
trailing?: React.ReactNode;
|
||||
/** The whole row is a target: hover fill and an inset focus ring. */
|
||||
interactive?: boolean;
|
||||
/**
|
||||
* The row whose detail is open beside the list (the team page's shared
|
||||
* resources drawer): the `bg-secondary` brand tint, kept on hover, and
|
||||
* `aria-current`, like a selected TableRow.
|
||||
*/
|
||||
selected?: boolean;
|
||||
/**
|
||||
* `sm` is the dense row of a narrow side panel (the graph node panel's
|
||||
* relationships): 6px / 8px padding, rounded, top-aligned so a small
|
||||
@@ -45,6 +51,7 @@ function ListRow({
|
||||
description,
|
||||
trailing,
|
||||
interactive = false,
|
||||
selected = false,
|
||||
size = 'default',
|
||||
asChild = false,
|
||||
className,
|
||||
@@ -59,7 +66,9 @@ function ListRow({
|
||||
// Inset, because a row list usually sits in an overflow-hidden rounded
|
||||
// box that would clip an outer ring.
|
||||
interactive &&
|
||||
'hover:bg-accent focus-visible:ring-ring/50 w-full text-left transition-colors outline-none focus-visible:ring-3 focus-visible:ring-inset',
|
||||
'focus-visible:ring-ring/50 w-full text-left transition-colors outline-none focus-visible:ring-3 focus-visible:ring-inset',
|
||||
interactive && !selected && 'hover:bg-accent',
|
||||
selected && 'bg-secondary hover:bg-secondary',
|
||||
!asChild && className,
|
||||
);
|
||||
const content = (
|
||||
@@ -80,7 +89,10 @@ function ListRow({
|
||||
if (asChild) {
|
||||
return (
|
||||
<li data-slot="list-row" className={className} {...props}>
|
||||
<Slot.Root className={rowClass}>
|
||||
<Slot.Root
|
||||
className={rowClass}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
>
|
||||
{React.isValidElement(children)
|
||||
? React.cloneElement(
|
||||
children as React.ReactElement<{ children?: React.ReactNode }>,
|
||||
@@ -94,7 +106,12 @@ function ListRow({
|
||||
}
|
||||
|
||||
return (
|
||||
<li data-slot="list-row" className={rowClass} {...props}>
|
||||
<li
|
||||
data-slot="list-row"
|
||||
className={rowClass}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import { canOpenAgentEditor } from '../agents/agentAccess';
|
||||
import SharedAgentCard from '../agents/SharedAgentCard';
|
||||
import { Agent } from '../agents/types';
|
||||
import ArtifactSidebar from '../components/ArtifactSidebar';
|
||||
@@ -12,6 +13,7 @@ import MessageInput from '../components/MessageInput';
|
||||
import { agentChatPath, agentEditPathFor } from '../agents/paths';
|
||||
import { useMediaQuery } from '../hooks';
|
||||
import {
|
||||
selectAgents,
|
||||
selectConversationId,
|
||||
selectSelectedAgent,
|
||||
selectToken,
|
||||
@@ -61,6 +63,7 @@ export default function Conversation() {
|
||||
const status = useSelector(selectStatus);
|
||||
const conversationId = useSelector(selectConversationId);
|
||||
const selectedAgent = useSelector(selectSelectedAgent);
|
||||
const agents = useSelector(selectAgents);
|
||||
const completedAttachments = useSelector(selectCompletedAttachments);
|
||||
const attachments = useSelector(selectAttachments);
|
||||
// A direct send (hero card) that must wait for pending attachments is
|
||||
@@ -401,7 +404,8 @@ export default function Conversation() {
|
||||
<SharedAgentCard
|
||||
agent={selectedAgent}
|
||||
onEdit={
|
||||
selectedAgent.id
|
||||
// Only a role that may open the edit page gets Edit.
|
||||
canOpenAgentEditor(selectedAgent, agents)
|
||||
? () => navigate(agentEditPathFor(selectedAgent))
|
||||
: undefined
|
||||
}
|
||||
|
||||
+218
-10
@@ -92,7 +92,10 @@
|
||||
"edit": "Prompt bearbeiten",
|
||||
"view": "Prompt anzeigen",
|
||||
"duplicate": "Prompt duplizieren",
|
||||
"delete": "Prompt löschen"
|
||||
"delete": "Prompt löschen",
|
||||
"deleteFailed": "Dieser Prompt konnte nicht gelöscht werden.",
|
||||
"saveFailed": "Dieser Prompt konnte nicht gespeichert werden.",
|
||||
"editConflict": "Jemand anderes hat diesen Prompt geändert. Öffne ihn erneut, um dessen Version zu sehen."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
@@ -437,7 +440,15 @@
|
||||
"editChunk": "Chunk bearbeiten",
|
||||
"editChunkDescription": "{{file}} · Chunk {{n}} · {{tokens}} Tokens",
|
||||
"previousChunk": "Vorheriger Chunk",
|
||||
"nextChunk": "Nächster Chunk"
|
||||
"nextChunk": "Nächster Chunk",
|
||||
"viewConfig": "Quelleneinstellungen ansehen",
|
||||
"errors": {
|
||||
"forbidden": "Dazu hast du für diese Quelle keine Berechtigung.",
|
||||
"delete": "Die Quelle konnte nicht gelöscht werden.",
|
||||
"sync": "Die Quelle konnte nicht synchronisiert werden.",
|
||||
"syncFrequency": "Die Synchronisierungshäufigkeit konnte nicht geändert werden.",
|
||||
"reingest": "Die Neuaufnahme konnte nicht gestartet werden."
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"label": "Analytik",
|
||||
@@ -729,7 +740,184 @@
|
||||
"teamLabel": "Team",
|
||||
"viaTeam": "über {{team}}",
|
||||
"removeAccess": "Zugriff entfernen",
|
||||
"access": "Zugriff"
|
||||
"access": "Zugriff",
|
||||
"showAll": "Alle {{count}} anzeigen",
|
||||
"andMore": "und {{count}} weitere",
|
||||
"back": "Zurück",
|
||||
"allSummary": "{{name}} · Teams: {{teams}} · Personen: {{people}}",
|
||||
"searchAccess": "Personen und Teams suchen…",
|
||||
"filterLabel": "Personen mit Zugriff filtern",
|
||||
"filter": {
|
||||
"all": "Alle",
|
||||
"teams": "Teams",
|
||||
"people": "Personen",
|
||||
"editors": "Bearbeiter"
|
||||
}
|
||||
},
|
||||
"accessChangeError": "Zugriff konnte nicht geändert werden.",
|
||||
"accessSettings": {
|
||||
"title": "Zugriffseinstellungen",
|
||||
"saveError": "Die Zugriffseinstellung konnte nicht gespeichert werden.",
|
||||
"agent": {
|
||||
"editors_can_share": {
|
||||
"label": "Bearbeiter dürfen teilen",
|
||||
"description": "Personen und Teams hinzufügen und ihren Zugriff ändern."
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "Bearbeiter dürfen löschen",
|
||||
"description": "Den Agenten für alle löschen."
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"label": "Bearbeiter dürfen Zugangsdaten verwalten",
|
||||
"description": "API-Schlüssel, Webhook und öffentlicher Link."
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"label": "Betrachter sehen Protokolle",
|
||||
"description": "Unterhaltungen und Analysen dieses Agenten."
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"editors_can_share": {
|
||||
"label": "Bearbeiter dürfen teilen",
|
||||
"description": "Personen und Teams hinzufügen und ihren Zugriff ändern."
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "Bearbeiter dürfen löschen",
|
||||
"description": "Die Quelle für alle löschen."
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"label": "Betrachter sehen Einstellungen",
|
||||
"description": "Chunking-, Retriever- und Sync-Einstellungen, nur lesend."
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"editors_can_change_credentials": {
|
||||
"label": "Bearbeiter dürfen Anmeldedaten und Verbindung ändern",
|
||||
"description": "Sie ersetzen gespeicherte Geheimnisse; niemand kann sie auslesen."
|
||||
},
|
||||
"editors_can_share": {
|
||||
"label": "Bearbeiter dürfen teilen",
|
||||
"description": "Personen und Teams hinzufügen und ihren Zugriff ändern."
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"label": "Betrachter dürfen es in eigenen Agenten nutzen",
|
||||
"description": "Es läuft mit deinen Anmeldedaten."
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"editors_can_share": {
|
||||
"label": "Bearbeiter dürfen teilen",
|
||||
"description": "Personen und Teams hinzufügen und ihren Zugriff ändern."
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"label": "Betrachter dürfen duplizieren",
|
||||
"description": "Eine eigene Kopie zum Bearbeiten anlegen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"editorHint": {
|
||||
"agent": "Bearbeiter können ihn ändern, einschließlich Protokollen, Zeitplänen und Zugangsdaten.",
|
||||
"agentNoAccessDetails": "Bearbeiter können ihn ändern, einschließlich Protokollen und Zeitplänen, aber nicht die Zugangsdaten.",
|
||||
"source": "Bearbeiter können Chunks und Dateien bearbeiten, synchronisieren und Einstellungen ändern.",
|
||||
"tool": "Bearbeiter können Aktionen ändern und Anmeldedaten ersetzen, aber keine Geheimnisse lesen.",
|
||||
"toolNoCredentials": "Bearbeiter können Aktionen ändern, aber keine Anmeldedaten.",
|
||||
"prompt": "Bearbeiter können den Text ändern.",
|
||||
"noShareNoDelete": "Sie können es weder teilen noch löschen.",
|
||||
"shareOnly": "Sie können es auch teilen, aber nicht löschen.",
|
||||
"deleteOnly": "Sie können es auch löschen, aber nicht teilen.",
|
||||
"shareAndDelete": "Sie können es auch teilen und löschen.",
|
||||
"noShare": "Sie können es weder teilen noch löschen.",
|
||||
"share": "Sie können es auch teilen, aber nicht löschen."
|
||||
},
|
||||
"capabilities": {
|
||||
"agent": {
|
||||
"viewers": "Betrachter: damit chatten und ihn anheften",
|
||||
"editors": "Bearbeiter: bearbeiten, veröffentlichen, Protokolle sehen und Zeitpläne verwalten"
|
||||
},
|
||||
"source": {
|
||||
"viewers": "Betrachter: durchsuchen und in eigenen Agenten nutzen",
|
||||
"editors": "Bearbeiter: Chunks und Dateien bearbeiten, synchronisieren, Einstellungen ändern"
|
||||
},
|
||||
"tool": {
|
||||
"viewers": "Betrachter: in den Agenten des Eigentümers nutzen",
|
||||
"editors": "Bearbeiter: Aktionen und Freigaben ändern"
|
||||
},
|
||||
"prompt": {
|
||||
"viewers": "Betrachter: lesen und in eigenen Agenten nutzen",
|
||||
"editors": "Bearbeiter: den Text ändern"
|
||||
},
|
||||
"switch": {
|
||||
"editors_can_share": {
|
||||
"on": "Bearbeiter können es teilen",
|
||||
"off": "Bearbeiter können es nicht teilen"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"on": "Bearbeiter können es löschen",
|
||||
"off": "Bearbeiter können es nicht löschen"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"on": "Bearbeiter verwalten API-Schlüssel, Webhook und öffentlichen Link",
|
||||
"off": "Bearbeiter verwalten weder API-Schlüssel noch Webhook oder öffentlichen Link"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"on": "Betrachter sehen Protokolle",
|
||||
"off": "Betrachter sehen keine Protokolle"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"on": "Betrachter sehen die Einstellungen",
|
||||
"off": "Betrachter sehen die Einstellungen nicht"
|
||||
},
|
||||
"editors_can_change_credentials": {
|
||||
"on": "Bearbeiter können Anmeldedaten ersetzen",
|
||||
"off": "Bearbeiter können Anmeldedaten nicht ändern"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"on": "Betrachter können es in eigenen Agenten nutzen",
|
||||
"off": "Betrachter können es nicht in eigenen Agenten nutzen"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"on": "Betrachter können es duplizieren",
|
||||
"off": "Betrachter können es nicht duplizieren"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sharedList": {
|
||||
"badgeWithEditors": "{{level}} · +{{count}} Bearbeiter",
|
||||
"meta": "{{type}} · {{owner}}",
|
||||
"filterLabel": "Geteilte Ressourcen filtern",
|
||||
"filter": {
|
||||
"all": "Alle",
|
||||
"agent": "Agenten",
|
||||
"source": "Quellen",
|
||||
"tool": "Werkzeuge",
|
||||
"prompt": "Prompts"
|
||||
},
|
||||
"search": "Geteilte suchen…",
|
||||
"noMatches": "Keine Treffer."
|
||||
},
|
||||
"drawer": {
|
||||
"close": "Schließen",
|
||||
"subtitle": "{{type}} · Eigentümer: {{owner}}",
|
||||
"open": "{{type}} öffnen",
|
||||
"manageSharing": "Freigabe verwalten",
|
||||
"owner": "Eigentümer",
|
||||
"shared": "Geteilt",
|
||||
"sharedOnBy": "{{date}} von {{name}}",
|
||||
"yourAccess": "Dein Zugriff",
|
||||
"yourAccessLevel": {
|
||||
"owner": "Eigentümer",
|
||||
"editor": "Bearbeiter",
|
||||
"viewer": "Betrachter",
|
||||
"none": "Kein Zugriff"
|
||||
},
|
||||
"accessIn": "Zugriff in {{team}}",
|
||||
"everyone": "Alle in {{team}}",
|
||||
"teamGrant": "Ganzes Team",
|
||||
"memberGrant": "Nur diese Person",
|
||||
"removeGrant": "Zugriff entfernen",
|
||||
"otherTeamsHint": "Auch mit anderen Teams geteilt? Diese Freigaben verwaltest du unter „Freigabe verwalten“.",
|
||||
"whatPeopleCanDo": "Was Personen hier dürfen",
|
||||
"capabilitiesHint": "Aus den Zugriffseinstellungen des Eigentümers. Hier nur lesend."
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
@@ -803,7 +991,6 @@
|
||||
"addServer": "MCP-Server hinzufügen",
|
||||
"editServer": "Server bearbeiten",
|
||||
"reconnectServer": "Server erneut verbinden",
|
||||
"reenterCredentials": "Gib deine Zugangsdaten erneut ein, um die Verbindung zu testen und zu aktualisieren.",
|
||||
"serverName": "Servername",
|
||||
"serverUrl": "Server-URL",
|
||||
"headerName": "Header-Name",
|
||||
@@ -848,7 +1035,18 @@
|
||||
"oauthFailed": "OAuth-Prozess fehlgeschlagen oder abgebrochen",
|
||||
"oauthTimeout": "OAuth-Prozess abgelaufen, bitte erneut versuchen",
|
||||
"timeoutRange": "Timeout muss zwischen 1 und 300 Sekunden liegen"
|
||||
}
|
||||
},
|
||||
"sharedByEditor": "Geteilt von {{owner}} · du bist Bearbeiter",
|
||||
"aTeammate": "einem Teammitglied",
|
||||
"sharedCredentialsNotice": "Gespeicherte Zugangsdaten bleiben verborgen. Was du eingibst, ersetzt sie für alle, die dieses Tool nutzen.",
|
||||
"serverChangedNotice": "Der Server hat sich geändert, daher werden die gespeicherten Zugangsdaten ({{credential}}) gelöscht. Gib sie für den neuen Server ein, um zu speichern.",
|
||||
"credentialNames": {
|
||||
"apiKey": "API-Schlüssel",
|
||||
"bearer": "Token",
|
||||
"password": "Passwort"
|
||||
},
|
||||
"savedKeyHint": "Ein Schlüssel ist gespeichert. Leer lassen, um ihn zu behalten (nur solange der Server unverändert ist).",
|
||||
"sharedOAuthOwnerOnly": "Nur der Eigentümer kann die Anmeldung neu verbinden. Du kannst das Tool umbenennen, aber weder Server noch Konto ändern."
|
||||
},
|
||||
"configErrors": {
|
||||
"required": "{{field}} ist erforderlich",
|
||||
@@ -856,7 +1054,14 @@
|
||||
"maxTimeout": "Das maximale Timeout beträgt 300 Sekunden"
|
||||
},
|
||||
"headerValuePlaceholder": "z. B. application/json",
|
||||
"toolIconTitle": "{{name}}-Symbol"
|
||||
"toolIconTitle": "{{name}}-Symbol",
|
||||
"view": "Ansehen",
|
||||
"inMyChats": "In meinen Chats",
|
||||
"useInMyChatsAria": "{{toolName}} in meinen Chats verwenden",
|
||||
"statusUpdateFailed": "Konnte nicht ändern, ob dieses Tool in deinen Chats ist.",
|
||||
"deleteFailed": "Dieses Tool konnte nicht gelöscht werden.",
|
||||
"sharedBy": "Geteilt von {{team}}",
|
||||
"savedSecretPlaceholder": "Gespeichert · neuen Wert eingeben zum Ersetzen"
|
||||
},
|
||||
"devices": {
|
||||
"label": "Geräte",
|
||||
@@ -1353,7 +1558,9 @@
|
||||
"test": "Testen",
|
||||
"learnMore": "Mehr erfahren",
|
||||
"resetKey": "Schlüssel zurücksetzen",
|
||||
"resetKeyConfirm": "Möchten Sie den API-Schlüssel wirklich zurücksetzen? Der aktuelle Schlüssel funktioniert sofort nicht mehr und diese Aktion kann nicht rückgängig gemacht werden."
|
||||
"resetKeyConfirm": "Möchten Sie den API-Schlüssel wirklich zurücksetzen? Der aktuelle Schlüssel funktioniert sofort nicht mehr und diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"actionFailed": "Das hat nicht funktioniert. Bitte versuche es erneut.",
|
||||
"apiKeyAfterPublish": "Veröffentliche den Agenten, um seinen API-Schlüssel zu erstellen."
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "API-Spezifikation importieren",
|
||||
@@ -1774,7 +1981,6 @@
|
||||
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
|
||||
"remove": "Remove",
|
||||
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
|
||||
"ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
|
||||
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
|
||||
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
|
||||
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
|
||||
@@ -1792,7 +1998,8 @@
|
||||
"modes": {
|
||||
"monitorOnly": "Monitor only",
|
||||
"scanAll": "Enforce everywhere"
|
||||
}
|
||||
},
|
||||
"readOnly": "Du kannst diese Richtlinie sehen, aber deine Rolle kann sie nicht ändern."
|
||||
},
|
||||
"byline": {
|
||||
"new": "Richten Sie den Agenten ein und veröffentlichen Sie ihn, um mit ihm zu chatten."
|
||||
@@ -2234,7 +2441,8 @@
|
||||
"classicDescription": "Erstelle einen Standard-KI-Agenten mit einem Modell, Tools und Wissensquellen",
|
||||
"workflowTitle": "Workflow-Agent",
|
||||
"workflowDescription": "Entwirf komplexe mehrstufige Workflows mit verschiedenen Modellen, bedingter Logik und Zustandsverwaltung"
|
||||
}
|
||||
},
|
||||
"deleteFailed": "Der Agent konnte nicht gelöscht werden. Bitte versuche es erneut."
|
||||
},
|
||||
"components": {
|
||||
"fileUpload": {
|
||||
|
||||
+218
-10
@@ -96,7 +96,10 @@
|
||||
"edit": "Edit prompt",
|
||||
"view": "View prompt",
|
||||
"duplicate": "Duplicate prompt",
|
||||
"delete": "Delete prompt"
|
||||
"delete": "Delete prompt",
|
||||
"deleteFailed": "Couldn't delete this prompt.",
|
||||
"saveFailed": "Couldn't save this prompt.",
|
||||
"editConflict": "Someone else changed this prompt. Reopen it to see their version."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
@@ -442,7 +445,15 @@
|
||||
"editChunk": "Edit chunk",
|
||||
"editChunkDescription": "{{file}} · chunk {{n}} · {{tokens}} tokens",
|
||||
"previousChunk": "Previous chunk",
|
||||
"nextChunk": "Next chunk"
|
||||
"nextChunk": "Next chunk",
|
||||
"viewConfig": "View source settings",
|
||||
"errors": {
|
||||
"forbidden": "You don't have permission to do that on this source.",
|
||||
"delete": "Couldn't delete the source.",
|
||||
"sync": "Couldn't sync the source.",
|
||||
"syncFrequency": "Couldn't change the sync frequency.",
|
||||
"reingest": "Couldn't start the reingest."
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"label": "Analytics",
|
||||
@@ -735,7 +746,184 @@
|
||||
"teamLabel": "Team",
|
||||
"viaTeam": "via {{team}}",
|
||||
"removeAccess": "Remove access",
|
||||
"access": "Access"
|
||||
"access": "Access",
|
||||
"showAll": "Show all {{count}}",
|
||||
"andMore": "and {{count}} more",
|
||||
"back": "Back",
|
||||
"allSummary": "{{name}} · Teams: {{teams}} · People: {{people}}",
|
||||
"searchAccess": "Search people and teams…",
|
||||
"filterLabel": "Filter people with access",
|
||||
"filter": {
|
||||
"all": "All",
|
||||
"teams": "Teams",
|
||||
"people": "People",
|
||||
"editors": "Editors"
|
||||
}
|
||||
},
|
||||
"accessChangeError": "Could not change access.",
|
||||
"accessSettings": {
|
||||
"title": "Access settings",
|
||||
"saveError": "Could not save the access setting.",
|
||||
"agent": {
|
||||
"editors_can_share": {
|
||||
"label": "Editors can share",
|
||||
"description": "Add people and teams and change their access."
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "Editors can delete",
|
||||
"description": "Delete the agent for everyone."
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"label": "Editors can manage access details",
|
||||
"description": "API key, webhook and public link."
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"label": "Viewers can see logs",
|
||||
"description": "Conversations and analytics for this agent."
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"editors_can_share": {
|
||||
"label": "Editors can share",
|
||||
"description": "Add people and teams and change their access."
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "Editors can delete",
|
||||
"description": "Delete the source for everyone."
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"label": "Viewers can see settings",
|
||||
"description": "Chunking, retriever and sync settings, read only."
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"editors_can_change_credentials": {
|
||||
"label": "Editors can change credentials and connection",
|
||||
"description": "They replace saved secrets; nobody can read them back."
|
||||
},
|
||||
"editors_can_share": {
|
||||
"label": "Editors can share",
|
||||
"description": "Add people and teams and change their access."
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"label": "Viewers can use it in their own agents",
|
||||
"description": "It runs with your credentials."
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"editors_can_share": {
|
||||
"label": "Editors can share",
|
||||
"description": "Add people and teams and change their access."
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"label": "Viewers can duplicate",
|
||||
"description": "Make their own copy to edit."
|
||||
}
|
||||
}
|
||||
},
|
||||
"editorHint": {
|
||||
"agent": "Editors can change it, including logs, schedules and access details.",
|
||||
"agentNoAccessDetails": "Editors can change it, including logs and schedules, but not access details.",
|
||||
"source": "Editors can edit chunks and files, sync and change settings.",
|
||||
"tool": "Editors can change actions and replace credentials, but can’t read secrets.",
|
||||
"toolNoCredentials": "Editors can change actions, but not credentials.",
|
||||
"prompt": "Editors can change the text.",
|
||||
"noShareNoDelete": "They can’t share or delete it.",
|
||||
"shareOnly": "They can also share it, but can’t delete it.",
|
||||
"deleteOnly": "They can also delete it, but can’t share it.",
|
||||
"shareAndDelete": "They can also share and delete it.",
|
||||
"noShare": "They can’t share or delete it.",
|
||||
"share": "They can also share it, but can’t delete it."
|
||||
},
|
||||
"capabilities": {
|
||||
"agent": {
|
||||
"viewers": "Viewers: chat with it and pin it",
|
||||
"editors": "Editors: edit it, publish it, see logs and manage schedules"
|
||||
},
|
||||
"source": {
|
||||
"viewers": "Viewers: browse, search and use it in their agents",
|
||||
"editors": "Editors: edit chunks and files, sync, change settings"
|
||||
},
|
||||
"tool": {
|
||||
"viewers": "Viewers: use it inside the owner’s agents",
|
||||
"editors": "Editors: change actions and approvals"
|
||||
},
|
||||
"prompt": {
|
||||
"viewers": "Viewers: read it and use it in their agents",
|
||||
"editors": "Editors: change the text"
|
||||
},
|
||||
"switch": {
|
||||
"editors_can_share": {
|
||||
"on": "Editors can share it",
|
||||
"off": "Editors can’t share it"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"on": "Editors can delete it",
|
||||
"off": "Editors can’t delete it"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"on": "Editors can manage the API key, webhook and public link",
|
||||
"off": "Editors can’t manage the API key, webhook or public link"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"on": "Viewers can see logs",
|
||||
"off": "Viewers can’t see logs"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"on": "Viewers can see its settings",
|
||||
"off": "Viewers can’t see its settings"
|
||||
},
|
||||
"editors_can_change_credentials": {
|
||||
"on": "Editors can replace credentials",
|
||||
"off": "Editors can’t change credentials"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"on": "Viewers can use it in their own agents",
|
||||
"off": "Viewers can’t use it in their own agents"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"on": "Viewers can duplicate it",
|
||||
"off": "Viewers can’t duplicate it"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sharedList": {
|
||||
"badgeWithEditors": "{{level}} · +{{count}} Editor",
|
||||
"meta": "{{type}} · {{owner}}",
|
||||
"filterLabel": "Filter shared resources",
|
||||
"filter": {
|
||||
"all": "All",
|
||||
"agent": "Agents",
|
||||
"source": "Sources",
|
||||
"tool": "Tools",
|
||||
"prompt": "Prompts"
|
||||
},
|
||||
"search": "Search shared…",
|
||||
"noMatches": "Nothing matches."
|
||||
},
|
||||
"drawer": {
|
||||
"close": "Close",
|
||||
"subtitle": "{{type}} · owned by {{owner}}",
|
||||
"open": "Open {{type}}",
|
||||
"manageSharing": "Manage sharing",
|
||||
"owner": "Owner",
|
||||
"shared": "Shared",
|
||||
"sharedOnBy": "{{date}} by {{name}}",
|
||||
"yourAccess": "Your access",
|
||||
"yourAccessLevel": {
|
||||
"owner": "Owner",
|
||||
"editor": "Editor",
|
||||
"viewer": "Viewer",
|
||||
"none": "No access"
|
||||
},
|
||||
"accessIn": "Access in {{team}}",
|
||||
"everyone": "Everyone in {{team}}",
|
||||
"teamGrant": "Whole team",
|
||||
"memberGrant": "Only this person",
|
||||
"removeGrant": "Remove access",
|
||||
"otherTeamsHint": "Shared with other teams too? Those grants are managed in “Manage sharing”.",
|
||||
"whatPeopleCanDo": "What people here can do",
|
||||
"capabilitiesHint": "From the owner’s access settings. Read-only here."
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
@@ -809,7 +997,6 @@
|
||||
"addServer": "Add MCP Server",
|
||||
"editServer": "Edit Server",
|
||||
"reconnectServer": "Reconnect Server",
|
||||
"reenterCredentials": "Re-enter your credentials to test and update the connection.",
|
||||
"serverName": "Server Name",
|
||||
"serverUrl": "Server URL",
|
||||
"headerName": "Header Name",
|
||||
@@ -854,7 +1041,18 @@
|
||||
"oauthFailed": "OAuth process failed or was cancelled",
|
||||
"oauthTimeout": "OAuth process timed out, please try again",
|
||||
"timeoutRange": "Timeout must be between 1 and 300 seconds"
|
||||
}
|
||||
},
|
||||
"sharedByEditor": "Shared by {{owner}} · you're an editor",
|
||||
"aTeammate": "a teammate",
|
||||
"sharedCredentialsNotice": "Saved credentials stay hidden. Anything you enter replaces them for everyone who uses this tool.",
|
||||
"serverChangedNotice": "The server changed, so the saved {{credential}} will be cleared. Enter it for the new server to save.",
|
||||
"credentialNames": {
|
||||
"apiKey": "API key",
|
||||
"bearer": "token",
|
||||
"password": "password"
|
||||
},
|
||||
"savedKeyHint": "A key is saved. Leave empty to keep it (only while the server is unchanged).",
|
||||
"sharedOAuthOwnerOnly": "Only the owner can reconnect its sign-in, so you can rename it but not change its server or account."
|
||||
},
|
||||
"configErrors": {
|
||||
"required": "{{field}} is required",
|
||||
@@ -862,7 +1060,14 @@
|
||||
"maxTimeout": "Maximum timeout is 300 seconds"
|
||||
},
|
||||
"headerValuePlaceholder": "e.g., application/json",
|
||||
"toolIconTitle": "{{name}} icon"
|
||||
"toolIconTitle": "{{name}} icon",
|
||||
"view": "View",
|
||||
"inMyChats": "In my chats",
|
||||
"useInMyChatsAria": "Use {{toolName}} in my chats",
|
||||
"statusUpdateFailed": "Couldn't change whether this tool is in your chats.",
|
||||
"deleteFailed": "Couldn't delete this tool.",
|
||||
"sharedBy": "Shared by {{team}}",
|
||||
"savedSecretPlaceholder": "Saved · enter a new value to replace"
|
||||
},
|
||||
"devices": {
|
||||
"label": "Devices",
|
||||
@@ -1359,7 +1564,9 @@
|
||||
"test": "Test",
|
||||
"learnMore": "Learn more",
|
||||
"resetKey": "Reset key",
|
||||
"resetKeyConfirm": "Are you sure you want to reset the API key? The current key will stop working immediately and this action cannot be undone."
|
||||
"resetKeyConfirm": "Are you sure you want to reset the API key? The current key will stop working immediately and this action cannot be undone.",
|
||||
"actionFailed": "That didn't work. Please try again.",
|
||||
"apiKeyAfterPublish": "Publish the agent to create its API key."
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "Import API Specification",
|
||||
@@ -1792,7 +1999,6 @@
|
||||
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
|
||||
"remove": "Remove",
|
||||
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
|
||||
"ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
|
||||
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
|
||||
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
|
||||
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
|
||||
@@ -1810,7 +2016,8 @@
|
||||
"modes": {
|
||||
"monitorOnly": "Monitor only",
|
||||
"scanAll": "Enforce everywhere"
|
||||
}
|
||||
},
|
||||
"readOnly": "You can see this policy, but your role can't change it."
|
||||
},
|
||||
"byline": {
|
||||
"new": "Set up the agent, then publish it to chat with it."
|
||||
@@ -2266,7 +2473,8 @@
|
||||
"classicDescription": "Create a standard AI agent with a single model, tools, and knowledge sources",
|
||||
"workflowTitle": "Workflow Agent",
|
||||
"workflowDescription": "Design complex multi-step workflows with different models, conditional logic, and state management"
|
||||
}
|
||||
},
|
||||
"deleteFailed": "Could not delete the agent. Please try again."
|
||||
},
|
||||
"components": {
|
||||
"fileUpload": {
|
||||
|
||||
+218
-10
@@ -92,7 +92,10 @@
|
||||
"edit": "Editar prompt",
|
||||
"view": "Ver prompt",
|
||||
"duplicate": "Duplicar prompt",
|
||||
"delete": "Eliminar prompt"
|
||||
"delete": "Eliminar prompt",
|
||||
"deleteFailed": "No se pudo eliminar este prompt.",
|
||||
"saveFailed": "No se pudo guardar este prompt.",
|
||||
"editConflict": "Otra persona cambió este prompt. Vuelve a abrirlo para ver su versión."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
@@ -437,7 +440,15 @@
|
||||
"editChunk": "Editar fragmento",
|
||||
"editChunkDescription": "{{file}} · fragmento {{n}} · {{tokens}} tokens",
|
||||
"previousChunk": "Fragmento anterior",
|
||||
"nextChunk": "Fragmento siguiente"
|
||||
"nextChunk": "Fragmento siguiente",
|
||||
"viewConfig": "Ver ajustes de la fuente",
|
||||
"errors": {
|
||||
"forbidden": "No tienes permiso para hacer eso en esta fuente.",
|
||||
"delete": "No se pudo eliminar la fuente.",
|
||||
"sync": "No se pudo sincronizar la fuente.",
|
||||
"syncFrequency": "No se pudo cambiar la frecuencia de sincronización.",
|
||||
"reingest": "No se pudo iniciar la reingesta."
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"label": "Analítica",
|
||||
@@ -729,7 +740,184 @@
|
||||
"teamLabel": "Equipo",
|
||||
"viaTeam": "vía {{team}}",
|
||||
"removeAccess": "Quitar acceso",
|
||||
"access": "Acceso"
|
||||
"access": "Acceso",
|
||||
"showAll": "Ver los {{count}}",
|
||||
"andMore": "y {{count}} más",
|
||||
"back": "Atrás",
|
||||
"allSummary": "{{name}} · Equipos: {{teams}} · Personas: {{people}}",
|
||||
"searchAccess": "Buscar personas y equipos…",
|
||||
"filterLabel": "Filtrar personas con acceso",
|
||||
"filter": {
|
||||
"all": "Todos",
|
||||
"teams": "Equipos",
|
||||
"people": "Personas",
|
||||
"editors": "Editores"
|
||||
}
|
||||
},
|
||||
"accessChangeError": "No se pudo cambiar el acceso.",
|
||||
"accessSettings": {
|
||||
"title": "Ajustes de acceso",
|
||||
"saveError": "No se pudo guardar el ajuste de acceso.",
|
||||
"agent": {
|
||||
"editors_can_share": {
|
||||
"label": "Los editores pueden compartir",
|
||||
"description": "Añadir personas y equipos y cambiar su acceso."
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "Los editores pueden eliminar",
|
||||
"description": "Eliminar el agente para todos."
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"label": "Los editores pueden gestionar los datos de acceso",
|
||||
"description": "Clave de API, webhook y enlace público."
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"label": "Los lectores pueden ver los registros",
|
||||
"description": "Conversaciones y analíticas de este agente."
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"editors_can_share": {
|
||||
"label": "Los editores pueden compartir",
|
||||
"description": "Añadir personas y equipos y cambiar su acceso."
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "Los editores pueden eliminar",
|
||||
"description": "Eliminar la fuente para todos."
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"label": "Los lectores pueden ver los ajustes",
|
||||
"description": "Ajustes de fragmentación, recuperador y sincronización, solo lectura."
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"editors_can_change_credentials": {
|
||||
"label": "Los editores pueden cambiar credenciales y conexión",
|
||||
"description": "Sustituyen los secretos guardados; nadie puede volver a leerlos."
|
||||
},
|
||||
"editors_can_share": {
|
||||
"label": "Los editores pueden compartir",
|
||||
"description": "Añadir personas y equipos y cambiar su acceso."
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"label": "Los lectores pueden usarla en sus propios agentes",
|
||||
"description": "Se ejecuta con tus credenciales."
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"editors_can_share": {
|
||||
"label": "Los editores pueden compartir",
|
||||
"description": "Añadir personas y equipos y cambiar su acceso."
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"label": "Los lectores pueden duplicarlo",
|
||||
"description": "Hacer su propia copia para editarla."
|
||||
}
|
||||
}
|
||||
},
|
||||
"editorHint": {
|
||||
"agent": "Los editores pueden cambiarlo, incluidos los registros, las programaciones y los datos de acceso.",
|
||||
"agentNoAccessDetails": "Los editores pueden cambiarlo, incluidos los registros y las programaciones, pero no los datos de acceso.",
|
||||
"source": "Los editores pueden editar fragmentos y archivos, sincronizar y cambiar ajustes.",
|
||||
"tool": "Los editores pueden cambiar acciones y sustituir credenciales, pero no pueden leer secretos.",
|
||||
"toolNoCredentials": "Los editores pueden cambiar acciones, pero no las credenciales.",
|
||||
"prompt": "Los editores pueden cambiar el texto.",
|
||||
"noShareNoDelete": "No pueden compartirlo ni eliminarlo.",
|
||||
"shareOnly": "También pueden compartirlo, pero no eliminarlo.",
|
||||
"deleteOnly": "También pueden eliminarlo, pero no compartirlo.",
|
||||
"shareAndDelete": "También pueden compartirlo y eliminarlo.",
|
||||
"noShare": "No pueden compartirlo ni eliminarlo.",
|
||||
"share": "También pueden compartirlo, pero no eliminarlo."
|
||||
},
|
||||
"capabilities": {
|
||||
"agent": {
|
||||
"viewers": "Lectores: chatear con él y fijarlo",
|
||||
"editors": "Editores: editarlo, publicarlo, ver registros y gestionar programaciones"
|
||||
},
|
||||
"source": {
|
||||
"viewers": "Lectores: explorar, buscar y usarla en sus agentes",
|
||||
"editors": "Editores: editar fragmentos y archivos, sincronizar, cambiar ajustes"
|
||||
},
|
||||
"tool": {
|
||||
"viewers": "Lectores: usarla dentro de los agentes del propietario",
|
||||
"editors": "Editores: cambiar acciones y aprobaciones"
|
||||
},
|
||||
"prompt": {
|
||||
"viewers": "Lectores: leerlo y usarlo en sus agentes",
|
||||
"editors": "Editores: cambiar el texto"
|
||||
},
|
||||
"switch": {
|
||||
"editors_can_share": {
|
||||
"on": "Los editores pueden compartirlo",
|
||||
"off": "Los editores no pueden compartirlo"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"on": "Los editores pueden eliminarlo",
|
||||
"off": "Los editores no pueden eliminarlo"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"on": "Los editores pueden gestionar la clave de API, el webhook y el enlace público",
|
||||
"off": "Los editores no pueden gestionar la clave de API, el webhook ni el enlace público"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"on": "Los lectores pueden ver los registros",
|
||||
"off": "Los lectores no pueden ver los registros"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"on": "Los lectores pueden ver sus ajustes",
|
||||
"off": "Los lectores no pueden ver sus ajustes"
|
||||
},
|
||||
"editors_can_change_credentials": {
|
||||
"on": "Los editores pueden sustituir credenciales",
|
||||
"off": "Los editores no pueden cambiar credenciales"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"on": "Los lectores pueden usarla en sus propios agentes",
|
||||
"off": "Los lectores no pueden usarla en sus propios agentes"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"on": "Los lectores pueden duplicarlo",
|
||||
"off": "Los lectores no pueden duplicarlo"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sharedList": {
|
||||
"badgeWithEditors": "{{level}} · +{{count}} Editor",
|
||||
"meta": "{{type}} · {{owner}}",
|
||||
"filterLabel": "Filtrar recursos compartidos",
|
||||
"filter": {
|
||||
"all": "Todos",
|
||||
"agent": "Agentes",
|
||||
"source": "Fuentes",
|
||||
"tool": "Herramientas",
|
||||
"prompt": "Prompts"
|
||||
},
|
||||
"search": "Buscar compartidos…",
|
||||
"noMatches": "No hay coincidencias."
|
||||
},
|
||||
"drawer": {
|
||||
"close": "Cerrar",
|
||||
"subtitle": "{{type}} · propiedad de {{owner}}",
|
||||
"open": "Abrir {{type}}",
|
||||
"manageSharing": "Gestionar uso compartido",
|
||||
"owner": "Propietario",
|
||||
"shared": "Compartido",
|
||||
"sharedOnBy": "{{date}} por {{name}}",
|
||||
"yourAccess": "Tu acceso",
|
||||
"yourAccessLevel": {
|
||||
"owner": "Propietario",
|
||||
"editor": "Editor",
|
||||
"viewer": "Lector",
|
||||
"none": "Sin acceso"
|
||||
},
|
||||
"accessIn": "Acceso en {{team}}",
|
||||
"everyone": "Todos en {{team}}",
|
||||
"teamGrant": "Todo el equipo",
|
||||
"memberGrant": "Solo esta persona",
|
||||
"removeGrant": "Quitar acceso",
|
||||
"otherTeamsHint": "¿También compartido con otros equipos? Esos accesos se gestionan en «Gestionar uso compartido».",
|
||||
"whatPeopleCanDo": "Qué pueden hacer aquí",
|
||||
"capabilitiesHint": "Según los ajustes de acceso del propietario. Solo lectura aquí."
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
@@ -803,7 +991,6 @@
|
||||
"addServer": "Add MCP Server",
|
||||
"editServer": "Edit Server",
|
||||
"reconnectServer": "Reconectar servidor",
|
||||
"reenterCredentials": "Vuelve a introducir tus credenciales para probar y actualizar la conexión.",
|
||||
"serverName": "Server Name",
|
||||
"serverUrl": "Server URL",
|
||||
"headerName": "Header Name",
|
||||
@@ -848,7 +1035,18 @@
|
||||
"oauthFailed": "OAuth process failed or was cancelled",
|
||||
"oauthTimeout": "OAuth process timed out, please try again",
|
||||
"timeoutRange": "Timeout must be between 1 and 300 seconds"
|
||||
}
|
||||
},
|
||||
"sharedByEditor": "Compartido por {{owner}} · eres editor",
|
||||
"aTeammate": "un compañero",
|
||||
"sharedCredentialsNotice": "Las credenciales guardadas permanecen ocultas. Lo que introduzcas las reemplaza para todos los que usan esta herramienta.",
|
||||
"serverChangedNotice": "El servidor cambió, así que se borrarán las credenciales guardadas ({{credential}}). Introdúcelas para el nuevo servidor para guardar.",
|
||||
"credentialNames": {
|
||||
"apiKey": "clave de API",
|
||||
"bearer": "token",
|
||||
"password": "contraseña"
|
||||
},
|
||||
"savedKeyHint": "Hay una clave guardada. Déjalo vacío para conservarla (solo mientras el servidor no cambie).",
|
||||
"sharedOAuthOwnerOnly": "Solo el propietario puede volver a conectar su inicio de sesión, así que puedes cambiarle el nombre, pero no su servidor ni su cuenta."
|
||||
},
|
||||
"configErrors": {
|
||||
"required": "{{field}} es obligatorio",
|
||||
@@ -856,7 +1054,14 @@
|
||||
"maxTimeout": "El tiempo de espera máximo es de 300 segundos"
|
||||
},
|
||||
"headerValuePlaceholder": "p. ej., application/json",
|
||||
"toolIconTitle": "Icono de {{name}}"
|
||||
"toolIconTitle": "Icono de {{name}}",
|
||||
"view": "Ver",
|
||||
"inMyChats": "En mis chats",
|
||||
"useInMyChatsAria": "Usar {{toolName}} en mis chats",
|
||||
"statusUpdateFailed": "No se pudo cambiar si esta herramienta está en tus chats.",
|
||||
"deleteFailed": "No se pudo eliminar esta herramienta.",
|
||||
"sharedBy": "Compartido por {{team}}",
|
||||
"savedSecretPlaceholder": "Guardado · introduce un valor nuevo para reemplazarlo"
|
||||
},
|
||||
"devices": {
|
||||
"label": "Dispositivos",
|
||||
@@ -1353,7 +1558,9 @@
|
||||
"test": "Test",
|
||||
"learnMore": "Learn more",
|
||||
"resetKey": "Restablecer clave",
|
||||
"resetKeyConfirm": "¿Seguro que quieres restablecer la clave de API? La clave actual dejará de funcionar de inmediato y esta acción no se puede deshacer."
|
||||
"resetKeyConfirm": "¿Seguro que quieres restablecer la clave de API? La clave actual dejará de funcionar de inmediato y esta acción no se puede deshacer.",
|
||||
"actionFailed": "No funcionó. Inténtalo de nuevo.",
|
||||
"apiKeyAfterPublish": "Publica el agente para crear su clave de API."
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "Importar especificación de API",
|
||||
@@ -1774,7 +1981,6 @@
|
||||
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
|
||||
"remove": "Remove",
|
||||
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
|
||||
"ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
|
||||
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
|
||||
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
|
||||
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
|
||||
@@ -1792,7 +1998,8 @@
|
||||
"modes": {
|
||||
"monitorOnly": "Monitor only",
|
||||
"scanAll": "Enforce everywhere"
|
||||
}
|
||||
},
|
||||
"readOnly": "Puedes ver esta política, pero tu rol no puede cambiarla."
|
||||
},
|
||||
"byline": {
|
||||
"new": "Configura el agente y publícalo para chatear con él."
|
||||
@@ -2234,7 +2441,8 @@
|
||||
"classicDescription": "Crea un agente de IA estándar con un solo modelo, herramientas y fuentes de conocimiento",
|
||||
"workflowTitle": "Agente de flujo de trabajo",
|
||||
"workflowDescription": "Diseña flujos de trabajo complejos de varios pasos con distintos modelos, lógica condicional y gestión de estado"
|
||||
}
|
||||
},
|
||||
"deleteFailed": "No se pudo eliminar el agente. Inténtalo de nuevo."
|
||||
},
|
||||
"components": {
|
||||
"fileUpload": {
|
||||
|
||||
+218
-10
@@ -92,7 +92,10 @@
|
||||
"edit": "プロンプトを編集",
|
||||
"view": "プロンプトを表示",
|
||||
"duplicate": "プロンプトを複製",
|
||||
"delete": "プロンプトを削除"
|
||||
"delete": "プロンプトを削除",
|
||||
"deleteFailed": "このプロンプトを削除できませんでした。",
|
||||
"saveFailed": "このプロンプトを保存できませんでした。",
|
||||
"editConflict": "他のユーザーがこのプロンプトを変更しました。開き直して最新の内容を確認してください。"
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
@@ -428,7 +431,15 @@
|
||||
"editChunk": "チャンクを編集",
|
||||
"editChunkDescription": "{{file}} · チャンク {{n}} · {{tokens}} トークン",
|
||||
"previousChunk": "前のチャンク",
|
||||
"nextChunk": "次のチャンク"
|
||||
"nextChunk": "次のチャンク",
|
||||
"viewConfig": "ソース設定を表示",
|
||||
"errors": {
|
||||
"forbidden": "このソースでその操作を行う権限がありません。",
|
||||
"delete": "ソースを削除できませんでした。",
|
||||
"sync": "ソースを同期できませんでした。",
|
||||
"syncFrequency": "同期頻度を変更できませんでした。",
|
||||
"reingest": "再取り込みを開始できませんでした。"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"label": "分析",
|
||||
@@ -720,7 +731,184 @@
|
||||
"teamLabel": "チーム",
|
||||
"viaTeam": "{{team}}経由",
|
||||
"removeAccess": "アクセス権を削除",
|
||||
"access": "アクセス"
|
||||
"access": "アクセス",
|
||||
"showAll": "すべて表示({{count}})",
|
||||
"andMore": "ほか {{count}} 件",
|
||||
"back": "戻る",
|
||||
"allSummary": "{{name}} · チーム: {{teams}} · ユーザー: {{people}}",
|
||||
"searchAccess": "ユーザーとチームを検索…",
|
||||
"filterLabel": "アクセス権を持つユーザーを絞り込む",
|
||||
"filter": {
|
||||
"all": "すべて",
|
||||
"teams": "チーム",
|
||||
"people": "ユーザー",
|
||||
"editors": "編集者"
|
||||
}
|
||||
},
|
||||
"accessChangeError": "アクセス権を変更できませんでした。",
|
||||
"accessSettings": {
|
||||
"title": "アクセス設定",
|
||||
"saveError": "アクセス設定を保存できませんでした。",
|
||||
"agent": {
|
||||
"editors_can_share": {
|
||||
"label": "編集者が共有できる",
|
||||
"description": "ユーザーやチームを追加し、アクセス権を変更します。"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "編集者が削除できる",
|
||||
"description": "全員に対してエージェントを削除します。"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"label": "編集者がアクセス情報を管理できる",
|
||||
"description": "API キー、Webhook、公開リンク。"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"label": "閲覧者がログを見られる",
|
||||
"description": "このエージェントの会話と分析。"
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"editors_can_share": {
|
||||
"label": "編集者が共有できる",
|
||||
"description": "ユーザーやチームを追加し、アクセス権を変更します。"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "編集者が削除できる",
|
||||
"description": "全員に対してソースを削除します。"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"label": "閲覧者が設定を見られる",
|
||||
"description": "チャンク分割、リトリーバー、同期の設定(読み取り専用)。"
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"editors_can_change_credentials": {
|
||||
"label": "編集者が認証情報と接続を変更できる",
|
||||
"description": "保存済みのシークレットを置き換えます。誰も読み戻せません。"
|
||||
},
|
||||
"editors_can_share": {
|
||||
"label": "編集者が共有できる",
|
||||
"description": "ユーザーやチームを追加し、アクセス権を変更します。"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"label": "閲覧者が自分のエージェントで使える",
|
||||
"description": "あなたの認証情報で実行されます。"
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"editors_can_share": {
|
||||
"label": "編集者が共有できる",
|
||||
"description": "ユーザーやチームを追加し、アクセス権を変更します。"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"label": "閲覧者が複製できる",
|
||||
"description": "編集用に自分のコピーを作成します。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editorHint": {
|
||||
"agent": "編集者はログ、スケジュール、アクセス情報を含めて変更できます。",
|
||||
"agentNoAccessDetails": "編集者はログとスケジュールを含めて変更できますが、アクセス情報は変更できません。",
|
||||
"source": "編集者はチャンクとファイルの編集、同期、設定の変更ができます。",
|
||||
"tool": "編集者はアクションの変更と認証情報の置き換えができますが、シークレットは読めません。",
|
||||
"toolNoCredentials": "編集者はアクションを変更できますが、認証情報は変更できません。",
|
||||
"prompt": "編集者はテキストを変更できます。",
|
||||
"noShareNoDelete": "共有と削除はできません。",
|
||||
"shareOnly": "共有もできますが、削除はできません。",
|
||||
"deleteOnly": "削除もできますが、共有はできません。",
|
||||
"shareAndDelete": "共有と削除もできます。",
|
||||
"noShare": "共有と削除はできません。",
|
||||
"share": "共有もできますが、削除はできません。"
|
||||
},
|
||||
"capabilities": {
|
||||
"agent": {
|
||||
"viewers": "閲覧者:チャットとピン留め",
|
||||
"editors": "編集者:編集、公開、ログの閲覧、スケジュールの管理"
|
||||
},
|
||||
"source": {
|
||||
"viewers": "閲覧者:閲覧、検索、自分のエージェントでの利用",
|
||||
"editors": "編集者:チャンクとファイルの編集、同期、設定の変更"
|
||||
},
|
||||
"tool": {
|
||||
"viewers": "閲覧者:所有者のエージェント内で利用",
|
||||
"editors": "編集者:アクションと承認の変更"
|
||||
},
|
||||
"prompt": {
|
||||
"viewers": "閲覧者:閲覧と自分のエージェントでの利用",
|
||||
"editors": "編集者:テキストの変更"
|
||||
},
|
||||
"switch": {
|
||||
"editors_can_share": {
|
||||
"on": "編集者は共有できます",
|
||||
"off": "編集者は共有できません"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"on": "編集者は削除できます",
|
||||
"off": "編集者は削除できません"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"on": "編集者は API キー、Webhook、公開リンクを管理できます",
|
||||
"off": "編集者は API キー、Webhook、公開リンクを管理できません"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"on": "閲覧者はログを見られます",
|
||||
"off": "閲覧者はログを見られません"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"on": "閲覧者は設定を見られます",
|
||||
"off": "閲覧者は設定を見られません"
|
||||
},
|
||||
"editors_can_change_credentials": {
|
||||
"on": "編集者は認証情報を置き換えられます",
|
||||
"off": "編集者は認証情報を変更できません"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"on": "閲覧者は自分のエージェントで使えます",
|
||||
"off": "閲覧者は自分のエージェントで使えません"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"on": "閲覧者は複製できます",
|
||||
"off": "閲覧者は複製できません"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sharedList": {
|
||||
"badgeWithEditors": "{{level}} · +{{count}} 編集者",
|
||||
"meta": "{{type}} · {{owner}}",
|
||||
"filterLabel": "共有リソースを絞り込む",
|
||||
"filter": {
|
||||
"all": "すべて",
|
||||
"agent": "エージェント",
|
||||
"source": "ソース",
|
||||
"tool": "ツール",
|
||||
"prompt": "プロンプト"
|
||||
},
|
||||
"search": "共有を検索…",
|
||||
"noMatches": "一致するものはありません。"
|
||||
},
|
||||
"drawer": {
|
||||
"close": "閉じる",
|
||||
"subtitle": "{{type}} · 所有者: {{owner}}",
|
||||
"open": "{{type}}を開く",
|
||||
"manageSharing": "共有を管理",
|
||||
"owner": "所有者",
|
||||
"shared": "共有日",
|
||||
"sharedOnBy": "{{date}}({{name}})",
|
||||
"yourAccess": "あなたのアクセス権",
|
||||
"yourAccessLevel": {
|
||||
"owner": "所有者",
|
||||
"editor": "編集者",
|
||||
"viewer": "閲覧者",
|
||||
"none": "アクセス権なし"
|
||||
},
|
||||
"accessIn": "{{team}} でのアクセス権",
|
||||
"everyone": "{{team}} の全員",
|
||||
"teamGrant": "チーム全体",
|
||||
"memberGrant": "この人のみ",
|
||||
"removeGrant": "アクセス権を削除",
|
||||
"otherTeamsHint": "ほかのチームとも共有していますか?それらは「共有を管理」で管理します。",
|
||||
"whatPeopleCanDo": "ここでできること",
|
||||
"capabilitiesHint": "所有者のアクセス設定に基づきます。ここでは読み取り専用です。"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
@@ -794,7 +982,6 @@
|
||||
"addServer": "Add MCP Server",
|
||||
"editServer": "Edit Server",
|
||||
"reconnectServer": "サーバーに再接続",
|
||||
"reenterCredentials": "接続をテストして更新するには、認証情報を再入力してください。",
|
||||
"serverName": "Server Name",
|
||||
"serverUrl": "Server URL",
|
||||
"headerName": "Header Name",
|
||||
@@ -839,7 +1026,18 @@
|
||||
"oauthFailed": "OAuth process failed or was cancelled",
|
||||
"oauthTimeout": "OAuth process timed out, please try again",
|
||||
"timeoutRange": "Timeout must be between 1 and 300 seconds"
|
||||
}
|
||||
},
|
||||
"sharedByEditor": "{{owner}} が共有 · あなたは編集者です",
|
||||
"aTeammate": "チームメンバー",
|
||||
"sharedCredentialsNotice": "保存済みの認証情報は表示されません。入力した内容は、このツールを使うすべての人の認証情報を置き換えます。",
|
||||
"serverChangedNotice": "サーバーが変更されたため、保存済みの{{credential}}は消去されます。保存するには新しいサーバー用に入力してください。",
|
||||
"credentialNames": {
|
||||
"apiKey": "API キー",
|
||||
"bearer": "トークン",
|
||||
"password": "パスワード"
|
||||
},
|
||||
"savedKeyHint": "キーが保存されています。空のままにすると保持されます(サーバーが変わらない場合のみ)。",
|
||||
"sharedOAuthOwnerOnly": "サインインを再接続できるのはオーナーだけです。名前は変更できますが、サーバーやアカウントは変更できません。"
|
||||
},
|
||||
"configErrors": {
|
||||
"required": "{{field}}は必須です",
|
||||
@@ -847,7 +1045,14 @@
|
||||
"maxTimeout": "タイムアウトの上限は300秒です"
|
||||
},
|
||||
"headerValuePlaceholder": "例: application/json",
|
||||
"toolIconTitle": "{{name}}のアイコン"
|
||||
"toolIconTitle": "{{name}}のアイコン",
|
||||
"view": "表示",
|
||||
"inMyChats": "自分のチャットで使用",
|
||||
"useInMyChatsAria": "{{toolName}} を自分のチャットで使用",
|
||||
"statusUpdateFailed": "このツールをチャットで使うかどうかを変更できませんでした。",
|
||||
"deleteFailed": "このツールを削除できませんでした。",
|
||||
"sharedBy": "{{team}} が共有",
|
||||
"savedSecretPlaceholder": "保存済み · 置き換えるには新しい値を入力"
|
||||
},
|
||||
"devices": {
|
||||
"label": "デバイス",
|
||||
@@ -1344,7 +1549,9 @@
|
||||
"test": "Test",
|
||||
"learnMore": "Learn more",
|
||||
"resetKey": "キーをリセット",
|
||||
"resetKeyConfirm": "APIキーをリセットしてもよろしいですか?現在のキーは直ちに無効になり、この操作は元に戻せません。"
|
||||
"resetKeyConfirm": "APIキーをリセットしてもよろしいですか?現在のキーは直ちに無効になり、この操作は元に戻せません。",
|
||||
"actionFailed": "うまくいきませんでした。もう一度お試しください。",
|
||||
"apiKeyAfterPublish": "APIキーを作成するには、エージェントを公開してください。"
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "API仕様のインポート",
|
||||
@@ -1761,7 +1968,6 @@
|
||||
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
|
||||
"remove": "Remove",
|
||||
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
|
||||
"ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
|
||||
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
|
||||
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
|
||||
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
|
||||
@@ -1779,7 +1985,8 @@
|
||||
"modes": {
|
||||
"monitorOnly": "Monitor only",
|
||||
"scanAll": "Enforce everywhere"
|
||||
}
|
||||
},
|
||||
"readOnly": "このポリシーは表示できますが、あなたのロールでは変更できません。"
|
||||
},
|
||||
"byline": {
|
||||
"new": "エージェントを設定し、公開するとチャットできます。"
|
||||
@@ -2221,7 +2428,8 @@
|
||||
"classicDescription": "単一のモデル、ツール、ナレッジソースを持つ標準的な AI エージェントを作成します",
|
||||
"workflowTitle": "ワークフローエージェント",
|
||||
"workflowDescription": "複数のモデル、条件ロジック、状態管理を使った複雑なマルチステップのワークフローを設計します"
|
||||
}
|
||||
},
|
||||
"deleteFailed": "エージェントを削除できませんでした。もう一度お試しください。"
|
||||
},
|
||||
"components": {
|
||||
"fileUpload": {
|
||||
|
||||
+218
-10
@@ -92,7 +92,10 @@
|
||||
"edit": "Редактировать промпт",
|
||||
"view": "Просмотреть промпт",
|
||||
"duplicate": "Дублировать промпт",
|
||||
"delete": "Удалить промпт"
|
||||
"delete": "Удалить промпт",
|
||||
"deleteFailed": "Не удалось удалить этот промпт.",
|
||||
"saveFailed": "Не удалось сохранить этот промпт.",
|
||||
"editConflict": "Кто-то другой изменил этот промпт. Откройте его заново, чтобы увидеть новую версию."
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
@@ -465,7 +468,15 @@
|
||||
"editChunk": "Редактировать фрагмент",
|
||||
"editChunkDescription": "{{file}} · фрагмент {{n}} · токенов: {{tokens}}",
|
||||
"previousChunk": "Предыдущий фрагмент",
|
||||
"nextChunk": "Следующий фрагмент"
|
||||
"nextChunk": "Следующий фрагмент",
|
||||
"viewConfig": "Посмотреть настройки источника",
|
||||
"errors": {
|
||||
"forbidden": "У вас нет прав на это действие с этим источником.",
|
||||
"delete": "Не удалось удалить источник.",
|
||||
"sync": "Не удалось синхронизировать источник.",
|
||||
"syncFrequency": "Не удалось изменить частоту синхронизации.",
|
||||
"reingest": "Не удалось запустить повторную загрузку."
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"label": "Аналитика",
|
||||
@@ -771,7 +782,184 @@
|
||||
"teamLabel": "Команда",
|
||||
"viaTeam": "через {{team}}",
|
||||
"removeAccess": "Убрать доступ",
|
||||
"access": "Доступ"
|
||||
"access": "Доступ",
|
||||
"showAll": "Показать все ({{count}})",
|
||||
"andMore": "и ещё {{count}}",
|
||||
"back": "Назад",
|
||||
"allSummary": "{{name}} · Команды: {{teams}} · Люди: {{people}}",
|
||||
"searchAccess": "Поиск людей и команд…",
|
||||
"filterLabel": "Фильтр пользователей с доступом",
|
||||
"filter": {
|
||||
"all": "Все",
|
||||
"teams": "Команды",
|
||||
"people": "Люди",
|
||||
"editors": "Редакторы"
|
||||
}
|
||||
},
|
||||
"accessChangeError": "Не удалось изменить доступ.",
|
||||
"accessSettings": {
|
||||
"title": "Настройки доступа",
|
||||
"saveError": "Не удалось сохранить настройку доступа.",
|
||||
"agent": {
|
||||
"editors_can_share": {
|
||||
"label": "Редакторы могут делиться",
|
||||
"description": "Добавлять людей и команды и менять их доступ."
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "Редакторы могут удалять",
|
||||
"description": "Удалить агента для всех."
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"label": "Редакторы управляют данными доступа",
|
||||
"description": "API-ключ, вебхук и публичная ссылка."
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"label": "Читатели видят журналы",
|
||||
"description": "Диалоги и аналитика этого агента."
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"editors_can_share": {
|
||||
"label": "Редакторы могут делиться",
|
||||
"description": "Добавлять людей и команды и менять их доступ."
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "Редакторы могут удалять",
|
||||
"description": "Удалить источник для всех."
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"label": "Читатели видят настройки",
|
||||
"description": "Настройки разбиения, ретривера и синхронизации, только чтение."
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"editors_can_change_credentials": {
|
||||
"label": "Редакторы могут менять учётные данные и подключение",
|
||||
"description": "Они заменяют сохранённые секреты; прочитать их не может никто."
|
||||
},
|
||||
"editors_can_share": {
|
||||
"label": "Редакторы могут делиться",
|
||||
"description": "Добавлять людей и команды и менять их доступ."
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"label": "Читатели могут использовать его в своих агентах",
|
||||
"description": "Он работает с вашими учётными данными."
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"editors_can_share": {
|
||||
"label": "Редакторы могут делиться",
|
||||
"description": "Добавлять людей и команды и менять их доступ."
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"label": "Читатели могут создавать копию",
|
||||
"description": "Сделать свою копию для редактирования."
|
||||
}
|
||||
}
|
||||
},
|
||||
"editorHint": {
|
||||
"agent": "Редакторы могут менять его, включая журналы, расписания и данные доступа.",
|
||||
"agentNoAccessDetails": "Редакторы могут менять его, включая журналы и расписания, но не данные доступа.",
|
||||
"source": "Редакторы могут править фрагменты и файлы, синхронизировать и менять настройки.",
|
||||
"tool": "Редакторы могут менять действия и заменять учётные данные, но не читать секреты.",
|
||||
"toolNoCredentials": "Редакторы могут менять действия, но не учётные данные.",
|
||||
"prompt": "Редакторы могут менять текст.",
|
||||
"noShareNoDelete": "Делиться и удалять они не могут.",
|
||||
"shareOnly": "Они также могут делиться, но не удалять.",
|
||||
"deleteOnly": "Они также могут удалять, но не делиться.",
|
||||
"shareAndDelete": "Они также могут делиться и удалять.",
|
||||
"noShare": "Делиться и удалять они не могут.",
|
||||
"share": "Они также могут делиться, но не удалять."
|
||||
},
|
||||
"capabilities": {
|
||||
"agent": {
|
||||
"viewers": "Читатели: общаться с ним и закреплять его",
|
||||
"editors": "Редакторы: править, публиковать, смотреть журналы и управлять расписаниями"
|
||||
},
|
||||
"source": {
|
||||
"viewers": "Читатели: просматривать, искать и использовать в своих агентах",
|
||||
"editors": "Редакторы: править фрагменты и файлы, синхронизировать, менять настройки"
|
||||
},
|
||||
"tool": {
|
||||
"viewers": "Читатели: использовать в агентах владельца",
|
||||
"editors": "Редакторы: менять действия и подтверждения"
|
||||
},
|
||||
"prompt": {
|
||||
"viewers": "Читатели: читать и использовать в своих агентах",
|
||||
"editors": "Редакторы: менять текст"
|
||||
},
|
||||
"switch": {
|
||||
"editors_can_share": {
|
||||
"on": "Редакторы могут делиться",
|
||||
"off": "Редакторы не могут делиться"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"on": "Редакторы могут удалять",
|
||||
"off": "Редакторы не могут удалять"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"on": "Редакторы управляют API-ключом, вебхуком и публичной ссылкой",
|
||||
"off": "Редакторы не управляют API-ключом, вебхуком и публичной ссылкой"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"on": "Читатели видят журналы",
|
||||
"off": "Читатели не видят журналы"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"on": "Читатели видят настройки",
|
||||
"off": "Читатели не видят настройки"
|
||||
},
|
||||
"editors_can_change_credentials": {
|
||||
"on": "Редакторы могут заменять учётные данные",
|
||||
"off": "Редакторы не могут менять учётные данные"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"on": "Читатели могут использовать его в своих агентах",
|
||||
"off": "Читатели не могут использовать его в своих агентах"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"on": "Читатели могут создавать копию",
|
||||
"off": "Читатели не могут создавать копию"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sharedList": {
|
||||
"badgeWithEditors": "{{level}} · +{{count}} ред.",
|
||||
"meta": "{{type}} · {{owner}}",
|
||||
"filterLabel": "Фильтр общих ресурсов",
|
||||
"filter": {
|
||||
"all": "Все",
|
||||
"agent": "Агенты",
|
||||
"source": "Источники",
|
||||
"tool": "Инструменты",
|
||||
"prompt": "Промпты"
|
||||
},
|
||||
"search": "Поиск в общих…",
|
||||
"noMatches": "Ничего не найдено."
|
||||
},
|
||||
"drawer": {
|
||||
"close": "Закрыть",
|
||||
"subtitle": "{{type}} · владелец: {{owner}}",
|
||||
"open": "Открыть: {{type}}",
|
||||
"manageSharing": "Управлять доступом",
|
||||
"owner": "Владелец",
|
||||
"shared": "Открыт",
|
||||
"sharedOnBy": "{{date}}, {{name}}",
|
||||
"yourAccess": "Ваш доступ",
|
||||
"yourAccessLevel": {
|
||||
"owner": "Владелец",
|
||||
"editor": "Редактор",
|
||||
"viewer": "Читатель",
|
||||
"none": "Нет доступа"
|
||||
},
|
||||
"accessIn": "Доступ в {{team}}",
|
||||
"everyone": "Все в {{team}}",
|
||||
"teamGrant": "Вся команда",
|
||||
"memberGrant": "Только этот человек",
|
||||
"removeGrant": "Убрать доступ",
|
||||
"otherTeamsHint": "Доступ есть и у других команд? Им управляют в «Управлять доступом».",
|
||||
"whatPeopleCanDo": "Что здесь можно делать",
|
||||
"capabilitiesHint": "Из настроек доступа владельца. Здесь только для чтения."
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
@@ -845,7 +1033,6 @@
|
||||
"addServer": "Add MCP Server",
|
||||
"editServer": "Edit Server",
|
||||
"reconnectServer": "Переподключить сервер",
|
||||
"reenterCredentials": "Введите учетные данные ещё раз, чтобы проверить и обновить подключение.",
|
||||
"serverName": "Server Name",
|
||||
"serverUrl": "Server URL",
|
||||
"headerName": "Header Name",
|
||||
@@ -890,7 +1077,18 @@
|
||||
"oauthFailed": "OAuth process failed or was cancelled",
|
||||
"oauthTimeout": "OAuth process timed out, please try again",
|
||||
"timeoutRange": "Timeout must be between 1 and 300 seconds"
|
||||
}
|
||||
},
|
||||
"sharedByEditor": "Предоставил(а) {{owner}} · вы редактор",
|
||||
"aTeammate": "участник команды",
|
||||
"sharedCredentialsNotice": "Сохранённые учётные данные скрыты. Введённые вами данные заменят их для всех, кто пользуется этим инструментом.",
|
||||
"serverChangedNotice": "Сервер изменился, поэтому сохранённый {{credential}} будет удалён. Введите его для нового сервера, чтобы сохранить.",
|
||||
"credentialNames": {
|
||||
"apiKey": "API-ключ",
|
||||
"bearer": "токен",
|
||||
"password": "пароль"
|
||||
},
|
||||
"savedKeyHint": "Ключ сохранён. Оставьте поле пустым, чтобы сохранить его (только пока сервер не изменился).",
|
||||
"sharedOAuthOwnerOnly": "Переподключить вход может только владелец: вы можете переименовать инструмент, но не менять его сервер или аккаунт."
|
||||
},
|
||||
"configErrors": {
|
||||
"required": "Поле «{{field}}» обязательно",
|
||||
@@ -898,7 +1096,14 @@
|
||||
"maxTimeout": "Максимальный тайм-аут — 300 секунд"
|
||||
},
|
||||
"headerValuePlaceholder": "например, application/json",
|
||||
"toolIconTitle": "Значок {{name}}"
|
||||
"toolIconTitle": "Значок {{name}}",
|
||||
"view": "Просмотр",
|
||||
"inMyChats": "В моих чатах",
|
||||
"useInMyChatsAria": "Использовать {{toolName}} в моих чатах",
|
||||
"statusUpdateFailed": "Не удалось изменить, используется ли этот инструмент в ваших чатах.",
|
||||
"deleteFailed": "Не удалось удалить этот инструмент.",
|
||||
"sharedBy": "Предоставлено: {{team}}",
|
||||
"savedSecretPlaceholder": "Сохранено · введите новое значение для замены"
|
||||
},
|
||||
"devices": {
|
||||
"label": "Устройства",
|
||||
@@ -1409,7 +1614,9 @@
|
||||
"test": "Test",
|
||||
"learnMore": "Learn more",
|
||||
"resetKey": "Сбросить ключ",
|
||||
"resetKeyConfirm": "Вы уверены, что хотите сбросить API-ключ? Текущий ключ немедленно перестанет работать, и это действие нельзя отменить."
|
||||
"resetKeyConfirm": "Вы уверены, что хотите сбросить API-ключ? Текущий ключ немедленно перестанет работать, и это действие нельзя отменить.",
|
||||
"actionFailed": "Не получилось. Попробуйте ещё раз.",
|
||||
"apiKeyAfterPublish": "Опубликуйте агента, чтобы создать его API-ключ."
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "Импорт спецификации API",
|
||||
@@ -1838,7 +2045,6 @@
|
||||
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
|
||||
"remove": "Remove",
|
||||
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
|
||||
"ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
|
||||
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
|
||||
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
|
||||
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
|
||||
@@ -1856,7 +2062,8 @@
|
||||
"modes": {
|
||||
"monitorOnly": "Monitor only",
|
||||
"scanAll": "Enforce everywhere"
|
||||
}
|
||||
},
|
||||
"readOnly": "Вы видите эту политику, но ваша роль не позволяет её изменить."
|
||||
},
|
||||
"byline": {
|
||||
"new": "Настройте агента и опубликуйте его, чтобы с ним общаться."
|
||||
@@ -2310,7 +2517,8 @@
|
||||
"classicDescription": "Создайте стандартного ИИ-агента с одной моделью, инструментами и источниками знаний",
|
||||
"workflowTitle": "Агент рабочего процесса",
|
||||
"workflowDescription": "Создавайте сложные многошаговые рабочие процессы с разными моделями, условной логикой и управлением состоянием"
|
||||
}
|
||||
},
|
||||
"deleteFailed": "Не удалось удалить агента. Попробуйте ещё раз."
|
||||
},
|
||||
"components": {
|
||||
"fileUpload": {
|
||||
|
||||
+218
-10
@@ -92,7 +92,10 @@
|
||||
"edit": "編輯提示詞",
|
||||
"view": "檢視提示詞",
|
||||
"duplicate": "複製提示詞",
|
||||
"delete": "刪除提示詞"
|
||||
"delete": "刪除提示詞",
|
||||
"deleteFailed": "無法刪除此提示詞。",
|
||||
"saveFailed": "無法儲存此提示詞。",
|
||||
"editConflict": "其他人已變更此提示詞。請重新開啟以查看其版本。"
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
@@ -428,7 +431,15 @@
|
||||
"editChunk": "編輯文本塊",
|
||||
"editChunkDescription": "{{file}} · 第 {{n}} 個文本塊 · {{tokens}} Token",
|
||||
"previousChunk": "上一個文本塊",
|
||||
"nextChunk": "下一個文本塊"
|
||||
"nextChunk": "下一個文本塊",
|
||||
"viewConfig": "檢視來源設定",
|
||||
"errors": {
|
||||
"forbidden": "你沒有權限對此來源執行該操作。",
|
||||
"delete": "無法刪除來源。",
|
||||
"sync": "無法同步來源。",
|
||||
"syncFrequency": "無法變更同步頻率。",
|
||||
"reingest": "無法開始重新匯入。"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"label": "分析",
|
||||
@@ -720,7 +731,184 @@
|
||||
"teamLabel": "團隊",
|
||||
"viaTeam": "透過 {{team}}",
|
||||
"removeAccess": "移除存取權",
|
||||
"access": "存取權"
|
||||
"access": "存取權",
|
||||
"showAll": "顯示全部 {{count}} 個",
|
||||
"andMore": "還有 {{count}} 個",
|
||||
"back": "返回",
|
||||
"allSummary": "{{name}} · 團隊:{{teams}} · 人員:{{people}}",
|
||||
"searchAccess": "搜尋人員和團隊…",
|
||||
"filterLabel": "篩選具有存取權的人員",
|
||||
"filter": {
|
||||
"all": "全部",
|
||||
"teams": "團隊",
|
||||
"people": "人員",
|
||||
"editors": "編輯者"
|
||||
}
|
||||
},
|
||||
"accessChangeError": "無法變更存取權。",
|
||||
"accessSettings": {
|
||||
"title": "存取設定",
|
||||
"saveError": "無法儲存存取設定。",
|
||||
"agent": {
|
||||
"editors_can_share": {
|
||||
"label": "編輯者可以共用",
|
||||
"description": "新增人員和團隊並變更其存取權。"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "編輯者可以刪除",
|
||||
"description": "為所有人刪除此代理。"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"label": "編輯者可以管理存取詳細資料",
|
||||
"description": "API 金鑰、Webhook 和公開連結。"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"label": "檢視者可以查看記錄",
|
||||
"description": "此代理的對話和分析。"
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"editors_can_share": {
|
||||
"label": "編輯者可以共用",
|
||||
"description": "新增人員和團隊並變更其存取權。"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "編輯者可以刪除",
|
||||
"description": "為所有人刪除此來源。"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"label": "檢視者可以查看設定",
|
||||
"description": "分塊、檢索器和同步設定,唯讀。"
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"editors_can_change_credentials": {
|
||||
"label": "編輯者可以變更憑證和連線",
|
||||
"description": "他們會取代已儲存的密鑰;任何人都無法讀回。"
|
||||
},
|
||||
"editors_can_share": {
|
||||
"label": "編輯者可以共用",
|
||||
"description": "新增人員和團隊並變更其存取權。"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"label": "檢視者可以在自己的代理中使用",
|
||||
"description": "它會以你的憑證執行。"
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"editors_can_share": {
|
||||
"label": "編輯者可以共用",
|
||||
"description": "新增人員和團隊並變更其存取權。"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"label": "檢視者可以複製",
|
||||
"description": "建立自己的副本來編輯。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editorHint": {
|
||||
"agent": "編輯者可以修改它,包括記錄、排程和存取詳細資料。",
|
||||
"agentNoAccessDetails": "編輯者可以修改它,包括記錄和排程,但不能修改存取詳細資料。",
|
||||
"source": "編輯者可以編輯分塊和檔案、同步並變更設定。",
|
||||
"tool": "編輯者可以變更動作並取代憑證,但無法讀取密鑰。",
|
||||
"toolNoCredentials": "編輯者可以變更動作,但不能變更憑證。",
|
||||
"prompt": "編輯者可以修改文字。",
|
||||
"noShareNoDelete": "他們不能共用或刪除它。",
|
||||
"shareOnly": "他們也可以共用它,但不能刪除。",
|
||||
"deleteOnly": "他們也可以刪除它,但不能共用。",
|
||||
"shareAndDelete": "他們也可以共用和刪除它。",
|
||||
"noShare": "他們不能共用或刪除它。",
|
||||
"share": "他們也可以共用它,但不能刪除。"
|
||||
},
|
||||
"capabilities": {
|
||||
"agent": {
|
||||
"viewers": "檢視者:與其對話並釘選",
|
||||
"editors": "編輯者:編輯、發布、查看記錄和管理排程"
|
||||
},
|
||||
"source": {
|
||||
"viewers": "檢視者:瀏覽、搜尋並在自己的代理中使用",
|
||||
"editors": "編輯者:編輯分塊和檔案、同步、變更設定"
|
||||
},
|
||||
"tool": {
|
||||
"viewers": "檢視者:在擁有者的代理中使用",
|
||||
"editors": "編輯者:變更動作和核准"
|
||||
},
|
||||
"prompt": {
|
||||
"viewers": "檢視者:閱讀並在自己的代理中使用",
|
||||
"editors": "編輯者:修改文字"
|
||||
},
|
||||
"switch": {
|
||||
"editors_can_share": {
|
||||
"on": "編輯者可以共用",
|
||||
"off": "編輯者不能共用"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"on": "編輯者可以刪除",
|
||||
"off": "編輯者不能刪除"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"on": "編輯者可以管理 API 金鑰、Webhook 和公開連結",
|
||||
"off": "編輯者不能管理 API 金鑰、Webhook 或公開連結"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"on": "檢視者可以查看記錄",
|
||||
"off": "檢視者不能查看記錄"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"on": "檢視者可以查看設定",
|
||||
"off": "檢視者不能查看設定"
|
||||
},
|
||||
"editors_can_change_credentials": {
|
||||
"on": "編輯者可以取代憑證",
|
||||
"off": "編輯者不能變更憑證"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"on": "檢視者可以在自己的代理中使用",
|
||||
"off": "檢視者不能在自己的代理中使用"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"on": "檢視者可以複製",
|
||||
"off": "檢視者不能複製"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sharedList": {
|
||||
"badgeWithEditors": "{{level}} · +{{count}} 編輯者",
|
||||
"meta": "{{type}} · {{owner}}",
|
||||
"filterLabel": "篩選共用資源",
|
||||
"filter": {
|
||||
"all": "全部",
|
||||
"agent": "代理",
|
||||
"source": "來源",
|
||||
"tool": "工具",
|
||||
"prompt": "提示"
|
||||
},
|
||||
"search": "搜尋共用…",
|
||||
"noMatches": "沒有相符項目。"
|
||||
},
|
||||
"drawer": {
|
||||
"close": "關閉",
|
||||
"subtitle": "{{type}} · 擁有者:{{owner}}",
|
||||
"open": "開啟{{type}}",
|
||||
"manageSharing": "管理共用",
|
||||
"owner": "擁有者",
|
||||
"shared": "共用時間",
|
||||
"sharedOnBy": "{{date}},由 {{name}}",
|
||||
"yourAccess": "你的存取權",
|
||||
"yourAccessLevel": {
|
||||
"owner": "擁有者",
|
||||
"editor": "編輯者",
|
||||
"viewer": "檢視者",
|
||||
"none": "無存取權"
|
||||
},
|
||||
"accessIn": "{{team}} 中的存取權",
|
||||
"everyone": "{{team}} 的所有人",
|
||||
"teamGrant": "整個團隊",
|
||||
"memberGrant": "僅此人",
|
||||
"removeGrant": "移除存取權",
|
||||
"otherTeamsHint": "也與其他團隊共用了嗎?這些授權在「管理共用」中管理。",
|
||||
"whatPeopleCanDo": "這裡的人員可以做什麼",
|
||||
"capabilitiesHint": "來自擁有者的存取設定。此處為唯讀。"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
@@ -794,7 +982,6 @@
|
||||
"addServer": "Add MCP Server",
|
||||
"editServer": "Edit Server",
|
||||
"reconnectServer": "重新連線伺服器",
|
||||
"reenterCredentials": "重新輸入您的憑證以測試並更新連線。",
|
||||
"serverName": "Server Name",
|
||||
"serverUrl": "Server URL",
|
||||
"headerName": "Header Name",
|
||||
@@ -839,7 +1026,18 @@
|
||||
"oauthFailed": "OAuth process failed or was cancelled",
|
||||
"oauthTimeout": "OAuth process timed out, please try again",
|
||||
"timeoutRange": "Timeout must be between 1 and 300 seconds"
|
||||
}
|
||||
},
|
||||
"sharedByEditor": "由 {{owner}} 分享 · 你是編輯者",
|
||||
"aTeammate": "一位隊友",
|
||||
"sharedCredentialsNotice": "已儲存的憑證不會顯示。你輸入的內容將為所有使用此工具的人取代它們。",
|
||||
"serverChangedNotice": "伺服器已變更,因此已儲存的{{credential}}將被清除。請為新伺服器輸入後再儲存。",
|
||||
"credentialNames": {
|
||||
"apiKey": "API 金鑰",
|
||||
"bearer": "權杖",
|
||||
"password": "密碼"
|
||||
},
|
||||
"savedKeyHint": "已儲存金鑰。留空即可保留(僅在伺服器未變更時)。",
|
||||
"sharedOAuthOwnerOnly": "只有擁有者可以重新連結其登入,因此你可以重新命名,但不能變更其伺服器或帳戶。"
|
||||
},
|
||||
"configErrors": {
|
||||
"required": "{{field}}為必填項",
|
||||
@@ -847,7 +1045,14 @@
|
||||
"maxTimeout": "逾時時間最長為 300 秒"
|
||||
},
|
||||
"headerValuePlaceholder": "例如:application/json",
|
||||
"toolIconTitle": "{{name}} 圖示"
|
||||
"toolIconTitle": "{{name}} 圖示",
|
||||
"view": "檢視",
|
||||
"inMyChats": "在我的聊天中",
|
||||
"useInMyChatsAria": "在我的聊天中使用 {{toolName}}",
|
||||
"statusUpdateFailed": "無法變更此工具是否用於你的聊天。",
|
||||
"deleteFailed": "無法刪除此工具。",
|
||||
"sharedBy": "由 {{team}} 分享",
|
||||
"savedSecretPlaceholder": "已儲存 · 輸入新值以取代"
|
||||
},
|
||||
"devices": {
|
||||
"label": "裝置",
|
||||
@@ -1344,7 +1549,9 @@
|
||||
"test": "Test",
|
||||
"learnMore": "Learn more",
|
||||
"resetKey": "重設金鑰",
|
||||
"resetKeyConfirm": "確定要重設 API 金鑰嗎?目前的金鑰將立即停止運作,此操作無法復原。"
|
||||
"resetKeyConfirm": "確定要重設 API 金鑰嗎?目前的金鑰將立即停止運作,此操作無法復原。",
|
||||
"actionFailed": "操作未成功,請再試一次。",
|
||||
"apiKeyAfterPublish": "發布此代理後即可建立其 API 金鑰。"
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "匯入 API 規格",
|
||||
@@ -1761,7 +1968,6 @@
|
||||
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
|
||||
"remove": "Remove",
|
||||
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
|
||||
"ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
|
||||
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
|
||||
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
|
||||
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
|
||||
@@ -1779,7 +1985,8 @@
|
||||
"modes": {
|
||||
"monitorOnly": "Monitor only",
|
||||
"scanAll": "Enforce everywhere"
|
||||
}
|
||||
},
|
||||
"readOnly": "你可以檢視此政策,但你的角色無法變更它。"
|
||||
},
|
||||
"byline": {
|
||||
"new": "設定好代理後發佈,即可與它對話。"
|
||||
@@ -2221,7 +2428,8 @@
|
||||
"classicDescription": "建立一個使用單一模型、工具和知識來源的標準 AI 代理",
|
||||
"workflowTitle": "工作流程代理",
|
||||
"workflowDescription": "設計包含不同模型、條件邏輯和狀態管理的複雜多步驟工作流程"
|
||||
}
|
||||
},
|
||||
"deleteFailed": "無法刪除此代理,請再試一次。"
|
||||
},
|
||||
"components": {
|
||||
"fileUpload": {
|
||||
|
||||
+218
-10
@@ -92,7 +92,10 @@
|
||||
"edit": "编辑提示词",
|
||||
"view": "查看提示词",
|
||||
"duplicate": "复制提示词",
|
||||
"delete": "删除提示词"
|
||||
"delete": "删除提示词",
|
||||
"deleteFailed": "无法删除此提示词。",
|
||||
"saveFailed": "无法保存此提示词。",
|
||||
"editConflict": "其他人已更改此提示词。请重新打开以查看其版本。"
|
||||
}
|
||||
},
|
||||
"sources": {
|
||||
@@ -428,7 +431,15 @@
|
||||
"editChunk": "编辑文本块",
|
||||
"editChunkDescription": "{{file}} · 第 {{n}} 个文本块 · {{tokens}} 个令牌",
|
||||
"previousChunk": "上一个文本块",
|
||||
"nextChunk": "下一个文本块"
|
||||
"nextChunk": "下一个文本块",
|
||||
"viewConfig": "查看来源设置",
|
||||
"errors": {
|
||||
"forbidden": "你无权对此来源执行该操作。",
|
||||
"delete": "无法删除来源。",
|
||||
"sync": "无法同步来源。",
|
||||
"syncFrequency": "无法更改同步频率。",
|
||||
"reingest": "无法开始重新导入。"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"label": "分析",
|
||||
@@ -720,7 +731,184 @@
|
||||
"teamLabel": "团队",
|
||||
"viaTeam": "通过 {{team}}",
|
||||
"removeAccess": "移除访问权限",
|
||||
"access": "访问权限"
|
||||
"access": "访问权限",
|
||||
"showAll": "显示全部 {{count}} 个",
|
||||
"andMore": "还有 {{count}} 个",
|
||||
"back": "返回",
|
||||
"allSummary": "{{name}} · 团队:{{teams}} · 人员:{{people}}",
|
||||
"searchAccess": "搜索人员和团队…",
|
||||
"filterLabel": "筛选有权访问的人员",
|
||||
"filter": {
|
||||
"all": "全部",
|
||||
"teams": "团队",
|
||||
"people": "人员",
|
||||
"editors": "编辑者"
|
||||
}
|
||||
},
|
||||
"accessChangeError": "无法更改访问权限。",
|
||||
"accessSettings": {
|
||||
"title": "访问设置",
|
||||
"saveError": "无法保存访问设置。",
|
||||
"agent": {
|
||||
"editors_can_share": {
|
||||
"label": "编辑者可以共享",
|
||||
"description": "添加人员和团队并更改其访问权限。"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "编辑者可以删除",
|
||||
"description": "为所有人删除该代理。"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"label": "编辑者可以管理访问详情",
|
||||
"description": "API 密钥、Webhook 和公开链接。"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"label": "查看者可以查看日志",
|
||||
"description": "该代理的对话和分析。"
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"editors_can_share": {
|
||||
"label": "编辑者可以共享",
|
||||
"description": "添加人员和团队并更改其访问权限。"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"label": "编辑者可以删除",
|
||||
"description": "为所有人删除该来源。"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"label": "查看者可以查看设置",
|
||||
"description": "分块、检索器和同步设置,只读。"
|
||||
}
|
||||
},
|
||||
"tool": {
|
||||
"editors_can_change_credentials": {
|
||||
"label": "编辑者可以更改凭据和连接",
|
||||
"description": "他们替换已保存的密钥;任何人都无法读回。"
|
||||
},
|
||||
"editors_can_share": {
|
||||
"label": "编辑者可以共享",
|
||||
"description": "添加人员和团队并更改其访问权限。"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"label": "查看者可以在自己的代理中使用",
|
||||
"description": "它使用你的凭据运行。"
|
||||
}
|
||||
},
|
||||
"prompt": {
|
||||
"editors_can_share": {
|
||||
"label": "编辑者可以共享",
|
||||
"description": "添加人员和团队并更改其访问权限。"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"label": "查看者可以复制",
|
||||
"description": "创建自己的副本进行编辑。"
|
||||
}
|
||||
}
|
||||
},
|
||||
"editorHint": {
|
||||
"agent": "编辑者可以修改它,包括日志、计划和访问详情。",
|
||||
"agentNoAccessDetails": "编辑者可以修改它,包括日志和计划,但不能修改访问详情。",
|
||||
"source": "编辑者可以编辑分块和文件、同步并更改设置。",
|
||||
"tool": "编辑者可以更改操作并替换凭据,但无法读取密钥。",
|
||||
"toolNoCredentials": "编辑者可以更改操作,但不能更改凭据。",
|
||||
"prompt": "编辑者可以修改文本。",
|
||||
"noShareNoDelete": "他们不能共享或删除它。",
|
||||
"shareOnly": "他们还可以共享它,但不能删除。",
|
||||
"deleteOnly": "他们还可以删除它,但不能共享。",
|
||||
"shareAndDelete": "他们还可以共享和删除它。",
|
||||
"noShare": "他们不能共享或删除它。",
|
||||
"share": "他们还可以共享它,但不能删除。"
|
||||
},
|
||||
"capabilities": {
|
||||
"agent": {
|
||||
"viewers": "查看者:与其对话并置顶",
|
||||
"editors": "编辑者:编辑、发布、查看日志和管理计划"
|
||||
},
|
||||
"source": {
|
||||
"viewers": "查看者:浏览、搜索并在自己的代理中使用",
|
||||
"editors": "编辑者:编辑分块和文件、同步、更改设置"
|
||||
},
|
||||
"tool": {
|
||||
"viewers": "查看者:在所有者的代理中使用",
|
||||
"editors": "编辑者:更改操作和审批"
|
||||
},
|
||||
"prompt": {
|
||||
"viewers": "查看者:阅读并在自己的代理中使用",
|
||||
"editors": "编辑者:修改文本"
|
||||
},
|
||||
"switch": {
|
||||
"editors_can_share": {
|
||||
"on": "编辑者可以共享",
|
||||
"off": "编辑者不能共享"
|
||||
},
|
||||
"editors_can_delete": {
|
||||
"on": "编辑者可以删除",
|
||||
"off": "编辑者不能删除"
|
||||
},
|
||||
"editors_can_manage_access_details": {
|
||||
"on": "编辑者可以管理 API 密钥、Webhook 和公开链接",
|
||||
"off": "编辑者不能管理 API 密钥、Webhook 或公开链接"
|
||||
},
|
||||
"viewers_can_see_logs": {
|
||||
"on": "查看者可以查看日志",
|
||||
"off": "查看者不能查看日志"
|
||||
},
|
||||
"viewers_can_see_config": {
|
||||
"on": "查看者可以查看设置",
|
||||
"off": "查看者不能查看设置"
|
||||
},
|
||||
"editors_can_change_credentials": {
|
||||
"on": "编辑者可以替换凭据",
|
||||
"off": "编辑者不能更改凭据"
|
||||
},
|
||||
"viewers_can_use_in_agents": {
|
||||
"on": "查看者可以在自己的代理中使用",
|
||||
"off": "查看者不能在自己的代理中使用"
|
||||
},
|
||||
"viewers_can_duplicate": {
|
||||
"on": "查看者可以复制",
|
||||
"off": "查看者不能复制"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sharedList": {
|
||||
"badgeWithEditors": "{{level}} · +{{count}} 编辑者",
|
||||
"meta": "{{type}} · {{owner}}",
|
||||
"filterLabel": "筛选共享资源",
|
||||
"filter": {
|
||||
"all": "全部",
|
||||
"agent": "代理",
|
||||
"source": "来源",
|
||||
"tool": "工具",
|
||||
"prompt": "提示词"
|
||||
},
|
||||
"search": "搜索共享…",
|
||||
"noMatches": "没有匹配项。"
|
||||
},
|
||||
"drawer": {
|
||||
"close": "关闭",
|
||||
"subtitle": "{{type}} · 所有者:{{owner}}",
|
||||
"open": "打开{{type}}",
|
||||
"manageSharing": "管理共享",
|
||||
"owner": "所有者",
|
||||
"shared": "共享时间",
|
||||
"sharedOnBy": "{{date}},由 {{name}}",
|
||||
"yourAccess": "你的访问权限",
|
||||
"yourAccessLevel": {
|
||||
"owner": "所有者",
|
||||
"editor": "编辑者",
|
||||
"viewer": "查看者",
|
||||
"none": "无访问权限"
|
||||
},
|
||||
"accessIn": "{{team}} 中的访问权限",
|
||||
"everyone": "{{team}} 的所有人",
|
||||
"teamGrant": "整个团队",
|
||||
"memberGrant": "仅此人",
|
||||
"removeGrant": "移除访问权限",
|
||||
"otherTeamsHint": "也与其他团队共享了?这些授权在“管理共享”中管理。",
|
||||
"whatPeopleCanDo": "这里的人员可以做什么",
|
||||
"capabilitiesHint": "来自所有者的访问设置。此处只读。"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
@@ -794,7 +982,6 @@
|
||||
"addServer": "Add MCP Server",
|
||||
"editServer": "Edit Server",
|
||||
"reconnectServer": "重新连接服务器",
|
||||
"reenterCredentials": "重新输入您的凭据以测试并更新连接。",
|
||||
"serverName": "Server Name",
|
||||
"serverUrl": "Server URL",
|
||||
"headerName": "Header Name",
|
||||
@@ -839,7 +1026,18 @@
|
||||
"oauthFailed": "OAuth process failed or was cancelled",
|
||||
"oauthTimeout": "OAuth process timed out, please try again",
|
||||
"timeoutRange": "Timeout must be between 1 and 300 seconds"
|
||||
}
|
||||
},
|
||||
"sharedByEditor": "由 {{owner}} 共享 · 你是编辑者",
|
||||
"aTeammate": "一位队友",
|
||||
"sharedCredentialsNotice": "已保存的凭据不会显示。你输入的内容将为所有使用此工具的人替换它们。",
|
||||
"serverChangedNotice": "服务器已更改,因此已保存的{{credential}}将被清除。请为新服务器输入后再保存。",
|
||||
"credentialNames": {
|
||||
"apiKey": "API 密钥",
|
||||
"bearer": "令牌",
|
||||
"password": "密码"
|
||||
},
|
||||
"savedKeyHint": "已保存密钥。留空即可保留(仅在服务器未更改时)。",
|
||||
"sharedOAuthOwnerOnly": "只有所有者可以重新连接其登录,因此你可以重命名它,但不能更改其服务器或账户。"
|
||||
},
|
||||
"configErrors": {
|
||||
"required": "{{field}}为必填项",
|
||||
@@ -847,7 +1045,14 @@
|
||||
"maxTimeout": "超时时间最长为 300 秒"
|
||||
},
|
||||
"headerValuePlaceholder": "例如:application/json",
|
||||
"toolIconTitle": "{{name}} 图标"
|
||||
"toolIconTitle": "{{name}} 图标",
|
||||
"view": "查看",
|
||||
"inMyChats": "在我的聊天中",
|
||||
"useInMyChatsAria": "在我的聊天中使用 {{toolName}}",
|
||||
"statusUpdateFailed": "无法更改此工具是否用于你的聊天。",
|
||||
"deleteFailed": "无法删除此工具。",
|
||||
"sharedBy": "由 {{team}} 共享",
|
||||
"savedSecretPlaceholder": "已保存 · 输入新值以替换"
|
||||
},
|
||||
"devices": {
|
||||
"label": "设备",
|
||||
@@ -1344,7 +1549,9 @@
|
||||
"test": "Test",
|
||||
"learnMore": "Learn more",
|
||||
"resetKey": "重置密钥",
|
||||
"resetKeyConfirm": "确定要重置 API 密钥吗?当前密钥将立即停止工作,此操作无法撤销。"
|
||||
"resetKeyConfirm": "确定要重置 API 密钥吗?当前密钥将立即停止工作,此操作无法撤销。",
|
||||
"actionFailed": "操作未成功,请重试。",
|
||||
"apiKeyAfterPublish": "发布该代理后即可创建其 API 密钥。"
|
||||
},
|
||||
"importSpec": {
|
||||
"title": "导入 API 规范",
|
||||
@@ -1761,7 +1968,6 @@
|
||||
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
|
||||
"remove": "Remove",
|
||||
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
|
||||
"ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
|
||||
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
|
||||
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
|
||||
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
|
||||
@@ -1779,7 +1985,8 @@
|
||||
"modes": {
|
||||
"monitorOnly": "Monitor only",
|
||||
"scanAll": "Enforce everywhere"
|
||||
}
|
||||
},
|
||||
"readOnly": "你可以查看此策略,但你的角色无法更改它。"
|
||||
},
|
||||
"byline": {
|
||||
"new": "设置好智能体后发布,即可与它对话。"
|
||||
@@ -2221,7 +2428,8 @@
|
||||
"classicDescription": "创建一个使用单一模型、工具和知识来源的标准 AI 智能体",
|
||||
"workflowTitle": "工作流智能体",
|
||||
"workflowDescription": "设计包含不同模型、条件逻辑和状态管理的复杂多步骤工作流"
|
||||
}
|
||||
},
|
||||
"deleteFailed": "无法删除该代理,请重试。"
|
||||
},
|
||||
"components": {
|
||||
"fileUpload": {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
shareAgent: vi.fn(),
|
||||
getAgentWebhook: vi.fn(),
|
||||
regenerateAgentKey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: (selector: (s: unknown) => unknown) =>
|
||||
selector({ preference: { token: null } }),
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/userService', () => ({ default: mocks }));
|
||||
|
||||
vi.mock('./ConfirmationModal', () => ({
|
||||
default: ({
|
||||
modalState,
|
||||
handleSubmit,
|
||||
}: {
|
||||
modalState: string;
|
||||
handleSubmit: () => void;
|
||||
}) =>
|
||||
modalState === 'ACTIVE' ? (
|
||||
<button type="button" data-testid="confirm-key" onClick={handleSubmit} />
|
||||
) : null,
|
||||
}));
|
||||
|
||||
import type { Agent } from '../agents/types';
|
||||
import AgentDetailsModal from './AgentDetailsModal';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const respond = (body: unknown, ok = true) =>
|
||||
Promise.resolve({ ok, json: () => Promise.resolve(body) });
|
||||
|
||||
describe('AgentDetailsModal', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
Object.values(mocks).forEach((m) => m.mockReset());
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
const render = async (agent: Partial<Agent>) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<AgentDetailsModal
|
||||
agent={{ id: 'a1', name: 'Bot', ...agent } as Agent}
|
||||
mode="edit"
|
||||
modalState="ACTIVE"
|
||||
setModalState={() => undefined}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// The three sections each have a Generate button until they hold a value.
|
||||
const generateButtons = () =>
|
||||
Array.from(document.querySelectorAll('button')).filter(
|
||||
(b) => b.textContent === 'modals.agentDetails.generate',
|
||||
);
|
||||
|
||||
it('generates a missing API key through the reset confirmation', async () => {
|
||||
mocks.regenerateAgentKey.mockReturnValue(respond({ key: 'new-key' }));
|
||||
await render({ status: 'published' });
|
||||
const [, apiKey] = generateButtons();
|
||||
await act(async () => apiKey.click());
|
||||
await act(async () =>
|
||||
document
|
||||
.querySelector<HTMLButtonElement>('[data-testid="confirm-key"]')!
|
||||
.click(),
|
||||
);
|
||||
expect(mocks.regenerateAgentKey).toHaveBeenCalledWith('a1', null);
|
||||
expect(document.body.textContent).toContain('new-key');
|
||||
});
|
||||
|
||||
it('asks a draft to publish first instead of offering a key', async () => {
|
||||
await render({ status: 'draft' });
|
||||
expect(generateButtons()).toHaveLength(2);
|
||||
expect(document.body.textContent).toContain(
|
||||
'modals.agentDetails.apiKeyAfterPublish',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows a refused public link in an alert', async () => {
|
||||
mocks.shareAgent.mockReturnValue(
|
||||
respond({ success: false, message: 'Not allowed' }, false),
|
||||
);
|
||||
await render({ status: 'published' });
|
||||
await act(async () => generateButtons()[0].click());
|
||||
const alert = document.querySelector('[role="alert"]');
|
||||
expect(alert?.textContent).toContain('Not allowed');
|
||||
});
|
||||
|
||||
it('shows a failed webhook in an alert with a fallback message', async () => {
|
||||
mocks.getAgentWebhook.mockReturnValue(respond({}, false));
|
||||
await render({ status: 'published' });
|
||||
const buttons = generateButtons();
|
||||
await act(async () => buttons[buttons.length - 1].click());
|
||||
const alert = document.querySelector('[role="alert"]');
|
||||
expect(alert?.textContent).toContain('modals.agentDetails.actionFailed');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { envVar } from '@/env';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { CircleX, ExternalLink } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -7,6 +7,7 @@ import { useSelector } from 'react-redux';
|
||||
import { Agent } from '../agents/types';
|
||||
import userService from '../api/services/userService';
|
||||
import CopyButton from '../components/CopyButton';
|
||||
import { Alert, AlertDescription } from '../components/ui/alert';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Modal } from '../components/ui/modal';
|
||||
import { SectionHeader } from '../components/ui/section-header';
|
||||
@@ -16,6 +17,18 @@ import ConfirmationModal from './ConfirmationModal';
|
||||
|
||||
const baseURL = envVar('VITE_BASE_URL');
|
||||
|
||||
/** The backend's `message` on a refused call, else null. */
|
||||
const errorMessage = async (response: Response): Promise<string | null> => {
|
||||
try {
|
||||
const body = await response.json();
|
||||
return typeof body?.message === 'string' && body.message.trim()
|
||||
? body.message
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
type AgentDetailsModalProps = {
|
||||
agent: Agent;
|
||||
mode: 'new' | 'edit' | 'draft';
|
||||
@@ -41,6 +54,8 @@ export default function AgentDetailsModal({
|
||||
const [webhookUrl, setWebhookUrl] = useState<string | null>(null);
|
||||
const [resetKeyConfirmState, setResetKeyConfirmState] =
|
||||
useState<ActiveState>('INACTIVE');
|
||||
// A failed generate or reset, shown in the modal until the next attempt.
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadingStates, setLoadingStates] = useState({
|
||||
publicLink: false,
|
||||
apiKey: false,
|
||||
@@ -54,49 +69,61 @@ export default function AgentDetailsModal({
|
||||
setLoadingStates((prev) => ({ ...prev, [key]: state }));
|
||||
};
|
||||
|
||||
const handleGeneratePublicLink = async () => {
|
||||
setLoading('publicLink', true);
|
||||
const response = await userService.shareAgent(
|
||||
{ id: agent.id ?? '', shared: true },
|
||||
token,
|
||||
);
|
||||
if (!response.ok) {
|
||||
setLoading('publicLink', false);
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
setSharedToken(data.shared_token);
|
||||
setLoading('publicLink', false);
|
||||
};
|
||||
|
||||
const handleGenerateWebhook = async () => {
|
||||
setLoading('webhook', true);
|
||||
const response = await userService.getAgentWebhook(agent.id ?? '', token);
|
||||
if (!response.ok) {
|
||||
setLoading('webhook', false);
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
setWebhookUrl(data.webhook_url);
|
||||
setLoading('webhook', false);
|
||||
};
|
||||
|
||||
const handleRegenerateKey = async () => {
|
||||
setLoading('apiKey', true);
|
||||
/**
|
||||
* Runs one of the modal's calls, showing its failure in the Alert.
|
||||
*
|
||||
* @param key Which button shows the spinner.
|
||||
* @param request The call; resolves to the response.
|
||||
* @param onSuccess Receives the parsed body of a successful response.
|
||||
*/
|
||||
const run = async (
|
||||
key: 'publicLink' | 'apiKey' | 'webhook',
|
||||
request: () => Promise<Response>,
|
||||
onSuccess: (data: Record<string, string>) => void,
|
||||
) => {
|
||||
setLoading(key, true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await userService.regenerateAgentKey(
|
||||
agent.id ?? '',
|
||||
token,
|
||||
);
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
setApiKey(data.key);
|
||||
onKeyRegenerated?.(data.key);
|
||||
const response = await request();
|
||||
if (!response.ok) {
|
||||
setError(
|
||||
(await errorMessage(response)) ??
|
||||
t('modals.agentDetails.actionFailed'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
onSuccess(await response.json());
|
||||
} catch {
|
||||
setError(t('modals.agentDetails.actionFailed'));
|
||||
} finally {
|
||||
setLoading('apiKey', false);
|
||||
setLoading(key, false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGeneratePublicLink = () =>
|
||||
run(
|
||||
'publicLink',
|
||||
() => userService.shareAgent({ id: agent.id ?? '', shared: true }, token),
|
||||
(data) => setSharedToken(data.shared_token),
|
||||
);
|
||||
|
||||
const handleGenerateWebhook = () =>
|
||||
run(
|
||||
'webhook',
|
||||
() => userService.getAgentWebhook(agent.id ?? '', token),
|
||||
(data) => setWebhookUrl(data.webhook_url),
|
||||
);
|
||||
|
||||
const handleRegenerateKey = () =>
|
||||
run(
|
||||
'apiKey',
|
||||
() => userService.regenerateAgentKey(agent.id ?? '', token),
|
||||
(data) => {
|
||||
setApiKey(data.key);
|
||||
onKeyRegenerated?.(data.key);
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSharedToken(agent.shared_token ?? null);
|
||||
setApiKey(agent.key ?? null);
|
||||
@@ -111,6 +138,12 @@ export default function AgentDetailsModal({
|
||||
size="md"
|
||||
>
|
||||
<div>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mt-6">
|
||||
<CircleX />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="mt-8 flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -216,8 +249,21 @@ export default function AgentDetailsModal({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : agent.status === 'draft' ? (
|
||||
// A draft has no key yet: the first one is minted on publish.
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('modals.agentDetails.apiKeyAfterPublish')}
|
||||
</p>
|
||||
) : (
|
||||
<Button type="button" variant="outline-primary" shape="pill">
|
||||
// No key shown on a published agent: minting one replaces
|
||||
// any key it has, so it goes through the reset confirmation.
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-primary"
|
||||
shape="pill"
|
||||
onClick={() => setResetKeyConfirmState('ACTIVE')}
|
||||
loading={loadingStates.apiKey}
|
||||
>
|
||||
{t('modals.agentDetails.generate')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: (selector: (state: unknown) => unknown) =>
|
||||
selector({ notifications: { recentEvents: [] }, preference: {} }),
|
||||
}));
|
||||
vi.mock('../preferences/preferenceSlice', () => ({
|
||||
selectToken: () => 'token',
|
||||
}));
|
||||
vi.mock('../notifications/notificationsSlice', () => ({
|
||||
selectRecentEvents: (state: { notifications: { recentEvents: unknown[] } }) =>
|
||||
state.notifications.recentEvents,
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) => {
|
||||
if (!opts || typeof opts !== 'object') return key;
|
||||
const { defaultValue: _d, interpolation: _i, ...rest } = opts;
|
||||
void _d;
|
||||
void _i;
|
||||
return Object.keys(rest).length ? `${key}:${JSON.stringify(rest)}` : key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const testMCPConnection = vi.fn();
|
||||
const saveMCPServer = vi.fn();
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: {
|
||||
testMCPConnection: (...args: unknown[]) => testMCPConnection(...args),
|
||||
saveMCPServer: (...args: unknown[]) => saveMCPServer(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
import MCPServerModal from './MCPServerModal';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const server = {
|
||||
id: 'tool-1',
|
||||
displayName: 'Carrier Rates MCP',
|
||||
server_url: 'https://mcp.dana-tools.dev/sse',
|
||||
auth_type: 'api_key',
|
||||
timeout: 30,
|
||||
oauth_scopes: '',
|
||||
has_encrypted_credentials: true,
|
||||
access: 'owner',
|
||||
owner_label: null as string | null,
|
||||
};
|
||||
|
||||
const json = (body: unknown, ok = true, status = 200) =>
|
||||
Promise.resolve({ ok, status, json: () => Promise.resolve(body) });
|
||||
|
||||
describe('MCPServerModal', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
testMCPConnection.mockReset();
|
||||
saveMCPServer.mockReset();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
const render = async (overrides: Partial<typeof server> = {}) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MCPServerModal
|
||||
modalState="ACTIVE"
|
||||
setModalState={() => {}}
|
||||
server={{ ...server, ...overrides }}
|
||||
onServerSaved={() => {}}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const text = () => document.body.textContent ?? '';
|
||||
const button = (label: string) =>
|
||||
Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>('button'),
|
||||
).find((b) => b.textContent === label)!;
|
||||
const typeInto = async (input: HTMLInputElement, value: string) => {
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
'value',
|
||||
)?.set?.call(input, value);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
};
|
||||
const urlInput = () =>
|
||||
document.body.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="https://example.com/mcp"]',
|
||||
)!;
|
||||
|
||||
it('tells an editor whose tool it is and that their entry replaces it for everyone', async () => {
|
||||
await render({ access: 'editor', owner_label: 'Lena Fischer' });
|
||||
expect(text()).toContain(
|
||||
'settings.tools.mcp.sharedByEditor:{"owner":"Lena Fischer"}',
|
||||
);
|
||||
const info = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="alert"]'),
|
||||
).find((a) =>
|
||||
a.textContent?.includes('settings.tools.mcp.sharedCredentialsNotice'),
|
||||
);
|
||||
expect(info?.dataset.variant ?? info?.className).toMatch(/info/);
|
||||
});
|
||||
|
||||
it('falls back to "a teammate" without an owner label', async () => {
|
||||
await render({ access: 'editor', owner_label: null });
|
||||
expect(text()).toContain(
|
||||
'settings.tools.mcp.sharedByEditor:{"owner":"settings.tools.mcp.aTeammate"}',
|
||||
);
|
||||
});
|
||||
|
||||
it("shows no sharing notices on the caller's own tool", async () => {
|
||||
await render();
|
||||
expect(text()).not.toContain('settings.tools.mcp.sharedByEditor');
|
||||
expect(text()).not.toContain('settings.tools.mcp.sharedCredentialsNotice');
|
||||
});
|
||||
|
||||
it('hints that a saved key is kept when left empty', async () => {
|
||||
await render();
|
||||
expect(text()).toContain('settings.tools.mcp.savedKeyHint');
|
||||
});
|
||||
|
||||
it('tests with the saved key while the server is unchanged', async () => {
|
||||
testMCPConnection.mockReturnValue(json({ success: true, tools: [] }));
|
||||
await render();
|
||||
await act(async () => button('settings.tools.mcp.testConnection').click());
|
||||
expect(testMCPConnection).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'tool-1' }),
|
||||
'token',
|
||||
);
|
||||
});
|
||||
|
||||
it('warns and requires a new key when the server host changes', async () => {
|
||||
await render({ access: 'editor', owner_label: 'Lena' });
|
||||
await typeInto(urlInput(), 'https://evil.example.com/sse');
|
||||
expect(text()).toContain(
|
||||
'settings.tools.mcp.serverChangedNotice:{"credential":"settings.tools.mcp.credentialNames.apiKey"}',
|
||||
);
|
||||
expect(text()).not.toContain('settings.tools.mcp.savedKeyHint');
|
||||
await act(async () => button('settings.tools.mcp.testConnection').click());
|
||||
expect(testMCPConnection).not.toHaveBeenCalled();
|
||||
expect(text()).toContain('settings.tools.mcp.errors.apiKeyRequired');
|
||||
});
|
||||
|
||||
it('keeps the saved key for a path-only change on the same host', async () => {
|
||||
await render();
|
||||
await typeInto(urlInput(), 'https://mcp.dana-tools.dev/v2/sse');
|
||||
expect(text()).not.toContain('settings.tools.mcp.serverChangedNotice');
|
||||
});
|
||||
|
||||
it("shows the server's save error in the error Alert", async () => {
|
||||
testMCPConnection.mockReturnValue(json({ success: true, tools: [] }));
|
||||
saveMCPServer.mockReturnValue(
|
||||
json({ success: false, message: 'Invalid server URL' }, false, 400),
|
||||
);
|
||||
await render();
|
||||
await act(async () => button('settings.tools.mcp.testConnection').click());
|
||||
await act(async () => button('settings.tools.mcp.save').click());
|
||||
expect(text()).toContain('Invalid server URL');
|
||||
});
|
||||
|
||||
it('lets an editor rename an OAuth tool without reconnecting it', async () => {
|
||||
saveMCPServer.mockReturnValue(json({ success: true }));
|
||||
await render({
|
||||
access: 'editor',
|
||||
owner_label: 'Lena',
|
||||
auth_type: 'oauth',
|
||||
has_encrypted_credentials: false,
|
||||
});
|
||||
expect(text()).toContain('settings.tools.mcp.sharedOAuthOwnerOnly');
|
||||
expect(urlInput().disabled).toBe(true);
|
||||
expect(button('settings.tools.mcp.testConnection')).toBeUndefined();
|
||||
const save = button('settings.tools.mcp.save');
|
||||
expect(save.disabled).toBe(false);
|
||||
await act(async () => save.click());
|
||||
expect(saveMCPServer).toHaveBeenCalledTimes(1);
|
||||
expect(testMCPConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps OAuth reconnect for the owner', async () => {
|
||||
await render({ auth_type: 'oauth', has_encrypted_credentials: false });
|
||||
expect(urlInput().disabled).toBe(false);
|
||||
expect(button('settings.tools.mcp.testConnection')).toBeDefined();
|
||||
expect(text()).not.toContain('settings.tools.mcp.sharedOAuthOwnerOnly');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CircleAlert, CircleCheck, TriangleAlert } from 'lucide-react';
|
||||
import { CircleAlert, CircleCheck, Info, TriangleAlert } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
@@ -30,6 +30,22 @@ interface MCPServerModalProps {
|
||||
onServerSaved: () => void;
|
||||
}
|
||||
|
||||
/** The host of a URL, or '' while it doesn't parse. */
|
||||
function hostOf(url: string): string {
|
||||
try {
|
||||
return new URL(url.trim()).host.toLowerCase();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// The saved secret each auth type keeps, named for the host-change notice.
|
||||
const SECRET_FIELDS: Record<string, { field: string; nameKey: string }> = {
|
||||
api_key: { field: 'api_key', nameKey: 'apiKey' },
|
||||
bearer: { field: 'bearer_token', nameKey: 'bearer' },
|
||||
basic: { field: 'password', nameKey: 'password' },
|
||||
};
|
||||
|
||||
export default function MCPServerModal({
|
||||
modalState,
|
||||
setModalState,
|
||||
@@ -95,6 +111,24 @@ export default function MCPServerModal({
|
||||
const [oauthCompleted, setOAuthCompleted] = useState(false);
|
||||
const [saveActive, setSaveActive] = useState(false);
|
||||
|
||||
// A tool shared with the caller (an editor reconnecting the owner's
|
||||
// server): its saved secrets stay hidden and a new entry replaces them.
|
||||
const isShared = !!server?.access && server.access !== 'owner';
|
||||
// Only the owner can re-run an OAuth sign-in (the tokens are theirs), so a
|
||||
// teammate may rename an OAuth tool but not change its server or account.
|
||||
const oauthOwnerOnly = isShared && server?.auth_type === 'oauth';
|
||||
const savedSecret = SECRET_FIELDS[formData.auth_type];
|
||||
const hasSavedSecret =
|
||||
!!server?.has_encrypted_credentials &&
|
||||
!!savedSecret &&
|
||||
formData.auth_type === server?.auth_type;
|
||||
// The server clears the saved secret when the host changes, so the key
|
||||
// can't be pointed at another server.
|
||||
const serverChanged =
|
||||
hasSavedSecret &&
|
||||
hostOf(formData.server_url) !== hostOf(server?.server_url || '');
|
||||
const keepSavedSecret = hasSavedSecret && !serverChanged;
|
||||
|
||||
const cleanupOAuthListener = useCallback(() => {
|
||||
setOauthTaskId(null);
|
||||
handledEventIdsRef.current = new Set();
|
||||
@@ -167,17 +201,17 @@ export default function MCPServerModal({
|
||||
|
||||
const authFieldChecks: { [key: string]: () => void } = {
|
||||
api_key: () => {
|
||||
if (!formData.api_key.trim())
|
||||
if (!formData.api_key.trim() && !keepSavedSecret)
|
||||
newErrors.api_key = t('settings.tools.mcp.errors.apiKeyRequired');
|
||||
},
|
||||
bearer: () => {
|
||||
if (!formData.bearer_token.trim())
|
||||
if (!formData.bearer_token.trim() && !keepSavedSecret)
|
||||
newErrors.bearer_token = t('settings.tools.mcp.errors.tokenRequired');
|
||||
},
|
||||
basic: () => {
|
||||
if (!formData.username.trim())
|
||||
newErrors.username = t('settings.tools.mcp.errors.usernameRequired');
|
||||
if (!formData.password.trim())
|
||||
if (!formData.password.trim() && !keepSavedSecret)
|
||||
newErrors.password = t('settings.tools.mcp.errors.passwordRequired');
|
||||
},
|
||||
};
|
||||
@@ -299,6 +333,7 @@ export default function MCPServerModal({
|
||||
setTestResult({
|
||||
success: true,
|
||||
message: t('settings.tools.mcp.oauthPopupBlocked', {
|
||||
interpolation: { escapeValue: false },
|
||||
defaultValue:
|
||||
'Popup blocked by browser. Click below to authorize:',
|
||||
}),
|
||||
@@ -369,7 +404,11 @@ export default function MCPServerModal({
|
||||
setOAuthCompleted(false);
|
||||
try {
|
||||
const config = buildToolConfig();
|
||||
const response = await userService.testMCPConnection({ config }, token);
|
||||
// The id lets the server test with the saved secret left empty.
|
||||
const response = await userService.testMCPConnection(
|
||||
{ config, ...(server?.id && { id: server.id }) },
|
||||
token,
|
||||
);
|
||||
const result = await response.json();
|
||||
|
||||
if (
|
||||
@@ -441,7 +480,10 @@ export default function MCPServerModal({
|
||||
resetForm();
|
||||
} else {
|
||||
setErrors({
|
||||
general: result.error || t('settings.tools.mcp.errors.saveFailed'),
|
||||
general:
|
||||
result.message ||
|
||||
result.error ||
|
||||
t('settings.tools.mcp.errors.saveFailed'),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
@@ -460,6 +502,11 @@ export default function MCPServerModal({
|
||||
label={t('settings.tools.mcp.authTypes.apiKey')}
|
||||
required
|
||||
error={errors.api_key}
|
||||
hint={
|
||||
keepSavedSecret
|
||||
? t('settings.tools.mcp.savedKeyHint')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
@@ -486,6 +533,9 @@ export default function MCPServerModal({
|
||||
label={t('settings.tools.mcp.authTypes.bearer')}
|
||||
required
|
||||
error={errors.bearer_token}
|
||||
hint={
|
||||
keepSavedSecret ? t('settings.tools.mcp.savedKeyHint') : undefined
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
@@ -516,6 +566,11 @@ export default function MCPServerModal({
|
||||
label={t('settings.tools.mcp.password')}
|
||||
required
|
||||
error={errors.password}
|
||||
hint={
|
||||
keepSavedSecret
|
||||
? t('settings.tools.mcp.savedKeyHint')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
@@ -536,6 +591,7 @@ export default function MCPServerModal({
|
||||
handleInputChange('oauth_scopes', e.target.value)
|
||||
}
|
||||
placeholder="read, write"
|
||||
disabled={oauthOwnerOnly}
|
||||
/>
|
||||
</FormField>
|
||||
);
|
||||
@@ -560,21 +616,31 @@ export default function MCPServerModal({
|
||||
})
|
||||
: t('settings.tools.mcp.addServer')
|
||||
}
|
||||
description={
|
||||
isShared
|
||||
? t('settings.tools.mcp.sharedByEditor', {
|
||||
interpolation: { escapeValue: false },
|
||||
owner: server.owner_label || t('settings.tools.mcp.aTeammate'),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
size="lg"
|
||||
mobileVariant="sheet"
|
||||
footer={
|
||||
<ModalActions
|
||||
footerStart={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={testConnection}
|
||||
loading={testing}
|
||||
size="lg"
|
||||
shape="pill"
|
||||
>
|
||||
{t('settings.tools.mcp.testConnection')}
|
||||
</Button>
|
||||
oauthOwnerOnly ? undefined : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={testConnection}
|
||||
loading={testing}
|
||||
size="lg"
|
||||
shape="pill"
|
||||
>
|
||||
{t('settings.tools.mcp.testConnection')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
cancelLabel={t('settings.tools.mcp.cancel')}
|
||||
onCancel={() => {
|
||||
@@ -584,23 +650,21 @@ export default function MCPServerModal({
|
||||
submitLabel={t('settings.tools.mcp.save')}
|
||||
onSubmit={handleSave}
|
||||
pending={loading}
|
||||
disabled={!saveActive}
|
||||
disabled={!saveActive && !oauthOwnerOnly}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
{server?.has_encrypted_credentials &&
|
||||
formData.auth_type !== 'oauth' && (
|
||||
<Alert variant="warning">
|
||||
<TriangleAlert className="size-4" aria-hidden="true" />
|
||||
<AlertDescription>
|
||||
{t('settings.tools.mcp.reenterCredentials', {
|
||||
defaultValue:
|
||||
'Re-enter your credentials to test and update the connection.',
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{isShared && (
|
||||
<Alert variant="info">
|
||||
<Info aria-hidden="true" />
|
||||
<AlertDescription>
|
||||
{t('settings.tools.mcp.sharedCredentialsNotice')}
|
||||
{oauthOwnerOnly &&
|
||||
` ${t('settings.tools.mcp.sharedOAuthOwnerOnly')}`}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<FormField
|
||||
label={t('settings.tools.mcp.serverName')}
|
||||
required
|
||||
@@ -624,13 +688,28 @@ export default function MCPServerModal({
|
||||
value={formData.server_url}
|
||||
onChange={(e) => handleInputChange('server_url', e.target.value)}
|
||||
placeholder="https://example.com/mcp"
|
||||
disabled={oauthOwnerOnly}
|
||||
/>
|
||||
</FormField>
|
||||
{serverChanged && (
|
||||
<Alert variant="warning">
|
||||
<TriangleAlert aria-hidden="true" />
|
||||
<AlertDescription>
|
||||
{t('settings.tools.mcp.serverChangedNotice', {
|
||||
interpolation: { escapeValue: false },
|
||||
credential: t(
|
||||
`settings.tools.mcp.credentialNames.${savedSecret.nameKey}`,
|
||||
),
|
||||
})}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField label={t('settings.tools.mcp.authType')}>
|
||||
<Select
|
||||
value={formData.auth_type}
|
||||
onValueChange={(v) => handleInputChange('auth_type', v)}
|
||||
disabled={oauthOwnerOnly}
|
||||
>
|
||||
<SelectTrigger size="field" className="w-full">
|
||||
<SelectValue placeholder={t('settings.tools.mcp.authType')} />
|
||||
|
||||
@@ -85,6 +85,10 @@ export type Doc = {
|
||||
// Access level when shared via a team: 'viewer' (read-only) or 'editor'
|
||||
// (full write). Null/absent for sources the caller owns.
|
||||
team_access?: 'viewer' | 'editor' | null;
|
||||
// The caller's role and what it allows (sources API); gate UI with
|
||||
// `can(doc, action)` from utils/accessUtils.
|
||||
access?: 'owner' | 'editor' | 'viewer' | null;
|
||||
allowed_actions?: string[];
|
||||
};
|
||||
|
||||
export type GetDocsResponse = {
|
||||
@@ -98,10 +102,16 @@ export type Prompt = {
|
||||
name: string;
|
||||
id: string;
|
||||
type: string;
|
||||
// The caller's role and what it allows (prompts API); gate UI with
|
||||
// `can(prompt, action)` from utils/accessUtils. Absent on presets.
|
||||
access?: 'owner' | 'editor' | 'viewer' | null;
|
||||
allowed_actions?: string[];
|
||||
team_access?: 'viewer' | 'editor' | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type PromptProps = {
|
||||
prompts: { name: string; id: string; type: string }[];
|
||||
prompts: Prompt[];
|
||||
selectedPrompt: { name: string; id: string; type: string };
|
||||
onSelectPrompt: (name: string, id: string, type: string) => void;
|
||||
setPrompts: (prompts: { name: string; id: string; type: string }[]) => void;
|
||||
|
||||
@@ -142,6 +142,48 @@ describe('buildAgentSection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAgentSection tabs for a draft', () => {
|
||||
it('shows only Overview until the agent is published', () => {
|
||||
const items = getSectionItems(
|
||||
buildAgentSection('a1', 'Bot', false, {
|
||||
access: 'owner',
|
||||
status: 'draft',
|
||||
allowed_actions: ['view', 'view_logs', 'manage_schedules'],
|
||||
}),
|
||||
).map((item) => item.key);
|
||||
expect(items).toEqual(['overview']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAgentSection tabs by role', () => {
|
||||
const keys = (actions?: string[]) =>
|
||||
getSectionItems(
|
||||
buildAgentSection(
|
||||
'a1',
|
||||
'Bot',
|
||||
false,
|
||||
actions ? { access: 'editor', allowed_actions: actions } : undefined,
|
||||
),
|
||||
).map((item) => item.key);
|
||||
|
||||
it('shows every tab before the agent has loaded', () => {
|
||||
expect(keys()).toEqual(['overview', 'logs', 'schedules']);
|
||||
});
|
||||
|
||||
it('shows an editor all three tabs', () => {
|
||||
expect(keys(['view', 'view_logs', 'manage_schedules'])).toEqual([
|
||||
'overview',
|
||||
'logs',
|
||||
'schedules',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows a viewer no tabs, or Logs when the owner shares them', () => {
|
||||
expect(keys(['pin', 'use'])).toEqual([]);
|
||||
expect(keys(['pin', 'use', 'view_logs'])).toEqual(['logs']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('depthOf', () => {
|
||||
it('puts chats, sections and records on their own level', () => {
|
||||
expect(depthOf(null)).toBe(0);
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
agentSchedulesPath,
|
||||
agentsFilterPath,
|
||||
} from '../agents/paths';
|
||||
import { type AccessFields } from '../utils/accessUtils';
|
||||
import { canAgent } from '../agents/agentAccess';
|
||||
|
||||
/** A single destination in a section's vertical nav. */
|
||||
export type SectionItem = {
|
||||
@@ -268,18 +270,54 @@ export const AGENTS_SECTION: Section = {
|
||||
],
|
||||
};
|
||||
|
||||
/** The action each agent tab's page needs. */
|
||||
const TAB_ACTIONS: Record<string, string> = {
|
||||
overview: 'view',
|
||||
logs: 'view_logs',
|
||||
schedules: 'manage_schedules',
|
||||
};
|
||||
|
||||
/**
|
||||
* The nav for a single agent. Built per route rather than declared, because
|
||||
* its title is the agent's name and its paths carry the agent's id.
|
||||
*
|
||||
* `access` is the agent's record once loaded: each tab shows only when the
|
||||
* caller's role allows its page (Overview `view`, Logs `view_logs`,
|
||||
* Schedules `manage_schedules`). Until the record arrives every tab shows,
|
||||
* and the route guard sends a caller who may not open a page back to the
|
||||
* list.
|
||||
*/
|
||||
export function buildAgentSection(
|
||||
agentId: string,
|
||||
agentName: string | undefined,
|
||||
workflow: boolean,
|
||||
access?: (AccessFields & { status?: string }) | null,
|
||||
): Section {
|
||||
const allows = (action: string) => !access || canAgent(access, action);
|
||||
const items: SectionItem[] = [
|
||||
{
|
||||
key: 'overview',
|
||||
path: agentEditPath(agentId, workflow),
|
||||
labelKey: 'agents.pageHeader.tabs.overview',
|
||||
icon: SquarePen,
|
||||
},
|
||||
{
|
||||
key: 'logs',
|
||||
path: agentLogsPath(agentId),
|
||||
labelKey: 'agents.pageHeader.tabs.logs',
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
key: 'schedules',
|
||||
path: agentSchedulesPath(agentId),
|
||||
labelKey: 'agents.pageHeader.tabs.schedules',
|
||||
icon: CalendarClock,
|
||||
},
|
||||
];
|
||||
const visible = items.filter((item) => allows(TAB_ACTIONS[item.key]));
|
||||
return {
|
||||
key: `agent:${agentId}`,
|
||||
rootPath: agentEditPath(agentId, workflow),
|
||||
rootPath: visible[0]?.path ?? agentEditPath(agentId, workflow),
|
||||
titleKey: 'agents.pageHeader.fallbackName',
|
||||
title: agentName?.trim() || undefined,
|
||||
matches: [
|
||||
@@ -289,31 +327,7 @@ export function buildAgentSection(
|
||||
],
|
||||
parentPath: AGENTS_MANAGE_ROOT,
|
||||
parentLabelKey: 'navigation.backToAgents',
|
||||
groups: [
|
||||
{
|
||||
key: 'agent',
|
||||
items: [
|
||||
{
|
||||
key: 'overview',
|
||||
path: agentEditPath(agentId, workflow),
|
||||
labelKey: 'agents.pageHeader.tabs.overview',
|
||||
icon: SquarePen,
|
||||
},
|
||||
{
|
||||
key: 'logs',
|
||||
path: agentLogsPath(agentId),
|
||||
labelKey: 'agents.pageHeader.tabs.logs',
|
||||
icon: ScrollText,
|
||||
},
|
||||
{
|
||||
key: 'schedules',
|
||||
path: agentSchedulesPath(agentId),
|
||||
labelKey: 'agents.pageHeader.tabs.schedules',
|
||||
icon: CalendarClock,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
groups: [{ key: 'agent', items: visible }],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -36,15 +36,20 @@ export function useSectionContext(): {
|
||||
const scoped = matchAgentScopedRoute(pathname);
|
||||
if (!scoped) return getSectionForPath(pathname);
|
||||
|
||||
const name = [
|
||||
const record = [
|
||||
...(agents ?? []),
|
||||
...(sharedAgents ?? []),
|
||||
...(selectedAgent ? [selectedAgent] : []),
|
||||
].find((agent) => agent.id === scoped.agentId)?.name;
|
||||
].find((agent) => agent.id === scoped.agentId);
|
||||
|
||||
// An agent saved moments ago may not be in the store yet; the section
|
||||
// falls back to a generic title until it arrives.
|
||||
return buildAgentSection(scoped.agentId, name, scoped.workflow);
|
||||
return buildAgentSection(
|
||||
scoped.agentId,
|
||||
record?.name,
|
||||
scoped.workflow,
|
||||
record,
|
||||
);
|
||||
}, [pathname, agents, sharedAgents, selectedAgent]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -25,13 +25,18 @@ export function useSectionResolver(): (pathname: string) => Section | null {
|
||||
const scoped = matchAgentScopedRoute(pathname);
|
||||
if (!scoped) return getSectionForPath(pathname);
|
||||
|
||||
const name = [
|
||||
const record = [
|
||||
...(agents ?? []),
|
||||
...(sharedAgents ?? []),
|
||||
...(selectedAgent ? [selectedAgent] : []),
|
||||
].find((agent) => agent.id === scoped.agentId)?.name;
|
||||
].find((agent) => agent.id === scoped.agentId);
|
||||
|
||||
return buildAgentSection(scoped.agentId, name, scoped.workflow);
|
||||
return buildAgentSection(
|
||||
scoped.agentId,
|
||||
record?.name,
|
||||
scoped.workflow,
|
||||
record,
|
||||
);
|
||||
},
|
||||
[agents, sharedAgents, selectedAgent],
|
||||
);
|
||||
|
||||
@@ -131,4 +131,40 @@ describe('PromptsModal', () => {
|
||||
'modals.prompts.systemVariablesDropdownLabel',
|
||||
);
|
||||
});
|
||||
|
||||
it('opens a prompt the caller may not edit read-only', async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<PromptsModal
|
||||
existingPrompts={[]}
|
||||
modalState="ACTIVE"
|
||||
setModalState={() => undefined}
|
||||
type="EDIT"
|
||||
newPromptName=""
|
||||
setNewPromptName={() => undefined}
|
||||
newPromptContent=""
|
||||
setNewPromptContent={() => undefined}
|
||||
editPromptName="Carrier rates"
|
||||
setEditPromptName={() => undefined}
|
||||
editPromptContent="Summarise {{ source.content }}"
|
||||
setEditPromptContent={() => undefined}
|
||||
currentPromptEdit={{ name: 'Carrier rates', id: 'p1', type: 'team' }}
|
||||
handleEditPrompt={() => undefined}
|
||||
readOnly
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(document.body.textContent).toContain('modals.prompts.viewPrompt');
|
||||
expect(
|
||||
document.body.querySelector<HTMLTextAreaElement>('textarea')?.readOnly,
|
||||
).toBe(true);
|
||||
expect(
|
||||
document.body.querySelector<HTMLInputElement>('input[type="text"]')
|
||||
?.disabled,
|
||||
).toBe(true);
|
||||
const labels = Array.from(document.body.querySelectorAll('button')).map(
|
||||
(b) => b.textContent,
|
||||
);
|
||||
expect(labels).not.toContain('modals.prompts.save');
|
||||
});
|
||||
});
|
||||
@@ -376,13 +376,13 @@ function EditPrompt({
|
||||
setEditPromptName,
|
||||
editPromptContent,
|
||||
setEditPromptContent,
|
||||
currentPromptEdit,
|
||||
isReadOnly,
|
||||
}: {
|
||||
editPromptName: string;
|
||||
setEditPromptName: (name: string) => void;
|
||||
editPromptContent: string;
|
||||
setEditPromptContent: (content: string) => void;
|
||||
currentPromptEdit: { name: string; id: string; type: string };
|
||||
isReadOnly: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const systemVariableOptions = React.useMemo(
|
||||
@@ -390,7 +390,6 @@ function EditPrompt({
|
||||
[t],
|
||||
);
|
||||
const toolVariables = useToolVariables();
|
||||
const isReadOnly = currentPromptEdit.type === 'public';
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -468,6 +467,7 @@ export default function PromptsModal({
|
||||
handleEditPrompt,
|
||||
onDuplicate,
|
||||
duplicateSourceName,
|
||||
readOnly = false,
|
||||
}: {
|
||||
existingPrompts: { name: string; id: string; type: string }[];
|
||||
modalState: ActiveState;
|
||||
@@ -491,6 +491,8 @@ export default function PromptsModal({
|
||||
handleEditPrompt?: (id: string, type: string) => void;
|
||||
onDuplicate?: () => void;
|
||||
duplicateSourceName?: string | null;
|
||||
/** Open an EDIT prompt as a view: the caller may not edit it. */
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const disableSave = React.useMemo(() => {
|
||||
if (type === 'EDIT') {
|
||||
@@ -515,7 +517,8 @@ export default function PromptsModal({
|
||||
]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const isReadOnly = type === 'EDIT' && currentPromptEdit.type === 'public';
|
||||
const isReadOnly =
|
||||
type === 'EDIT' && (readOnly || currentPromptEdit.type === 'public');
|
||||
const closeModal = () => setModalState('INACTIVE');
|
||||
|
||||
let view;
|
||||
@@ -554,7 +557,7 @@ export default function PromptsModal({
|
||||
setEditPromptName={setEditPromptName}
|
||||
editPromptContent={editPromptContent}
|
||||
setEditPromptContent={setEditPromptContent}
|
||||
currentPromptEdit={currentPromptEdit}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,41 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
const dispatch = vi.fn();
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: () => 'test-token',
|
||||
useDispatch: () => dispatch,
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/userService', () => ({ default: {} }));
|
||||
const deletePrompt = vi.fn();
|
||||
const updatePrompt = vi.fn();
|
||||
const getSinglePrompt = vi.fn();
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: {
|
||||
deletePrompt: (...args: unknown[]) => deletePrompt(...args),
|
||||
updatePrompt: (...args: unknown[]) => updatePrompt(...args),
|
||||
getSinglePrompt: (...args: unknown[]) => getSinglePrompt(...args),
|
||||
},
|
||||
}));
|
||||
vi.mock('../teams/ShareToTeamModal', () => ({ default: () => null }));
|
||||
vi.mock('../preferences/PromptsModal', () => ({ default: () => null }));
|
||||
vi.mock('../modals/ConfirmationModal', () => ({ default: () => null }));
|
||||
const promptsModalProps = vi.fn();
|
||||
vi.mock('../preferences/PromptsModal', () => ({
|
||||
default: (props: unknown) => {
|
||||
promptsModalProps(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
vi.mock('../modals/ConfirmationModal', () => ({
|
||||
default: ({ handleSubmit }: { handleSubmit: () => void }) => (
|
||||
<button type="button" data-testid="confirm" onClick={handleSubmit}>
|
||||
confirm
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import Prompts from './Prompts';
|
||||
|
||||
@@ -206,4 +229,203 @@ describe('Prompts', () => {
|
||||
expect(action.dataset.slot).toBe('tooltip-trigger');
|
||||
}
|
||||
});
|
||||
|
||||
describe('access', () => {
|
||||
const json = (body: unknown, ok = true, status = 200) =>
|
||||
Promise.resolve({ ok, status, json: () => Promise.resolve(body) });
|
||||
const own = {
|
||||
id: 'own',
|
||||
name: 'Own prompt',
|
||||
type: 'private',
|
||||
access: 'owner' as const,
|
||||
allowed_actions: [
|
||||
'delete',
|
||||
'duplicate',
|
||||
'edit',
|
||||
'manage_settings',
|
||||
'share',
|
||||
'use',
|
||||
],
|
||||
};
|
||||
const editor = {
|
||||
id: 'ed',
|
||||
name: 'Editor prompt',
|
||||
type: 'team',
|
||||
access: 'editor' as const,
|
||||
allowed_actions: ['duplicate', 'edit', 'use'],
|
||||
};
|
||||
const viewer = {
|
||||
id: 'vw',
|
||||
name: 'Viewer prompt',
|
||||
type: 'team',
|
||||
access: 'viewer' as const,
|
||||
allowed_actions: ['duplicate', 'use'],
|
||||
};
|
||||
const viewerNoCopy = {
|
||||
id: 'vn',
|
||||
name: 'Locked prompt',
|
||||
type: 'team',
|
||||
access: 'viewer' as const,
|
||||
allowed_actions: ['use'],
|
||||
};
|
||||
const all = [prompts[0], own, editor, viewer, viewerNoCopy];
|
||||
|
||||
beforeEach(() => {
|
||||
dispatch.mockReset();
|
||||
deletePrompt.mockReset();
|
||||
updatePrompt.mockReset();
|
||||
getSinglePrompt.mockReset();
|
||||
promptsModalProps.mockReset();
|
||||
});
|
||||
|
||||
const openPicker = () =>
|
||||
act(() => {
|
||||
const trigger = container.querySelector<HTMLButtonElement>(
|
||||
'button[role="combobox"]',
|
||||
)!;
|
||||
trigger.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
|
||||
);
|
||||
trigger.click();
|
||||
});
|
||||
const row = (name: string) =>
|
||||
Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>(
|
||||
'[data-slot="command-item"]',
|
||||
),
|
||||
).find((item) => item.textContent?.includes(name))!;
|
||||
const actionsOf = (name: string) =>
|
||||
Array.from(row(name).querySelectorAll('button')).map((b) =>
|
||||
b.getAttribute('aria-label'),
|
||||
);
|
||||
const lastModalProps = () =>
|
||||
promptsModalProps.mock.calls.at(-1)![0] as {
|
||||
readOnly?: boolean;
|
||||
handleEditPrompt: (id: string, type: string) => void;
|
||||
onDuplicate?: () => void;
|
||||
};
|
||||
|
||||
it('gives the owner Edit, Duplicate, Share and Delete', () => {
|
||||
renderPrompts({ prompts: all, selectedPrompt: own });
|
||||
openPicker();
|
||||
expect(actionsOf('Own prompt')).toEqual([
|
||||
'settings.general.promptActions.edit',
|
||||
'settings.general.promptActions.duplicate',
|
||||
'agents.shareWithTeam',
|
||||
'settings.general.promptActions.delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives an editor Edit and Duplicate', () => {
|
||||
renderPrompts({ prompts: all, selectedPrompt: own });
|
||||
openPicker();
|
||||
expect(actionsOf('Editor prompt')).toEqual([
|
||||
'settings.general.promptActions.edit',
|
||||
'settings.general.promptActions.duplicate',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives a viewer View, and Duplicate only when allowed', () => {
|
||||
renderPrompts({ prompts: all, selectedPrompt: own });
|
||||
openPicker();
|
||||
expect(actionsOf('Viewer prompt')).toEqual([
|
||||
'settings.general.promptActions.view',
|
||||
'settings.general.promptActions.duplicate',
|
||||
]);
|
||||
expect(actionsOf('Locked prompt')).toEqual([
|
||||
'settings.general.promptActions.view',
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens a viewer's prompt read-only", async () => {
|
||||
getSinglePrompt.mockReturnValue(json({ content: 'Hello' }));
|
||||
renderPrompts({ prompts: all, selectedPrompt: own });
|
||||
openPicker();
|
||||
await act(async () => {
|
||||
(row('Viewer prompt').querySelector('button') as HTMLElement).click();
|
||||
});
|
||||
expect(lastModalProps().readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the row and shows an error when the delete fails', async () => {
|
||||
deletePrompt.mockReturnValue(json({ success: false }, false, 403));
|
||||
const setPrompts = vi.fn();
|
||||
renderPrompts({ prompts: all, selectedPrompt: own, setPrompts });
|
||||
openPicker();
|
||||
await act(async () => {
|
||||
(
|
||||
row('Own prompt').querySelector(
|
||||
'button[aria-label="settings.general.promptActions.delete"]',
|
||||
) as HTMLElement
|
||||
).click();
|
||||
});
|
||||
await act(async () => {
|
||||
(
|
||||
document.body.querySelector('[data-testid="confirm"]') as HTMLElement
|
||||
).click();
|
||||
});
|
||||
expect(setPrompts).not.toHaveBeenCalled();
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: {
|
||||
variant: 'destructive',
|
||||
message: 'settings.general.promptActions.deleteFailed',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('removes the row once the delete succeeds', async () => {
|
||||
deletePrompt.mockReturnValue(json({ success: true }));
|
||||
const setPrompts = vi.fn();
|
||||
renderPrompts({ prompts: all, selectedPrompt: own, setPrompts });
|
||||
openPicker();
|
||||
await act(async () => {
|
||||
(
|
||||
row('Own prompt').querySelector(
|
||||
'button[aria-label="settings.general.promptActions.delete"]',
|
||||
) as HTMLElement
|
||||
).click();
|
||||
});
|
||||
await act(async () => {
|
||||
(
|
||||
document.body.querySelector('[data-testid="confirm"]') as HTMLElement
|
||||
).click();
|
||||
});
|
||||
expect(setPrompts).toHaveBeenCalledWith(
|
||||
all.filter((p) => p.id !== 'own'),
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the loaded updated_at and reports a 409 as an edit conflict', async () => {
|
||||
getSinglePrompt.mockReturnValue(
|
||||
json({ content: 'Hello', updated_at: '2026-09-01T10:00:00Z' }),
|
||||
);
|
||||
updatePrompt.mockReturnValue(
|
||||
json({ success: false, code: 'stale_write' }, false, 409),
|
||||
);
|
||||
renderPrompts({ prompts: all, selectedPrompt: own });
|
||||
openPicker();
|
||||
await act(async () => {
|
||||
(row('Editor prompt').querySelector('button') as HTMLElement).click();
|
||||
});
|
||||
expect(lastModalProps().readOnly).toBe(false);
|
||||
await act(async () => lastModalProps().handleEditPrompt('ed', 'team'));
|
||||
expect(updatePrompt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'ed',
|
||||
expected_updated_at: '2026-09-01T10:00:00Z',
|
||||
}),
|
||||
'test-token',
|
||||
);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: {
|
||||
variant: 'destructive',
|
||||
message: 'settings.general.promptActions.editConflict',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ChevronDown, Copy, Eye, Pencil, Trash2, Users } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import userService from '../api/services/userService';
|
||||
import {
|
||||
@@ -22,12 +22,22 @@ import {
|
||||
import { SectionHeader } from '../components/ui/section-header';
|
||||
import { SettingRow } from '../components/ui/setting-row';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import { ActiveState, PromptProps } from '../models/misc';
|
||||
import { ActiveState, Prompt, PromptProps } from '../models/misc';
|
||||
import { showActionToast } from '../notifications/actionToastSlice';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import ShareToTeamModal from '../teams/ShareToTeamModal';
|
||||
import PromptsModal from '../preferences/PromptsModal';
|
||||
import { can } from '../utils/accessUtils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Presets (`public`) carry no access fields and are never edited in place.
|
||||
const canEditPrompt = (prompt: Prompt) =>
|
||||
prompt.type !== 'public' && can(prompt, 'edit');
|
||||
const canDeletePrompt = (prompt: Prompt) =>
|
||||
prompt.type !== 'public' && can(prompt, 'delete');
|
||||
const canSharePrompt = (prompt: Prompt) =>
|
||||
prompt.type !== 'public' && can(prompt, 'share');
|
||||
|
||||
type PromptsDropdownProps = {
|
||||
className?: string;
|
||||
};
|
||||
@@ -62,18 +72,27 @@ export default function Prompts({
|
||||
labelSurface = 'card',
|
||||
}: ExtendedPromptProps) {
|
||||
const token = useSelector(selectToken);
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const showError = (message: string) =>
|
||||
dispatch(showActionToast({ variant: 'destructive', message }));
|
||||
const pickerId = React.useId();
|
||||
const titleText = title ? title : t('settings.general.prompt');
|
||||
const [newPromptName, setNewPromptName] = React.useState('');
|
||||
const [newPromptContent, setNewPromptContent] = React.useState('');
|
||||
const [editPromptName, setEditPromptName] = React.useState('');
|
||||
const [editPromptContent, setEditPromptContent] = React.useState('');
|
||||
const [currentPromptEdit, setCurrentPromptEdit] = React.useState({
|
||||
const [currentPromptEdit, setCurrentPromptEdit] = React.useState<Prompt>({
|
||||
id: '',
|
||||
name: '',
|
||||
type: '',
|
||||
});
|
||||
// The open prompt's version, sent back so a save over someone else's
|
||||
// newer edit is refused (409) instead of overwriting it.
|
||||
const [editPromptUpdatedAt, setEditPromptUpdatedAt] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [editReadOnly, setEditReadOnly] = React.useState(false);
|
||||
const [modalType, setModalType] = React.useState<'ADD' | 'EDIT'>('ADD');
|
||||
const [duplicateSource, setDuplicateSource] = React.useState<string | null>(
|
||||
null,
|
||||
@@ -138,13 +157,15 @@ export default function Prompts({
|
||||
|
||||
const confirmDeletePrompt = () => {
|
||||
if (promptToDelete) {
|
||||
setPrompts(prompts.filter((prompt) => prompt.id !== promptToDelete.id));
|
||||
userService
|
||||
.deletePrompt({ id: promptToDelete.id }, token)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete prompt');
|
||||
}
|
||||
setPrompts(
|
||||
prompts.filter((prompt) => prompt.id !== promptToDelete.id),
|
||||
);
|
||||
// Only change selection if we're deleting the currently selected prompt
|
||||
if (
|
||||
prompts.length > 0 &&
|
||||
@@ -163,6 +184,7 @@ export default function Prompts({
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
showError(t('settings.general.promptActions.deleteFailed'));
|
||||
});
|
||||
setPromptToDelete(null);
|
||||
}
|
||||
@@ -176,17 +198,16 @@ export default function Prompts({
|
||||
}
|
||||
const promptContent = await response.json();
|
||||
setEditPromptContent(promptContent.content);
|
||||
setEditPromptUpdatedAt(promptContent.updated_at ?? null);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const openEditModal = (prompt: {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}) => {
|
||||
const openEditModal = (prompt: Prompt) => {
|
||||
setModalType('EDIT');
|
||||
setEditReadOnly(!canEditPrompt(prompt));
|
||||
setEditPromptUpdatedAt(null);
|
||||
setEditPromptName(prompt.name);
|
||||
setEditPromptContent('');
|
||||
handleFetchPromptContent(prompt.id);
|
||||
@@ -244,12 +265,22 @@ export default function Prompts({
|
||||
id: id,
|
||||
name: editPromptName,
|
||||
content: editPromptContent,
|
||||
...(editPromptUpdatedAt && {
|
||||
expected_updated_at: editPromptUpdatedAt,
|
||||
}),
|
||||
},
|
||||
token,
|
||||
)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update prompt');
|
||||
showError(
|
||||
t(
|
||||
response.status === 409
|
||||
? 'settings.general.promptActions.editConflict'
|
||||
: 'settings.general.promptActions.saveFailed',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (setPrompts) {
|
||||
const existingPromptIndex = prompts.findIndex(
|
||||
@@ -322,12 +353,7 @@ export default function Prompts({
|
||||
<CommandEmpty>{t('settings.sources.noResults')}</CommandEmpty>
|
||||
{prompts.map((prompt) => {
|
||||
const isActive = selectedPrompt?.id === prompt.id;
|
||||
const canModify = prompt.type !== 'public';
|
||||
// Sharing is an owner-only action: hide it for public
|
||||
// prompts and prompts shared into the workspace by a
|
||||
// team.
|
||||
const canShare =
|
||||
prompt.type !== 'public' && prompt.type !== 'team';
|
||||
const canEdit = canEditPrompt(prompt);
|
||||
return (
|
||||
<CommandItem
|
||||
key={prompt.id}
|
||||
@@ -346,29 +372,31 @@ export default function Prompts({
|
||||
openEditModal(prompt);
|
||||
}}
|
||||
label={
|
||||
canModify
|
||||
canEdit
|
||||
? t('settings.general.promptActions.edit')
|
||||
: t('settings.general.promptActions.view')
|
||||
}
|
||||
>
|
||||
{canModify ? (
|
||||
{canEdit ? (
|
||||
<Pencil className="text-current" aria-hidden="true" />
|
||||
) : (
|
||||
<Eye className="text-current" aria-hidden="true" />
|
||||
)}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="ghost-on-accent"
|
||||
size="icon-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDuplicatePrompt(prompt);
|
||||
}}
|
||||
label={t('settings.general.promptActions.duplicate')}
|
||||
>
|
||||
<Copy className="text-current" aria-hidden="true" />
|
||||
</IconButton>
|
||||
{canShare && (
|
||||
{can(prompt, 'duplicate') && (
|
||||
<IconButton
|
||||
variant="ghost-on-accent"
|
||||
size="icon-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDuplicatePrompt(prompt);
|
||||
}}
|
||||
label={t('settings.general.promptActions.duplicate')}
|
||||
>
|
||||
<Copy className="text-current" aria-hidden="true" />
|
||||
</IconButton>
|
||||
)}
|
||||
{canSharePrompt(prompt) && (
|
||||
<IconButton
|
||||
variant="ghost-on-accent"
|
||||
size="icon-xs"
|
||||
@@ -385,7 +413,7 @@ export default function Prompts({
|
||||
<Users className="text-current" aria-hidden="true" />
|
||||
</IconButton>
|
||||
)}
|
||||
{canModify && (
|
||||
{canDeletePrompt(prompt) && (
|
||||
<IconButton
|
||||
variant="ghost-destructive-on-accent"
|
||||
size="icon-xs"
|
||||
@@ -408,12 +436,16 @@ export default function Prompts({
|
||||
</Popover>
|
||||
);
|
||||
|
||||
const editButton = selectedPrompt?.id && selectedPrompt.type !== 'public' && (
|
||||
// The listed row carries the access fields; a stored selection may not.
|
||||
const selectedListed =
|
||||
prompts.find((prompt) => prompt.id === selectedPrompt?.id) ??
|
||||
selectedPrompt;
|
||||
const editButton = selectedPrompt?.id && canEditPrompt(selectedListed) && (
|
||||
<IconButton
|
||||
variant="ghost-muted"
|
||||
size="icon-xs"
|
||||
shape="pill"
|
||||
onClick={() => openEditModal(selectedPrompt)}
|
||||
onClick={() => openEditModal(selectedListed)}
|
||||
label={t('settings.general.promptActions.edit')}
|
||||
icon={Pencil}
|
||||
/>
|
||||
@@ -502,7 +534,16 @@ export default function Prompts({
|
||||
currentPromptEdit={currentPromptEdit}
|
||||
handleAddPrompt={handleAddPrompt}
|
||||
handleEditPrompt={handleSaveChanges}
|
||||
onDuplicate={handleDuplicateFromModal}
|
||||
readOnly={editReadOnly}
|
||||
onDuplicate={
|
||||
can(
|
||||
prompts.find((prompt) => prompt.id === currentPromptEdit.id) ??
|
||||
currentPromptEdit,
|
||||
'duplicate',
|
||||
)
|
||||
? handleDuplicateFromModal
|
||||
: undefined
|
||||
}
|
||||
duplicateSourceName={duplicateSource}
|
||||
/>
|
||||
{promptToDelete && (
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: () => null,
|
||||
useDispatch: () => vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: { updateSourceConfig: vi.fn() },
|
||||
}));
|
||||
|
||||
import type { Doc } from '../models/misc';
|
||||
import SourceConfigModal from './SourceConfigModal';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const doc = (fields: Partial<Doc>): Doc => ({
|
||||
id: 'src-1',
|
||||
name: 'Contracts',
|
||||
date: '',
|
||||
model: '',
|
||||
...fields,
|
||||
});
|
||||
|
||||
describe('SourceConfigModal access', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
const render = async (document: Doc) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<SourceConfigModal
|
||||
modalState="ACTIVE"
|
||||
setModalState={vi.fn()}
|
||||
document={document}
|
||||
onReingest={vi.fn()}
|
||||
onEnableGraphRAG={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const readOnlyNotice = () =>
|
||||
document.body.textContent?.includes(
|
||||
'settings.sources.configModal.readOnly',
|
||||
);
|
||||
|
||||
it('a viewer with view_config only sees the read-only notice', async () => {
|
||||
await render(
|
||||
doc({
|
||||
access: 'viewer',
|
||||
ownership: 'team',
|
||||
team_access: 'viewer',
|
||||
allowed_actions: ['use', 'view_config'],
|
||||
}),
|
||||
);
|
||||
expect(readOnlyNotice()).toBe(true);
|
||||
});
|
||||
|
||||
it('an editor can edit (no read-only notice)', async () => {
|
||||
await render(
|
||||
doc({
|
||||
access: 'editor',
|
||||
ownership: 'team',
|
||||
team_access: 'editor',
|
||||
allowed_actions: ['edit', 'use', 'view_config'],
|
||||
}),
|
||||
);
|
||||
expect(readOnlyNotice()).toBe(false);
|
||||
});
|
||||
|
||||
it('follows allowed_actions over the legacy team_access', async () => {
|
||||
await render(
|
||||
doc({
|
||||
access: 'viewer',
|
||||
ownership: 'team',
|
||||
team_access: 'editor',
|
||||
allowed_actions: ['use', 'view_config'],
|
||||
}),
|
||||
);
|
||||
expect(readOnlyNotice()).toBe(true);
|
||||
});
|
||||
|
||||
it('an owned source without access fields is editable', async () => {
|
||||
await render(doc({}));
|
||||
expect(readOnlyNotice()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { Modal, ModalActions } from '../components/ui/modal';
|
||||
import { ActiveState, Doc } from '../models/misc';
|
||||
import type { Model } from '../models/types';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import { can } from '../utils/accessUtils';
|
||||
|
||||
import RetrievalOptions, {
|
||||
chunkingChanged,
|
||||
@@ -47,10 +48,9 @@ export default function SourceConfigModal({
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
|
||||
// 'team' viewers cannot write; the backend rejects with 403, but we also
|
||||
// disable the form up-front for a clearer read-only experience.
|
||||
const isReadOnly =
|
||||
document?.ownership === 'team' && document?.team_access !== 'editor';
|
||||
// Without `edit` (a viewer opening View config) the form is read-only; the
|
||||
// backend rejects a write with 403 anyway.
|
||||
const isReadOnly = !!document && !can(document, 'edit');
|
||||
|
||||
const [initial, setInitial] = useState<RetrievalOptionsValue>(() =>
|
||||
configToOptions(document?.config),
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import { act, useState } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
const { dispatch, service, view } = vi.hoisted(() => ({
|
||||
// The heavy children: each view reports the canEdit it was given.
|
||||
view:
|
||||
(testId: string) =>
|
||||
({ canEdit }: { canEdit?: boolean }) => (
|
||||
<div data-testid={testId} data-can-edit={String(canEdit)} />
|
||||
),
|
||||
dispatch: vi.fn(),
|
||||
service: {
|
||||
getConfig: vi.fn(),
|
||||
manageSync: vi.fn(),
|
||||
syncSource: vi.fn(),
|
||||
syncConnector: vi.fn(),
|
||||
reingestSource: vi.fn(),
|
||||
getDirectoryStructure: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useDispatch: () => dispatch,
|
||||
useSelector: (selector: (state: unknown) => unknown) =>
|
||||
selector({
|
||||
preference: { token: null },
|
||||
upload: { tasks: [] },
|
||||
graphBuild: { builds: {} },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useDebouncedValue: (value: unknown) => value,
|
||||
useLoaderState: (initial: boolean) => useState(initial),
|
||||
useMediaQuery: () => ({ isMobile: false, isDesktop: true }),
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/userService', () => ({ default: service }));
|
||||
vi.mock('../api/services/modelService', () => ({
|
||||
default: { getModels: vi.fn(), transformModels: vi.fn(() => []) },
|
||||
}));
|
||||
vi.mock('../preferences/preferenceApi', () => ({
|
||||
getDocs: vi.fn(async () => []),
|
||||
getDocsWithPagination: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
vi.mock('../components/Chunks', () => ({ default: view('chunks') }));
|
||||
vi.mock('../components/FileTree', () => ({ default: view('file-tree') }));
|
||||
vi.mock('../components/ConnectorTree', () => ({
|
||||
default: view('connector-tree'),
|
||||
}));
|
||||
vi.mock('../components/WikiViewer', () => ({ default: view('wiki') }));
|
||||
vi.mock('../components/graph/GraphSourceView', () => ({
|
||||
default: view('graph'),
|
||||
}));
|
||||
vi.mock('./SourceConfigModal', () => ({ default: () => null }));
|
||||
vi.mock('./TestRetrievalModal', () => ({ default: () => null }));
|
||||
vi.mock('./ConvertToWikiModal', () => ({ default: () => null }));
|
||||
vi.mock('./EnableGraphRAGModal', () => ({ default: () => null }));
|
||||
vi.mock('../teams/ShareToTeamModal', () => ({ default: () => null }));
|
||||
vi.mock('../upload/Upload', () => ({ default: () => null }));
|
||||
|
||||
import type { Doc } from '../models/misc';
|
||||
import Sources from './Sources';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const OWNER = ['delete', 'edit', 'manage_settings', 'reconnect', 'share'];
|
||||
const EDITOR = ['edit', 'use', 'view_config'];
|
||||
const VIEWER = ['use', 'view_config'];
|
||||
|
||||
const doc = (fields: Partial<Doc> = {}): Doc => ({
|
||||
id: 'src-1',
|
||||
name: 'Contracts',
|
||||
date: '',
|
||||
model: '',
|
||||
...fields,
|
||||
});
|
||||
|
||||
describe('Sources access', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
dispatch.mockReset();
|
||||
Object.values(service).forEach((fn) => fn.mockReset());
|
||||
service.getConfig.mockResolvedValue({ json: async () => ({}) });
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
const render = async (document: Doc) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Sources
|
||||
paginatedDocuments={[document]}
|
||||
handleDeleteDocument={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const menuItems = async () => {
|
||||
const trigger = container.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="menu-button-src-1"]',
|
||||
)!;
|
||||
await act(async () => {
|
||||
trigger.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
|
||||
);
|
||||
trigger.click();
|
||||
});
|
||||
return Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).map((el) => el.textContent);
|
||||
};
|
||||
|
||||
const clickItem = async (label: string) => {
|
||||
const item = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).find((el) => el.textContent === label)!;
|
||||
await act(async () => item.click());
|
||||
};
|
||||
|
||||
const toasts = () =>
|
||||
dispatch.mock.calls
|
||||
.map(([action]) => action)
|
||||
.filter((action) => action?.type === 'actionToast/showActionToast');
|
||||
|
||||
it('owner: Edit config, Test retrieval, Convert, Share and Delete', async () => {
|
||||
await render(
|
||||
doc({
|
||||
access: 'owner',
|
||||
allowed_actions: [...OWNER, 'use', 'view_config'],
|
||||
}),
|
||||
);
|
||||
expect(await menuItems()).toEqual([
|
||||
'settings.sources.view',
|
||||
'settings.sources.editConfig',
|
||||
'settings.sources.testRetrieval.action',
|
||||
'settings.sources.wiki.convert.action',
|
||||
'settings.sources.shareWithTeam',
|
||||
'convTile.delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a source with no access fields is the caller’s own', async () => {
|
||||
await render(doc());
|
||||
const items = await menuItems();
|
||||
expect(items).toContain('settings.sources.shareWithTeam');
|
||||
expect(items).toContain('convTile.delete');
|
||||
});
|
||||
|
||||
it('editor: edits but cannot share or delete', async () => {
|
||||
await render(
|
||||
doc({
|
||||
access: 'editor',
|
||||
ownership: 'team',
|
||||
team_access: 'editor',
|
||||
allowed_actions: EDITOR,
|
||||
}),
|
||||
);
|
||||
expect(await menuItems()).toEqual([
|
||||
'settings.sources.view',
|
||||
'settings.sources.editConfig',
|
||||
'settings.sources.testRetrieval.action',
|
||||
'settings.sources.wiki.convert.action',
|
||||
]);
|
||||
});
|
||||
|
||||
it('editors_can_share / editors_can_delete widen the editor menu', async () => {
|
||||
await render(
|
||||
doc({
|
||||
access: 'editor',
|
||||
ownership: 'team',
|
||||
allowed_actions: [...EDITOR, 'share', 'delete'],
|
||||
}),
|
||||
);
|
||||
const items = await menuItems();
|
||||
expect(items).toContain('settings.sources.shareWithTeam');
|
||||
expect(items).toContain('convTile.delete');
|
||||
});
|
||||
|
||||
it('viewer: View config and Test retrieval only', async () => {
|
||||
await render(
|
||||
doc({
|
||||
access: 'viewer',
|
||||
ownership: 'team',
|
||||
team_access: 'viewer',
|
||||
allowed_actions: VIEWER,
|
||||
}),
|
||||
);
|
||||
expect(await menuItems()).toEqual([
|
||||
'settings.sources.view',
|
||||
'settings.sources.viewConfig',
|
||||
'settings.sources.testRetrieval.action',
|
||||
]);
|
||||
});
|
||||
|
||||
it('viewer without view_config: no config item', async () => {
|
||||
await render(
|
||||
doc({ access: 'viewer', ownership: 'team', allowed_actions: ['use'] }),
|
||||
);
|
||||
expect(await menuItems()).toEqual([
|
||||
'settings.sources.view',
|
||||
'settings.sources.testRetrieval.action',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sync and reingest are editor actions', async () => {
|
||||
const synced = { syncFrequency: 'daily', ingestStatus: 'failed' as const };
|
||||
await render(doc({ ...synced, access: 'editor', allowed_actions: EDITOR }));
|
||||
let items = await menuItems();
|
||||
expect(items).toContain('settings.sources.reingest');
|
||||
expect(items).toContain('settings.sources.syncNow');
|
||||
expect(items).toContain('settings.sources.syncFrequency.option');
|
||||
|
||||
await act(async () => root.unmount());
|
||||
root = createRoot(container);
|
||||
document.body.querySelectorAll('[role="menu"]').forEach((m) => m.remove());
|
||||
await render(doc({ ...synced, access: 'viewer', allowed_actions: VIEWER }));
|
||||
items = await menuItems();
|
||||
expect(items).not.toContain('settings.sources.reingest');
|
||||
expect(items).not.toContain('settings.sources.syncNow');
|
||||
expect(items).not.toContain('settings.sources.syncFrequency.option');
|
||||
});
|
||||
|
||||
it('a failed Sync now shows an error toast', async () => {
|
||||
service.syncSource.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: async () => ({ success: false, message: 'Forbidden' }),
|
||||
});
|
||||
await render(doc({ syncFrequency: 'daily' }));
|
||||
await menuItems();
|
||||
await clickItem('settings.sources.syncNow');
|
||||
expect(toasts()).toEqual([
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({ variant: 'destructive' }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('a failed sync-frequency change shows an error toast', async () => {
|
||||
service.manageSync.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({ success: false }),
|
||||
});
|
||||
await render(doc({ syncFrequency: 'daily' }));
|
||||
await menuItems();
|
||||
const weekly = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).filter(
|
||||
(el) => el.textContent === 'settings.sources.syncFrequency.option',
|
||||
)[2];
|
||||
await act(async () => weekly.click());
|
||||
expect(toasts()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('a failed reingest shows an error toast', async () => {
|
||||
service.reingestSource.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: async () => ({ success: false, message: 'Forbidden' }),
|
||||
});
|
||||
await render(doc({ ingestStatus: 'failed' }));
|
||||
await menuItems();
|
||||
await clickItem('settings.sources.reingest');
|
||||
expect(toasts()).toHaveLength(1);
|
||||
});
|
||||
|
||||
const openView = async (document: Doc) => {
|
||||
await render(document);
|
||||
await act(async () =>
|
||||
container.querySelector<HTMLElement>('[aria-label="Contracts"]')!.click(),
|
||||
);
|
||||
};
|
||||
|
||||
const canEditOf = (testId: string) =>
|
||||
container
|
||||
.querySelector(`[data-testid="${testId}"]`)
|
||||
?.getAttribute('data-can-edit');
|
||||
|
||||
it.each([
|
||||
['chunks', {}],
|
||||
['file-tree', { isNested: true }],
|
||||
['connector-tree', { isNested: true, type: 'connector:file' }],
|
||||
['wiki', { type: 'wiki' }],
|
||||
['graph', { config: { kind: 'graphrag' } }],
|
||||
] as const)(
|
||||
'the %s view is read-only for a viewer',
|
||||
async (testId, fields) => {
|
||||
await openView(
|
||||
doc({ ...fields, access: 'viewer', allowed_actions: VIEWER }),
|
||||
);
|
||||
expect(canEditOf(testId)).toBe('false');
|
||||
},
|
||||
);
|
||||
|
||||
it('the source view is editable for an editor', async () => {
|
||||
await openView(
|
||||
doc({ isNested: true, access: 'editor', allowed_actions: EDITOR }),
|
||||
);
|
||||
expect(canEditOf('file-tree')).toBe('true');
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ import { useDebouncedValue, useLoaderState } from '../hooks';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import { ActiveState, Doc, DocumentsProps } from '../models/misc';
|
||||
import type { Model } from '../models/types';
|
||||
import { showActionToast } from '../notifications/actionToastSlice';
|
||||
import ShareToTeamModal from '../teams/ShareToTeamModal';
|
||||
import { getDocs, getDocsWithPagination } from '../preferences/preferenceApi';
|
||||
import {
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
selectUploadTasks,
|
||||
updateUploadTask,
|
||||
} from '../upload/uploadSlice';
|
||||
import { can, roleOf } from '../utils/accessUtils';
|
||||
import { formatDate } from '../utils/dateTimeUtils';
|
||||
import FileTree from '../components/FileTree';
|
||||
import ConnectorTree from '../components/ConnectorTree';
|
||||
@@ -128,6 +130,19 @@ export default function Sources({
|
||||
// badge survives closing the modal and reflects the real backend state.
|
||||
const graphBuilds = useSelector(selectGraphBuilds);
|
||||
|
||||
/**
|
||||
* Shows a failed source action as a destructive toast: the forbidden
|
||||
* message on a 403, else the action's own.
|
||||
*/
|
||||
const showActionError = (message: string, status?: number) =>
|
||||
dispatch(
|
||||
showActionToast({
|
||||
variant: 'destructive',
|
||||
message:
|
||||
status === 403 ? t('settings.sources.errors.forbidden') : message,
|
||||
}),
|
||||
);
|
||||
|
||||
const refreshDocs = useCallback(
|
||||
(
|
||||
field: 'date' | 'tokens' | undefined,
|
||||
@@ -179,10 +194,18 @@ export default function Sources({
|
||||
setLoading(true);
|
||||
userService
|
||||
.manageSync({ source_id: doc.id, sync_frequency }, token)
|
||||
.then(() => {
|
||||
.then((response: Response) => {
|
||||
if (!response.ok) {
|
||||
showActionError(
|
||||
t('settings.sources.errors.syncFrequency'),
|
||||
response.status,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return getDocs(token);
|
||||
})
|
||||
.then((data) => {
|
||||
if (data === null) return null;
|
||||
dispatch(setSourceDocs(data));
|
||||
return getDocsWithPagination(
|
||||
sortField,
|
||||
@@ -194,12 +217,16 @@ export default function Sources({
|
||||
);
|
||||
})
|
||||
.then((paginatedData) => {
|
||||
if (paginatedData === null) return;
|
||||
dispatch(
|
||||
setPaginatedDocuments(paginatedData ? paginatedData.docs : []),
|
||||
);
|
||||
setTotalPages(paginatedData ? paginatedData.totalPages : 0);
|
||||
})
|
||||
.catch((error) => console.error('Error in handleManageSync:', error))
|
||||
.catch((error) => {
|
||||
console.error('Error in handleManageSync:', error);
|
||||
showActionError(t('settings.sources.errors.syncFrequency'));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
@@ -229,34 +256,28 @@ export default function Sources({
|
||||
if (!doc.id) {
|
||||
return;
|
||||
}
|
||||
const syncFailed = t('settings.sources.errors.sync');
|
||||
try {
|
||||
let response: Response;
|
||||
if (doc.type?.startsWith('connector')) {
|
||||
const provider = await getConnectorProvider(doc);
|
||||
if (!provider) {
|
||||
console.error('Sync now failed: provider not found');
|
||||
showActionError(syncFailed);
|
||||
return;
|
||||
}
|
||||
const response = await userService.syncConnector(
|
||||
doc.id,
|
||||
provider,
|
||||
token,
|
||||
);
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
console.error('Sync now failed:', data.error || data.message);
|
||||
}
|
||||
return;
|
||||
response = await userService.syncConnector(doc.id, provider, token);
|
||||
} else {
|
||||
response = await userService.syncSource({ source_id: doc.id }, token);
|
||||
}
|
||||
const response = await userService.syncSource(
|
||||
{ source_id: doc.id },
|
||||
token,
|
||||
);
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
console.error('Sync now failed:', data.error || data.message);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !data?.success) {
|
||||
console.error('Sync now failed:', data?.error || data?.message);
|
||||
showActionError(syncFailed, response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error syncing source:', error);
|
||||
showActionError(syncFailed);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -285,23 +306,25 @@ export default function Sources({
|
||||
{ source_id: sourceId },
|
||||
token,
|
||||
);
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
console.error('Reingest failed:', data.error || data.message);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !data?.success) {
|
||||
console.error('Reingest failed:', data?.error || data?.message);
|
||||
dispatch(
|
||||
updateUploadTask({
|
||||
id: reingestTaskId,
|
||||
updates: {
|
||||
status: 'failed',
|
||||
errorMessage: data.error || data.message,
|
||||
errorMessage: data?.error || data?.message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
showActionError(t('settings.sources.errors.reingest'), response.status);
|
||||
return;
|
||||
}
|
||||
refreshDocs(undefined, currentPage, rowsPerPage);
|
||||
} catch (error) {
|
||||
console.error('Error reingesting source:', error);
|
||||
showActionError(t('settings.sources.errors.reingest'));
|
||||
dispatch(
|
||||
updateUploadTask({
|
||||
id: reingestTaskId,
|
||||
@@ -334,9 +357,8 @@ export default function Sources({
|
||||
const getActionOptions = (index: number, document: Doc): MenuOption[] => {
|
||||
const isWiki = document.config?.kind === 'wiki' || document.type === 'wiki';
|
||||
const isGraphRAG = document.config?.kind === 'graphrag';
|
||||
// 'team' viewers cannot write; convert is owner/editor only.
|
||||
const canEdit =
|
||||
document.ownership !== 'team' || document.team_access === 'editor';
|
||||
// The server's allowed_actions decide every write (utils/accessUtils).
|
||||
const canEdit = can(document, 'edit');
|
||||
const actions: MenuOption[] = [
|
||||
{
|
||||
icon: isGraphRAG ? Network : Eye,
|
||||
@@ -352,7 +374,7 @@ export default function Sources({
|
||||
},
|
||||
];
|
||||
|
||||
if (document.ingestStatus === 'failed') {
|
||||
if (canEdit && document.ingestStatus === 'failed') {
|
||||
actions.push({
|
||||
icon: RefreshCw,
|
||||
label: t('settings.sources.reingest'),
|
||||
@@ -363,7 +385,7 @@ export default function Sources({
|
||||
});
|
||||
}
|
||||
|
||||
if (document.syncFrequency) {
|
||||
if (canEdit && document.syncFrequency) {
|
||||
// One row per sync frequency; the current one carries the check.
|
||||
syncOptions.forEach((opt) => {
|
||||
actions.push({
|
||||
@@ -387,10 +409,13 @@ export default function Sources({
|
||||
});
|
||||
}
|
||||
|
||||
if (document.id && !isWiki) {
|
||||
// Editors edit the config; a viewer may read it (view_config).
|
||||
if (document.id && !isWiki && (canEdit || can(document, 'view_config'))) {
|
||||
actions.push({
|
||||
icon: SlidersHorizontal,
|
||||
label: t('settings.sources.editConfig'),
|
||||
label: canEdit
|
||||
? t('settings.sources.editConfig')
|
||||
: t('settings.sources.viewConfig'),
|
||||
onClick: () => {
|
||||
setDocumentToConfigure(document);
|
||||
setConfigModalState('ACTIVE');
|
||||
@@ -429,9 +454,8 @@ export default function Sources({
|
||||
});
|
||||
}
|
||||
|
||||
// Sharing is an owner-only action: hide it for sources shared into the
|
||||
// user's workspace by a team.
|
||||
if (document.ownership !== 'team' && document.id) {
|
||||
// Owner-only unless the owner lets editors share (editors_can_share).
|
||||
if (document.id && can(document, 'share')) {
|
||||
actions.push({
|
||||
icon: Users,
|
||||
label: t('settings.sources.shareWithTeam'),
|
||||
@@ -442,14 +466,16 @@ export default function Sources({
|
||||
});
|
||||
}
|
||||
|
||||
actions.push({
|
||||
icon: Trash2,
|
||||
label: t('convTile.delete'),
|
||||
onClick: () => {
|
||||
handleDeleteConfirmation(index, document);
|
||||
},
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (can(document, 'delete')) {
|
||||
actions.push({
|
||||
icon: Trash2,
|
||||
label: t('convTile.delete'),
|
||||
onClick: () => {
|
||||
handleDeleteConfirmation(index, document);
|
||||
},
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
};
|
||||
@@ -521,6 +547,9 @@ export default function Sources({
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
// Chunk, file, wiki and graph writes follow the source's `edit` action.
|
||||
const viewCanEdit = documentToView ? can(documentToView, 'edit') : false;
|
||||
|
||||
return documentToView ? (
|
||||
<div className="flex flex-col">
|
||||
{documentToView.config?.kind === 'wiki' ||
|
||||
@@ -528,10 +557,7 @@ export default function Sources({
|
||||
<WikiViewer
|
||||
docId={documentToView.id || ''}
|
||||
sourceName={documentToView.name}
|
||||
canEdit={
|
||||
documentToView.ownership !== 'team' ||
|
||||
documentToView.team_access === 'editor'
|
||||
}
|
||||
canEdit={viewCanEdit}
|
||||
onBackToDocuments={() => setDocumentToView(undefined)}
|
||||
headerAction={testRetrievalAction}
|
||||
/>
|
||||
@@ -541,6 +567,7 @@ export default function Sources({
|
||||
sourceName={documentToView.name}
|
||||
sourceType={documentToView.type}
|
||||
isNested={!!documentToView.isNested}
|
||||
canEdit={viewCanEdit}
|
||||
onBackToDocuments={() => setDocumentToView(undefined)}
|
||||
headerAction={testRetrievalAction}
|
||||
/>
|
||||
@@ -548,6 +575,7 @@ export default function Sources({
|
||||
documentToView.type === 'connector:file' ? (
|
||||
<ConnectorTree
|
||||
docId={documentToView.id || ''}
|
||||
canEdit={viewCanEdit}
|
||||
sourceName={documentToView.name}
|
||||
onBackToDocuments={() => setDocumentToView(undefined)}
|
||||
headerAction={testRetrievalAction}
|
||||
@@ -555,6 +583,7 @@ export default function Sources({
|
||||
) : (
|
||||
<FileTree
|
||||
docId={documentToView.id || ''}
|
||||
canEdit={viewCanEdit}
|
||||
sourceName={documentToView.name}
|
||||
onBackToDocuments={() => setDocumentToView(undefined)}
|
||||
headerAction={testRetrievalAction}
|
||||
@@ -564,6 +593,7 @@ export default function Sources({
|
||||
<Chunks
|
||||
documentId={documentToView.id || ''}
|
||||
documentName={documentToView.name}
|
||||
canEdit={viewCanEdit}
|
||||
handleGoBack={() => setDocumentToView(undefined)}
|
||||
headerAction={testRetrievalAction}
|
||||
/>
|
||||
@@ -663,10 +693,10 @@ export default function Sources({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start justify-start gap-1">
|
||||
{document.ownership === 'team' && (
|
||||
{roleOf(document) !== 'owner' && (
|
||||
<Badge variant="neutral">
|
||||
<Users className="size-3" aria-hidden="true" />
|
||||
{document.team_access === 'editor'
|
||||
{roleOf(document) === 'editor'
|
||||
? t('teamAccess.editor')
|
||||
: t('teamAccess.viewer')}
|
||||
</Badge>
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
// A JWT whose payload is {"sub":"me"}.
|
||||
const TOKEN = `x.${btoa(JSON.stringify({ sub: 'me' }))}.y`;
|
||||
|
||||
const mockState = {
|
||||
preference: {
|
||||
token: TOKEN,
|
||||
agents: [],
|
||||
sourceDocs: [],
|
||||
prompts: [],
|
||||
},
|
||||
teams: {
|
||||
teams: [] as Array<Record<string, unknown>>,
|
||||
currentTeamId: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: (selector: (s: unknown) => unknown) => selector(mockState),
|
||||
useDispatch: () => () => ({ unwrap: () => Promise.resolve() }),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) => {
|
||||
if (!opts) return key;
|
||||
const params = Object.entries(opts)
|
||||
.filter(([k]) => k !== 'defaultValue' && k !== 'interpolation')
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(',');
|
||||
return params ? `${key}(${params})` : key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../navigation/SectionShell', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
vi.mock('../navigation/DetailBreadcrumb', () => ({ default: () => null }));
|
||||
vi.mock('../components/PageToolbar', () => ({ default: () => null }));
|
||||
vi.mock('../modals/ConfirmationModal', () => ({ default: () => null }));
|
||||
vi.mock('../teams/ShareToTeamModal', () => ({
|
||||
default: () => <div data-testid="share-modal" />,
|
||||
}));
|
||||
// Render the ⋯ menu's options inline so tests can see them.
|
||||
vi.mock('../components/ui/dropdown-menu', () => ({
|
||||
ActionMenu: ({ options }: { options: Array<{ label: string }> }) => (
|
||||
<div data-testid="team-menu">
|
||||
{options.map((o) => (
|
||||
<span key={o.label}>{o.label}</span>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: {
|
||||
getAgents: () => Promise.resolve({ json: () => Promise.resolve([]) }),
|
||||
getUserTools: () =>
|
||||
Promise.resolve({ json: () => Promise.resolve({ tools: [] }) }),
|
||||
},
|
||||
}));
|
||||
|
||||
const listMembers = vi.fn();
|
||||
const listGrants = vi.fn();
|
||||
const unshare = vi.fn();
|
||||
const share = vi.fn();
|
||||
const getResourceSettings = vi.fn();
|
||||
|
||||
vi.mock('../api/services/teamsService', () => ({
|
||||
default: {
|
||||
listMembers: (...a: unknown[]) => listMembers(...a),
|
||||
listGrants: (...a: unknown[]) => listGrants(...a),
|
||||
unshare: (...a: unknown[]) => unshare(...a),
|
||||
share: (...a: unknown[]) => share(...a),
|
||||
getResourceSettings: (...a: unknown[]) => getResourceSettings(...a),
|
||||
},
|
||||
}));
|
||||
|
||||
import Teams from './Teams';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const OWNER = {
|
||||
access: 'owner',
|
||||
allowed_actions: [
|
||||
'delete',
|
||||
'edit',
|
||||
'manage_settings',
|
||||
'share',
|
||||
'use',
|
||||
'view',
|
||||
],
|
||||
};
|
||||
const VIEWER = { access: 'viewer', allowed_actions: ['pin', 'use'] };
|
||||
|
||||
const grant = (over: Record<string, unknown> = {}) => ({
|
||||
resource_type: 'source',
|
||||
resource_id: 's1',
|
||||
access_level: 'viewer',
|
||||
target_user_id: null,
|
||||
resource_name: 'Key Accounts',
|
||||
owner_id: 'lena',
|
||||
owner_label: 'Lena Fischer',
|
||||
target_user_label: null,
|
||||
created_at: '2026-09-12T10:00:00Z',
|
||||
granted_by_label: 'Lena Fischer',
|
||||
caller: OWNER,
|
||||
...over,
|
||||
});
|
||||
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const body = () => document.body;
|
||||
const rowButtons = () =>
|
||||
Array.from(
|
||||
body().querySelectorAll<HTMLButtonElement>(
|
||||
'[data-testid="shared-resource-row"]',
|
||||
),
|
||||
);
|
||||
|
||||
describe('Teams page', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const setTeam = (over: Record<string, unknown> = {}) => {
|
||||
mockState.teams.teams = [
|
||||
{
|
||||
id: 't1',
|
||||
name: 'Revenue Ops',
|
||||
slug: 'revenue-ops',
|
||||
owner_id: 'me',
|
||||
member_role: 'team_admin',
|
||||
...over,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setTeam();
|
||||
listMembers.mockReset().mockResolvedValue({ members: [] });
|
||||
listGrants
|
||||
.mockReset()
|
||||
.mockResolvedValue({ grants: [], team_role: 'team_admin' });
|
||||
unshare.mockReset().mockResolvedValue({ success: true });
|
||||
share.mockReset().mockResolvedValue({ success: true });
|
||||
getResourceSettings.mockReset().mockResolvedValue({
|
||||
success: true,
|
||||
resource_type: 'source',
|
||||
resource_id: 's1',
|
||||
settings: [
|
||||
{ key: 'editors_can_share', value: false, default: false },
|
||||
{ key: 'editors_can_delete', value: false, default: false },
|
||||
{ key: 'viewers_can_see_config', value: true, default: true },
|
||||
],
|
||||
access: 'owner',
|
||||
allowed_actions: OWNER.allowed_actions,
|
||||
});
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
const render = async () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter
|
||||
initialEntries={[{ pathname: '/teams', state: { openTeamId: 't1' } }]}
|
||||
>
|
||||
<Teams />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
};
|
||||
|
||||
const openDrawer = async (index = 0) => {
|
||||
act(() => rowButtons()[index].click());
|
||||
await flush();
|
||||
};
|
||||
|
||||
it('groups duplicate grants into one row per resource', async () => {
|
||||
listGrants.mockResolvedValue({
|
||||
team_role: 'team_admin',
|
||||
grants: [
|
||||
grant(),
|
||||
grant({
|
||||
access_level: 'editor',
|
||||
target_user_id: 'dana',
|
||||
target_user_label: 'Dana Whitfield',
|
||||
}),
|
||||
grant({
|
||||
resource_type: 'prompt',
|
||||
resource_id: 's1',
|
||||
resource_name: 'Pre-call brief',
|
||||
}),
|
||||
],
|
||||
});
|
||||
await render();
|
||||
const rows = rowButtons();
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].textContent).toContain('Key Accounts');
|
||||
expect(rows[0].textContent).toContain(
|
||||
'settings.teams.sharedList.badgeWithEditors(level=viewer,count=1)',
|
||||
);
|
||||
expect(rows[0].textContent).toContain(
|
||||
'settings.teams.sharedList.meta(type=settings.teams.resourceType.source,owner=Lena Fischer)',
|
||||
);
|
||||
// Filter pills with counts.
|
||||
const pills = Array.from(body().querySelectorAll('[role="radio"]'));
|
||||
expect(pills.map((p) => p.textContent)).toEqual([
|
||||
'settings.teams.sharedList.filter.all 2',
|
||||
'settings.teams.sharedList.filter.agent 0',
|
||||
'settings.teams.sharedList.filter.source 1',
|
||||
'settings.teams.sharedList.filter.tool 0',
|
||||
'settings.teams.sharedList.filter.prompt 1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows role selects, remove and Manage sharing to a caller who can share', async () => {
|
||||
listGrants.mockResolvedValue({
|
||||
team_role: 'team_member',
|
||||
grants: [
|
||||
grant(),
|
||||
grant({
|
||||
access_level: 'editor',
|
||||
target_user_id: 'dana',
|
||||
target_user_label: 'Dana Whitfield',
|
||||
}),
|
||||
],
|
||||
});
|
||||
setTeam({ member_role: 'team_member' });
|
||||
await render();
|
||||
await openDrawer();
|
||||
const sheet = body().querySelector('[data-slot="sheet-content"]');
|
||||
expect(sheet).not.toBeNull();
|
||||
expect(sheet!.textContent).toContain(
|
||||
'settings.teams.drawer.accessIn(team=Revenue Ops)',
|
||||
);
|
||||
expect(sheet!.querySelectorAll('[role="combobox"]')).toHaveLength(2);
|
||||
expect(
|
||||
sheet!.querySelectorAll(
|
||||
'button[aria-label="settings.teams.drawer.removeGrant"]',
|
||||
),
|
||||
).toHaveLength(2);
|
||||
expect(sheet!.textContent).toContain('settings.teams.drawer.manageSharing');
|
||||
// What people here can do, from the settings.
|
||||
expect(sheet!.textContent).toContain(
|
||||
'settings.teams.capabilities.source.viewers',
|
||||
);
|
||||
expect(sheet!.textContent).toContain(
|
||||
'settings.teams.capabilities.switch.editors_can_share.off',
|
||||
);
|
||||
// The selected row keeps its tint while the drawer is open.
|
||||
expect(rowButtons()[0].className).toContain('bg-secondary');
|
||||
});
|
||||
|
||||
it('gives a team admin who cannot share badges and remove only', async () => {
|
||||
listGrants.mockResolvedValue({
|
||||
team_role: 'team_admin',
|
||||
grants: [grant({ caller: VIEWER })],
|
||||
});
|
||||
await render();
|
||||
await openDrawer();
|
||||
const sheet = body().querySelector('[data-slot="sheet-content"]')!;
|
||||
expect(sheet.querySelectorAll('[role="combobox"]')).toHaveLength(0);
|
||||
expect(
|
||||
sheet.querySelectorAll(
|
||||
'button[aria-label="settings.teams.drawer.removeGrant"]',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(sheet.textContent).not.toContain(
|
||||
'settings.teams.drawer.manageSharing',
|
||||
);
|
||||
});
|
||||
|
||||
it('is read-only for a member who cannot share', async () => {
|
||||
setTeam({ member_role: 'team_member', owner_id: 'someone' });
|
||||
listGrants.mockResolvedValue({
|
||||
team_role: 'team_member',
|
||||
grants: [grant({ caller: VIEWER })],
|
||||
});
|
||||
await render();
|
||||
await openDrawer();
|
||||
const sheet = body().querySelector('[data-slot="sheet-content"]')!;
|
||||
expect(sheet.querySelectorAll('[role="combobox"]')).toHaveLength(0);
|
||||
expect(
|
||||
sheet.querySelectorAll(
|
||||
'button[aria-label="settings.teams.drawer.removeGrant"]',
|
||||
),
|
||||
).toHaveLength(0);
|
||||
expect(sheet.textContent).toContain(
|
||||
'settings.teams.drawer.open(type=settings.teams.resourceType.source)',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends target_user_id when removing a per-member grant', async () => {
|
||||
listGrants.mockResolvedValue({
|
||||
team_role: 'team_admin',
|
||||
grants: [
|
||||
grant({
|
||||
access_level: 'editor',
|
||||
target_user_id: 'dana',
|
||||
target_user_label: 'Dana Whitfield',
|
||||
}),
|
||||
],
|
||||
});
|
||||
await render();
|
||||
await openDrawer();
|
||||
const remove = body().querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="settings.teams.drawer.removeGrant"]',
|
||||
);
|
||||
act(() => remove!.click());
|
||||
await flush();
|
||||
expect(unshare).toHaveBeenCalledWith(
|
||||
't1',
|
||||
{ resource_type: 'source', resource_id: 's1', target_user_id: 'dana' },
|
||||
TOKEN,
|
||||
);
|
||||
});
|
||||
|
||||
it('shows Delete team only to the team owner', async () => {
|
||||
await render();
|
||||
let menu = body().querySelector('[data-testid="team-menu"]');
|
||||
expect(menu?.textContent).toContain('settings.teams.deleteTeam');
|
||||
|
||||
act(() => root.unmount());
|
||||
root = createRoot(container);
|
||||
setTeam({ owner_id: 'someone-else', member_role: 'team_admin' });
|
||||
await render();
|
||||
menu = body().querySelector('[data-testid="team-menu"]');
|
||||
expect(menu?.textContent).toContain('settings.teams.editTeam');
|
||||
expect(menu?.textContent).not.toContain('settings.teams.deleteTeam');
|
||||
});
|
||||
|
||||
it('prefers is_owner over owner_id when the server sends it', async () => {
|
||||
setTeam({ owner_id: 'me', is_owner: false, member_role: 'team_admin' });
|
||||
await render();
|
||||
const menu = body().querySelector('[data-testid="team-menu"]');
|
||||
expect(menu?.textContent).not.toContain('settings.teams.deleteTeam');
|
||||
});
|
||||
});
|
||||
+658
-72
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Bot,
|
||||
Check,
|
||||
ChevronRight,
|
||||
CircleAlert,
|
||||
FileText,
|
||||
@@ -9,17 +11,27 @@ import {
|
||||
Trash2,
|
||||
Users,
|
||||
Wrench,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import teamsService, {
|
||||
AccessLevel,
|
||||
ResourceSettingsResponse,
|
||||
ResourceType,
|
||||
TeamGrant,
|
||||
TeamRole,
|
||||
} from '../api/services/teamsService';
|
||||
import userService from '../api/services/userService';
|
||||
import {
|
||||
agentChatPath,
|
||||
agentEditPath,
|
||||
agentEditPathFor,
|
||||
} from '../agents/paths';
|
||||
import SearchInput from '../components/SearchInput';
|
||||
import SkeletonLoader from '../components/SkeletonLoader';
|
||||
import DetailBreadcrumb from '../navigation/DetailBreadcrumb';
|
||||
import SectionShell from '../navigation/SectionShell';
|
||||
@@ -34,6 +46,10 @@ import {
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
} from '../components/ui/card';
|
||||
import {
|
||||
DescriptionItem,
|
||||
DescriptionList,
|
||||
} from '../components/ui/description-list';
|
||||
import { ActionMenu } from '../components/ui/dropdown-menu';
|
||||
import { EmptyState } from '../components/ui/empty-state';
|
||||
import { FormField } from '../components/ui/form-field';
|
||||
@@ -49,7 +65,15 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../components/ui/select';
|
||||
import { Separator } from '../components/ui/separator';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetTitle,
|
||||
} from '../components/ui/sheet';
|
||||
import { Textarea } from '../components/ui/textarea';
|
||||
import { ToggleGroup, ToggleGroupItem } from '../components/ui/toggle-group';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import { ActiveState } from '../models/misc';
|
||||
import { showActionToast } from '../notifications/actionToastSlice';
|
||||
@@ -61,6 +85,12 @@ import {
|
||||
setAgents,
|
||||
} from '../preferences/preferenceSlice';
|
||||
import { AppDispatch } from '../store';
|
||||
import {
|
||||
capabilityLines,
|
||||
errorMessage,
|
||||
resolveSettings,
|
||||
} from '../teams/accessSettings';
|
||||
import ShareToTeamModal from '../teams/ShareToTeamModal';
|
||||
import {
|
||||
createTeam,
|
||||
deleteTeam,
|
||||
@@ -70,6 +100,9 @@ import {
|
||||
selectTeamsLoading,
|
||||
Team,
|
||||
} from '../teams/teamsSlice';
|
||||
import { can } from '../utils/accessUtils';
|
||||
import { formatDateOnly } from '../utils/dateTimeUtils';
|
||||
import { decodeJwtPayload } from '../utils/jwtUtils';
|
||||
|
||||
type Member = {
|
||||
user_id: string;
|
||||
@@ -78,12 +111,50 @@ type Member = {
|
||||
source: string;
|
||||
};
|
||||
|
||||
type Grant = {
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
access_level: string;
|
||||
type Grant = TeamGrant;
|
||||
|
||||
// All of one team's grants on one resource: the whole-team grant (if any)
|
||||
// and the per-member grants, shown as a single row.
|
||||
type SharedResource = {
|
||||
key: string;
|
||||
type: ResourceType;
|
||||
id: string;
|
||||
grants: Grant[];
|
||||
teamGrant: Grant | null;
|
||||
memberGrants: Grant[];
|
||||
};
|
||||
|
||||
type ResourceFilter = 'all' | ResourceType;
|
||||
|
||||
const resourceKey = (g: Pick<Grant, 'resource_type' | 'resource_id'>) =>
|
||||
`${g.resource_type}:${g.resource_id}`;
|
||||
|
||||
const grantKey = (g: Grant) => `${resourceKey(g)}:${g.target_user_id ?? ''}`;
|
||||
|
||||
/** Group grants by resource, keeping the server's order of first sighting. */
|
||||
export function groupGrants(grants: Grant[]): SharedResource[] {
|
||||
const byKey = new Map<string, SharedResource>();
|
||||
grants.forEach((g) => {
|
||||
const key = resourceKey(g);
|
||||
let entry = byKey.get(key);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
key,
|
||||
type: g.resource_type,
|
||||
id: g.resource_id,
|
||||
grants: [],
|
||||
teamGrant: null,
|
||||
memberGrants: [],
|
||||
};
|
||||
byKey.set(key, entry);
|
||||
}
|
||||
entry.grants.push(g);
|
||||
if (g.target_user_id) entry.memberGrants.push(g);
|
||||
else entry.teamGrant = g;
|
||||
});
|
||||
return Array.from(byKey.values());
|
||||
}
|
||||
|
||||
const RESOURCE_TYPES: ReadonlyArray<ResourceType> = [
|
||||
'agent',
|
||||
'source',
|
||||
@@ -91,6 +162,14 @@ const RESOURCE_TYPES: ReadonlyArray<ResourceType> = [
|
||||
'tool',
|
||||
];
|
||||
|
||||
// Filter pill order on the shared resources list.
|
||||
const FILTER_TYPES: ReadonlyArray<ResourceType> = [
|
||||
'agent',
|
||||
'source',
|
||||
'tool',
|
||||
'prompt',
|
||||
];
|
||||
|
||||
// Member subs (OIDC subs) can be long; truncate the middle for readability
|
||||
// while keeping the ends identifiable when no email is available.
|
||||
const truncateSub = (sub: string): string =>
|
||||
@@ -145,6 +224,26 @@ export default function Teams() {
|
||||
useState<ActiveState>('INACTIVE');
|
||||
const [memberToRemove, setMemberToRemove] = useState<string | null>(null);
|
||||
|
||||
// The caller's role in the selected team, as the grants endpoint reports it.
|
||||
const [teamRole, setTeamRole] = useState<TeamRole | null>(null);
|
||||
// Shared resources list: type filter, search, and the row whose drawer is
|
||||
// open (kept while "Manage sharing" has the drawer closed).
|
||||
const [resourceFilter, setResourceFilter] = useState<ResourceFilter>('all');
|
||||
const [resourceQuery, setResourceQuery] = useState('');
|
||||
const [openResourceKey, setOpenResourceKey] = useState<string | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [drawerSettings, setDrawerSettings] =
|
||||
useState<ResourceSettingsResponse | null>(null);
|
||||
const [busyGrants, setBusyGrants] = useState<Set<string>>(new Set());
|
||||
const [shareTarget, setShareTarget] = useState<SharedResource | null>(null);
|
||||
|
||||
// The caller's own sub, to tell whether they own the selected team when
|
||||
// the server doesn't send `is_owner`.
|
||||
const currentUserId = useMemo(() => {
|
||||
const payload = token ? decodeJwtPayload(token) : null;
|
||||
return typeof payload?.sub === 'string' ? payload.sub : undefined;
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(loadTeams({ token }));
|
||||
}, []);
|
||||
@@ -173,6 +272,7 @@ export default function Teams() {
|
||||
// render so names appear as soon as those lists hydrate. Falls back to a
|
||||
// truncated id when the resource isn't found (e.g. not yet loaded).
|
||||
const resolveResourceName = (g: Grant): string => {
|
||||
if (g.resource_name) return g.resource_name;
|
||||
switch (g.resource_type) {
|
||||
case 'agent':
|
||||
return (
|
||||
@@ -227,11 +327,17 @@ export default function Teams() {
|
||||
// members/grants while this team's fetch is in flight.
|
||||
setMembers([]);
|
||||
setGrants([]);
|
||||
setTeamRole(null);
|
||||
setOpenResourceKey(null);
|
||||
setDrawerOpen(false);
|
||||
setResourceFilter('all');
|
||||
setResourceQuery('');
|
||||
try {
|
||||
const m = await teamsService.listMembers(team.id, token);
|
||||
setMembers(m?.members ?? []);
|
||||
const g = await teamsService.listGrants(team.id, undefined, token);
|
||||
setGrants(g?.grants ?? []);
|
||||
setTeamRole(g?.team_role ?? null);
|
||||
// Agents/sources/prompts are normally hydrated at app init, but a fresh
|
||||
// load landing directly on /teams may not have agents yet. Backfill them
|
||||
// (only when missing and an agent is actually shared) so the row resolves
|
||||
@@ -332,15 +438,15 @@ export default function Teams() {
|
||||
token,
|
||||
);
|
||||
if (!res || res.success === false) {
|
||||
setEditError(t('settings.teams.updateFailed'));
|
||||
setEditError(res?.message ?? t('settings.teams.updateFailed'));
|
||||
return;
|
||||
}
|
||||
// Reflect locally and refresh the list so the card/switcher update too.
|
||||
setSelected({ ...selected, name, description });
|
||||
dispatch(loadTeams({ token }));
|
||||
setEditOpen(false);
|
||||
} catch {
|
||||
setEditError(t('settings.teams.updateFailed'));
|
||||
} catch (error) {
|
||||
setEditError(errorMessage(error, t('settings.teams.updateFailed')));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -412,8 +518,12 @@ export default function Teams() {
|
||||
setNewMemberRole('team_member');
|
||||
setAddMemberOpen(false);
|
||||
openTeam(selected);
|
||||
} catch {
|
||||
setAddMemberError(t('settings.teams.addMemberError'));
|
||||
} catch (error) {
|
||||
// The backend returns 404 with a message when the email maps to no
|
||||
// known user ("they must sign in first"); show its message.
|
||||
setAddMemberError(
|
||||
errorMessage(error, t('settings.teams.addMemberError')),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -429,8 +539,8 @@ export default function Teams() {
|
||||
if (res?.success === false)
|
||||
reportError(res.message ?? t('settings.teams.updateFailed'));
|
||||
openTeam(selected);
|
||||
} catch {
|
||||
reportError(t('settings.teams.roleChangeError'));
|
||||
} catch (error) {
|
||||
reportError(errorMessage(error, t('settings.teams.roleChangeError')));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -446,8 +556,8 @@ export default function Teams() {
|
||||
try {
|
||||
await teamsService.removeMember(selected.id, memberId, token);
|
||||
openTeam(selected);
|
||||
} catch {
|
||||
reportError(t('settings.teams.removeMemberError'));
|
||||
} catch (error) {
|
||||
reportError(errorMessage(error, t('settings.teams.removeMemberError')));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -463,29 +573,200 @@ export default function Teams() {
|
||||
try {
|
||||
await dispatch(deleteTeam({ id: team.id, token })).unwrap();
|
||||
if (selected?.id === team.id) setSelected(null);
|
||||
} catch {
|
||||
reportError(t('settings.teams.deleteTeamError'));
|
||||
} catch (error) {
|
||||
reportError(errorMessage(error, t('settings.teams.deleteTeamError')));
|
||||
}
|
||||
};
|
||||
|
||||
// Re-read the team's grants after a change (the drawer follows them).
|
||||
const refreshGrants = async () => {
|
||||
if (!selected) return;
|
||||
try {
|
||||
const g = await teamsService.listGrants(selected.id, undefined, token);
|
||||
setGrants(g?.grants ?? []);
|
||||
setTeamRole(g?.team_role ?? null);
|
||||
} catch (error) {
|
||||
reportError(errorMessage(error, t('settings.teams.openTeamError')));
|
||||
}
|
||||
};
|
||||
|
||||
const setGrantBusy = (key: string, busy: boolean) =>
|
||||
setBusyGrants((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (busy) next.add(key);
|
||||
else next.delete(key);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Remove one grant: the whole-team grant, or one member's (which needs
|
||||
// its target_user_id, or the server would drop the team grant instead).
|
||||
const handleUnshare = async (grant: Grant) => {
|
||||
if (!selected) return;
|
||||
const key = grantKey(grant);
|
||||
setGrantBusy(key, true);
|
||||
try {
|
||||
await teamsService.unshare(
|
||||
selected.id,
|
||||
{
|
||||
resource_type: grant.resource_type as ResourceType,
|
||||
resource_type: grant.resource_type,
|
||||
resource_id: grant.resource_id,
|
||||
target_user_id: grant.target_user_id ?? undefined,
|
||||
},
|
||||
token,
|
||||
);
|
||||
openTeam(selected);
|
||||
} catch {
|
||||
reportError(t('settings.teams.unshareError'));
|
||||
} catch (error) {
|
||||
reportError(errorMessage(error, t('settings.teams.unshareError')));
|
||||
} finally {
|
||||
setGrantBusy(key, false);
|
||||
await refreshGrants();
|
||||
}
|
||||
};
|
||||
|
||||
const isAdmin = selected?.member_role === 'team_admin';
|
||||
const handleGrantAccess = async (grant: Grant, level: AccessLevel) => {
|
||||
if (!selected || grant.access_level === level) return;
|
||||
const key = grantKey(grant);
|
||||
setGrantBusy(key, true);
|
||||
try {
|
||||
await teamsService.share(
|
||||
selected.id,
|
||||
{
|
||||
resource_type: grant.resource_type,
|
||||
resource_id: grant.resource_id,
|
||||
access_level: level,
|
||||
target_user_id: grant.target_user_id ?? undefined,
|
||||
},
|
||||
token,
|
||||
);
|
||||
} catch (error) {
|
||||
reportError(errorMessage(error, t('settings.teams.accessChangeError')));
|
||||
} finally {
|
||||
setGrantBusy(key, false);
|
||||
await refreshGrants();
|
||||
}
|
||||
};
|
||||
|
||||
// The grants endpoint's live team_role wins over the list's member_role.
|
||||
const isAdmin = (teamRole ?? selected?.member_role) === 'team_admin';
|
||||
// Only the team's owner may delete it. Prefer the server's `is_owner`;
|
||||
// older payloads only carry `owner_id`.
|
||||
const isTeamOwner = selected
|
||||
? typeof selected.is_owner === 'boolean'
|
||||
? selected.is_owner
|
||||
: Boolean(currentUserId) && selected.owner_id === currentUserId
|
||||
: false;
|
||||
|
||||
const sharedResources = useMemo(() => groupGrants(grants), [grants]);
|
||||
const resourceCounts = useMemo(() => {
|
||||
const counts: Record<ResourceFilter, number> = {
|
||||
all: sharedResources.length,
|
||||
agent: 0,
|
||||
source: 0,
|
||||
tool: 0,
|
||||
prompt: 0,
|
||||
};
|
||||
sharedResources.forEach((r) => {
|
||||
if (r.type in counts) counts[r.type] += 1;
|
||||
});
|
||||
return counts;
|
||||
}, [sharedResources]);
|
||||
|
||||
const resourceName = (r: SharedResource): string =>
|
||||
resolveResourceName(r.grants[0]);
|
||||
const ownerLabel = (r: SharedResource): string => {
|
||||
const g = r.grants[0];
|
||||
return g.owner_label || (g.owner_id ? truncateSub(g.owner_id) : '—');
|
||||
};
|
||||
|
||||
const visibleResources = sharedResources.filter((r) => {
|
||||
if (resourceFilter !== 'all' && r.type !== resourceFilter) return false;
|
||||
const needle = resourceQuery.trim().toLowerCase();
|
||||
if (!needle) return true;
|
||||
return `${resourceName(r)} ${ownerLabel(r)}`.toLowerCase().includes(needle);
|
||||
});
|
||||
|
||||
// The strongest access this team has, plus "+N Editor" when per-member
|
||||
// editor grants sit on top of a viewer team grant.
|
||||
const resourceBadge = (r: SharedResource): string => {
|
||||
const memberEditors = r.memberGrants.filter(
|
||||
(g) => g.access_level === 'editor',
|
||||
).length;
|
||||
if (r.teamGrant) {
|
||||
const level = accessLevelLabel(r.teamGrant.access_level);
|
||||
return r.teamGrant.access_level === 'viewer' && memberEditors > 0
|
||||
? t('settings.teams.sharedList.badgeWithEditors', {
|
||||
interpolation: { escapeValue: false },
|
||||
level,
|
||||
count: memberEditors,
|
||||
})
|
||||
: level;
|
||||
}
|
||||
return accessLevelLabel(memberEditors > 0 ? 'editor' : 'viewer');
|
||||
};
|
||||
|
||||
const openResource =
|
||||
sharedResources.find((r) => r.key === openResourceKey) ?? null;
|
||||
const openCaller = openResource?.grants.find((g) => g.caller)?.caller ?? null;
|
||||
const callerCanShare = can(openCaller, 'share');
|
||||
|
||||
const openDrawerFor = (r: SharedResource) => {
|
||||
setOpenResourceKey(r.key);
|
||||
setDrawerOpen(true);
|
||||
setDrawerSettings(null);
|
||||
teamsService
|
||||
.getResourceSettings(r.type, r.id, token)
|
||||
.then((res) => setDrawerSettings(res))
|
||||
.catch(() => {
|
||||
// The capabilities list falls back to the default rules.
|
||||
});
|
||||
};
|
||||
|
||||
const closeDrawer = () => {
|
||||
setDrawerOpen(false);
|
||||
setOpenResourceKey(null);
|
||||
};
|
||||
|
||||
// Where "Open {{type}}" goes: an agent's edit page when the caller may
|
||||
// view its config, else its chat; the list page for the other types.
|
||||
const openAssetPath = (r: SharedResource): string => {
|
||||
switch (r.type) {
|
||||
case 'agent': {
|
||||
if (!can(openCaller, 'view')) return agentChatPath(r.id);
|
||||
const agent = agents?.find((a) => a.id === r.id);
|
||||
return agent ? agentEditPathFor(agent) : agentEditPath(r.id);
|
||||
}
|
||||
case 'source':
|
||||
return '/settings/sources';
|
||||
case 'tool':
|
||||
return '/settings/tools';
|
||||
case 'prompt':
|
||||
return '/settings/general';
|
||||
default:
|
||||
return '/settings';
|
||||
}
|
||||
};
|
||||
|
||||
const callerAccessLabel = (access?: string | null): string =>
|
||||
access
|
||||
? t(`settings.teams.drawer.yourAccessLevel.${access}`, {
|
||||
interpolation: { escapeValue: false },
|
||||
defaultValue: access,
|
||||
})
|
||||
: t('settings.teams.drawer.yourAccessLevel.none');
|
||||
|
||||
const grantedAt = (r: SharedResource): string => {
|
||||
const first = [...r.grants]
|
||||
.filter((g) => g.created_at)
|
||||
.sort((a, b) => (a.created_at! < b.created_at! ? -1 : 1))[0];
|
||||
if (!first?.created_at) return '—';
|
||||
const date = formatDateOnly(first.created_at);
|
||||
return first.granted_by_label
|
||||
? t('settings.teams.drawer.sharedOnBy', {
|
||||
interpolation: { escapeValue: false },
|
||||
date,
|
||||
name: first.granted_by_label,
|
||||
})
|
||||
: date;
|
||||
};
|
||||
|
||||
const roleBadge = (role: TeamRole) => (
|
||||
<Badge variant={role === 'team_admin' ? 'default' : 'neutral'}>
|
||||
@@ -631,20 +912,28 @@ export default function Teams() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
{(isAdmin || isTeamOwner) && (
|
||||
<ActionMenu
|
||||
options={[
|
||||
{
|
||||
label: t('settings.teams.editTeam'),
|
||||
icon: Pencil,
|
||||
onClick: openEditModal,
|
||||
},
|
||||
{
|
||||
label: t('settings.teams.deleteTeam'),
|
||||
icon: Trash2,
|
||||
variant: 'destructive',
|
||||
onClick: () => requestDeleteTeam(selected),
|
||||
},
|
||||
...(isAdmin
|
||||
? [
|
||||
{
|
||||
label: t('settings.teams.editTeam'),
|
||||
icon: Pencil,
|
||||
onClick: openEditModal,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isTeamOwner
|
||||
? [
|
||||
{
|
||||
label: t('settings.teams.deleteTeam'),
|
||||
icon: Trash2,
|
||||
variant: 'destructive' as const,
|
||||
onClick: () => requestDeleteTeam(selected),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
triggerLabel={t('settings.teams.teamActions')}
|
||||
className="shrink-0"
|
||||
@@ -737,54 +1026,350 @@ export default function Teams() {
|
||||
<SectionHeader
|
||||
as="h4"
|
||||
size="sm"
|
||||
title={`${t('settings.teams.sharedResources')} · ${grants.length}`}
|
||||
title={`${t('settings.teams.sharedResources')} · ${sharedResources.length}`}
|
||||
/>
|
||||
{grants.length === 0 ? (
|
||||
{sharedResources.length === 0 ? (
|
||||
<EmptyState size="sm" title={t('settings.teams.nothingShared')} />
|
||||
) : (
|
||||
<ListRows>
|
||||
{grants.map((g) => (
|
||||
<ListRow
|
||||
key={`${g.resource_type}-${g.resource_id}`}
|
||||
leading={
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md"
|
||||
title={resourceTypeLabel(g.resource_type)}
|
||||
>
|
||||
{resourceTypeIcon(g.resource_type)}
|
||||
</span>
|
||||
}
|
||||
title={
|
||||
<span title={g.resource_id}>
|
||||
{resolveResourceName(g)}
|
||||
</span>
|
||||
}
|
||||
trailing={
|
||||
<>
|
||||
<Badge variant="neutral">
|
||||
{accessLevelLabel(g.access_level)}
|
||||
</Badge>
|
||||
{isAdmin && (
|
||||
<IconButton
|
||||
variant="ghost-destructive"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
label={t('settings.teams.unshare')}
|
||||
icon={Trash2}
|
||||
onClick={() => handleUnshare(g)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
|
||||
<div className="bg-muted max-w-full rounded-full p-1">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
size="xs"
|
||||
value={resourceFilter}
|
||||
onValueChange={(value) =>
|
||||
value && setResourceFilter(value as ResourceFilter)
|
||||
}
|
||||
aria-label={t('settings.teams.sharedList.filterLabel')}
|
||||
>
|
||||
{(['all', ...FILTER_TYPES] as ResourceFilter[]).map(
|
||||
(value) => (
|
||||
<ToggleGroupItem key={value} value={value}>
|
||||
{t(`settings.teams.sharedList.filter.${value}`)}{' '}
|
||||
{resourceCounts[value]}
|
||||
</ToggleGroupItem>
|
||||
),
|
||||
)}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<SearchInput
|
||||
size="sm"
|
||||
className="w-full sm:w-56"
|
||||
placeholder={t('settings.teams.sharedList.search')}
|
||||
value={resourceQuery}
|
||||
onChange={(e) => setResourceQuery(e.target.value)}
|
||||
/>
|
||||
))}
|
||||
</ListRows>
|
||||
</div>
|
||||
{visibleResources.length === 0 ? (
|
||||
<EmptyState
|
||||
size="sm"
|
||||
title={t('settings.teams.sharedList.noMatches')}
|
||||
/>
|
||||
) : (
|
||||
<ListRows>
|
||||
{visibleResources.map((r) => {
|
||||
const isOpen = drawerOpen && openResourceKey === r.key;
|
||||
return (
|
||||
<ListRow
|
||||
key={r.key}
|
||||
interactive
|
||||
selected={isOpen}
|
||||
asChild
|
||||
leading={
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md"
|
||||
>
|
||||
{resourceTypeIcon(r.type)}
|
||||
</span>
|
||||
}
|
||||
title={
|
||||
<span title={resourceName(r)}>
|
||||
{resourceName(r)}
|
||||
</span>
|
||||
}
|
||||
description={t('settings.teams.sharedList.meta', {
|
||||
interpolation: { escapeValue: false },
|
||||
type: resourceTypeLabel(r.type),
|
||||
owner: ownerLabel(r),
|
||||
})}
|
||||
trailing={
|
||||
<>
|
||||
<Badge variant="neutral" className="shrink-0">
|
||||
{resourceBadge(r)}
|
||||
</Badge>
|
||||
<ChevronRight
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="shared-resource-row"
|
||||
onClick={() => openDrawerFor(r)}
|
||||
/>
|
||||
</ListRow>
|
||||
);
|
||||
})}
|
||||
</ListRows>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Sheet
|
||||
open={drawerOpen && openResource !== null}
|
||||
onOpenChange={(open) => !open && closeDrawer()}
|
||||
>
|
||||
{openResource && selected && (
|
||||
<SheetContent
|
||||
side="right"
|
||||
size="detail"
|
||||
className="p-0"
|
||||
closeLabel={t('settings.teams.drawer.close')}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
{/* pr-12 keeps the header clear of the close X. */}
|
||||
<div className="flex items-center gap-3 px-6 pt-6 pr-12 pb-4">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md"
|
||||
>
|
||||
{resourceTypeIcon(openResource.type)}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<SheetTitle className="truncate">
|
||||
{resourceName(openResource)}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{t('settings.teams.drawer.subtitle', {
|
||||
interpolation: { escapeValue: false },
|
||||
type: resourceTypeLabel(openResource.type),
|
||||
owner: ownerLabel(openResource),
|
||||
})}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 px-6 pb-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => {
|
||||
const path = openAssetPath(openResource);
|
||||
closeDrawer();
|
||||
navigate(path);
|
||||
}}
|
||||
>
|
||||
<ArrowUpRight aria-hidden />
|
||||
{t('settings.teams.drawer.open', {
|
||||
interpolation: { escapeValue: false },
|
||||
type: resourceTypeLabel(openResource.type),
|
||||
})}
|
||||
</Button>
|
||||
{callerCanShare && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={() => {
|
||||
setDrawerOpen(false);
|
||||
setShareTarget(openResource);
|
||||
}}
|
||||
>
|
||||
<Users aria-hidden />
|
||||
{t('settings.teams.drawer.manageSharing')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-6 px-6 py-6">
|
||||
<DescriptionList size="sm">
|
||||
<DescriptionItem label={t('settings.teams.drawer.owner')}>
|
||||
{ownerLabel(openResource)}
|
||||
</DescriptionItem>
|
||||
<DescriptionItem label={t('settings.teams.drawer.shared')}>
|
||||
{grantedAt(openResource)}
|
||||
</DescriptionItem>
|
||||
<DescriptionItem
|
||||
label={t('settings.teams.drawer.yourAccess')}
|
||||
>
|
||||
{callerAccessLabel(openCaller?.access)}
|
||||
</DescriptionItem>
|
||||
</DescriptionList>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<SectionHeader
|
||||
as="h3"
|
||||
size="xs"
|
||||
title={t('settings.teams.drawer.accessIn', {
|
||||
interpolation: { escapeValue: false },
|
||||
team: selected.name,
|
||||
})}
|
||||
/>
|
||||
<Card variant="subtle" padding="none">
|
||||
<ListRows>
|
||||
{[
|
||||
...(openResource.teamGrant
|
||||
? [openResource.teamGrant]
|
||||
: []),
|
||||
...openResource.memberGrants,
|
||||
].map((g) => {
|
||||
const isTeam = !g.target_user_id;
|
||||
const label = isTeam
|
||||
? t('settings.teams.drawer.everyone', {
|
||||
interpolation: { escapeValue: false },
|
||||
team: selected.name,
|
||||
})
|
||||
: g.target_user_label ||
|
||||
truncateSub(g.target_user_id!);
|
||||
const busy = busyGrants.has(grantKey(g));
|
||||
return (
|
||||
<ListRow
|
||||
key={grantKey(g)}
|
||||
leading={
|
||||
<span aria-hidden="true" className="contents">
|
||||
<Avatar
|
||||
alt=""
|
||||
size="sm"
|
||||
variant="primary"
|
||||
shape={isTeam ? 'square' : 'circle'}
|
||||
>
|
||||
{initialOf(isTeam ? selected.name : label)}
|
||||
</Avatar>
|
||||
</span>
|
||||
}
|
||||
title={<span title={label}>{label}</span>}
|
||||
description={
|
||||
isTeam
|
||||
? t('settings.teams.drawer.teamGrant')
|
||||
: t('settings.teams.drawer.memberGrant')
|
||||
}
|
||||
trailing={
|
||||
<>
|
||||
{callerCanShare ? (
|
||||
<Select
|
||||
value={g.access_level}
|
||||
disabled={busy}
|
||||
onValueChange={(value) =>
|
||||
handleGrantAccess(g, value as AccessLevel)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-28 shrink-0"
|
||||
aria-label={t(
|
||||
'settings.teams.share.access',
|
||||
)}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(['viewer', 'editor'] as const).map(
|
||||
(level) => (
|
||||
<SelectItem key={level} value={level}>
|
||||
{accessLevelLabel(level)}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Badge variant="neutral" className="shrink-0">
|
||||
{accessLevelLabel(g.access_level)}
|
||||
</Badge>
|
||||
)}
|
||||
{(callerCanShare || isAdmin) && (
|
||||
<IconButton
|
||||
variant="ghost-destructive"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
disabled={busy}
|
||||
label={t(
|
||||
'settings.teams.drawer.removeGrant',
|
||||
)}
|
||||
icon={Trash2}
|
||||
onClick={() => handleUnshare(g)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ListRows>
|
||||
</Card>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.teams.drawer.otherTeamsHint')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<SectionHeader
|
||||
as="h3"
|
||||
size="xs"
|
||||
title={t('settings.teams.drawer.whatPeopleCanDo')}
|
||||
/>
|
||||
<ul className="flex flex-col gap-2 text-sm">
|
||||
{capabilityLines(
|
||||
t,
|
||||
openResource.type,
|
||||
resolveSettings(
|
||||
openResource.type,
|
||||
drawerSettings?.settings,
|
||||
),
|
||||
).map((line) => (
|
||||
<li key={line.key} className="flex items-center gap-2">
|
||||
{line.allowed ? (
|
||||
<Check
|
||||
className="text-success size-4 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<X
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
line.allowed ? undefined : 'text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{line.text}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.teams.drawer.capabilitiesHint')}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
)}
|
||||
</Sheet>
|
||||
|
||||
{shareTarget && (
|
||||
<ShareToTeamModal
|
||||
resourceType={shareTarget.type}
|
||||
resourceId={shareTarget.id}
|
||||
resourceName={resourceName(shareTarget)}
|
||||
onClose={() => {
|
||||
setShareTarget(null);
|
||||
// Back to the drawer, with the grants the dialog may have changed.
|
||||
setDrawerOpen(true);
|
||||
refreshGrants();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={createOpen}
|
||||
onOpenChange={(open) =>
|
||||
@@ -923,6 +1508,7 @@ export default function Teams() {
|
||||
|
||||
<ConfirmationModal
|
||||
message={t('settings.teams.deleteTeamConfirmation', {
|
||||
interpolation: { escapeValue: false },
|
||||
name: teamToDelete?.name ?? '',
|
||||
})}
|
||||
modalState={deleteTeamModalState}
|
||||
|
||||
@@ -393,4 +393,144 @@ describe('ToolConfig', () => {
|
||||
expect(cancel?.dataset.size).toBe('sm');
|
||||
expect(cancel?.dataset.shape).toBe('pill');
|
||||
});
|
||||
|
||||
describe('access', () => {
|
||||
const viewer = { access: 'viewer', allowed_actions: ['use'] };
|
||||
const editorNoCreds = {
|
||||
access: 'editor',
|
||||
allowed_actions: ['edit', 'use', 'use_in_own'],
|
||||
};
|
||||
const configTool = {
|
||||
...userTool,
|
||||
configRequirements: {
|
||||
token: { type: 'string', label: 'Token', secret: true, required: true },
|
||||
},
|
||||
config: { has_encrypted_credentials: true },
|
||||
} as unknown as UserToolType;
|
||||
// A disabled fieldset disables its controls in the browser; jsdom doesn't
|
||||
// apply that to `:disabled`, so check for the fieldset as well.
|
||||
const disabled = (el: Element) =>
|
||||
el.matches(':disabled') || el.closest('fieldset[disabled]') !== null;
|
||||
const nameInput = () =>
|
||||
container.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="settings.tools.customNamePlaceholder"]',
|
||||
)!;
|
||||
const expandFirstAction = async () => {
|
||||
await act(async () => {
|
||||
(
|
||||
container.querySelector('[class*="cursor-pointer"]') as HTMLElement
|
||||
).click();
|
||||
});
|
||||
};
|
||||
|
||||
it('opens read-only without edit or edit_credentials: no Save, every field disabled', async () => {
|
||||
await render({ ...configTool, ...viewer } as UserToolType);
|
||||
expect(buttonByText('settings.tools.save')).toBeUndefined();
|
||||
expect(nameInput().disabled).toBe(true);
|
||||
const secret = Array.from(
|
||||
container.querySelectorAll<HTMLInputElement>('input'),
|
||||
).find((i) => i.placeholder === '••••••••');
|
||||
expect(secret && disabled(secret)).toBe(true);
|
||||
container
|
||||
.querySelectorAll<HTMLButtonElement>('[role="switch"]')
|
||||
.forEach((sw) => expect(disabled(sw)).toBe(true));
|
||||
await expandFirstAction();
|
||||
container
|
||||
.querySelectorAll<HTMLInputElement>('table input')
|
||||
.forEach((input) => expect(disabled(input)).toBe(true));
|
||||
});
|
||||
|
||||
it('keeps the actions search usable when read-only', async () => {
|
||||
await render({ ...userTool, ...viewer } as UserToolType);
|
||||
const label = Array.from(container.querySelectorAll('label')).find(
|
||||
(el) => el.textContent === 'settings.tools.searchActions',
|
||||
)!;
|
||||
expect(
|
||||
container.querySelector<HTMLInputElement>(
|
||||
`input[id="${label.htmlFor}"]`,
|
||||
)?.disabled,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('hides the API tool Import and Add action buttons when read-only', async () => {
|
||||
await render({ ...apiTool, ...viewer } as APIToolType);
|
||||
expect(buttonByText('settings.tools.importSpec')).toBeUndefined();
|
||||
expect(buttonByText('settings.tools.addAction')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lets an editor without edit_credentials rename but not touch credentials', async () => {
|
||||
await render({ ...configTool, ...editorNoCreds } as UserToolType);
|
||||
expect(nameInput().disabled).toBe(false);
|
||||
const authInputs = Array.from(
|
||||
container.querySelectorAll<HTMLInputElement>('input'),
|
||||
).filter((i) => i !== nameInput() && !i.closest('table'));
|
||||
const credential = authInputs.find((i) => i.placeholder === '••••••••');
|
||||
expect(credential && disabled(credential)).toBe(true);
|
||||
});
|
||||
|
||||
it("disables an API tool's URL and header values without edit_credentials", async () => {
|
||||
await render({ ...apiTool, ...editorNoCreds } as APIToolType);
|
||||
await expandFirstAction();
|
||||
const url = Array.from(
|
||||
container.querySelectorAll<HTMLInputElement>('input'),
|
||||
).find((i) => i.value === 'https://example.com');
|
||||
expect(url?.disabled).toBe(true);
|
||||
const headerValue = container.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="settings.tools.headerValuePlaceholder"]',
|
||||
);
|
||||
expect(headerValue?.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('masks a saved API header value with a replace-to-change placeholder', async () => {
|
||||
const saved = JSON.parse(JSON.stringify(apiTool)) as APIToolType;
|
||||
saved.config.actions.list.headers.properties.Accept.has_value = true;
|
||||
await render(saved);
|
||||
await act(async () => {
|
||||
(
|
||||
container.querySelector('[class*="cursor-pointer"]') as HTMLElement
|
||||
).click();
|
||||
});
|
||||
const masked = container.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="settings.tools.savedSecretPlaceholder"]',
|
||||
);
|
||||
expect(masked).not.toBeNull();
|
||||
expect(masked?.type).toBe('password');
|
||||
expect(masked?.value).toBe('');
|
||||
});
|
||||
|
||||
it('shows a non-2xx save response as a destructive Alert', async () => {
|
||||
updateTool.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: () => Promise.resolve({ success: false, message: 'Forbidden' }),
|
||||
});
|
||||
const goBack = vi.fn();
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ToolConfig
|
||||
tool={{ ...userTool, customName: '' }}
|
||||
setTool={() => {}}
|
||||
handleGoBack={goBack}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const name = container.querySelector<HTMLInputElement>(
|
||||
'input[placeholder="settings.tools.customNamePlaceholder"]',
|
||||
);
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
'value',
|
||||
)?.set?.call(name, 'Renamed');
|
||||
name?.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
await act(async () => {
|
||||
buttonByText('settings.tools.save')?.click();
|
||||
});
|
||||
expect(
|
||||
container.querySelector<HTMLElement>('[role="alert"]')?.textContent,
|
||||
).toBe('settings.tools.saveFailed');
|
||||
expect(goBack).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,7 @@ import ImportSpecModal from '../modals/ImportSpecModal';
|
||||
import { ActiveState } from '../models/misc';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import { getMethodBadgeVariant } from '../utils/httpMethodColors';
|
||||
import { can } from '../utils/accessUtils';
|
||||
import { areObjectsEqual } from '../utils/objectUtils';
|
||||
import { cn, focusRing } from '@/lib/utils';
|
||||
import { APIActionType, APIToolType, UserToolType } from './types';
|
||||
@@ -52,6 +53,16 @@ const BODY_TYPE_HINT_KEYS: Record<string, string> = {
|
||||
'application/octet-stream': 'octetStream',
|
||||
};
|
||||
|
||||
/**
|
||||
* What the caller may change on the open tool (`utils/accessUtils` `can`):
|
||||
* `canEdit` covers the name and the actions, `canEditCredentials` the
|
||||
* secrets, URLs and header / query values.
|
||||
*/
|
||||
const ToolAccessContext = React.createContext({
|
||||
canEdit: true,
|
||||
canEditCredentials: true,
|
||||
});
|
||||
|
||||
/** Maps a body content type to its hint's locale key suffix (JSON by default). */
|
||||
function bodyTypeHintKey(contentType?: string): string {
|
||||
return BODY_TYPE_HINT_KEYS[contentType || 'application/json'] ?? 'json';
|
||||
@@ -108,6 +119,14 @@ export default function ToolConfig({
|
||||
Set<number>
|
||||
>(new Set());
|
||||
const { t } = useTranslation();
|
||||
const canEdit = can(tool, 'edit');
|
||||
const canEditCredentials = can(tool, 'edit_credentials');
|
||||
// Neither: the tool opens as a read-only view with no Save.
|
||||
const readOnly = !canEdit && !canEditCredentials;
|
||||
const access = React.useMemo(
|
||||
() => ({ canEdit, canEditCredentials }),
|
||||
[canEdit, canEditCredentials],
|
||||
);
|
||||
|
||||
const toggleUserActionExpand = (index: number) => {
|
||||
setExpandedUserActions((prev) => {
|
||||
@@ -245,6 +264,24 @@ export default function ToolConfig({
|
||||
});
|
||||
};
|
||||
|
||||
/** Sends the edit; a non-2xx response throws so the caller shows it. */
|
||||
const saveTool = async (configToSave: { [key: string]: any }) => {
|
||||
const response = await userService.updateTool(
|
||||
{
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
displayName: tool.displayName,
|
||||
customName: customName,
|
||||
description: tool.description,
|
||||
config: configToSave,
|
||||
actions: 'actions' in tool ? tool.actions : [],
|
||||
status: tool.status,
|
||||
},
|
||||
token,
|
||||
);
|
||||
if (!response?.ok) throw new Error('Failed to save tool');
|
||||
};
|
||||
|
||||
const handleSaveChanges = async () => {
|
||||
if (!validateConfig()) return;
|
||||
const configToSave = buildConfigToSave();
|
||||
@@ -253,19 +290,7 @@ export default function ToolConfig({
|
||||
setSaveError('');
|
||||
|
||||
try {
|
||||
await userService.updateTool(
|
||||
{
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
displayName: tool.displayName,
|
||||
customName: customName,
|
||||
description: tool.description,
|
||||
config: configToSave,
|
||||
actions: 'actions' in tool ? tool.actions : [],
|
||||
status: tool.status,
|
||||
},
|
||||
token,
|
||||
);
|
||||
await saveTool(configToSave);
|
||||
setInitialState({
|
||||
customName,
|
||||
configValues: { ...configValues },
|
||||
@@ -357,16 +382,18 @@ export default function ToolConfig({
|
||||
currentLabel={tool.customName || tool.displayName || tool.name}
|
||||
onParentClick={handleBackClick}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={handleSaveChanges}
|
||||
disabled={!hasUnsavedChanges}
|
||||
loading={saving}
|
||||
>
|
||||
{t('settings.tools.save')}
|
||||
</Button>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
onClick={handleSaveChanges}
|
||||
disabled={!hasUnsavedChanges}
|
||||
loading={saving}
|
||||
>
|
||||
{t('settings.tools.save')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{saveError && (
|
||||
<Alert variant="destructive" className="mb-2">
|
||||
@@ -383,6 +410,7 @@ export default function ToolConfig({
|
||||
value={customName}
|
||||
onChange={(e) => setCustomName(e.target.value)}
|
||||
placeholder={t('settings.tools.customNamePlaceholder')}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="mt-1">
|
||||
@@ -394,7 +422,10 @@ export default function ToolConfig({
|
||||
size="xs"
|
||||
title={t('settings.tools.authentication')}
|
||||
/>
|
||||
<div className="max-w-96">
|
||||
<fieldset
|
||||
disabled={!canEditCredentials}
|
||||
className="max-w-96 min-w-0"
|
||||
>
|
||||
<ConfigFields
|
||||
labelSurface="background"
|
||||
configRequirements={configRequirements}
|
||||
@@ -406,7 +437,7 @@ export default function ToolConfig({
|
||||
!!(tool as any).config?.has_encrypted_credentials
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -415,7 +446,7 @@ export default function ToolConfig({
|
||||
<SectionHeader
|
||||
title={t('settings.tools.actions')}
|
||||
actions={
|
||||
tool.name === 'api_tool' ? (
|
||||
tool.name === 'api_tool' && canEdit ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -441,7 +472,9 @@ export default function ToolConfig({
|
||||
<>
|
||||
{tool.config.actions &&
|
||||
Object.keys(tool.config.actions).length > 0 ? (
|
||||
<APIToolConfig tool={tool as APIToolType} setTool={setTool} />
|
||||
<ToolAccessContext.Provider value={access}>
|
||||
<APIToolConfig tool={tool as APIToolType} setTool={setTool} />
|
||||
</ToolAccessContext.Provider>
|
||||
) : (
|
||||
<EmptyState
|
||||
size="sm"
|
||||
@@ -470,9 +503,10 @@ export default function ToolConfig({
|
||||
{filteredUserActions.map(({ action, originalIndex }) => {
|
||||
const isExpanded = expandedUserActions.has(originalIndex);
|
||||
return (
|
||||
<div
|
||||
<fieldset
|
||||
key={originalIndex}
|
||||
className="border-border w-full rounded-xl border"
|
||||
disabled={!canEdit}
|
||||
className="border-border w-full min-w-0 rounded-xl border"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -730,7 +764,7 @@ export default function ToolConfig({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
@@ -768,19 +802,7 @@ export default function ToolConfig({
|
||||
setSaveError('');
|
||||
|
||||
try {
|
||||
await userService.updateTool(
|
||||
{
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
displayName: tool.displayName,
|
||||
customName: customName,
|
||||
description: tool.description,
|
||||
config: configToSave,
|
||||
actions: 'actions' in tool ? tool.actions : [],
|
||||
status: tool.status,
|
||||
},
|
||||
token,
|
||||
);
|
||||
await saveTool(configToSave);
|
||||
setShowUnsavedModal(false);
|
||||
handleGoBack();
|
||||
} catch {
|
||||
@@ -811,6 +833,7 @@ function APIToolConfig({
|
||||
}) {
|
||||
const [apiTool, setApiTool] = React.useState<APIToolType>(tool);
|
||||
const { t } = useTranslation();
|
||||
const { canEdit, canEditCredentials } = React.useContext(ToolAccessContext);
|
||||
const [actionToDelete, setActionToDelete] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
@@ -925,9 +948,10 @@ function APIToolConfig({
|
||||
{filteredActions.map(([actionName, action], actionIndex) => {
|
||||
const isExpanded = expandedActions.has(actionName);
|
||||
return (
|
||||
<div
|
||||
<fieldset
|
||||
key={actionIndex}
|
||||
className="border-border w-full rounded-xl border"
|
||||
disabled={!canEdit}
|
||||
className="border-border w-full min-w-0 rounded-xl border"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -1024,6 +1048,7 @@ function APIToolConfig({
|
||||
<Input
|
||||
type="text"
|
||||
value={action.url}
|
||||
disabled={!canEditCredentials}
|
||||
onChange={(e) => {
|
||||
setApiTool((prevApiTool) => {
|
||||
const updatedActions = {
|
||||
@@ -1213,7 +1238,7 @@ function APIToolConfig({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -1249,6 +1274,7 @@ function APIActionTable({
|
||||
) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { canEditCredentials } = React.useContext(ToolAccessContext);
|
||||
const idPrefix = React.useId();
|
||||
|
||||
const [action, setAction] = React.useState<APIActionType>(apiAction);
|
||||
@@ -1541,10 +1567,18 @@ function APIActionTable({
|
||||
<TableCell>
|
||||
<Input
|
||||
value={param.value}
|
||||
disabled={param.filled_by_llm}
|
||||
disabled={
|
||||
param.filled_by_llm ||
|
||||
(section === 'query_params' && !canEditCredentials)
|
||||
}
|
||||
onChange={(e) =>
|
||||
handlePropertyChange(section, key, 'value', e.target.value)
|
||||
}
|
||||
{...(section === 'query_params' &&
|
||||
param.has_value && {
|
||||
type: 'password',
|
||||
placeholder: t('settings.tools.savedSecretPlaceholder'),
|
||||
})}
|
||||
size="sm"
|
||||
/>
|
||||
</TableCell>
|
||||
@@ -1697,7 +1731,14 @@ function APIActionTable({
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder={t('settings.tools.headerValuePlaceholder')}
|
||||
// A saved value never comes back: empty keeps it.
|
||||
type={param.has_value ? 'password' : 'text'}
|
||||
placeholder={
|
||||
param.has_value
|
||||
? t('settings.tools.savedSecretPlaceholder')
|
||||
: t('settings.tools.headerValuePlaceholder')
|
||||
}
|
||||
disabled={!canEditCredentials}
|
||||
size="sm"
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { act, useState } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
const dispatch = vi.fn();
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: () => 'token',
|
||||
useDispatch: () => dispatch,
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) => {
|
||||
const { interpolation: _i, ...rest } = opts ?? {};
|
||||
void _i;
|
||||
return Object.keys(rest).length ? `${key}:${JSON.stringify(rest)}` : key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../hooks')>()),
|
||||
useLoaderState: (initial: boolean) => useState(initial),
|
||||
}));
|
||||
|
||||
// The menu is a Radix dropdown; the tests only need its options.
|
||||
vi.mock('../components/ui/dropdown-menu', () => ({
|
||||
ActionMenu: ({
|
||||
options,
|
||||
}: {
|
||||
options: { label: string; onClick: () => void }[];
|
||||
}) => (
|
||||
<div data-testid="menu">
|
||||
{options.map((o) => (
|
||||
<button key={o.label} type="button" onClick={o.onClick}>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const toolConfigProps = vi.fn();
|
||||
vi.mock('./ToolConfig', () => ({
|
||||
default: (props: unknown) => {
|
||||
toolConfigProps(props);
|
||||
return <div data-testid="tool-config" />;
|
||||
},
|
||||
}));
|
||||
vi.mock('./RemoteDeviceConfig', () => ({ default: () => null }));
|
||||
vi.mock('../modals/AddToolModal', () => ({ default: () => null }));
|
||||
vi.mock('../modals/ConfirmationModal', () => ({ default: () => null }));
|
||||
const mcpModalProps = vi.fn();
|
||||
vi.mock('../modals/MCPServerModal', () => ({
|
||||
default: (props: unknown) => {
|
||||
mcpModalProps(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
vi.mock('../teams/ShareToTeamModal', () => ({ default: () => null }));
|
||||
vi.mock('../api/services/devicesService', () => ({ default: {} }));
|
||||
|
||||
const getUserTools = vi.fn();
|
||||
const updateToolStatus = vi.fn();
|
||||
vi.mock('../api/services/userService', () => ({
|
||||
default: {
|
||||
getUserTools: (...args: unknown[]) => getUserTools(...args),
|
||||
getMCPAuthStatus: () =>
|
||||
Promise.resolve({ json: () => Promise.resolve({ success: false }) }),
|
||||
updateToolStatus: (...args: unknown[]) => updateToolStatus(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
import Tools from './Tools';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const baseTool = {
|
||||
name: 'mcp_tool',
|
||||
displayName: 'Carrier Rates MCP',
|
||||
description: 'Live rates',
|
||||
config: { server_url: 'https://mcp.example.com/sse', auth_type: 'api_key' },
|
||||
actions: [],
|
||||
};
|
||||
|
||||
const ownTool = {
|
||||
...baseTool,
|
||||
id: 'own',
|
||||
displayName: 'own',
|
||||
status: true,
|
||||
in_chat: true,
|
||||
access: 'owner',
|
||||
allowed_actions: [
|
||||
'delete',
|
||||
'edit',
|
||||
'edit_credentials',
|
||||
'manage_settings',
|
||||
'share',
|
||||
'use',
|
||||
'use_in_own',
|
||||
],
|
||||
};
|
||||
const editorTool = {
|
||||
...baseTool,
|
||||
id: 'ed',
|
||||
displayName: 'ed',
|
||||
status: true,
|
||||
in_chat: false,
|
||||
ownership: 'team',
|
||||
team_access: 'editor',
|
||||
access: 'editor',
|
||||
allowed_actions: ['edit', 'edit_credentials', 'use', 'use_in_own'],
|
||||
shared_via: 'Logistics',
|
||||
owner_label: 'lena@example.com',
|
||||
};
|
||||
const viewerTool = {
|
||||
...baseTool,
|
||||
id: 'vw',
|
||||
displayName: 'vw',
|
||||
status: true,
|
||||
in_chat: false,
|
||||
ownership: 'team',
|
||||
team_access: 'viewer',
|
||||
access: 'viewer',
|
||||
allowed_actions: ['use'],
|
||||
};
|
||||
|
||||
const jsonResponse = (body: unknown, ok = true, status = 200) =>
|
||||
Promise.resolve({ ok, status, json: () => Promise.resolve(body) });
|
||||
|
||||
describe('Tools', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
dispatch.mockReset();
|
||||
getUserTools.mockReset();
|
||||
updateToolStatus.mockReset();
|
||||
toolConfigProps.mockReset();
|
||||
mcpModalProps.mockReset();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = async (tools: unknown[]) => {
|
||||
getUserTools.mockImplementation(() => jsonResponse({ tools }));
|
||||
await act(async () => {
|
||||
root.render(<Tools />);
|
||||
});
|
||||
};
|
||||
|
||||
const card = (name: string) =>
|
||||
Array.from(container.querySelectorAll<HTMLElement>('[data-slot="card"]'))
|
||||
.filter((c) => c.querySelector('[data-testid="menu"]'))
|
||||
.find((c) => c.querySelector('h2')?.textContent === name)!;
|
||||
const menuLabels = (id: string) =>
|
||||
Array.from(card(id).querySelectorAll('[data-testid="menu"] button')).map(
|
||||
(b) => b.textContent,
|
||||
);
|
||||
const switchOf = (id: string) =>
|
||||
card(id).querySelector<HTMLButtonElement>('[role="switch"]')!;
|
||||
|
||||
it('shows Edit, Reconnect, Share and Delete to the owner', async () => {
|
||||
await render([ownTool]);
|
||||
expect(menuLabels('own')).toEqual([
|
||||
'settings.tools.edit',
|
||||
'settings.tools.reconnect',
|
||||
'settings.tools.shareWithTeam',
|
||||
'settings.tools.delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows Edit and Reconnect to an editor', async () => {
|
||||
await render([editorTool]);
|
||||
expect(menuLabels('ed')).toEqual([
|
||||
'settings.tools.edit',
|
||||
'settings.tools.reconnect',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows only View to a viewer, which opens the config read-only', async () => {
|
||||
await render([viewerTool]);
|
||||
expect(menuLabels('vw')).toEqual(['settings.tools.view']);
|
||||
await act(async () => {
|
||||
(
|
||||
card('vw').querySelector('[data-testid="menu"] button') as HTMLElement
|
||||
).click();
|
||||
});
|
||||
expect(container.querySelector('[data-testid="tool-config"]')).not.toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the tool owner and role to the Reconnect modal', async () => {
|
||||
await render([editorTool]);
|
||||
const reconnect = Array.from(
|
||||
card('ed').querySelectorAll<HTMLButtonElement>(
|
||||
'[data-testid="menu"] button',
|
||||
),
|
||||
).find((b) => b.textContent === 'settings.tools.reconnect')!;
|
||||
await act(async () => reconnect.click());
|
||||
const last = mcpModalProps.mock.calls.at(-1)![0] as {
|
||||
server: Record<string, unknown>;
|
||||
};
|
||||
expect(last.server).toMatchObject({
|
||||
id: 'ed',
|
||||
displayName: 'ed',
|
||||
access: 'editor',
|
||||
owner_label: 'lena@example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('labels the switch "In my chats" and binds it to in_chat', async () => {
|
||||
await render([ownTool, editorTool]);
|
||||
const sw = switchOf('ed');
|
||||
expect(sw.getAttribute('aria-checked')).toBe('false');
|
||||
expect(switchOf('own').getAttribute('aria-checked')).toBe('true');
|
||||
const label = card('ed').querySelector<HTMLLabelElement>(
|
||||
`label[for="${sw.id}"]`,
|
||||
);
|
||||
expect(label?.textContent).toBe('settings.tools.inMyChats');
|
||||
expect(sw.getAttribute('aria-label')).toBe(
|
||||
'settings.tools.useInMyChatsAria:{"toolName":"ed"}',
|
||||
);
|
||||
});
|
||||
|
||||
it('disables the switch, keeping its label, for a shared tool without use_in_own', async () => {
|
||||
await render([viewerTool]);
|
||||
const sw = switchOf('vw');
|
||||
expect(sw.disabled).toBe(true);
|
||||
expect(card('vw').querySelector(`label[for="${sw.id}"]`)?.textContent).toBe(
|
||||
'settings.tools.inMyChats',
|
||||
);
|
||||
});
|
||||
|
||||
it('reverts the switch and shows an error toast when the update fails', async () => {
|
||||
await render([editorTool]);
|
||||
updateToolStatus.mockImplementation(() =>
|
||||
jsonResponse({ success: false, message: 'Forbidden' }, false, 403),
|
||||
);
|
||||
await act(async () => switchOf('ed').click());
|
||||
expect(updateToolStatus).toHaveBeenCalledWith(
|
||||
{ id: 'ed', status: true },
|
||||
'token',
|
||||
);
|
||||
expect(switchOf('ed').getAttribute('aria-checked')).toBe('false');
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({ variant: 'destructive' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the new value when the update succeeds', async () => {
|
||||
await render([editorTool]);
|
||||
updateToolStatus.mockImplementation(() => jsonResponse({ success: true }));
|
||||
await act(async () => switchOf('ed').click());
|
||||
expect(switchOf('ed').getAttribute('aria-checked')).toBe('true');
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Pencil, RefreshCw, Trash2, Users } from 'lucide-react';
|
||||
import { Eye, Pencil, RefreshCw, Trash2, Users } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import devicesService from '../api/services/devicesService';
|
||||
import userService from '../api/services/userService';
|
||||
@@ -12,6 +12,7 @@ import ToolIcon from '../components/ToolIcon';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Card, CardDescription, CardTitle } from '../components/ui/card';
|
||||
import { Label } from '../components/ui/label';
|
||||
import { Switch } from '../components/ui/switch';
|
||||
import { ActionMenu, type MenuOption } from '../components/ui/dropdown-menu';
|
||||
import { EmptyState } from '../components/ui/empty-state';
|
||||
@@ -20,8 +21,11 @@ import AddToolModal from '../modals/AddToolModal';
|
||||
import ConfirmationModal from '../modals/ConfirmationModal';
|
||||
import MCPServerModal from '../modals/MCPServerModal';
|
||||
import { ActiveState } from '../models/misc';
|
||||
import { showActionToast } from '../notifications/actionToastSlice';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import ShareToTeamModal from '../teams/ShareToTeamModal';
|
||||
import { can, isOwner } from '../utils/accessUtils';
|
||||
import { canAddToolToOwn, toolInChat } from '../utils/toolUtils';
|
||||
import RemoteDeviceConfig from './RemoteDeviceConfig';
|
||||
import ToolConfig from './ToolConfig';
|
||||
import { APIToolType, UserToolType } from './types';
|
||||
@@ -29,6 +33,7 @@ import { APIToolType, UserToolType } from './types';
|
||||
export default function Tools() {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [searchTerm, setSearchTerm] = React.useState('');
|
||||
const [addToolModalState, setAddToolModalState] =
|
||||
@@ -81,7 +86,21 @@ export default function Tools() {
|
||||
.catch((error) => console.error('Failed to revoke device:', error));
|
||||
return;
|
||||
}
|
||||
userService.deleteTool({ id: toolToDelete.id }, token).then(afterDelete);
|
||||
userService
|
||||
.deleteTool({ id: toolToDelete.id }, token)
|
||||
.then((response: Response) => {
|
||||
if (response.ok) return afterDelete();
|
||||
setDeleteModalState('INACTIVE');
|
||||
dispatch(
|
||||
showActionToast({
|
||||
variant: 'destructive',
|
||||
message: t('settings.tools.deleteFailed'),
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) =>
|
||||
console.error('Failed to delete tool:', error),
|
||||
);
|
||||
};
|
||||
|
||||
const handleReconnect = (tool: UserToolType) => {
|
||||
@@ -97,41 +116,51 @@ export default function Tools() {
|
||||
timeout: config.timeout || 30,
|
||||
oauth_scopes: oauthScopes,
|
||||
has_encrypted_credentials: !!config.has_encrypted_credentials,
|
||||
access: tool.access ?? (isOwner(tool) ? 'owner' : tool.team_access),
|
||||
owner_label: tool.owner_label ?? null,
|
||||
});
|
||||
setReconnectModalState('ACTIVE');
|
||||
};
|
||||
|
||||
const getMenuOptions = (tool: UserToolType): MenuOption[] => {
|
||||
const canEdit = can(tool, 'edit') || can(tool, 'edit_credentials');
|
||||
const options: MenuOption[] = [
|
||||
{
|
||||
icon: Pencil,
|
||||
label: t('settings.tools.edit'),
|
||||
onClick: () => handleSettingsClick(tool),
|
||||
variant: 'default',
|
||||
},
|
||||
{
|
||||
icon: Trash2,
|
||||
label: t('settings.tools.delete'),
|
||||
onClick: () => handleDeleteTool(tool),
|
||||
variant: 'destructive',
|
||||
},
|
||||
canEdit
|
||||
? {
|
||||
icon: Pencil,
|
||||
label: t('settings.tools.edit'),
|
||||
onClick: () => handleSettingsClick(tool),
|
||||
variant: 'default',
|
||||
}
|
||||
: {
|
||||
icon: Eye,
|
||||
label: t('settings.tools.view'),
|
||||
onClick: () => handleSettingsClick(tool),
|
||||
variant: 'default',
|
||||
},
|
||||
];
|
||||
// Sharing is an owner-only action: hide it for tools shared into the
|
||||
// user's workspace by a team.
|
||||
if (tool.ownership !== 'team') {
|
||||
options.splice(options.length - 1, 0, {
|
||||
if (tool.name === 'mcp_tool' && can(tool, 'edit_credentials')) {
|
||||
options.push({
|
||||
icon: RefreshCw,
|
||||
label: t('settings.tools.reconnect'),
|
||||
onClick: () => handleReconnect(tool),
|
||||
variant: 'default',
|
||||
});
|
||||
}
|
||||
if (can(tool, 'share')) {
|
||||
options.push({
|
||||
icon: Users,
|
||||
label: t('settings.tools.shareWithTeam'),
|
||||
onClick: () => setToolToShare(tool),
|
||||
variant: 'default',
|
||||
});
|
||||
}
|
||||
if (tool.name === 'mcp_tool') {
|
||||
options.splice(1, 0, {
|
||||
icon: RefreshCw,
|
||||
label: t('settings.tools.reconnect'),
|
||||
onClick: () => handleReconnect(tool),
|
||||
variant: 'default',
|
||||
if (can(tool, 'delete')) {
|
||||
options.push({
|
||||
icon: Trash2,
|
||||
label: t('settings.tools.delete'),
|
||||
onClick: () => handleDeleteTool(tool),
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
return options;
|
||||
@@ -174,18 +203,37 @@ export default function Tools() {
|
||||
});
|
||||
};
|
||||
|
||||
const setToolInChat = (toolId: string, value: boolean) =>
|
||||
setUserTools((prevTools) =>
|
||||
prevTools.map((tool) =>
|
||||
tool.id !== toolId
|
||||
? tool
|
||||
: isOwner(tool)
|
||||
? { ...tool, status: value, in_chat: value }
|
||||
: { ...tool, in_chat: value },
|
||||
),
|
||||
);
|
||||
|
||||
// The switch moves at once and flips back when the server refuses it.
|
||||
const updateToolStatus = (toolId: string, newStatus: boolean) => {
|
||||
setToolInChat(toolId, newStatus);
|
||||
const fail = () => {
|
||||
setToolInChat(toolId, !newStatus);
|
||||
dispatch(
|
||||
showActionToast({
|
||||
variant: 'destructive',
|
||||
message: t('settings.tools.statusUpdateFailed'),
|
||||
}),
|
||||
);
|
||||
};
|
||||
userService
|
||||
.updateToolStatus({ id: toolId, status: newStatus }, token)
|
||||
.then(() => {
|
||||
setUserTools((prevTools) =>
|
||||
prevTools.map((tool) =>
|
||||
tool.id === toolId ? { ...tool, status: newStatus } : tool,
|
||||
),
|
||||
);
|
||||
.then((response: Response) => {
|
||||
if (!response.ok) fail();
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch((error: unknown) => {
|
||||
console.error('Failed to update tool status:', error);
|
||||
fail();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -317,6 +365,7 @@ export default function Tools() {
|
||||
<ToolIcon
|
||||
name={tool.name}
|
||||
title={t('settings.tools.toolIconTitle', {
|
||||
interpolation: { escapeValue: false },
|
||||
name: tool.displayName,
|
||||
})}
|
||||
className="size-6"
|
||||
@@ -372,14 +421,22 @@ export default function Tools() {
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute right-4 bottom-4">
|
||||
<div className="absolute right-4 bottom-4 flex items-center gap-2">
|
||||
<Label
|
||||
htmlFor={`toolToggle-${index}`}
|
||||
className="text-muted-foreground text-xs font-normal"
|
||||
>
|
||||
{t('settings.tools.inMyChats')}
|
||||
</Label>
|
||||
<Switch
|
||||
checked={tool.status}
|
||||
checked={toolInChat(tool)}
|
||||
onCheckedChange={(checked) =>
|
||||
updateToolStatus(tool.id, checked)
|
||||
}
|
||||
disabled={!canAddToolToOwn(tool)}
|
||||
id={`toolToggle-${index}`}
|
||||
aria-label={t('settings.tools.toggleToolAria', {
|
||||
aria-label={t('settings.tools.useInMyChatsAria', {
|
||||
interpolation: { escapeValue: false },
|
||||
toolName: tool.customName || tool.displayName,
|
||||
})}
|
||||
/>
|
||||
@@ -401,6 +458,7 @@ export default function Tools() {
|
||||
/>
|
||||
<ConfirmationModal
|
||||
message={t('settings.tools.deleteWarning', {
|
||||
interpolation: { escapeValue: false },
|
||||
toolName:
|
||||
toolToDelete?.customName || toolToDelete?.displayName || '',
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
|
||||
const { dispatch, service, state } = vi.hoisted(() => ({
|
||||
dispatch: vi.fn(),
|
||||
service: { deletePath: vi.fn() },
|
||||
state: {
|
||||
preference: {
|
||||
token: null,
|
||||
sourceDocs: [
|
||||
{ id: 'a', name: 'A' },
|
||||
{ id: 'b', name: 'B' },
|
||||
],
|
||||
paginatedDocuments: [{ id: 'b', name: 'B' }],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useDispatch: () => dispatch,
|
||||
useSelector: (selector: (s: unknown) => unknown) => selector(state),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
useMediaQuery: () => ({ isMobile: false, isDesktop: true }),
|
||||
}));
|
||||
|
||||
vi.mock('../api/services/userService', () => ({ default: service }));
|
||||
vi.mock('../navigation/SectionShell', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
vi.mock('../navigation/SectionIndexPage', () => ({ default: () => null }));
|
||||
vi.mock('./Analytics', () => ({ default: () => null }));
|
||||
vi.mock('./CustomModels', () => ({ default: () => null }));
|
||||
vi.mock('./General', () => ({ default: () => null }));
|
||||
vi.mock('./Logs', () => ({ default: () => null }));
|
||||
vi.mock('./PersonalAccessTokens', () => ({ default: () => null }));
|
||||
vi.mock('./Tools', () => ({ default: () => null }));
|
||||
// Sources: one button that deletes the only listed source.
|
||||
vi.mock('./Sources', () => ({
|
||||
default: ({
|
||||
paginatedDocuments,
|
||||
handleDeleteDocument,
|
||||
}: {
|
||||
paginatedDocuments: { id: string; name: string }[];
|
||||
handleDeleteDocument: (index: number, doc: unknown) => void;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteDocument(0, paginatedDocuments[0])}
|
||||
>
|
||||
DELETE
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import Settings from './index';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
describe('Settings source delete', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
dispatch.mockReset();
|
||||
service.deletePath.mockReset();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const clickDelete = async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={['/settings/sources']}>
|
||||
<Routes>
|
||||
<Route path="/settings/*" element={<Settings />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await act(async () => container.querySelector('button')!.click());
|
||||
};
|
||||
|
||||
const actionTypes = () => dispatch.mock.calls.map(([a]) => a?.type);
|
||||
|
||||
it('a forbidden delete shows an error toast and keeps the source', async () => {
|
||||
service.deletePath.mockResolvedValue({ ok: false, status: 403 });
|
||||
await clickDelete();
|
||||
expect(service.deletePath).toHaveBeenCalledWith('b', null);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'actionToast/showActionToast',
|
||||
payload: expect.objectContaining({
|
||||
variant: 'destructive',
|
||||
message: 'settings.sources.errors.forbidden',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(actionTypes()).not.toContain('preference/setSourceDocs');
|
||||
});
|
||||
|
||||
it('a failed request shows an error toast', async () => {
|
||||
service.deletePath.mockRejectedValue(new Error('network'));
|
||||
await clickDelete();
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({
|
||||
message: 'settings.sources.errors.delete',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('a successful delete drops the source by id from both lists', async () => {
|
||||
service.deletePath.mockResolvedValue({ ok: true, status: 200 });
|
||||
await clickDelete();
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'preference/setPaginatedDocuments',
|
||||
payload: [],
|
||||
});
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: 'preference/setSourceDocs',
|
||||
payload: [{ id: 'a', name: 'A' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
|
||||
|
||||
@@ -6,6 +7,7 @@ import { useMediaQuery } from '../hooks';
|
||||
import { Doc } from '../models/misc';
|
||||
import SectionIndexPage from '../navigation/SectionIndexPage';
|
||||
import SectionShell from '../navigation/SectionShell';
|
||||
import { showActionToast } from '../notifications/actionToastSlice';
|
||||
import { SETTINGS_SECTION } from '../navigation/sections';
|
||||
import {
|
||||
selectPaginatedDocuments,
|
||||
@@ -29,6 +31,7 @@ import Tools from './Tools';
|
||||
* `/settings` shows the destination list as page content instead.
|
||||
*/
|
||||
export default function Settings() {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const location = useLocation();
|
||||
const { isMobile } = useMediaQuery();
|
||||
@@ -39,27 +42,36 @@ export default function Settings() {
|
||||
const documents = useSelector(selectSourceDocs);
|
||||
const paginatedDocuments = useSelector(selectPaginatedDocuments);
|
||||
|
||||
const updateDocumentsList = (documents: Doc[], index: number) => [
|
||||
...documents.slice(0, index),
|
||||
...documents.slice(index + 1),
|
||||
];
|
||||
const showDeleteError = (message: string) =>
|
||||
dispatch(showActionToast({ variant: 'destructive', message }));
|
||||
|
||||
const handleDeleteClick = (index: number, doc: Doc) => {
|
||||
/**
|
||||
* Deletes a source and drops it from both lists by id. A refused or failed
|
||||
* delete (403 for a role without `delete`) shows a destructive toast and
|
||||
* leaves the lists alone.
|
||||
*/
|
||||
const handleDeleteClick = (_index: number, doc: Doc) => {
|
||||
const withoutDoc = (list: Doc[]) => list.filter((d) => d.id !== doc.id);
|
||||
userService
|
||||
.deletePath(doc.id ?? '', token)
|
||||
.then((response) => {
|
||||
if (response.ok && documents) {
|
||||
if (paginatedDocuments) {
|
||||
dispatch(
|
||||
setPaginatedDocuments(
|
||||
updateDocumentsList(paginatedDocuments, index),
|
||||
),
|
||||
);
|
||||
}
|
||||
dispatch(setSourceDocs(updateDocumentsList(documents, index)));
|
||||
.then((response: Response) => {
|
||||
if (!response.ok) {
|
||||
showDeleteError(
|
||||
response.status === 403
|
||||
? t('settings.sources.errors.forbidden')
|
||||
: t('settings.sources.errors.delete'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (paginatedDocuments) {
|
||||
dispatch(setPaginatedDocuments(withoutDoc(paginatedDocuments)));
|
||||
}
|
||||
if (documents) dispatch(setSourceDocs(withoutDoc(documents)));
|
||||
})
|
||||
.catch((error) => console.error(error));
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
showDeleteError(t('settings.sources.errors.delete'));
|
||||
});
|
||||
};
|
||||
|
||||
if (showIndex) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ConfigRequirements } from '../../modals/types';
|
||||
import type { Access } from '../../utils/accessUtils';
|
||||
|
||||
export type ChunkType = {
|
||||
doc_id: string;
|
||||
@@ -140,6 +141,9 @@ export type ParameterGroupType = {
|
||||
value: string | number;
|
||||
filled_by_llm: boolean;
|
||||
required?: boolean;
|
||||
// A saved secret header / query value: the server sends `value: ""`
|
||||
// and this flag; an empty value on save keeps the stored one.
|
||||
has_value?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -168,6 +172,16 @@ export type UserToolType = {
|
||||
// Access level when shared via a team: 'viewer' (use) or 'editor' (edit
|
||||
// actions; secrets stay owner-only). Null/absent for tools the caller owns.
|
||||
team_access?: 'viewer' | 'editor' | null;
|
||||
// Caller's role and what it may do (`utils/accessUtils` `can`).
|
||||
access?: Access | null;
|
||||
allowed_actions?: string[];
|
||||
// Whether the tool joins the caller's own agentless chats: the owner's
|
||||
// `status`, or a grantee's personal preference (default off).
|
||||
in_chat?: boolean;
|
||||
// A team through which a shared tool reaches the caller.
|
||||
shared_via?: string | null;
|
||||
// The owner's email or name, when the server can resolve it.
|
||||
owner_label?: string | null;
|
||||
config: {
|
||||
[key: string]: any;
|
||||
};
|
||||
@@ -228,4 +242,8 @@ export type APIToolType = {
|
||||
status: boolean;
|
||||
config: { actions: { [key: string]: APIActionType } };
|
||||
configRequirements?: ConfigRequirements;
|
||||
access?: Access | null;
|
||||
allowed_actions?: string[];
|
||||
ownership?: 'user' | 'team';
|
||||
team_access?: 'viewer' | 'editor' | null;
|
||||
};
|
||||
@@ -0,0 +1,324 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
const mockState = {
|
||||
preference: { token: 'tok' },
|
||||
teams: {
|
||||
teams: [
|
||||
{ id: 't1', name: 'Logistics', slug: 'logistics', owner_id: 'u1' },
|
||||
{ id: 't2', name: 'Sales Ops', slug: 'sales', owner_id: 'u1' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useSelector: (selector: (s: unknown) => unknown) => selector(mockState),
|
||||
useDispatch: () => () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) => {
|
||||
if (!opts) return key;
|
||||
const params = Object.entries(opts)
|
||||
.filter(([k]) => k !== 'defaultValue' && k !== 'interpolation')
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(',');
|
||||
return params ? `${key}(${params})` : key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const listResourceShares = vi.fn();
|
||||
const getResourceSettings = vi.fn();
|
||||
const updateResourceSettings = vi.fn();
|
||||
const listMembers = vi.fn();
|
||||
const share = vi.fn();
|
||||
const unshare = vi.fn();
|
||||
|
||||
vi.mock('../api/services/teamsService', () => ({
|
||||
default: {
|
||||
listResourceShares: (...a: unknown[]) => listResourceShares(...a),
|
||||
getResourceSettings: (...a: unknown[]) => getResourceSettings(...a),
|
||||
updateResourceSettings: (...a: unknown[]) => updateResourceSettings(...a),
|
||||
listMembers: (...a: unknown[]) => listMembers(...a),
|
||||
share: (...a: unknown[]) => share(...a),
|
||||
unshare: (...a: unknown[]) => unshare(...a),
|
||||
},
|
||||
}));
|
||||
|
||||
import ShareToTeamModal from './ShareToTeamModal';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const OWNER_ACTIONS = ['delete', 'edit', 'manage_settings', 'share', 'use'];
|
||||
const EDITOR_ACTIONS = ['edit', 'share', 'use'];
|
||||
|
||||
const settingsResponse = (
|
||||
values: Record<string, boolean> = {},
|
||||
access: 'owner' | 'editor' = 'owner',
|
||||
) => ({
|
||||
success: true,
|
||||
resource_type: 'agent',
|
||||
resource_id: 'a1',
|
||||
settings: [
|
||||
{ key: 'editors_can_share', default: false },
|
||||
{ key: 'editors_can_delete', default: false },
|
||||
{ key: 'editors_can_manage_access_details', default: true },
|
||||
{ key: 'viewers_can_see_logs', default: false },
|
||||
].map((s) => ({ ...s, value: values[s.key] ?? s.default })),
|
||||
access,
|
||||
allowed_actions: access === 'owner' ? OWNER_ACTIONS : EDITOR_ACTIONS,
|
||||
});
|
||||
|
||||
const apiError = (message: string) =>
|
||||
Object.assign(new Error(message), { name: 'TeamsApiError', status: 403 });
|
||||
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const body = () => document.body;
|
||||
const text = () => body().textContent ?? '';
|
||||
const buttonByText = (label: string) =>
|
||||
Array.from(body().querySelectorAll('button')).find((b) =>
|
||||
b.textContent?.trim().startsWith(label),
|
||||
);
|
||||
|
||||
describe('ShareToTeamModal', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
listResourceShares.mockReset().mockResolvedValue({ shares: [] });
|
||||
getResourceSettings.mockReset().mockResolvedValue(settingsResponse());
|
||||
updateResourceSettings.mockReset();
|
||||
listMembers.mockReset().mockResolvedValue({ members: [] });
|
||||
share.mockReset().mockResolvedValue({ success: true });
|
||||
unshare.mockReset().mockResolvedValue({ success: true });
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
const render = async () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<ShareToTeamModal
|
||||
resourceType="agent"
|
||||
resourceId="a1"
|
||||
resourceName="QBR Report Builder"
|
||||
onClose={() => undefined}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
await flush();
|
||||
};
|
||||
|
||||
describe('access settings', () => {
|
||||
it('shows a collapsed Access settings toggle to the owner', async () => {
|
||||
await render();
|
||||
const toggle = buttonByText('settings.teams.accessSettings.title');
|
||||
expect(toggle).toBeDefined();
|
||||
expect(toggle?.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(toggle?.getAttribute('data-variant')).toBe('section-toggle');
|
||||
expect(body().querySelectorAll('[role="switch"]')).toHaveLength(0);
|
||||
act(() => toggle!.click());
|
||||
expect(body().querySelectorAll('[role="switch"]')).toHaveLength(4);
|
||||
expect(text()).toContain(
|
||||
'settings.teams.accessSettings.agent.editors_can_share.label',
|
||||
);
|
||||
});
|
||||
|
||||
it('opens by default when a setting is off its default', async () => {
|
||||
getResourceSettings.mockResolvedValue(
|
||||
settingsResponse({ editors_can_share: true }),
|
||||
);
|
||||
await render();
|
||||
const toggle = buttonByText('settings.teams.accessSettings.title');
|
||||
expect(toggle?.getAttribute('aria-expanded')).toBe('true');
|
||||
expect(body().querySelectorAll('[role="switch"]')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('is hidden from an editor without manage_settings', async () => {
|
||||
getResourceSettings.mockResolvedValue(settingsResponse({}, 'editor'));
|
||||
await render();
|
||||
expect(buttonByText('settings.teams.accessSettings.title')).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('PUTs the new value when a switch is toggled', async () => {
|
||||
updateResourceSettings.mockResolvedValue(
|
||||
settingsResponse({ editors_can_share: true }),
|
||||
);
|
||||
await render();
|
||||
act(() => buttonByText('settings.teams.accessSettings.title')!.click());
|
||||
const sw = body().querySelector<HTMLButtonElement>(
|
||||
'#share-setting-editors_can_share',
|
||||
);
|
||||
act(() => sw!.click());
|
||||
await flush();
|
||||
expect(updateResourceSettings).toHaveBeenCalledWith(
|
||||
'agent',
|
||||
'a1',
|
||||
{ editors_can_share: true },
|
||||
'tok',
|
||||
);
|
||||
expect(sw!.getAttribute('aria-checked')).toBe('true');
|
||||
});
|
||||
|
||||
it('reverts the switch and shows an Alert when the PUT fails', async () => {
|
||||
updateResourceSettings.mockRejectedValue(apiError('Forbidden'));
|
||||
await render();
|
||||
act(() => buttonByText('settings.teams.accessSettings.title')!.click());
|
||||
const sw = body().querySelector<HTMLButtonElement>(
|
||||
'#share-setting-editors_can_share',
|
||||
);
|
||||
act(() => sw!.click());
|
||||
await flush();
|
||||
expect(sw!.getAttribute('aria-checked')).toBe('false');
|
||||
const alert = body().querySelector('[role="alert"]');
|
||||
expect(alert?.textContent).toContain('Forbidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('editor hint', () => {
|
||||
it('describes the default editor rights', async () => {
|
||||
await render();
|
||||
const hint = body().querySelector('[data-testid="share-editor-hint"]');
|
||||
expect(hint?.textContent).toContain('settings.teams.editorHint.agent');
|
||||
expect(hint?.textContent).toContain(
|
||||
'settings.teams.editorHint.noShareNoDelete',
|
||||
);
|
||||
expect(hint?.className).toContain('text-muted-foreground');
|
||||
expect(hint?.className).toContain('mt-1.5');
|
||||
expect(hint?.className).toContain('text-xs');
|
||||
});
|
||||
|
||||
it('says editors can also share when the switch is on', async () => {
|
||||
getResourceSettings.mockResolvedValue(
|
||||
settingsResponse({
|
||||
editors_can_share: true,
|
||||
editors_can_manage_access_details: false,
|
||||
}),
|
||||
);
|
||||
await render();
|
||||
const hint = body().querySelector('[data-testid="share-editor-hint"]');
|
||||
expect(hint?.textContent).toContain(
|
||||
'settings.teams.editorHint.agentNoAccessDetails',
|
||||
);
|
||||
expect(hint?.textContent).toContain(
|
||||
'settings.teams.editorHint.shareOnly',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('long list', () => {
|
||||
const manyShares = (n: number) =>
|
||||
Array.from({ length: n }, (_, i) => ({
|
||||
team_id: i % 2 ? 't1' : 't2',
|
||||
team_name: i % 2 ? 'Logistics' : 'Sales Ops',
|
||||
access_level: i < 2 ? 'editor' : 'viewer',
|
||||
target_user_id: i < 2 ? null : `user-${i}`,
|
||||
created_at: `2026-09-${String(10 + i).padStart(2, '0')}T00:00:00Z`,
|
||||
}));
|
||||
|
||||
it('shows every grant and no Show all link up to 5', async () => {
|
||||
listResourceShares.mockResolvedValue({ shares: manyShares(5) });
|
||||
await render();
|
||||
expect(buttonByText('settings.teams.share.showAll')).toBe(undefined);
|
||||
// You + 5 grants.
|
||||
expect(body().querySelectorAll('[data-slot="list-row"]')).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('shows You + 3 most recent and a Show all link above 5', async () => {
|
||||
listResourceShares.mockResolvedValue({ shares: manyShares(8) });
|
||||
await render();
|
||||
const rows = body().querySelectorAll('[data-slot="list-row"]');
|
||||
expect(rows).toHaveLength(4);
|
||||
// Most recent first: user-7, user-6, user-5.
|
||||
expect(rows[1].textContent).toContain('user-7');
|
||||
expect(text()).toContain('settings.teams.share.andMore(count=5)');
|
||||
const showAll = buttonByText('settings.teams.share.showAll');
|
||||
expect(showAll?.getAttribute('data-variant')).toBe('link');
|
||||
expect(body().querySelector('.max-h-72')).toBeNull();
|
||||
});
|
||||
|
||||
it('opens the full list step with search and filters', async () => {
|
||||
listResourceShares.mockResolvedValue({ shares: manyShares(8) });
|
||||
await render();
|
||||
act(() => buttonByText('settings.teams.share.showAll')!.click());
|
||||
expect(text()).toContain(
|
||||
'settings.teams.share.allSummary(name=QBR Report Builder,teams=2,people=6)',
|
||||
);
|
||||
expect(buttonByText('settings.teams.share.back')).toBeDefined();
|
||||
// You + all 8.
|
||||
expect(body().querySelectorAll('[data-slot="list-row"]')).toHaveLength(9);
|
||||
|
||||
const teamsPill = Array.from(
|
||||
body().querySelectorAll<HTMLButtonElement>('[role="radio"]'),
|
||||
).find((b) =>
|
||||
b.textContent?.includes('settings.teams.share.filter.teams'),
|
||||
);
|
||||
act(() => teamsPill!.click());
|
||||
// The two whole-team grants; You is only listed under All.
|
||||
expect(body().querySelectorAll('[data-slot="list-row"]')).toHaveLength(2);
|
||||
|
||||
const allPill = Array.from(
|
||||
body().querySelectorAll<HTMLButtonElement>('[role="radio"]'),
|
||||
).find((b) => b.textContent?.includes('settings.teams.share.filter.all'));
|
||||
act(() => allPill!.click());
|
||||
const search = body().querySelector<HTMLInputElement>(
|
||||
'input[aria-label="settings.teams.share.searchAccess"]',
|
||||
);
|
||||
act(() => {
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
'value',
|
||||
)!.set!;
|
||||
setter.call(search, 'user-6');
|
||||
search!.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
const rows = body().querySelectorAll('[data-slot="list-row"]');
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].textContent).toContain('user-6');
|
||||
|
||||
act(() => buttonByText('settings.teams.share.back')!.click());
|
||||
expect(buttonByText('settings.teams.share.showAll')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the server message when a share fails', async () => {
|
||||
listResourceShares.mockResolvedValue({
|
||||
shares: [
|
||||
{
|
||||
team_id: 't1',
|
||||
team_name: 'Logistics',
|
||||
access_level: 'viewer',
|
||||
target_user_id: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
unshare.mockRejectedValue(apiError('Not allowed'));
|
||||
await render();
|
||||
const remove = body().querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="settings.teams.share.removeAccess"]',
|
||||
);
|
||||
act(() => remove!.click());
|
||||
await flush();
|
||||
expect(body().querySelector('[role="alert"]')?.textContent).toContain(
|
||||
'Not allowed',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,23 @@
|
||||
import { CircleAlert, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ChevronRight,
|
||||
CircleAlert,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import teamsService, {
|
||||
AccessLevel,
|
||||
ResourceSetting,
|
||||
ResourceSettingsResponse,
|
||||
ResourceShare,
|
||||
ResourceType,
|
||||
TeamMember,
|
||||
} from '../api/services/teamsService';
|
||||
import SearchInput from '../components/SearchInput';
|
||||
import { Alert, AlertDescription } from '../components/ui/alert';
|
||||
import { Avatar } from '../components/ui/avatar';
|
||||
import { Button } from '../components/ui/button';
|
||||
@@ -24,6 +33,9 @@ import { IconButton } from '../components/ui/icon-button';
|
||||
import { ListRow, ListRows } from '../components/ui/list-row';
|
||||
import { Modal } from '../components/ui/modal';
|
||||
import { SectionHeader } from '../components/ui/section-header';
|
||||
import { SettingRow, SettingRows } from '../components/ui/setting-row';
|
||||
import { Switch } from '../components/ui/switch';
|
||||
import { ToggleGroup, ToggleGroupItem } from '../components/ui/toggle-group';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -36,9 +48,18 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../components/ui/select';
|
||||
import { cn } from '../lib/utils';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import { AppDispatch } from '../store';
|
||||
import { can } from '../utils/accessUtils';
|
||||
import { decodeJwtPayload } from '../utils/jwtUtils';
|
||||
import {
|
||||
anyChanged,
|
||||
editorHint,
|
||||
errorMessage,
|
||||
resolveSettings,
|
||||
settingCopy,
|
||||
} from './accessSettings';
|
||||
import { loadTeams, selectTeams } from './teamsSlice';
|
||||
|
||||
type Props = {
|
||||
@@ -69,6 +90,13 @@ const initialOf = (label: string): string => {
|
||||
const shareKey = (share: ResourceShare): string =>
|
||||
`${share.team_id}:${share.target_user_id ?? ''}`;
|
||||
|
||||
// Up to this many grants the dialog lists them all; above it, You plus the
|
||||
// PREVIEW_COUNT most recent and a "Show all" step.
|
||||
const SHORT_LIST_MAX = 5;
|
||||
const PREVIEW_COUNT = 3;
|
||||
|
||||
type AccessFilter = 'all' | 'teams' | 'people' | 'editors';
|
||||
|
||||
type Suggestion =
|
||||
| { kind: 'team'; key: string; teamId: string; teamName: string }
|
||||
| {
|
||||
@@ -101,6 +129,21 @@ export default function ShareToTeamModal({
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
// The owner's per-resource switches and the caller's own access, from
|
||||
// GET /api/resource_settings. Null until loaded (or when it fails: the hint
|
||||
// then uses the defaults and the settings group stays hidden).
|
||||
const [settingsInfo, setSettingsInfo] =
|
||||
useState<ResourceSettingsResponse | null>(null);
|
||||
// Access settings is collapsed by default and opens itself once when a
|
||||
// switch is off its default; after that it follows the user's clicks.
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [savingSettings, setSavingSettings] = useState<Set<string>>(new Set());
|
||||
|
||||
// 'all' is the "People with access" step with search and filters.
|
||||
const [step, setStep] = useState<'main' | 'all'>('main');
|
||||
const [accessQuery, setAccessQuery] = useState('');
|
||||
const [accessFilter, setAccessFilter] = useState<AccessFilter>('all');
|
||||
|
||||
// The access level applied to the next suggestion picked from the combobox.
|
||||
const [accessLevel, setAccessLevel] = useState<AccessLevel>('viewer');
|
||||
|
||||
@@ -171,8 +214,62 @@ export default function ShareToTeamModal({
|
||||
useEffect(() => {
|
||||
dispatch(loadTeams({ token }));
|
||||
refreshShares();
|
||||
setSettingsInfo(null);
|
||||
teamsService
|
||||
.getResourceSettings(resourceType, resourceId, token)
|
||||
.then((r) => {
|
||||
setSettingsInfo(r);
|
||||
if (anyChanged(resolveSettings(resourceType, r?.settings))) {
|
||||
setSettingsOpen(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Without settings the hint falls back to the defaults and the
|
||||
// owner-only group stays hidden; sharing itself still works.
|
||||
});
|
||||
}, [resourceType, resourceId]);
|
||||
|
||||
const settings = useMemo<ResourceSetting[]>(
|
||||
() => resolveSettings(resourceType, settingsInfo?.settings),
|
||||
[resourceType, settingsInfo],
|
||||
);
|
||||
const canManageSettings = can(settingsInfo, 'manage_settings');
|
||||
|
||||
// Optimistically flip one switch, then adopt the server's answer; revert
|
||||
// and show an Alert when the PUT fails.
|
||||
const toggleSetting = async (key: string, value: boolean) => {
|
||||
if (!settingsInfo) return;
|
||||
const previous = settingsInfo;
|
||||
setActionError(null);
|
||||
setSettingsInfo({
|
||||
...previous,
|
||||
settings: resolveSettings(resourceType, previous.settings).map((s) =>
|
||||
s.key === key ? { ...s, value } : s,
|
||||
),
|
||||
});
|
||||
setSavingSettings((prev) => new Set(prev).add(key));
|
||||
try {
|
||||
const r = await teamsService.updateResourceSettings(
|
||||
resourceType,
|
||||
resourceId,
|
||||
{ [key]: value },
|
||||
token,
|
||||
);
|
||||
if (r?.settings) setSettingsInfo(r);
|
||||
} catch (error) {
|
||||
setSettingsInfo(previous);
|
||||
setActionError(
|
||||
errorMessage(error, t('settings.teams.accessSettings.saveError')),
|
||||
);
|
||||
} finally {
|
||||
setSavingSettings((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Stable signature of the current team set; lets the fan-out effect below
|
||||
// depend on team identity rather than the array reference (which is a fresh
|
||||
// object on every loadTeams.fulfilled and would otherwise re-fire on reload).
|
||||
@@ -327,8 +424,8 @@ export default function ShareToTeamModal({
|
||||
},
|
||||
token,
|
||||
);
|
||||
} catch {
|
||||
setActionError(t('settings.teams.share.shareError'));
|
||||
} catch (error) {
|
||||
setActionError(errorMessage(error, t('settings.teams.share.shareError')));
|
||||
} finally {
|
||||
// Clear this action's in-flight flag *before* reconciling so the refresh
|
||||
// adopts the server's canonical state for this pick (while still
|
||||
@@ -363,8 +460,8 @@ export default function ShareToTeamModal({
|
||||
},
|
||||
token,
|
||||
);
|
||||
} catch {
|
||||
setActionError(t('settings.teams.share.shareError'));
|
||||
} catch (error) {
|
||||
setActionError(errorMessage(error, t('settings.teams.share.shareError')));
|
||||
} finally {
|
||||
// Free this row before reconciling so the refresh adopts server state for
|
||||
// it; other rows still busy keep their in-flight optimistic edits.
|
||||
@@ -389,8 +486,10 @@ export default function ShareToTeamModal({
|
||||
},
|
||||
token,
|
||||
);
|
||||
} catch {
|
||||
setActionError(t('settings.teams.share.unshareError'));
|
||||
} catch (error) {
|
||||
setActionError(
|
||||
errorMessage(error, t('settings.teams.share.unshareError')),
|
||||
);
|
||||
} finally {
|
||||
setRowBusy(key, false);
|
||||
await refreshShares();
|
||||
@@ -398,8 +497,12 @@ export default function ShareToTeamModal({
|
||||
};
|
||||
|
||||
const title = resourceName
|
||||
? t('settings.teams.share.titleNamed', { name: resourceName })
|
||||
? t('settings.teams.share.titleNamed', {
|
||||
interpolation: { escapeValue: false },
|
||||
name: resourceName,
|
||||
})
|
||||
: t('settings.teams.share.titleGeneric', {
|
||||
interpolation: { escapeValue: false },
|
||||
type: t(`settings.teams.resourceType.${resourceType}`).toLowerCase(),
|
||||
});
|
||||
|
||||
@@ -448,7 +551,10 @@ export default function ShareToTeamModal({
|
||||
: memberDisplay(share.team_id, share.target_user_id!);
|
||||
const secondary = isTeam
|
||||
? t('settings.teams.share.teamLabel')
|
||||
: t('settings.teams.share.viaTeam', { team: name });
|
||||
: t('settings.teams.share.viaTeam', {
|
||||
interpolation: { escapeValue: false },
|
||||
team: name,
|
||||
});
|
||||
return (
|
||||
<ListRow
|
||||
key={shareKey(share)}
|
||||
@@ -469,6 +575,388 @@ export default function ShareToTeamModal({
|
||||
);
|
||||
};
|
||||
|
||||
// Grant rows for the preview: the most recent first (by created_at when
|
||||
// the server sends it, else the server's order, newest last).
|
||||
const recentShares = useMemo(() => {
|
||||
const indexed = shares.map((share, index) => ({ share, index }));
|
||||
indexed.sort((x, y) => {
|
||||
const ax = x.share.created_at ?? '';
|
||||
const ay = y.share.created_at ?? '';
|
||||
if (ax !== ay) return ax < ay ? 1 : -1;
|
||||
return y.index - x.index;
|
||||
});
|
||||
return indexed.map((entry) => entry.share);
|
||||
}, [shares]);
|
||||
|
||||
const isLongList = shares.length > SHORT_LIST_MAX;
|
||||
const previewShares = isLongList
|
||||
? recentShares.slice(0, PREVIEW_COUNT)
|
||||
: shares;
|
||||
|
||||
const teamGrantCount = shares.filter((s) => !s.target_user_id).length;
|
||||
const personGrantCount = shares.length - teamGrantCount;
|
||||
const editorGrantCount = shares.filter(
|
||||
(s) => s.access_level === 'editor',
|
||||
).length;
|
||||
|
||||
const shareLabel = (share: ResourceShare): string => {
|
||||
const name = share.team_name ?? teamName(share.team_id);
|
||||
return share.target_user_id
|
||||
? `${memberDisplay(share.team_id, share.target_user_id)} ${share.target_user_id} ${name}`
|
||||
: name;
|
||||
};
|
||||
|
||||
const filteredShares = shares.filter((share) => {
|
||||
if (accessFilter === 'teams' && share.target_user_id) return false;
|
||||
if (accessFilter === 'people' && !share.target_user_id) return false;
|
||||
if (accessFilter === 'editors' && share.access_level !== 'editor')
|
||||
return false;
|
||||
return matches(shareLabel(share), accessQuery);
|
||||
});
|
||||
|
||||
const openAllStep = () => {
|
||||
setAccessQuery('');
|
||||
setAccessFilter('all');
|
||||
setStep('all');
|
||||
};
|
||||
|
||||
// The caller's own row: the owner, or an editor the owner let share.
|
||||
const yourRole =
|
||||
settingsInfo?.access === 'editor'
|
||||
? t('settings.teams.share.accessLevel.editor')
|
||||
: t('settings.teams.share.owner');
|
||||
|
||||
const youRow = (
|
||||
<ListRow
|
||||
leading={
|
||||
<Avatar alt="" variant="primary" size="default" shape="circle">
|
||||
{initialOf(t('settings.teams.share.you'))}
|
||||
</Avatar>
|
||||
}
|
||||
title={t('settings.teams.share.you')}
|
||||
trailing={
|
||||
<span className="text-muted-foreground shrink-0 pr-3 text-sm">
|
||||
{yourRole}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const errors = (
|
||||
<>
|
||||
{loadError && (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlert className="size-4" aria-hidden="true" />
|
||||
<AlertDescription>
|
||||
{t('settings.teams.share.loadError')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{actionError && (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlert className="size-4" aria-hidden="true" />
|
||||
<AlertDescription>{actionError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const filterOptions: Array<{ value: AccessFilter; count: number }> = [
|
||||
{ value: 'all', count: shares.length },
|
||||
{ value: 'teams', count: teamGrantCount },
|
||||
{ value: 'people', count: personGrantCount },
|
||||
{ value: 'editors', count: editorGrantCount },
|
||||
];
|
||||
|
||||
const allStep = (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="-ml-3 w-fit justify-start"
|
||||
onClick={() => setStep('main')}
|
||||
>
|
||||
<ArrowLeft aria-hidden />
|
||||
{t('settings.teams.share.back')}
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-foreground text-xl leading-tight font-semibold">
|
||||
{t('settings.teams.share.peopleWithAccess')}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
{t('settings.teams.share.allSummary', {
|
||||
interpolation: { escapeValue: false },
|
||||
name: resourceName ?? '',
|
||||
teams: teamGrantCount,
|
||||
people: personGrantCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{errors}
|
||||
<SearchInput
|
||||
placeholder={t('settings.teams.share.searchAccess')}
|
||||
labelSurface="card"
|
||||
value={accessQuery}
|
||||
onChange={(e) => setAccessQuery(e.target.value)}
|
||||
/>
|
||||
<div className="bg-muted w-fit max-w-full rounded-full p-1">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
size="xs"
|
||||
value={accessFilter}
|
||||
onValueChange={(value) =>
|
||||
value && setAccessFilter(value as AccessFilter)
|
||||
}
|
||||
aria-label={t('settings.teams.share.filterLabel')}
|
||||
>
|
||||
{filterOptions.map((option) => (
|
||||
<ToggleGroupItem key={option.value} value={option.value}>
|
||||
{t(`settings.teams.share.filter.${option.value}`)} {option.count}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<ListRows>
|
||||
{accessFilter === 'all' && !accessQuery.trim() && youRow}
|
||||
{filteredShares.map(renderShareRow)}
|
||||
</ListRows>
|
||||
{filteredShares.length === 0 && (
|
||||
<p className="text-muted-foreground px-4 text-sm">
|
||||
{t('settings.teams.share.noMatches')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const accessSettings = canManageSettings && (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="section-toggle"
|
||||
size="sm"
|
||||
aria-expanded={settingsOpen}
|
||||
className="-ml-3 w-fit justify-start"
|
||||
onClick={() => setSettingsOpen((open) => !open)}
|
||||
>
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'transition-transform duration-200',
|
||||
settingsOpen && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{t('settings.teams.accessSettings.title')}
|
||||
</span>
|
||||
</Button>
|
||||
</h3>
|
||||
{settingsOpen && (
|
||||
<SettingRows>
|
||||
{settings.map((setting) => {
|
||||
const copy = settingCopy(t, resourceType, setting.key);
|
||||
const id = `share-setting-${setting.key}`;
|
||||
return (
|
||||
<SettingRow
|
||||
key={setting.key}
|
||||
htmlFor={id}
|
||||
label={copy.label}
|
||||
description={copy.description || undefined}
|
||||
>
|
||||
<Switch
|
||||
id={id}
|
||||
checked={setting.value}
|
||||
disabled={savingSettings.has(setting.key)}
|
||||
onCheckedChange={(value) => toggleSetting(setting.key, value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
);
|
||||
})}
|
||||
</SettingRows>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
const mainStep = (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t('settings.teams.share.subtitle')}
|
||||
</p>
|
||||
{errors}
|
||||
</div>
|
||||
|
||||
{teams.length === 0 ? (
|
||||
<p className="text-sm">{t('settings.teams.share.noTeams')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
{/* Add row: type-ahead combobox + access level select. */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="combobox"
|
||||
role="combobox"
|
||||
aria-expanded={pickerOpen}
|
||||
disabled={committing}
|
||||
data-placeholder=""
|
||||
className="min-w-0 flex-1 justify-start"
|
||||
>
|
||||
<span className="truncate">
|
||||
{t('settings.teams.share.addPlaceholder')}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[min(22rem,calc(100vw-2rem))] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t('settings.teams.share.searchPlaceholder')}
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
{!hasSuggestions && (
|
||||
<CommandEmpty>
|
||||
{t('settings.teams.share.noMatches')}
|
||||
</CommandEmpty>
|
||||
)}
|
||||
{teamSuggestions.length > 0 && (
|
||||
<CommandGroup
|
||||
heading={t('settings.teams.share.teamsGroup')}
|
||||
>
|
||||
{teamSuggestions.map((suggestion) => (
|
||||
<CommandItem
|
||||
key={suggestion.key}
|
||||
value={suggestion.key}
|
||||
onSelect={() => commitSuggestion(suggestion)}
|
||||
>
|
||||
<Avatar
|
||||
alt=""
|
||||
variant="primary"
|
||||
size="xs"
|
||||
shape="square"
|
||||
>
|
||||
{initialOf(suggestion.teamName)}
|
||||
</Avatar>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{suggestion.teamName}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{memberSuggestions.length > 0 && (
|
||||
<CommandGroup
|
||||
heading={t('settings.teams.share.peopleGroup')}
|
||||
>
|
||||
{memberSuggestions.map((suggestion) =>
|
||||
suggestion.kind === 'member' ? (
|
||||
<CommandItem
|
||||
key={suggestion.key}
|
||||
value={`${suggestion.key} ${suggestion.label} ${suggestion.teamName}`}
|
||||
onSelect={() => commitSuggestion(suggestion)}
|
||||
>
|
||||
<Avatar
|
||||
alt=""
|
||||
variant="primary"
|
||||
size="xs"
|
||||
shape="circle"
|
||||
>
|
||||
{initialOf(suggestion.label)}
|
||||
</Avatar>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{suggestion.label}
|
||||
<span className="text-muted-foreground">
|
||||
{' · '}
|
||||
{suggestion.teamName}
|
||||
</span>
|
||||
</span>
|
||||
</CommandItem>
|
||||
) : null,
|
||||
)}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Select
|
||||
value={accessLevel}
|
||||
disabled={committing}
|
||||
onValueChange={(value) => setAccessLevel(value as AccessLevel)}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-9 w-28 shrink-0"
|
||||
aria-label={t('settings.teams.share.access')}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="viewer">
|
||||
{t('settings.teams.share.accessLevel.viewer')}
|
||||
</SelectItem>
|
||||
<SelectItem value="editor">
|
||||
{t('settings.teams.share.accessLevel.editor')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p
|
||||
data-testid="share-editor-hint"
|
||||
className="text-muted-foreground mt-1.5 text-xs"
|
||||
>
|
||||
{editorHint(t, resourceType, settings)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* People with access. */}
|
||||
<section className="flex flex-col gap-1">
|
||||
<SectionHeader
|
||||
as="h3"
|
||||
size="xs"
|
||||
title={t('settings.teams.share.peopleWithAccess')}
|
||||
actions={
|
||||
isLongList && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="inline"
|
||||
onClick={openAllStep}
|
||||
>
|
||||
{t('settings.teams.share.showAll', {
|
||||
count: shares.length,
|
||||
})}
|
||||
<ArrowRight className="size-3" aria-hidden />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ListRows>
|
||||
{youRow}
|
||||
{previewShares.map(renderShareRow)}
|
||||
</ListRows>
|
||||
{isLongList && (
|
||||
<p className="text-muted-foreground px-4 text-xs">
|
||||
{t('settings.teams.share.andMore', {
|
||||
count: shares.length - previewShares.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{accessSettings}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
@@ -477,7 +965,12 @@ export default function ShareToTeamModal({
|
||||
if (!open && !inFlight) onClose();
|
||||
}}
|
||||
isPerformingTask={inFlight}
|
||||
title={title}
|
||||
// The "all" step draws its own heading under a Back button (Upload's
|
||||
// step pattern); the dialog keeps an accessible title either way.
|
||||
title={
|
||||
step === 'all' ? t('settings.teams.share.peopleWithAccess') : title
|
||||
}
|
||||
hideTitle={step === 'all'}
|
||||
size="md"
|
||||
footer={
|
||||
<Button size="lg" shape="pill" disabled={inFlight} onClick={onClose}>
|
||||
@@ -485,178 +978,7 @@ export default function ShareToTeamModal({
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{t('settings.teams.share.subtitle')}
|
||||
</p>
|
||||
|
||||
{loadError && (
|
||||
<Alert variant="destructive" className="mt-3">
|
||||
<CircleAlert className="size-4" aria-hidden="true" />
|
||||
<AlertDescription>
|
||||
{t('settings.teams.share.loadError')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{actionError && (
|
||||
<Alert variant="destructive" className="mt-3">
|
||||
<CircleAlert className="size-4" aria-hidden="true" />
|
||||
<AlertDescription>{actionError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{teams.length === 0 ? (
|
||||
<p className="mt-6 text-sm">{t('settings.teams.share.noTeams')}</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Add row: type-ahead combobox + access level select. */}
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="combobox"
|
||||
role="combobox"
|
||||
aria-expanded={pickerOpen}
|
||||
disabled={committing}
|
||||
data-placeholder=""
|
||||
className="min-w-0 flex-1 justify-start"
|
||||
>
|
||||
<span className="truncate">
|
||||
{t('settings.teams.share.addPlaceholder')}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[min(22rem,calc(100vw-2rem))] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t('settings.teams.share.searchPlaceholder')}
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
{!hasSuggestions && (
|
||||
<CommandEmpty>
|
||||
{t('settings.teams.share.noMatches')}
|
||||
</CommandEmpty>
|
||||
)}
|
||||
{teamSuggestions.length > 0 && (
|
||||
<CommandGroup
|
||||
heading={t('settings.teams.share.teamsGroup')}
|
||||
>
|
||||
{teamSuggestions.map((suggestion) => (
|
||||
<CommandItem
|
||||
key={suggestion.key}
|
||||
value={suggestion.key}
|
||||
onSelect={() => commitSuggestion(suggestion)}
|
||||
>
|
||||
<Avatar
|
||||
alt=""
|
||||
variant="primary"
|
||||
size="xs"
|
||||
shape="square"
|
||||
>
|
||||
{initialOf(suggestion.teamName)}
|
||||
</Avatar>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{suggestion.teamName}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{memberSuggestions.length > 0 && (
|
||||
<CommandGroup
|
||||
heading={t('settings.teams.share.peopleGroup')}
|
||||
>
|
||||
{memberSuggestions.map((suggestion) =>
|
||||
suggestion.kind === 'member' ? (
|
||||
<CommandItem
|
||||
key={suggestion.key}
|
||||
value={`${suggestion.key} ${suggestion.label} ${suggestion.teamName}`}
|
||||
onSelect={() => commitSuggestion(suggestion)}
|
||||
>
|
||||
<Avatar
|
||||
alt=""
|
||||
variant="primary"
|
||||
size="xs"
|
||||
shape="circle"
|
||||
>
|
||||
{initialOf(suggestion.label)}
|
||||
</Avatar>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{suggestion.label}
|
||||
<span className="text-muted-foreground">
|
||||
{' · '}
|
||||
{suggestion.teamName}
|
||||
</span>
|
||||
</span>
|
||||
</CommandItem>
|
||||
) : null,
|
||||
)}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Select
|
||||
value={accessLevel}
|
||||
disabled={committing}
|
||||
onValueChange={(value) => setAccessLevel(value as AccessLevel)}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-9 w-28 shrink-0"
|
||||
aria-label={t('settings.teams.share.access')}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="viewer">
|
||||
{t('settings.teams.share.accessLevel.viewer')}
|
||||
</SelectItem>
|
||||
<SelectItem value="editor">
|
||||
{t('settings.teams.share.accessLevel.editor')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* People with access. */}
|
||||
<section className="mt-6 flex flex-col gap-1">
|
||||
<SectionHeader
|
||||
as="h3"
|
||||
size="xs"
|
||||
title={t('settings.teams.share.peopleWithAccess')}
|
||||
/>
|
||||
<ListRows className="max-h-72 overflow-auto">
|
||||
{/* Owner — pinned, non-interactive. */}
|
||||
<ListRow
|
||||
leading={
|
||||
<Avatar
|
||||
alt=""
|
||||
variant="primary"
|
||||
size="default"
|
||||
shape="circle"
|
||||
>
|
||||
{initialOf(t('settings.teams.share.you'))}
|
||||
</Avatar>
|
||||
}
|
||||
title={t('settings.teams.share.you')}
|
||||
trailing={
|
||||
<span className="text-muted-foreground shrink-0 pr-3 text-sm">
|
||||
{t('settings.teams.share.owner')}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{shares.map(renderShareRow)}
|
||||
</ListRows>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
{step === 'all' ? allStep : mainStep}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import type {
|
||||
ResourceSetting,
|
||||
ResourceType,
|
||||
} from '../api/services/teamsService';
|
||||
|
||||
/**
|
||||
* The owner's per-resource switches ("Option A"), mirrored from
|
||||
* `SWITCHES` in `docsgpt/api/user/resource_access.py`: key and default, in
|
||||
* the order the share dialog lists them. Only used for labels, ordering and
|
||||
* a fallback before the server's `/api/resource_settings` answers; the
|
||||
* server's values always win.
|
||||
*/
|
||||
export const SWITCH_DEFAULTS: Record<
|
||||
ResourceType,
|
||||
ReadonlyArray<{ key: string; default: boolean }>
|
||||
> = {
|
||||
agent: [
|
||||
{ key: 'editors_can_share', default: false },
|
||||
{ key: 'editors_can_delete', default: false },
|
||||
{ key: 'editors_can_manage_access_details', default: true },
|
||||
{ key: 'viewers_can_see_logs', default: false },
|
||||
],
|
||||
source: [
|
||||
{ key: 'editors_can_share', default: false },
|
||||
{ key: 'editors_can_delete', default: false },
|
||||
{ key: 'viewers_can_see_config', default: true },
|
||||
],
|
||||
tool: [
|
||||
{ key: 'editors_can_change_credentials', default: true },
|
||||
{ key: 'editors_can_share', default: false },
|
||||
{ key: 'viewers_can_use_in_agents', default: true },
|
||||
],
|
||||
prompt: [
|
||||
{ key: 'editors_can_share', default: false },
|
||||
{ key: 'viewers_can_duplicate', default: true },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* The switches for `type`, in display order, with the server's values
|
||||
* merged over the defaults. Unknown server keys are appended.
|
||||
*/
|
||||
export function resolveSettings(
|
||||
type: ResourceType,
|
||||
server: ResourceSetting[] | null | undefined,
|
||||
): ResourceSetting[] {
|
||||
const byKey = new Map((server ?? []).map((s) => [s.key, s]));
|
||||
const known = SWITCH_DEFAULTS[type].map((d) => {
|
||||
const s = byKey.get(d.key);
|
||||
return s ?? { key: d.key, value: d.default, default: d.default };
|
||||
});
|
||||
const extra = (server ?? []).filter(
|
||||
(s) => !SWITCH_DEFAULTS[type].some((d) => d.key === s.key),
|
||||
);
|
||||
return [...known, ...extra];
|
||||
}
|
||||
|
||||
export const settingValue = (
|
||||
settings: ResourceSetting[],
|
||||
key: string,
|
||||
): boolean => settings.find((s) => s.key === key)?.value ?? false;
|
||||
|
||||
/** Whether any switch is off its default (opens Access settings by default). */
|
||||
export const anyChanged = (settings: ResourceSetting[]): boolean =>
|
||||
settings.some((s) => s.value !== s.default);
|
||||
|
||||
/**
|
||||
* What an Editor can do on this resource, as one or two sentences: a base
|
||||
* sentence per type, then whether they may also share or delete it.
|
||||
*/
|
||||
export function editorHint(
|
||||
t: TFunction,
|
||||
type: ResourceType,
|
||||
settings: ResourceSetting[],
|
||||
): string {
|
||||
const on = (key: string) => settingValue(settings, key);
|
||||
let base: string;
|
||||
if (type === 'agent') {
|
||||
base = on('editors_can_manage_access_details')
|
||||
? t('settings.teams.editorHint.agent')
|
||||
: t('settings.teams.editorHint.agentNoAccessDetails');
|
||||
} else if (type === 'tool') {
|
||||
base = on('editors_can_change_credentials')
|
||||
? t('settings.teams.editorHint.tool')
|
||||
: t('settings.teams.editorHint.toolNoCredentials');
|
||||
} else {
|
||||
base = t(`settings.teams.editorHint.${type}`);
|
||||
}
|
||||
const share = on('editors_can_share');
|
||||
let tail: string;
|
||||
if (type === 'agent' || type === 'source') {
|
||||
const del = on('editors_can_delete');
|
||||
tail = t(
|
||||
share && del
|
||||
? 'settings.teams.editorHint.shareAndDelete'
|
||||
: share
|
||||
? 'settings.teams.editorHint.shareOnly'
|
||||
: del
|
||||
? 'settings.teams.editorHint.deleteOnly'
|
||||
: 'settings.teams.editorHint.noShareNoDelete',
|
||||
);
|
||||
} else {
|
||||
tail = t(
|
||||
share
|
||||
? 'settings.teams.editorHint.share'
|
||||
: 'settings.teams.editorHint.noShare',
|
||||
);
|
||||
}
|
||||
return `${base} ${tail}`;
|
||||
}
|
||||
|
||||
/** A switch's label and description in the share dialog. */
|
||||
export const settingCopy = (
|
||||
t: TFunction,
|
||||
type: ResourceType,
|
||||
key: string,
|
||||
): { label: string; description: string } => ({
|
||||
label: t(`settings.teams.accessSettings.${type}.${key}.label`, {
|
||||
defaultValue: key,
|
||||
}),
|
||||
description: t(`settings.teams.accessSettings.${type}.${key}.description`, {
|
||||
defaultValue: '',
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* The read-only "What people here can do" lines: the viewer and editor
|
||||
* baselines, then one line per switch (a check when on, an x when off).
|
||||
*/
|
||||
export function capabilityLines(
|
||||
t: TFunction,
|
||||
type: ResourceType,
|
||||
settings: ResourceSetting[],
|
||||
): Array<{ key: string; allowed: boolean; text: string }> {
|
||||
return [
|
||||
{
|
||||
key: 'viewers',
|
||||
allowed: true,
|
||||
text: t(`settings.teams.capabilities.${type}.viewers`),
|
||||
},
|
||||
{
|
||||
key: 'editors',
|
||||
allowed: true,
|
||||
text: t(`settings.teams.capabilities.${type}.editors`),
|
||||
},
|
||||
...settings.map((s) => ({
|
||||
key: s.key,
|
||||
allowed: s.value,
|
||||
text: t(
|
||||
`settings.teams.capabilities.switch.${s.key}.${s.value ? 'on' : 'off'}`,
|
||||
{ defaultValue: s.key },
|
||||
),
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
// A teams API error carries the server's message; anything else (a network
|
||||
// failure) falls back to the caller's generic copy.
|
||||
export const errorMessage = (error: unknown, fallback: string): string =>
|
||||
error instanceof Error && error.name === 'TeamsApiError' && error.message
|
||||
? error.message
|
||||
: fallback;
|
||||
@@ -0,0 +1,65 @@
|
||||
const remove = vi.fn();
|
||||
const list = vi.fn();
|
||||
|
||||
vi.mock('../api/services/teamsService', () => ({
|
||||
default: {
|
||||
remove: (...args: unknown[]) => remove(...args),
|
||||
list: (...args: unknown[]) => list(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
|
||||
import reducer, { deleteTeam, loadTeams, Team } from './teamsSlice';
|
||||
|
||||
const team: Team = { id: 't1', name: 'Ops', slug: 'ops', owner_id: 'u1' };
|
||||
|
||||
const makeStore = () =>
|
||||
configureStore({
|
||||
reducer: { teams: reducer },
|
||||
preloadedState: {
|
||||
teams: {
|
||||
teams: [team],
|
||||
currentTeamId: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('teamsSlice', () => {
|
||||
beforeEach(() => {
|
||||
remove.mockReset();
|
||||
list.mockReset();
|
||||
});
|
||||
|
||||
it('keeps the team when the delete is rejected (403)', async () => {
|
||||
remove.mockRejectedValue(new Error('Only the owner can delete'));
|
||||
const store = makeStore();
|
||||
const result = await store.dispatch(deleteTeam({ id: 't1', token: null }));
|
||||
expect(result.type).toBe('teams/delete/rejected');
|
||||
expect(store.getState().teams.teams).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps the team when a 2xx body says success:false', async () => {
|
||||
remove.mockResolvedValue({ success: false, message: 'nope' });
|
||||
const store = makeStore();
|
||||
await store.dispatch(deleteTeam({ id: 't1', token: null }));
|
||||
expect(store.getState().teams.teams).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('removes the team on success', async () => {
|
||||
remove.mockResolvedValue({ success: true });
|
||||
const store = makeStore();
|
||||
await store.dispatch(deleteTeam({ id: 't1', token: null }));
|
||||
expect(store.getState().teams.teams).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('records a load failure instead of an empty list', async () => {
|
||||
list.mockRejectedValue(new Error('boom'));
|
||||
const store = makeStore();
|
||||
await store.dispatch(loadTeams({ token: null }));
|
||||
expect(store.getState().teams.error).toBe('boom');
|
||||
expect(store.getState().teams.teams).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,9 @@ export type Team = {
|
||||
slug: string;
|
||||
description?: string | null;
|
||||
owner_id: string;
|
||||
// Whether the caller owns the team (only the owner may delete it). Newer
|
||||
// servers send it; older ones only `owner_id`, compared to the caller's sub.
|
||||
is_owner?: boolean;
|
||||
member_role?: TeamRole;
|
||||
// Annotations from the list endpoint for the teams grid cards.
|
||||
member_count?: number;
|
||||
@@ -35,7 +38,11 @@ const initialState: TeamsState = {
|
||||
export const loadTeams = createAsyncThunk<Team[], { token: string | null }>(
|
||||
'teams/load',
|
||||
async ({ token }) => {
|
||||
// teamsService rejects on a non-2xx, so a failed load lands in .rejected.
|
||||
const r = await teamsService.list(token);
|
||||
if (r?.success === false) {
|
||||
throw new Error(r?.message ?? 'Failed to load teams');
|
||||
}
|
||||
return (r?.teams as Team[]) ?? [];
|
||||
},
|
||||
);
|
||||
@@ -58,7 +65,12 @@ export const deleteTeam = createAsyncThunk<
|
||||
string,
|
||||
{ id: string; token: string | null }
|
||||
>('teams/delete', async ({ id, token }) => {
|
||||
await teamsService.remove(id, token);
|
||||
// A non-2xx (e.g. 403 for a non-owner) rejects in teamsService, so the team
|
||||
// stays in the list; a 2xx that still says success:false rejects too.
|
||||
const r = await teamsService.remove(id, token);
|
||||
if (r?.success === false) {
|
||||
throw new Error(r?.message ?? 'Failed to delete team');
|
||||
}
|
||||
return id;
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { can, isOwner, roleOf } from './accessUtils';
|
||||
|
||||
describe('can', () => {
|
||||
it('reads allowed_actions from the server', () => {
|
||||
const item = {
|
||||
access: 'editor' as const,
|
||||
allowed_actions: ['edit', 'use'],
|
||||
};
|
||||
expect(can(item, 'edit')).toBe(true);
|
||||
expect(can(item, 'delete')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats an item without access fields as the caller’s own', () => {
|
||||
// A row created in this session, before the list is refetched.
|
||||
expect(can({}, 'delete')).toBe(true);
|
||||
expect(can({ access: 'owner' }, 'delete')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the role defaults for a shared item without an action list', () => {
|
||||
// An API older than the frontend sends only the legacy team fields.
|
||||
const viewer = {
|
||||
ownership: 'team' as const,
|
||||
team_access: 'viewer' as const,
|
||||
};
|
||||
expect(can(viewer, 'pin')).toBe(true);
|
||||
expect(can(viewer, 'use')).toBe(true);
|
||||
expect(can(viewer, 'view_config')).toBe(true);
|
||||
expect(can(viewer, 'edit')).toBe(false);
|
||||
const editor = { access: 'editor' as const };
|
||||
expect(can(editor, 'edit')).toBe(true);
|
||||
expect(can(editor, 'view_logs')).toBe(true);
|
||||
expect(can(editor, 'delete')).toBe(false);
|
||||
expect(can(editor, 'share')).toBe(false);
|
||||
expect(can(editor, 'manage_settings')).toBe(false);
|
||||
});
|
||||
|
||||
it('denies a missing item', () => {
|
||||
expect(can(null, 'use')).toBe(false);
|
||||
expect(can(undefined, 'use')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('roleOf / isOwner', () => {
|
||||
it('prefers access, then the legacy team fields', () => {
|
||||
expect(roleOf({ access: 'viewer' })).toBe('viewer');
|
||||
expect(roleOf({ ownership: 'team', team_access: 'editor' })).toBe('editor');
|
||||
expect(roleOf({ ownership: 'team' })).toBe('viewer');
|
||||
expect(roleOf({})).toBe('owner');
|
||||
expect(isOwner({ access: 'editor' })).toBe(false);
|
||||
expect(isOwner({})).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Access to a team-shared resource (agent, source, tool, prompt).
|
||||
*
|
||||
* The backend resolves the caller's role and the owner's per-asset switches
|
||||
* and sends the result on every list and get response as `access` plus
|
||||
* `allowed_actions` (`docsgpt/api/user/resource_access.py`). The UI only asks
|
||||
* `can(item, action)`; it never re-derives the rules from the role.
|
||||
*/
|
||||
export type Access = 'owner' | 'editor' | 'viewer';
|
||||
|
||||
export type AccessFields = {
|
||||
access?: Access | null;
|
||||
allowed_actions?: string[];
|
||||
/** Legacy fields, still sent: `team` marks a resource shared with you. */
|
||||
ownership?: 'user' | 'team';
|
||||
team_access?: 'viewer' | 'editor' | null;
|
||||
};
|
||||
|
||||
/** The caller's role on an item; an item with no access fields is their own. */
|
||||
export function roleOf(item: AccessFields): Access {
|
||||
if (item.access) return item.access;
|
||||
if (item.ownership === 'team') return item.team_access ?? 'viewer';
|
||||
return 'owner';
|
||||
}
|
||||
|
||||
export function isOwner(item: AccessFields): boolean {
|
||||
return roleOf(item) === 'owner';
|
||||
}
|
||||
|
||||
/**
|
||||
* Each role's default actions, used only when a shared item arrives without
|
||||
* `allowed_actions` (an API older than this frontend). Mirrors the defaults
|
||||
* of `ACTIONS` in `docsgpt/api/user/resource_access.py`; action names don't
|
||||
* collide across resource types, so one table covers all four. Owner-only
|
||||
* actions (share, delete, move, settings) are never in it.
|
||||
*/
|
||||
const VIEWER_DEFAULTS = [
|
||||
'use',
|
||||
'pin',
|
||||
'use_in_own',
|
||||
'view_config',
|
||||
'duplicate',
|
||||
];
|
||||
const ROLE_DEFAULTS: Record<Exclude<Access, 'owner'>, ReadonlySet<string>> = {
|
||||
viewer: new Set(VIEWER_DEFAULTS),
|
||||
editor: new Set([
|
||||
...VIEWER_DEFAULTS,
|
||||
'view',
|
||||
'edit',
|
||||
'publish',
|
||||
'edit_policy',
|
||||
'view_logs',
|
||||
'manage_schedules',
|
||||
'export',
|
||||
'manage_access_details',
|
||||
'edit_credentials',
|
||||
]),
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the caller may perform `action` on `item`.
|
||||
*
|
||||
* Uses the server's `allowed_actions` when present. Without it the caller's
|
||||
* own items allow everything (a row created in this session, before a
|
||||
* refetch), and a shared item gets its role's defaults, so a stale API
|
||||
* degrades to sensible menus rather than empty ones.
|
||||
*/
|
||||
export function can(
|
||||
item: AccessFields | null | undefined,
|
||||
action: string,
|
||||
): boolean {
|
||||
if (!item) return false;
|
||||
if (item.allowed_actions) return item.allowed_actions.includes(action);
|
||||
const role = roleOf(item);
|
||||
return role === 'owner' || ROLE_DEFAULTS[role].has(action);
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isChatToolVisible, isClassicAgentToolVisible } from './toolUtils';
|
||||
import {
|
||||
canAddToolToOwn,
|
||||
isAgentPickerToolVisible,
|
||||
isChatPickerToolVisible,
|
||||
isChatToolVisible,
|
||||
isClassicAgentToolVisible,
|
||||
toolInChat,
|
||||
} from './toolUtils';
|
||||
|
||||
// Regression for the filter drift introduced when ``scheduler`` was
|
||||
// dual-registered (both ``default: true`` and ``builtin: true``). The
|
||||
@@ -38,3 +45,86 @@ describe('isClassicAgentToolVisible', () => {
|
||||
expect(isClassicAgentToolVisible({})).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// The "In my chats" switch: the owner's value is ``status``; a grantee's is
|
||||
// their own ``in_chat`` preference, which the server sends for everyone.
|
||||
describe('toolInChat', () => {
|
||||
it('prefers in_chat over status', () => {
|
||||
expect(toolInChat({ status: true, in_chat: false })).toBe(false);
|
||||
expect(toolInChat({ status: false, in_chat: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to status when in_chat is absent', () => {
|
||||
expect(toolInChat({ status: true })).toBe(true);
|
||||
expect(toolInChat({ status: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAddToolToOwn', () => {
|
||||
it("allows the caller's own tools", () => {
|
||||
expect(canAddToolToOwn({})).toBe(true);
|
||||
expect(canAddToolToOwn({ access: 'owner', allowed_actions: [] })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('follows use_in_own for a shared tool', () => {
|
||||
expect(
|
||||
canAddToolToOwn({ access: 'viewer', allowed_actions: ['use'] }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canAddToolToOwn({
|
||||
access: 'viewer',
|
||||
allowed_actions: ['use', 'use_in_own'],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the role default for a legacy shared row', () => {
|
||||
// Viewers and editors may add a shared tool to their own agents by default.
|
||||
expect(canAddToolToOwn({ ownership: 'team', team_access: 'editor' })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isChatPickerToolVisible', () => {
|
||||
it('hides shared tools the caller may not add to their own chats', () => {
|
||||
expect(
|
||||
isChatPickerToolVisible({ access: 'viewer', allowed_actions: ['use'] }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps shared tools with use_in_own and the caller's own tools", () => {
|
||||
expect(
|
||||
isChatPickerToolVisible({
|
||||
access: 'editor',
|
||||
allowed_actions: ['use', 'use_in_own', 'edit'],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(isChatPickerToolVisible({})).toBe(true);
|
||||
});
|
||||
|
||||
it('still drops pure builtins', () => {
|
||||
expect(isChatPickerToolVisible({ builtin: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAgentPickerToolVisible', () => {
|
||||
it('keeps own tools and shared tools usable in own agents', () => {
|
||||
expect(isAgentPickerToolVisible({})).toBe(true);
|
||||
expect(
|
||||
isAgentPickerToolVisible({
|
||||
access: 'viewer',
|
||||
allowed_actions: ['use', 'use_in_own'],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('hides shared tools without use_in_own and workflow-only builtins', () => {
|
||||
expect(
|
||||
isAgentPickerToolVisible({ access: 'viewer', allowed_actions: ['use'] }),
|
||||
).toBe(false);
|
||||
expect(isAgentPickerToolVisible({ workflow_only: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { type AccessFields, can, isOwner } from './accessUtils';
|
||||
|
||||
type ToolLabelSource = {
|
||||
customName?: string | null;
|
||||
displayName?: string | null;
|
||||
@@ -31,3 +33,32 @@ export const isChatToolVisible = (tool: {
|
||||
export const isClassicAgentToolVisible = (tool: {
|
||||
workflow_only?: boolean;
|
||||
}): boolean => !tool.workflow_only;
|
||||
|
||||
/**
|
||||
* Whether a tool is on in the caller's own agentless chats (the "In my chats"
|
||||
* switch). The server sends `in_chat` for every row: the owner's `status`, or
|
||||
* a grantee's personal preference. Older payloads only carry `status`.
|
||||
*/
|
||||
export const toolInChat = (tool: {
|
||||
status?: boolean;
|
||||
in_chat?: boolean;
|
||||
}): boolean => Boolean(tool.in_chat ?? tool.status);
|
||||
|
||||
/**
|
||||
* Whether the caller may add a tool to their own agents and chats: always
|
||||
* for their own tools, and for a shared one only with `use_in_own`.
|
||||
*/
|
||||
export const canAddToolToOwn = (tool: AccessFields): boolean =>
|
||||
isOwner(tool) || can(tool, 'use_in_own');
|
||||
|
||||
// Composer Tools picker: the chat-popup rule, minus shared tools the caller
|
||||
// can't turn on for their own chats.
|
||||
export const isChatPickerToolVisible = (
|
||||
tool: AccessFields & { default?: boolean; builtin?: boolean },
|
||||
): boolean => isChatToolVisible(tool) && canAddToolToOwn(tool);
|
||||
|
||||
// Classic agent and workflow-node pickers: the classic rule, minus shared
|
||||
// tools the caller can't add to their own agents (`use_in_own` off).
|
||||
export const isAgentPickerToolVisible = (
|
||||
tool: AccessFields & { workflow_only?: boolean },
|
||||
): boolean => isClassicAgentToolVisible(tool) && canAddToolToOwn(tool);
|
||||
Reference in new issue
Block a user