Roles audit and revamp

This commit is contained in:
Pavel committed 2026-09-29 10:42:39 +04:00
1 parent 208d34abc7
commit 98afa5d93c
134 files changed
+13725 -1702

No files matched your search

+4 -1
View File
@@ -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
+4 -4
View File
@@ -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
}
+266
View File
@@ -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' },
});
});
});
+90 -65
View File
@@ -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 -1
View File
@@ -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);
});
});
+83
View File
@@ -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}</>;
}
+196 -3
View File
@@ -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' },
});
});
});
+92 -62
View File
@@ -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>
+54
View File
@@ -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);
});
});
+51
View File
@@ -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);
}
+32 -4
View File
@@ -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. */}
+5
View File
@@ -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'];
}
/**
+1
View File
@@ -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 },
});
});
});
+91
View File
@@ -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)),
};
+35
View File
@@ -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',
]);
});
});
+39 -25
View File
@@ -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),
},
]
: []),
]}
/>
</>
+91 -4
View File
@@ -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' },
};
}
});
});
+31 -22
View File
@@ -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={
+10 -1
View File
@@ -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"
+4
View File
@@ -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;
+39 -9
View File
@@ -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"');
});
});
+20 -3
View File
@@ -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>
);
+5 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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');
});
});
+86 -40
View File
@@ -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>
)}
+200
View File
@@ -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');
});
});
+108 -29
View File
@@ -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')} />
+11 -1
View File
@@ -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;
+42
View File
@@ -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);
+40 -26
View File
@@ -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 }],
};
}
+8 -3
View File
@@ -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');
});
});
+8 -5
View File
@@ -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}
/>
);
}
+225 -3
View File
@@ -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',
},
}),
);
});
});
});
+75 -34
View File
@@ -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);
});
});
+4 -4
View File
@@ -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),
+318
View File
@@ -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');
});
});
+77 -47
View File
@@ -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>
+358
View File
@@ -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
View File
@@ -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}
+140
View File
@@ -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();
});
});
+89 -48
View File
@@ -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>
+266
View File
@@ -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();
});
});
+93 -35
View File
@@ -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 || '',
})}
+138
View File
@@ -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' }],
});
});
});
+28 -16
View File
@@ -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) {
+18
View File
@@ -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',
);
});
});
+504 -182
View File
@@ -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>
);
}
+164
View File
@@ -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;
+65
View File
@@ -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);
});
});
+13 -1
View File
@@ -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;
});
+54
View File
@@ -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);
});
});
+76
View File
@@ -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);
}
+91 -1
View File
@@ -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);
});
});
+31
View File
@@ -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);